GoogleCTF 2025 Reverse-multiarch WriteUp

2.5k 詞

很恶心的双重vm,通宵大概做了十个小时

main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
__int64 __fastcall main(int a1, char **a2, char **a3)
{
__int64 v3; // rax
__int64 v4; // rbp
_BYTE *ptr; // rbx

setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
if ( a1 <= 1 )
{
fprintf(stderr, "[E] usage: %s [path to .masm file]\n", *a2);
return 2;
}
else
{
fwrite("[I] initializing multiarch emulator\n", 1u, 0x24u, stderr);
v3 = InitEmu(a2[1]);
v4 = v3;
if ( v3 )
{
ptr = (_BYTE *)InitOpc(v3);
fwrite("[I] executing program\n", 1u, 0x16u, stderr);
while ( (unsigned __int8)ExecuteOpc((__int64)ptr) )
;
if ( ptr[48] )
{
fwrite("[E] execution failed\n", 1u, 0x15u, stderr);
DbgPrint(ptr, 1);
}
else
{
fwrite("[I] done!\n", 1u, 0xAu, stderr);
}
UnmapAndFree(ptr);
frees(v4);
return 0;
}
else
{
fwrite("[E] couldn't load multiarch program\n", 1u, 0x24u, stderr);
return 1;
}
}
}

InitEmulator函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void *__fastcall InitEmu(const char *filename)
{
FILE *stream_1; // rax
FILE *stream; // rbx
void *v3; // rbp
int *v5; // rax
char *v6; // rax
int *v7; // rax
char *v8; // rax
_QWORD ptr_[7]; // [rsp+0h] [rbp-38h] BYREF

ptr_[3] = __readfsqword(0x28u);
stream_1 = fopen(filename, "r");
stream = stream_1;
if ( !stream_1 )
{
v5 = __errno_location();
v6 = strerror(*v5);
fprintf(stderr, "[E] couldn't open file %s - %s\n", filename, v6);
return 0;
}
ptr_[0] = 0;
ptr_[1] = 0;
if ( fread(ptr_, 1u, 4u, stream_1) != 4 )
{
v7 = __errno_location();
v8 = strerror(*v7);
fprintf(stderr, "[E] couldn't read magic - %s\n", v8);
LABEL_9:
fclose(stream);
return 0;
}
if ( strncmp((const char *)ptr_, "MASM", 4u) )
{
fwrite("[E] bad magic\n", 1u, 0xEu, stderr);
goto LABEL_9;
}
v3 = calloc(1u, 0x30u);
if ( !(unsigned __int8)AnalyzeFile(v3, 4, stream)
|| !(unsigned __int8)AnalyzeFile(v3, 9, stream)
|| !(unsigned __int8)AnalyzeFile(v3, 14, stream) )
{
if ( v3 )
frees(v3);
goto LABEL_9;
}
return v3;
}

解析了MASM文件的文件头和结构,包含MASM文件头、字节码段、文本段和一个暂时作用还未知的段(后面发现是用于选择vm函数),观察内存中的v3:

1
2
3
4
5
6
[heap]:000055555555C490 dq offset opcode
[heap]:000055555555C498 dq 165h
[heap]:000055555555C4A0 dq offset plaintext
[heap]:000055555555C4A8 dq 150h
[heap]:000055555555C4B0 dq offset data
[heap]:000055555555C4B8 dq 2Dh

其中的数值是每段的长度,对应文件头(19字节)中的:

1
2
3
4
4D 41 53 4D ; MASM
01 13 00 65 01 ; OPCODE
02 78 01 50 01 ; PLAINTEXT
03 C8 02 2D 00 ; DATA

ExecuteOpc函数:

1
2
3
4
5
6
7
8
9
10
11
12
__int64 __fastcall ExecuteOpc(__int64 ptr)
{
char v1; // al

v1 = GetFuncChoice();
if ( !v1 )
return Func0(ptr);
if ( v1 == 1 )
return Func1(ptr);
fwrite("[E] nice qubit\n", 1u, 0xFu, stderr);
return 0;
}

这里会进行VM函数的选择,由v1控制,v1来源于:

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned __int8 __fastcall GetFuncChoice(__int64 a1)
{
int v1; // edx
int v2; // eax
int v3; // eax

v1 = *(_DWORD *)(a1 + 51);
v2 = v1 - 4089;
if ( v1 - 4096 >= 0 )
v2 = *(_DWORD *)(a1 + 51) - 4096;
v3 = *(unsigned __int8 *)(*(_QWORD *)(a1 + 24) + (v2 >> 3));
return _bittest(&v3, v1 & 7);
}

