プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

L3akCTF 2025 Rev-genre Alpha write-up

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]16REG_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の与え方だけを理解しています。それでも役に立ちます。