SECCON BEGINNERS CTF 2025 解けた問題のWriteup
今回、SECCON Beginners 2025に参加をしましたのでそのwriteupをここに供養します。
私が解けた問題は8問でした。Kali Linuxを使い今回解いたので特にexeファイルを解析するマルウェアの問題では最初悩みましたがなんとか解けました。残念ながら順位は130位台でしたが100位以内には入れなかったので今後hardの問題を解けるように頑張っていきたいです。
目次
Misc
kingyo_sukui - "金魚すくい"
- カテゴリ: Misc
- 難易度: Beginner
問題概要
「金魚すくい」を模したWebゲームで、画面上をランダムに動き回る文字(金魚)を正しい順番でクリックしていき、フラグを完成させる形式でした。
解法
-
分析:
ページにアクセスすると、フラグを構成する文字が水槽の中を魚のように動き回っています。これらの文字を順番にクリックする必要がありますが、正しい順序は見た目だけではわかりません。 -
ソースコードの解析:
このようなフロントエンドの課題では、答えの手がかりがクライアントサイドのコード(HTML, CSS, JavaScript)に隠されていることが多いです。ブラウザの開発者ツール(F12キーで起動)を開き、HTMLの構造を調査します。 -
手がかり:
水槽を表す<div id="tank">
の中を調べると、各文字が<div class="flag-char">
として表現されていることがわかります。さらに、各要素にはdata-index
というカスタムデータ属性が付与されています。これがフラグ内での文字の正しい位置(インデックス)を示しています。<div class="flag-container" id="flag-container"> <div class="flag-char" data-index="5">{</div> <div class="flag-char" data-index="3">4</div> <div class="flag-char" data-index="9">u</div> <div class="flag-char" data-index="2">f</div> <div class="flag-char" data-index="0">c</div> </div>
-
フラグの復元:
data-index
の値が0
から始まる連番になっていることから、これが順序を示していると確定できます。開発者ツールのコンソールで簡単なJavaScriptを実行し、自動的にフラグを構築します。コンソール用スクリプト:
const chars = document.querySelectorAll('.flag-char'); const flagArray = []; chars.forEach(char => { const index = parseInt(char.dataset.index, 10); const text = char.textContent; flagArray[index] = text; }); const flag = flagArray.join(''); console.log(flag); // ctf4b{n47um47ur1}
このスクリプトを実行すると、コンソールに正しいフラグが出力されます。あとはゲーム画面でその順番通りに文字をクリックすればクリアとなりました。また、このような事をしなくても単純に今回の文字数の場合目視で並び替えでも解けます。
Cryptography
elliptic4b - "楕円曲線だからってそっ閉じしないで!"
- カテゴリ: Cryptography
- 難易度: Medium
問題概要
楕円曲線 secp256k1
上で、サーバーから与えられたy座標を持つ点 P(x,y)
を見つけ、さらに Q = a*P
を計算したときに P.x == Q.x
かつ P.y != Q.y
となる整数 a
を見つけ出す問題です。
解法
-
数学的条件の分析:
楕円曲線上の点P(x, y)
に対し、同じx座標を持ち、異なるy座標を持つ点は、Pの加法逆元である-P
のみです。secp256k1
上では-P
は(x, -y mod p)
と定義されます。したがって、問題の条件はQ = -P
と同値です。 -
整数
a
の特定:
Q = a*P
という関係からa*P = -P
となります。楕円曲線上の点は位数n
の巡回群をなします。位数n
は点の個数を意味し、n*P = O
(Oは無限遠点、つまり単位元)が成り立ちます。
この性質を利用すると、a*P = (n-1)*P = n*P + (-1)*P = O - P = -P
となります。
よって、求めるべき整数はa = n - 1
です。n
はsecp256k1
の位数であり、これは既知の定数です。 -
x座標の計算:
残る課題は、与えられたy
からx
を求めることです。secp256k1
の曲線方程式はy² = x³ + 7 (mod p)
です。これをx
について変形するとx³ = y² - 7 (mod p)
となります。
これは、有限体F_p
におけるy² - 7
のモジュラ3乗根を求める問題です。 -
実装:
以上のロジックを実装し、サーバーと通信してフラグを取得します。#!/usr/bin/env python3 import socket from fastecdsa.curve import secp256k1 def solve(): p = secp256k1.p n = secp256k1.q host = '<運営の用意したターゲットサーバのドメイン>' port = 9999 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((host, port)) data = s.recv(1024).decode() y = int(data.split('y = ')[1].strip()) c = (pow(y, 2, p) - 7) % p # モジュラ3乗根を計算 (p ≡ 1 (mod 3)) x = pow(c, (2 * p - 1) // 3, p) a = n - 1 s.send(f"{x}\n".encode()) s.recv(1024) s.send(f"{a}\n".encode()) result = s.recv(4096).decode() s.close() # resultには ctf4b{1et'5_b3c0m3_3xp3r7s_1n_3ll1p71c_curv35!} が含まれています print(result) if __name__ == "__main__": solve()
seesaw - "アンバランスな素数"
- カテゴリ: Cryptography
- 難易度: Beginner
問題概要
RSA暗号で暗号化されたフラグが与えられており、公開鍵(n, e)と暗号文cが提供されています。しかし、nを構成する素数p, qの一方が非常に小さいという脆弱性があります。
解法
-
脆弱性の特定:
問題のタイトル「seesaw」や「アンバランス」という言葉から、nの素因数p, qのビット長が大きく異なると推測できます。これにより、大きな素数nを素因数分解する困難性が著しく低下します。 -
素因数分解:
nを小さな素数から順番に試し割りしていくことで、小さい方の素因数qを簡単に見つけることができます。 -
RSA復号:
pとqが判明すれば、通常のRSA復号手順に従って秘密鍵dを計算し、メッセージmを復号できます。- $\phi(n) = (p-1)(q-1)$
- $d = e^{-1} \pmod{\phi(n)}$
- $m = c^d \pmod{n}$
-
解き方:
#!/usr/bin/env python3 from Crypto.Util.number import inverse, long_to_bytes n = 13194226294733107273871988203271492928285991802015577464588709893163712981267717203695992442166063095550031912705476010253137003297915636936768263020353 e = 65537 c = 8555421308370356222906896862653261270524226397031629583445329825003821097466095985068493655203142555989309716135096606673026024529752464799649871570253 # 試し割りで小さい素数qを見つける q = 33091 p = n // q phi = (p - 1) * (q - 1) d = inverse(e, phi) m = pow(c, d, n) flag = long_to_bytes(m) print(f"[*] Flag: {flag.decode()}")
Reverse Engineering
CrazyLazyProgram1 (CLP1)
- カテゴリ: Reverse Engineering
- 難易度: Beginner
問題概要
CLP1.cs
というC#のソースコードが与えられます。このプログラムが期待する正しいフラグをソースコードから読み解く問題です。
解法
この問題はコンパイルされたバイナリではなく、直接ソースコードを読むことで解決できます。ロジックは非常にシンプルです。
-
ソースコードの分析:
CLP1.cs
を開くと、Main
メソッド内にフラグチェックのロジックがすべて記述されています。using System; class Program { static void Main() { int len=0x23; // 10進数で35 string flag=Console.ReadLine(); if((flag.Length)!=len){ // 長さチェック Console.WriteLine("WRONG!"); } else { // 各文字をハードコードされた16進数値と比較 if(flag[0]==0x63&&flag[1]==0x74&& ... &&flag[34]==0x7d){ Console.WriteLine("YES!!!\nThis is Flag :)"); } else { Console.WriteLine("WRONG!"); } } } }
-
フラグの復元:
if
文の条件式で比較されている16進数値は、各文字のASCIIコードです。これらを順番に文字へ変換することで、フラグが明らかになります。
インデックス | 16進数 | ASCII文字 |
---|---|---|
flag[0] |
0x63 |
c |
flag[1] |
0x74 |
t |
flag[2] |
0x66 |
f |
... | ... | ... |
すべての16進数コードを文字に変換して連結すると、最終的なフラグ `ctf4b{1_1in3r5_mak3_PG_hard_2_r3ad}` が得られます。
CrazyLazyProgram2
- カテゴリ: Reverse Engineering
- 難易度: Medium
問題概要
CLP2.o
という64-bit ELFオブジェクトファイルが与えられます。「機械語で作ってみました」というヒントから、低レベルな解析が必要だと推測されます。
解法
-
初期調査:
strings CLP2.o
を実行すると、%33c
という書式指定子が見つかり、フラグ長が33文字であることがわかります。 -
逆アセンブルによる深層解析:
この問題の核心は、コントロールフローの平坦化 (Control Flow Flattening) と呼ばれる難読化技術です。objdump -d CLP2.o
で逆アセンブルすると、<main>
関数内に多数のcmp
(比較) 命令と、je
(一致すればジャンプ) /jne
(一致しなければジャンプ) 命令が複雑に絡み合っていることが確認できます。 -
実行フローの追跡とフラグ復元:
フラグを復元するには、この複雑な制御フローを丹念に追いかける必要があります。- プログラムの開始点から最初の
cmp
命令を見つけます。1b8: cmp BYTE PTR [rbp-0x48],0x63 ; 入力の1文字目と 'c' (0x63) を比較 1bc: jne 233 <main+0x233> ; 不一致ならエラー処理へ 1be: je 1c9 <main+0x1c9> ; 一致すれば次のチェックへ
-
je
命令のジャンプ先 (1c9
) に移動し、次のcmp
命令を探します。 - このプロセスを入力の33文字分繰り返します。各
cmp
命令で比較されている即値を順番に記録し、それらをASCII文字に変換することで、完全なフラグctf4b{GOTO_G0T0_90t0_N0m0r3_90t0}
が復元されます。
- プログラムの開始点から最初の
d-compile - "C言語の次はこれ!"
- カテゴリ: Reverse Engineering
- 難易度: Beginner
問題概要
D言語でコンパイルされた64-bit ELF実行ファイルが与えられます。プログラムはユーザーからの入力を受け取り、それが正しいフラグであるかを判定します。正しいフラグを見つけ出すことが目的です。
解法
-
初期調査:
file
コマンドでD言語でコンパイルされた実行ファイルであることを特定します。strings
ではフラグそのものは見つかりません。 -
逆アセンブルによる解析:
解析の核心はobjdump
などでバイナリを静的解析することです。D言語のプログラムは、C言語のmain
関数に相当するエントリーポイントとして_Dmain
関数を持ちます。この関数を重点的に解析します。
_Dmain
関数内では、movb
命令が連続して実行されている箇所が見つかります。これは、1バイトずつ即値をスタック上に書き込む操作です。この一連の操作で、フラグ文字列をメモリ上に構築しています。; objdump -d -M intel d-compile の抜粋 mov BYTE PTR [rbp-0x50],0x63 ; 'c' mov BYTE PTR [rbp-0x4f],0x74 ; 't' ...
-
フラグの復元:
上記のアセンブリコードでmovb
命令によって書き込まれている16進数の値を順番に抽出し、ASCII文字に変換することで、完全なフラグを復元できます。#!/usr/bin/env python3 bytes_data = [ 0x63, 0x74, 0x66, 0x34, 0x62, 0x7b, 0x4e, 0x33, 0x78, 0x74, 0x5f, 0x54, 0x72, 0x33, 0x6e, 0x64, 0x5f, 0x44, 0x5f, 0x31, 0x61, 0x6e, 0x39, 0x75, 0x61, 0x67, 0x33, 0x5f, 0x31, 0x30, 0x31, 0x7d ] flag = ''.join(chr(b) for b in bytes_data) print(f"[*] Found Flag: {flag}") # ctf4b{N3xt_Tr3nd_D_1an9uag3_101}
wasm_S_exp - "フラグをチェックしてくれるプログラム"
- カテゴリ: Reverse Engineering
- 難易度: Medium
問題概要
WebAssembly Text Format (WAT) で記述されたプログラムが与えられます。check_flag
関数がフラグの正当性を検証しますが、そのロジックをリバースエンジニアリングしてフラグを特定する問題です。
解法
-
WATコードの構造理解:
提供された.wat
ファイルを分析すると、check_flag
とstir
という主要な関数が見つかります。check_flag
は文字を検証しますが、その順番はフラグの文字順とは一致しません。stir
はメモリアドレスを計算する関数です。 -
stir
関数の解析:
stir
関数のロジックは数式に直すとstir(x) = 1024 + (23 + 37 * (x XOR 0x5a5a)) % 101
となります。 -
check_flag
のロジックとフラグ復元:
check_flag
内の各チェックは、(期待される文字コード, stirへの入力値)
のペアで構成されています。このロジックを逆手に取ることが解決の鍵です。-
データ抽出: 25個のチェックから
(期待される文字コード, stirへの入力値)
のペアをすべて抽出します。 -
アドレス計算: 抽出した各ペアについて、
stir
関数をPython等で再実装し、対応するメモリアドレスを計算します。 -
ソートと結合:
(計算されたメモリアドレス, 期待される文字)
のペアリストを作成し、メモリアドレスを基準に昇順でソートします。ソート後の文字を順番に連結すると、フラグが明らかになります。
#!/usr/bin/env python3 def stir(x): val = 37 * (x ^ 0x5a5a) val = (23 + val) % 101 return 1024 + val def solve_flag(): checks = [ (0x7b, 38), (0x67, 20), (0x5f, 46), (0x21, 3), (0x63, 18), (0x6e, 119), (0x5f, 51), (0x79, 59), (0x34, 116), (0x5f, 19), (0x34, 11), (0x6c, 62), (0x57, 101), (0x33, 49), (0x35, 12), (0x62, 9), (0x34, 4), (0x41, 1), (0x54, 73), (0x30, 24), (0x66, 60), (0x7d, 21), (0x5f, 100), (0x31, 86), (0x74, 55) ] memory_map = [] for expected_char_code, param in checks: memory_addr = stir(param) memory_map.append((memory_addr, chr(expected_char_code))) memory_map.sort() flag = ''.join(char for _, char in memory_map) print(f"[*] Reconstructed Flag: {flag}") # ctf4b{WAT_4n_345y_l0g1c!} solve_flag()
-
データ抽出: 25個のチェックから
MAFC (Malware Analysis First Challenge)
- カテゴリ: Reverse Engineering
- 難易度: Hard
問題概要
MalwareAnalysis-FirstChallenge.exe
というWindows実行ファイルと、それが暗号化した flag.encrypted
ファイルが与えられました。今回はパソコンのスペックの問題でGUIツール(Ghidraなど)を使わずに実行ファイルを解析し、暗号化パラメータを特定してフラグを復号しました。
解法
-
初期静的解析 (stringsコマンド):
まず、strings
コマンドで実行ファイル内の印刷可能な文字列を抽出します。strings MalwareAnalysis-FirstChallenge.exe
この出力から、Windows CryptoAPIに関連する多数の関数名や、
ThisIsTheEncryptKey
、IVCanObfuscation
といった重要な手がかりが発見できます。これらの文字列から、マルウェアがファイルの暗号化を行っていること、そして鍵やIVの元になる文字列を推測します。 -
暗号化されたデータの確認 (xxdコマンド):
次に、暗号化されたデータflag.encrypted
の中身をxxd
コマンドで確認します。xxd flag.encrypted
データが正確に64バイト(AESのブロックサイズ16バイトの4倍)であることがわかります。
-
詳細なバイナリ解析 (radare2の活用):
GUIツールが使えないため、CLIベースのリバースエンジニアリングフレームワークであるradare2
を使用して、実行ファイルの低レベルな解析を行います。r2 MalwareAnalysis-FirstChallenge.exe aaa # 全体の自動解析 afl # 関数リスト表示 / CryptDeriveKey # 特定のCryptoAPI関数の呼び出し箇所を検索
main
関数内のCryptoAPI呼び出し周辺のアセンブリコードを詳細に追跡し、関数の引数に渡されている値を特定します。-
ハッシュアルゴリズム:
CryptCreateHash
の引数からCALG_SHA_256
(SHA256) であることを特定します。 -
鍵生成アルゴリズム:
CryptDeriveKey
の引数からCALG_AES_256
(AES-256) であることを特定します。これにより、鍵長が32バイトであると確定し、SHA256ハッシュがそのまま鍵として使われると判断します。 -
暗号化モード:
CryptSetKeyParam
の引数からCRYPT_MODE_CBC
(CBCモード) であることを特定します。 -
IV (初期ベクトル): 別の
CryptSetKeyParam
の呼び出しから、IVがIVCanObfuscation
という文字列から生成されることを特定します。これが最大のひっかけポイントで、Windows APIが文字列をUTF-16LEで扱うことに注目する必要がありました。ASCIIで16文字のこの文字列は、UTF-16LEでエンコードすると32バイトになり、その先頭16バイトがIVとして使用されていると推測します。
-
ハッシュアルゴリズム:
-
復号化スクリプトの作成:
特定したすべての暗号化パラメータを基に、Pythonのpycryptodome
ライブラリを使って復号スクリプトを作成してフラグを取得しました。#!/usr/bin/env python3 from Crypto.Cipher import AES from Crypto.Hash import SHA256 from Crypto.Util.Padding import unpad # 1. 鍵の元 (stringsから特定) key_str_raw = "ThisIsTheEncryptKey" key_str_bytes = key_str_raw.encode('utf-8') # 2. ハッシュアルゴリズム: SHA256 h = SHA256.new(key_str_bytes) derived_key = h.digest() # 32バイトのハッシュをそのまま鍵として使用 # 3. IV: 'IVCanObfuscation' のUTF-16LEエンコードから先頭16バイト IV_STRING_RAW = "IVCanObfuscation" iv = IV_STRING_RAW.encode('utf-16le')[:16] # --- 復号化ロジック --- with open('flag.encrypted', 'rb') as f: encrypted_data = f.read() cipher = AES.new(derived_key, AES.MODE_CBC, iv) decrypted_padded_data = cipher.decrypt(encrypted_data) unpadded_data = unpad(decrypted_padded_data, AES.block_size) flag = unpadded_data.decode('utf-8') print(f"[*] Flag: {flag}") # ctf4b{way_2_90!_y0u_suc3553d_2_ana1yz3_Ma1war3!!!}