a1是MASM文件结构体,a1+51的部分转为dword对应的初始值是0x1000(4096),v2根据v1是否大于等于4096来选择要减去的值(初始值为0),a1+24是MASM文件中第三个数据段,则v3为这个数据段中v2>>3索引的字节,最终返回的是v3的第v1&7个比特位

整个vm结构体的排布为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[heap]:000055555555C7E0 dq offset opcode
[heap]:000055555555C7E8 dq offset plaintext
[heap]:000055555555C7F0 dq offset memory
[heap]:000055555555C7F8 dq offset data
[heap]:000055555555C800 dq 2Dh ; data的len
[heap]:000055555555C808 dq offset GetFlagENV
[heap]:000055555555C810 db 0
[heap]:000055555555C811 db 0
[heap]:000055555555C812 db 0 ; offset50,用于jmp的判断
[heap]:000055555555C813 db 0 ; offset51,当前opcode执行索引
[heap]:000055555555C814 db 10h
[heap]:000055555555C815 db 0
[heap]:000055555555C816 db 0
[heap]:000055555555C817 db 0FFh ; memory偏移量指针
[heap]:000055555555C818 db 8Eh

实际动态调试输入一个值后发现Func0执行到第10次时读取了用户输入,然后又执行了8次,才第一次执行Func1,Func1共计执行5次后返回错误退出,对于func0,其字节码长度均为5,是一个字节opcode+一个dword参数的形式:

字节码 作用
0x10、0x20、0x30、0x40 load
0x50 nop
0x60、0x61、0x62、0x63 add sub xor and
0x70、0x71、0x72 jmp jnz jz
0x80 cmp
0xA000、0xA001、0xA002、0xA003、0xA004、0xA005、0xA006 scanf、unsupport、stdout、srand、rand、output flag、malloc

其中A0开头的“系统调用”的参数要在其上一条指令中加载,且必定为0x10+参数值

查看opcode前18步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0x10, 0x4B, 0x00, 0x00, 0x00, //加载0x4B到memory的0xEFF偏移量(7FFFF7FBAEFF),对应字符串长度
0x30, 0x00, 0x20, 0x00, 0x00, //加载0x2000到memory[0xEFB],这里是plaintext段的起始地址,这里是Welcome to the multiarch of madness! Let's see how well you understand it.
0x10, 0x02, 0x00, 0x00, 0x00, //加载0x02到memory[0xEFA]
0xA0, 0x00, 0x00, 0x00, 0x00, //系统调用号02,对应stdout
0x10, 0x2B, 0x00, 0x00, 0x00, //加载0x2B
0x30, 0xAD, 0x20, 0x00, 0x00, //加载0x20AD,这里是Challenge 1 - What's your favorite number?
0x10, 0x02, 0x00, 0x00, 0x00, //加载0x02
0xA0, 0x00, 0x00, 0x00, 0x00, //stdout
0x10, 0x00, 0x00, 0x00, 0x00, //加载0x00
0xA0, 0x00, 0x00, 0x00, 0x00, //系统调用号00,对应scanf("%u"),读取一个32位无符号整数,写入memory[0xEFC]
0x20, 0x37, 0x13, 0x00, 0x00, //加载0x1337到memory[0xEFA]
0x20, 0x39, 0x05, 0x00, 0x00, //加载0x539到memory[0xEF8]
0x30, 0x09, 0x53, 0x67, 0x08, //加载0x8675309到memory[0xEF4]
0x62, 0x00, 0x00, 0x00, 0x00, //0x13370539 ^ 0x8675309 = 0x1B505630 写入memory[0xEF8]
0x60, 0x00, 0x00, 0x00, 0x00, //0x1B505630 + input 写入memory[0xEFC]
0x30, 0xAA, 0xAA, 0xAA, 0xAA, //加载0xAAAAAAAA到memory[0xEF8]
0x80, 0x00, 0x00, 0x00, 0x00, //cmp result with 0xAAAAAAAA
0x72, 0x0B, 0x11, 0x00, 0x00, //jz 0x110B(不通过检验)

Challenge1:2405061754

之后来到Func1部分,Func1的字节码分的很细碎:

其他类

字节码 作用
0x10 mov mem num
0x11u-0x14 push reg0-reg3
0x15u-0x18 pop reg0-reg3
0xc0-0xff v9case5→load?
0x70-0x7f mov rega regb
0x80-0x8f mov reg num

系统调用类

字节码 作用
1-0 scanf
1-1 stdin into mem
1-2 stdout
1-3 srand
1-4 rand
1-5 unsupport
1-6 malloc

