L3akCTF 2025へ参加しました。その中のRevジャンルAlphaのwrite-up記事です。
IDAの解析結果ファイル等を、GitHubで公開しています。
環境
WindowsのWSL2(Ubuntu 24.04)を使って取り組みました。
Windows(ホスト)
c:\>ver Microsoft Windows [Version 10.0.19045.6093] c:\>wsl -l -v NAME STATE VERSION * Ubuntu-24.04 Running 2 docker-desktop Running 2 Ubuntu-22.04 Stopped 2 c:\>
他ソフト
- IDA Version 9.1.250226 Windows x64 (64-bit address size)
WSL2(Ubuntu 24.04)
$ cat /proc/version Linux version 6.6.87.2-microsoft-standard-WSL2 (root@439a258ad544) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 $ cat /etc/os-release PRETTY_NAME="Ubuntu 24.04.2 LTS" NAME="Ubuntu" VERSION_ID="24.04" VERSION="24.04.2 LTS (Noble Numbat)" VERSION_CODENAME=noble ID=ubuntu ID_LIKE=debian HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" UBUNTU_CODENAME=noble LOGO=ubuntu-logo $ python3 --version Python 3.12.3 $ python3 -m pip show pip | grep Version: Version: 24.0 $ python3 -m pip show angr | grep Version: Version: 9.2.2 $ gdb --version GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git Copyright (C) 2024 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. $ gdb --batch --eval-command 'version' | grep 'Pwndbg:' Pwndbg: 2025.05.30 build: 5cff331b $ strace --version strace -- version 6.8 Copyright (c) 1991-2024 The strace developers <https://strace.io>. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. Optional features enabled: stack-trace=libunwind stack-demangle m32-mpers mx32-mpers $
解けた問題
[Rev] Alpha (34 teams solves, 451 points)
Asked my sister her opinion about CS majors: "Akward and Smelly". Typical ME cope.
配布ファイルとして、chalがありました:
$ file chal chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=778ac9952dc57234848 $
意図的なSIGILLと、sigactionでのシグナルハンドラー登録と、main関数内容書き換え
IDAで開いて逆コンパイルすると、main関数の途中で逆アセンブルに失敗していました:

不審に思って色々調べていると、init_arrayセクションに登録されている0x1283の関数で、SIGILL時用のシグナルハンドラーを登録していました:
void __fastcall ProcInitArray_SigAction() { sigaction act; // [rsp+0h] [rbp-A0h] BYREF unsigned __int64 qwCanary; // [rsp+98h] [rbp-8h] qwCanary = __readfsqword(0x28u); memset(&act, 0, sizeof(act)); act.sa_handler = (__sighandler_t)ProcSignalHandler; sigemptyset(&act.sa_mask); act.sa_flags = SA_SIGINFO; sigaction(SIGILL, &act, 0); }
SIGILL用に登録しているシグナルハンドラーは次の内容です:
void __fastcall ProcSignalHandler(int sig, siginfo_t *info, ucontext_t *ucontext) { unsigned int i; // [rsp+24h] [rbp-14h] ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)main; for ( i = 0; i <= 0x3F; ++i ) *((_BYTE *)main + i) ^= g_byteArraySize2560_XorPatch[g_dwPatchIndex++]; unk_1350 = 0x37; // mainの逆アセンブル失敗箇所を引き続き0x37に保つ。おそらくSIGILL続行用 }
main関数の先頭から64バイト分の機械語を書き換えて、main関数へ制御を戻しています!ここでふと.textセクションのフラグを見ると、読み書き実行可能な設定にバッチリなっていました:
$ readelf --wide --sections chal | grep .text [16] .text PROGBITS 0000000000001100 001100 000679 00 WAX 0 0 16 $
というわけで今回の問題は、SIGILLをトリガーとするシグナルハンドラーでmain関数の内容を更新しつつ処理を続行する問題です。適当な入力で確かめると38回もSIGILLを発動していました:
$ echo test | strace ./chal 2>&1 | grep SIGILL | uniq -c
1 rt_sigaction(SIGILL, {sa_handler=0x5b93b48141e9, sa_mask=[], sa_flags=SA_RESTORER|SA_SIGINFO, sa_restorer=0x77c7d7645330}, NULL, 8) = 0
38 --- SIGILL {si_signo=SIGILL, si_code=ILL_ILLOPN, si_addr=0x5b93b4814350} ---
$
なおコンテスト中では、シグナルハンドラーの第3引数がvoid*と思っていて、実際はucontext_t*だということは調べていませんでした。「main関数のアドレスを入れているならきっとRIPレジスタを上書きしているんだろう!」という推測で突っ走っていました。manページsigaction(2) - Linux manual pageを見ても、siginfo_tの型がvoid handler(int sig, siginfo_t *info, void *ucontext)と何故かvoid*止まりです。説明ではThis is a pointer to a ucontext_t structure, cast to void *と書かれており、他の型が入る余地はなさそうに見えます。一体なぜ最初からucontext_t*と書かれていないのかは謎です。
その他、ucontext->uc_mcontext.gregs[16]の16がREG_RIPの意味であることは、本記事執筆中にIDAのEnum機能で認識してもらいました。人力で調べる場合はglibc/sysdeps/unix/sysv/linux/x86/sys/ucontext.h at master · lattera/glibcあたりを調べることになると思います。
全体としてのmain関数実行命令を抽出
SIGILLが38回起こっているということは、main関数書き換えも38回行われているということです。「人力デバッグ実行→逆アセンブル結果確認」するには辛い回数なので、自動化を考えました。
個人的には最近この手の処理は、GDBのPython APIを試しています。色々調べまくりながら次の処理を書きました:
#!/usr/bin/env python3 # Usage: gdb -q -x ./my-script.py chal # Note: pwngdb is required (due to piebase command) import gdb # Python API利用コードのデバッグ時に役立ちます gdb.execute("set python print-stack full") INPUT_FILENAME = "input.txt" with open(INPUT_FILENAME, "w") as f: f.write("test") # piebaseコマンドは実行後にのみ動作します gdb.execute(f"starti < {INPUT_FILENAME}") def get_va(rva: int) -> int: output = gdb.execute(f"piebase {hex(rva)}", to_string=True) return int(output.split(" = ")[1], 0) addr_main = get_va(0x1310) addr_sighandler = get_va(0x11E9) addr_sighandler_ret = get_va(0x1282) print(f"{addr_main = :#018x}") def print_main_instructions_until_bad(): for line in gdb.execute(f"x/64i {hex(addr_main)}", to_string=True).splitlines(): if "nop" in line: continue if "(bad)" in line: break print(line) class MyTraceBreakpoint(gdb.Breakpoint): def __init__(self, spec: str): super(MyTraceBreakpoint, self).__init__( spec, type=gdb.BP_HARDWARE_BREAKPOINT, internal=False, temporary=False, ) def stop(self): # 機械語を読めてもあまり嬉しくなかった。逆アセンブル結果のほうが嬉しい。 # memory = gdb.inferiors()[0].read_memory(addr_main, 0x40).tobytes() # print(memory) print_main_instructions_until_bad() # signal handlerで書き換えられた後のmain内容 return False # Continue execution (equivalent to `silent` in GDB) # 何故かmain先頭へハードウェアブレークポイントを設置しても1回目だけヒットしたので、signal handlerからreturnするところで再ダンプする MyTraceBreakpoint(f"*{hex(addr_sighandler_ret)}") # 最初のmain内容 print_main_instructions_until_bad() gdb.execute("handle SIGILL pass noprint") # SIGILL発生時にgdbを止めない # gdb.execute(rf"""dprintf *{hex(addr_sighandler)}, "SIGILL!\n" """) gdb.execute("continue") gdb.execute("quit")
gdb -q -x ./my-script.py chal > trace_result.txt のように実行結果をファイルに出力し、pwndbgの起動時メッセージ等を除去したものがGitHub掲載のtrace_result.txtです。700行以上あるので本記事には掲載しません。GitHub側をご参照ください。
逆アセンブル結果を眺めると、分岐があるのは最後の正誤判定らしい箇所だけで、そこまでは上から下に逐次実行されることが分かりました。
main関数から呼び出している他関数内容を理解
chalバイナリは、main関数やシグナルハンドラー関係の関数以外にも、いくつか関数があります。それらの関数の逆コンパイル結果を見ると、ビットごとに何か計算していそうな雰囲気がしました:
unsigned int __fastcall sub_1381(unsigned int a1, unsigned int a2) { unsigned int v3; // [rsp+14h] [rbp-14h] unsigned int v4; // [rsp+18h] [rbp-10h] unsigned int i; // [rsp+1Ch] [rbp-Ch] v3 = 0; v4 = 0; for ( i = 0; i < 0x20; ++i ) { v4 |= (1 << i) & (v3 ^ a2 ^ a1); v3 = 2 * ((1 << i) & (a2 & (v3 | a1) | v3 & a1)); } return v4; }
直感として「これらの関数は回りくどいループ処理をしていますが、実際の処理は至極単純なはず」という予感がしました。関数はすべて整数引数整数戻り値なので、Pythonからの呼び出しも簡単そうです。Simple Flag Checkerの7つの解き方による動的解析入門【AlpacaHack Round 4 作問者writeup】 - keymoonの日記を参考にliefライブラリの使い方を調べながら、次のコードを書きました:
#!/usr/bin/env python import ctypes import lief # https://lief.re/doc/latest/installation.html bin: lief.ELF.Binary = lief.parse("./chal") # 最後の関数だけ引数1個、他は引数2個 function_rva_list = [0x1381, 0x1401, 0x153B, 0x15B8, 0x164A, 0x16C8, 0x1728] for rva in function_rva_list: bin.add_exported_function(rva, f"rva_{hex(rva)}") bin[lief.ELF.DynamicEntry.TAG.FLAGS_1].remove(lief.ELF.DynamicEntryFlags.FLAG.PIE) bin.write("chal.so") lib = ctypes.CDLL("./chal.so") test_pattern = list(range(10)) + [0x10, 0x100, 0x1000, 0xFFFFFFFF] for rva in function_rva_list: func_name = f"rva_{hex(rva)}" func = lib[func_name] for a in test_pattern: for b in test_pattern: result = func(a, b) print(f"{func_name}({a}, {b}) = {result}")
実行すると、各種関数の機能が分かりました:
| 関数のRVA | 機能 |
|---|---|
0x1381 |
Add |
0x1401 |
Sub |
0x153B |
Mul |
0x15B8 |
BitAnd |
0x164A |
BitOr |
0x16C8 |
Xor |
0x1728 |
BitNot |
解析結果のアセンブリコードを再アセンブル
main関数で全体として実行されるアセンブリ命令列や、1つ前のセクションで解析した関数機能を使って、アセンブリコードを記述しました。インラインアセンブリでの書き方がよく分からなかったので、zer0pts-ctf-2023-public/rev/decompile_me/challenge/main.S at master · zer0pts/zer0pts-ctf-2023-publicを参考にしながら.S形式のアセンブリコードを書きました。全体としてはやはり700行を超えているのでGitHub掲載のcombined.Sをご参照ください。
困った点などです:
main関数では入力読み込みにfgets関数を使っていました。アセンブリコードではstdin引数をどのように書いたらいいのか分からなかったので、gets関数で代用しました。- 最後に分岐して出力するメッセージ内容2種類も適当に用意しました。
- アセンブリコードでの
call先内容は、テキストエディターの置換機能で編集しました。 Mul機能の関数の実装が不安でした。念入りにrdxレジスタの0初期化などを行いました。おそらく過剰です。- 他の関数は素直に実装できました。
gcc combined.S -o reassembledで、無事にアセンブル成功しました。
シンボリック実行でフラグ取得
アセンブル結果をIDAで確認すると、「入力文字列のうち何文字か使って計算し、結果が特定の値になること」を繰り返し検証しているようです。例えば次のような処理が多くあります:
v3 = strFlagSize64[9]; v4 = strFlagSize64[0]; v5 = ProcBitOr_sub_164A((unsigned int)strFlagSize64[13], (unsigned int)strFlagSize64[12]); v6 = ProcXor_sub_16C8(v5, v4); bIsCorrect = (unsigned int)ProcMul_sub_153B(v6, v3) == 4902 && bIsCorrect;
angrでシンボリック実行して正解フラグを求めました:
#!/usr/bin/env python3 import angr project = angr.Project("./reassembled", load_options={"auto_load_libs": False}) state = project.factory.entry_state( args=[project.filename], add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY}, ) simgr = project.factory.simulation_manager(state) simgr.explore(find=lambda s: b"msg1" in s.posix.dumps(1)) if len(simgr.found) == 0: raise Exception("Not found...") solution = simgr.found[0] print(solution.posix.dumps(0))
実行しました:
$ time ./solve_by_angr.py
WARNING | 2025-07-16 02:14:08,074 | angr.state_plugins.unicorn_engine | failed loading "angr_native.so", unicorn support disabled (libunicorn.so.1: cannot open shared object file: No such file or directory)
WARNING | 2025-07-16 02:14:08,601 | cle.loader | The main binary is a position-independent executable. It is being loaded with a base address of 0x400000.
WARNING | 2025-07-16 02:14:08,685 | angr.procedures.libc.gets | The use of gets in a program usually causes buffer overflows. You may want to adjust SimStateLibc.max_gets_size to properly mimic an overflowing read.
WARNING | 2025-07-16 02:14:16,510 | angr.engines.successors | Exit state has over 256 possible solutions. Likely unconstrained; skipping. <BV64 Reverse(packet_0_stdin_1_2040[1207:1144])>
b'L3AK{R3m0V&_Qu@n~iF!3rs}\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
./solve_by_angr.py 9.35s user 0.35s system 98% cpu 9.814 total
$ ./chal
FLAG: L3AK{R3m0V&_Qu@n~iF!3rs}
Correct!
$
フラグを入手できました: L3AK{R3m0V&_Qu@n~iF!3rs}
感想
- 色々な手法が必要で、面白い問題でした!
- GDBのPython API関連はBreakpoints In Python (Debugging with GDB)などにドキュメントはありますが、サンプルコードがほとんど無いので意味読解が難しいです。
- 私は未だに、angr実行時に入力へ制約を与える方法を理解していません。
find,avoidの与え方だけを理解しています。それでも役に立ちます。