运算类

字节码 作用
0x20 add reg, reg
0x21 add reg, num
0x30 sub reg, reg
0x31 sub reg, num
0x40 xor reg, reg
0x41 xor reg, num
0x50 mul reg, reg
0x51 mul reg, num

跳转类

字节码 作用
0x60 call
0x61 retn
0x62 jz
0x63 jnz
0x64 js (offset50)
0x68 jmp

继续查看Challenge2,从第91字节开始进入func1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
0xC5, 0x02, 0x00, 0x00, 0x00, //加载0x2到VM结构体偏移量59的位置(系统调用参数)
0xCD, 0xD8, 0x20, 0x00, 0x00, //加载0x20D8到VM结构体偏移量63的位置(字符串地址)
0xD5, 0x1E, 0x00, 0x00, 0x00, //加载0x1E到VM结构体偏移量67的位置(字符串长度)
0x01, //stdout,对应Challenge 2 - Tell me a joke:
0x31, 0x50, 0x20, 0x00, 0x00, 0x00, //sub reg[-1] 0x20 (0x8F00 → 0x8EE0)
0xCE, //将0x8EE0写入memory[0xEDC]
0x12, //加载0x8EE0到VM结构体偏移量63的位置
0xD5, 0x20, 0x00, 0x00, 0x00, //加载0x20到VM结构体偏移量67的位置
0xC5, 0x01, 0x00, 0x00, 0x00, //加载0x1到VM结构体偏移量59的位置(系统调用参数)
0x01, //等待输入后写入memory[0xEE0]
0x15, //将0x8EE0写入VM结构体偏移量59的位置
0xCD, 0x20, 0x00, 0x00, 0x00, //加载0x20到VM结构体偏移量63的位置
0x60, 0x1C, 0x11, 0x00, 0x00, //call 0x111C from 0x1084
- - - - - - 0x111C - - - - - -
0xD0, //将0x8EE0写入VM结构体偏移量67的位置
0x20, 0x12, //将偏移量59的0x8EE0加上偏移量63的0x20得到0x8F00
0x11, //将0x8F00写入memory[0xED8]
0xCD, 0x00, 0x00, 0x00, 0x00, //将VM结构体偏移量63的位置清零
0x1125: 0xA1, 0xDA, //将明文的一个四字加载到VM结构体偏移量71的位置
0x51, 0x40, 0xBE, 0xBA, 0xFE, 0xCA, //将其乘上0xCAFEBABE,低位存在偏移量59,高位存在71
0x40, 0x24, //计算结果的高位异或上一个结果的高位(第一个异或0)
0x15, //将0x8F00加载到偏移量59
0x11,
0x72,
0x62, 0x42, 0x11, 0x00, 0x00, //jz 0x1142 所有4字处理完毕(一共32字节)后跳转
0x21, 0x30, 0x04, 0x00, 0x00, 0x00, //处理字节数+4
0x68, 0x25, 0x11, 0x00, 0x00, //jmp 0x1125
- - - - - - 0x1142 - - - - - -
0xC1,
0x61, //retn 0x1088
- - - - - - 0x1088 - - - - - -
0x80, 0x31, 0x73, 0x00, 0x00, //cmp with 0x7331
0x63, 0x0B, 0x11, 0x00, 0x00, //jnz 0x110B(不通过检验)

Chanllenge2只需构造任意字符串,每4个字节按小端序合并为一个dword,乘以0xCAFEBABE后保留高位,再异或前一项的高位后,最后一个dword为0x7331即可,一个可行的解是~VVD$T}WI%m54lAEA%-n,3ufs[sp[1@=(z3求解):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from z3 import *
import struct

def solve_for_string(target_last_xor=0x7331, length=32):
solver = Solver()
num_dwords = length // 4
byte_vars = [BitVec(f'b{i}', 8) for i in range(length)]
for b in byte_vars:
solver.add(b >= 0x20, b <= 0x7E)
dwords = []
for i in range(num_dwords):
dword_bytes = [byte_vars[i*4+3], byte_vars[i*4+2], byte_vars[i*4+1], byte_vars[i*4]]
dword = Concat(dword_bytes)
dword_32 = BitVec(f'dword_{i}', 32)
solver.add(dword_32 == dword)
dwords.append(dword_32)
mul_results = []
for i in range(num_dwords):
dword_64 = ZeroExt(32, dwords[i])
multiplication = dword_64 * 0xCAFEBABE
high_bits = Extract(63, 32, multiplication)
high_bits_32 = BitVec(f'mul_high_{i}', 32)
solver.add(high_bits_32 == high_bits)
mul_results.append(high_bits_32)
xors = []
for i in range(num_dwords):
if i == 0:
xor_result = mul_results[0]
xors.append(xor_result)
else:
xor_result = BitVec(f'xor_result_{i}', 32)
solver.add(xor_result == mul_results[i] ^ xors[i-1])
xors.append(xor_result)
solver.add(xors[-1] == target_last_xor)
if solver.check() == sat:
model = solver.model()
result_bytes = []
for i in range(length):
result_bytes.append(model[byte_vars[i]].as_long())
return bytes(result_bytes).decode('latin-1')
else:
return "No solution found"

result = solve_for_string()
print(f"{result}")
print(f"hex: {result.encode().hex()}")

Challenge3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
- - - - - - 0x1092 - - - - - -
0xC5, 0x00, 0x00, 0x00, 0x00, //将偏移量59清零
//——>进入func0
0x10, 0x5A, 0x00, 0x00, 0x00, //加载0x5A
0x30, 0xF6, 0x20, 0x00, 0x00, //加载0x20F6
0x10, 0x02, 0x00, 0x00, 0x00, //加载调用号02
0xA0, 0x00, 0x00, 0x00, 0x00, //系统调用打印语句:Challenge 3 - Almost there! But can you predict the future? What number am I thinking of?
//——>进入func1
0x01, //读取输入,存入偏移量59
0xC8, //备份到偏移量63
0xC5, 0x03, 0x00, 0x00, 0x00, //将3写入偏移量59的位置
0x01, //系统调用3(srand),种子为用户输入的值
0xD5, 0x00, 0x00, 0x00, 0x00, //将偏移量67清零
0x10B8: 0x60, 0x45, 0x11, 0x00, 0x00, //call 0x1145
- - - - - - 0x1145 - - - - - -
0xC5, 0x00, 0x37, 0x13, 0x00, //将0x133700写入偏移量59
//——>进入func0
0x10, 0x04, 0x00, 0x00, 0x00, //加载系统调用号0x4
0xA0, 0x00, 0x00, 0x00, 0x00, //系统调用4(rand),生成两次rand,其低16位用于组合成一个新的32位rand值,组合方式为第二次的低16位|第一次的第16位,并写入内存
//——>进入func1
0x16, //将第一次的rand值复制回偏移量63
0x40, 0x12, //将0x133700异或生成的随机值,结果写回偏移量59
0x11, //将异或结果复制入内存
//——>进入func0
0x30, 0xF2, 0xF2, 0xF2, 0xF2, //加载0xF2F2F2F2
0x62, 0x00, 0x00, 0x00, 0x00, //再将刚才的异或结果再次异或0xF2F2F2F2
//——>进入func1
0x15, //将最终结果写回偏移量59
0x61, //retn 0x10BD
- - - - - - 0x10BD - - - - - -
0x10, 0xFF, 0xFF, 0xFF, 0x00, //将0xFFFFFF写入内存
0x11,
//——>进入func0
0x63, 0x00, 0x00, 0x00, 0x00, //& 0xFFFFFF
0x30, 0xEE, 0xFF, 0xC0, 0x00, //写入0xC0FFEE
0x80, 0x00, 0x00, 0x00, 0x00, //cmp
0x71, 0xEC, 0x10, 0x00, 0x00, //jnz 10D7
//——>进入func1
0x21, 0x30, 0x01, 0x00, 0x00, 0x00, //偏移量67 + 1
0x82, 0x0A, 0x00, 0x00, 0x00,
0x62, 0x0B, 0x11, //jnz 0x110B(不通过检验,但是这次好像没跳转)
0x68, 0xB8, 0x10, 0x00, 0x00, //jmp 0x10B8
//接下来带上第一轮一共循环了20轮

20轮循环理论上只要其中有一轮等于0xC0FFEE就可以,因此可以编写爆破脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<stdio.h>
#include<stdlib.h>

int main() {
for(int i = 0; i <= 100000000; i++){
srand(i);
for(int j = 0; j < 18; j++){
int a = rand();
}
unsigned int num = rand() & 0xFFFF;
num |= rand() << 16;
num ^= 0x133700;
num ^= 0xF2F2F2F2;
num &= 0xFFFFFF;
if(num == 0xC0FFEE){
printf("%d\n", i);
}
}
}

可以求出来很多很多符合要求的nums.

flag:CTF{st3ph3n_str4ng3_0nly_us3s_m1ps_wh4t_a_n00b}