堆溢出漏洞利用总结

0x00 前言

历时三天的速成,终于把报告上交了🙄

不过话说回来,堆可能是pwn里最难啃的骨头了,没什么固定做法,复杂多变,感觉自己十个脑子也不够用…

自己记录了一些最基本的知识点,但是看过堆题的朋友应该明白,

就这?还想做堆?啥也不是!

0x01 背景知识

堆(heap)是一种全局的数据结构,主要是指用户动态申请的内存(如调用malloc、alloc、new等函数),不同于栈的是,堆具有更多的灵活性,因此堆漏洞的利用也更加复杂,更加零散,在此我总结一下堆溢出漏洞的几种常见的利用方法。

以下是学习堆所需要掌握并熟悉的基本知识点(自学)。

  1. glibc malloc中三种最基本的堆块数据结构:heap_info, malloc_state, malloc_chunk;

  2. chunk内存块结构及各字段功能;

  3. bins类型及空闲内存块的管理组织方法;

  4. malloc()、free()工作流程;

0x02 基本漏洞类型

1.常规堆溢出

堆缓冲区溢出与栈缓冲区溢出类似,指堆上的缓冲区被填入过多数据,导致堆中其他数据被覆盖,主要分为两种情况:

(1)覆盖本堆块内部数据,通常发生在结构体中,如果结构体中数组溢出,则覆盖后续变量。

(2)覆盖后续堆块数据,会影响后续堆块的数据,甚至破坏堆块结构。

对于这两种情况可以类比栈溢出原理,没有太多技巧性的知识,但是CTF中不会出现单纯利用堆溢出的题目,通常多种基本漏洞会相互结合,所以要求常见基本漏洞类型都要掌握并且能够灵活使用。


2.Off By One

缓冲区溢出的一种,但是比较特殊,只能溢出1个字节。

有两种利用思路:

(1) 溢出字节为可控制任意字节:通过修改大小造成块结构之间出现重叠,从而泄露其他块数据,或是覆盖其他块数据。

(2) 溢出字节为 NULL 字节:溢出的一个字节恰好覆盖下一堆块的size域的最低位,将PREV_INUSE位置0,这样前块会被认为是 free 块。这时可以选择使用 unlink 方法进行处理(后面将详细介绍),这时 prev_size 域就会启用,就可以伪造 prev_size ,从而造成块之间发生重叠。

下面是一个简单的off by one的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char buffer[40]="";
void *chunk1;
chunk1=malloc(24);
puts("Get Input");
gets(buffer);
if(strlen(buffer)==24)
{
strcpy(chunk1,buffer);
}
return 0;
}

程序很简单,但并非安全,问题在于strlen 在计算长度的时候不会把结束符 ‘\x00’ 计算在内,但strcpy 在拷贝的时候会把 ‘\x00’ 也算上,所以就会造成 off by one。(for循环中也比较常见)

调试一下,便于理解,在main函数下断点,然后单步到输入位置,查看一下chunk情况,

当我们输入24个’A’之后,很明显下一位低字节被覆盖为’\x00’

Note:有一个点要注意,为什么申请了24个字节,chunk大小是0x21呢,也就是说为什么用户数据部分大小只有0x10?

其实是这样的,当前一堆块正在使用时,下一堆块的prev_size也被当作数据部分(大小0x08),只有前一堆块free时,prev_size域才有意义。


3.Use After Free

Use After Free(UAF)即释放后使用漏洞。若堆指针在释放后未置空,形成悬挂指针,当下次访问该指针时,依然能够访问原指针所指向的堆内容,形成漏洞。通常需要根据具体情况分析,以判断是否具有信息泄露和信息修改的功能。

这是比赛时最常规的一种方法,绝大多数题目都要借助它来完成,简单来说当我们第一次申请的内存释放之后,没有进行内存回收,下次申请的时候还能申请到这一块内存,导致我们可以用以前的内存指针来访问修改过的内存。

同样,用一个 UAF的程序展示简单的漏洞利用,以便于理解。

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
#include <stdio.h>
#include <stdlib.h>
typedef void (*func_ptr)(char *);
void sys1(char command[])
{
system(command);
}
void echo(char content[])
{
printf("%s",content);
}
int main()
{
func_ptr *p1=(func_ptr*)malloc(4*sizeof(int));//申请了4个int大小的内存
printf("malloc addr: %p\n",p1);//因为前2个也就是0x10是用来管理chunk的
p1[2]=echo;//所以从第三个开始
p1[2]("hello world\n");
free(p1); //在这里free了p1,但并未将p1置空,导致后续可以再使用p1指针
p1[2]("hello again\n"); //p1指针未被置空,虽然free了,但仍可使用.
func_ptr *p2=(func_ptr*)malloc(4*sizeof(int));
//free一块内存后,再次申请同样大小的指针会把刚刚释放的内存分配出来
printf("malloc addr: %p\n",p2);
printf("malloc addr: %p\n",p1);//p2与p1指针指向的内存为同一地址
p2[2]=sys1; //在这里将p1指针里面保存的echo函数指针覆盖成为了sys1指针.
p1[2]("/bin/sh");
return 0;
}

很明显,p1,p2指针指向了同一地址,原因就是p1被释放后,放入fastbin,当p2再次申请同样大小的空间时,直接从fastbin中取出刚刚被释放、且大小合适的内存空间(即p1),以提高分配速度,如果程序员粗心大意,那么就会造成了可以被利用的UAF漏洞。

成功获取shell。


4.Double Free

Double Free是UAF较为特殊的一种,也是比赛中经常被使用的基本方法之一,简单的说,double free是任意地址写的一种技巧,要与堆管理的其他特性相结合使用,先不谈利用,这里我只是介绍一下double free最基础的原理。以fastbin为例,fastbin 是 LIFO 的数据结构,使用单向链表实现。根据fastbin 的特性,释放的chunk 会以单向链表的形式回收到fastbin 里面,然后通过 fastbin->fd 来遍历。由于 free 的过程会对 free list 做检查,我们不能连续两次 free 同一个 chunk,所以这里在两次 free 之间,增加了一次对其他 chunk 的 free 过程,从而绕过了检查顺利执行,然后再 malloc 三次,就在同一个地址 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
33
34
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
fprintf(stderr, "Allocating 3 buffers.\n");
char *a = malloc(9);
char *b = malloc(9);
char *c = malloc(9);
strcpy(a, "AAAAAAAA");
strcpy(b, "BBBBBBBB");
strcpy(c, "CCCCCCCC");
fprintf(stderr, "1st malloc(9) %p points to %s\n", a, a);
fprintf(stderr, "2nd malloc(9) %p points to %s\n", b, b);
fprintf(stderr, "3rd malloc(9) %p points to %s\n", c, c);

fprintf(stderr, "Freeing the first one %p.\n", a);
free(a);
fprintf(stderr, "Then freeing another one %p.\n", b);
free(b);
fprintf(stderr, "Freeing the first one %p again.\n", a);
free(a);

fprintf(stderr, "Allocating 3 buffers.\n");
char *d = malloc(9);
char *e = malloc(9);
char *f = malloc(9);
strcpy(d, "DDDDDDDD");
fprintf(stderr, "4st malloc(9) %p points to %s the first time\n", d, d);
strcpy(e, "EEEEEEEE");
fprintf(stderr, "5nd malloc(9) %p points to %s\n", e, e);
strcpy(f, "FFFFFFFF");
fprintf(stderr, "6rd malloc(9) %p points to %s the second time\n", f, f);
}

程序free(a)了两次,可以一步一步调试chunk的情况,

三次malloc之后

free(a)之后,可以看到a被加到了fastbin中

free(b)之后,可以看到b也被加到fastbin中,并且fd指针指向了a的地址

再次free(a)之后,a->fd又指向了b,也就是说a再一次被添加到fastbin,同时b->fd=a,所以实际上形成了一个环。

最后三次malloc之后,发现0x44(‘D’)不见了,其实是第二次malloc a的时候0x46将其覆盖了。这就是double free的基本原理,但可以想象,如果第一次申请a的时候,将fd指针修改成有意义的地址,那么我们就可以做到任意地址写(可以结合堆溢出)。

在 libc-2.26 之后,即使两次 free,也没有触发 double-free 的异常检测,这是因为 tcache 的机制有关,水平有限,这里暂不探讨。


0x03 堆溢出漏洞利用

1. House of spirit(fastbin)

利用技术:

(1)fastbin为单链表(只用到fd),结构简单,容易伪造

(2)为了提高分配效率,安全检查少

(3)只针对fastbin大小的chunk,small/large chunk不适用

(4)存在堆溢出、use-after-free 等能控制 chunk 内容的漏洞

利用思路:

  1. 空闲fast chunk如果发生溢出被覆盖,则链表指针fd可以被修改

  2. 通过修改链表指针fd,在fastbin中引入伪造的free chunk(最重要的是必须保证伪造的chunk结构合法)

  3. 下次分配时分配伪造的fast chunk

  4. 伪造的fast chunk可以在以下位置:

在栈上伪造fast chunk:覆盖返回地址

在bss上伪造fast chunk:修改全局变量

在堆上伪造fast chunk:修改堆上数据

以fastbin_dup_into_stack为例,该程序便是利用double free进行fastbin attack其余两种可类比该例子

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

int main() {
unsigned long long stack_var = 0x21;
fprintf(stderr, "Allocating 3 buffers.\n");
char *a = malloc(9);
char *b = malloc(9);
char *c = malloc(9);
strcpy(a, "AAAAAAAA");
strcpy(b, "BBBBBBBB");
strcpy(c, "CCCCCCCC");
fprintf(stderr, "1st malloc(9) %p points to %s\n", a, a);
fprintf(stderr, "2nd malloc(9) %p points to %s\n", b, b);
fprintf(stderr, "3rd malloc(9) %p points to %s\n", c, c);

fprintf(stderr, "Freeing the first one %p.\n", a);
free(a);
fprintf(stderr, "Then freeing another one %p.\n", b);
free(b);
fprintf(stderr, "Freeing the first one %p again.\n", a);
free(a);

fprintf(stderr, "Allocating 4 buffers.\n");
unsigned long long *d = malloc(9);
*d = (unsigned long long) (((char*)&stack_var) - sizeof(d));
fprintf(stderr, "4nd malloc(9) %p points to %p\n", d, &d);
char *e = malloc(9);
strcpy(e, "EEEEEEEE");
fprintf(stderr, "5nd malloc(9) %p points to %s\n", e, e);
char *f = malloc(9);
strcpy(f, "FFFFFFFF");
fprintf(stderr, "6rd malloc(9) %p points to %s\n", f, f);
char *g = malloc(9);
strcpy(g, "GGGGGGGG");
fprintf(stderr, "7th malloc(9) %p points to %s\n", g, g);
}

这个程序展示了怎样通过修改指针,将其指向一个伪造的 free chunk,在伪造的地址处 malloc 出一个 chunk。Double free之前的程序基本没变,关键点在于我们的下一次malloc:

1
2
unsigned long long *d = malloc(9);
*d = (unsigned long long) (((char*)&stack_var) - sizeof(d));

填充了一个地址:栈地址-0x8

这一步的意义就是在于在栈上构造了一个合法chunk,伪造的chunk要有合法的堆头信息,所以应从size域-0x8开始。

特别要注意的是,fake fastbin中的size需要与改写指针的fastbin块大小一致,且p位为1。

可以看一下栈中的位置

而在fastbin中,原本的a堆块fd指针已经改为栈中对应的地址,因此当下一次malloc a时,就会在我们伪造的假chunk分配内存。

如果能实现在栈中的任意地址写,那么就可以用栈的方法获取shell。

同样,如果存在堆溢出漏洞,也可以进行free chunk fd指针的修改,但是为了总结相关原理和便于理解,我举的例子都很简单,仅适合入门者学习参考。


2. house of force

利用条件:

1.能够以溢出的方式控制到top chunk的size域

2.能够自由地控制堆分配尺寸的大小

3.可以构造size拿到top chunk本身之外的内存,如libc的内存空间

这种方法主要是指堆块溢出覆盖top chunk中的size域的情况,通过将其修改为一个非常大的数据,从而可以申请非常大的空间,使得新top chunk的头部落到想要修改的位置。在下次申请时,就能够得到目标内存,从而实现泄露和改写。

利用步骤如下:

(1)首先先泄露出堆地址。

(2)利用堆溢出,将top chunk的size域修改为很大的数

(3)申请大块内存(可以通过堆地址和目标地址的距离进行计算),使得top chunk的头部落在目标地址范围内。

(4)再次申请内存,那么新申请的内存即为目标地址,通常情况下(未开启FullRelro),一般是将目标地址设为got表地址。

当我们计算好当前堆块与目标地址之间的偏移后,申请该大小的堆块,让chunk头会恰好落在目标地址前(低地址),这时再次申请,我们就可以改写目标地址内容。


unlink攻击技术是利用glibc malloc的内存回收机制,通过堆溢出等方法进行内存修改。

要想利用unlink,首先要了解free()的工作过程:

(1)如果size<max fast,放入fastbin,结束

(2)如果前一个chunk是free的,unlink前面的chunk,合并两个chunk,并放入unsorted bin

(3)如果后一个chunk是free的,则unlink后面的chunk,合并两个chunk,并放入unsorted bin

(4)如果后一个是top chunk,则将当前chunk并入top chunk

(5)前后chunk都不是free的,则直接放入unsorted bin

相关代码如下:

image-20200723164353967

流程大体是这样的:

(1)将前一个chunk占用的内存合并到当前chunk;

(2)修改指向当前chunk的指针,改为指向前一个chunk。

(3)使用unlink宏,将前一个free chunk从双向循环链表中移除

向前合并和向后合并过程类似,这里不再赘述。

了解了unlink的基本原理之后,可以结合例子理解

存在unlink攻击漏洞的程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdlib.h>
#include <string.h>

int main( int argc, char * argv[] )
{
char * first, * second;

first = malloc( 666 );
second = malloc( 12 );
if(argc!=1)
strcpy( first, argv[1] );

free( first );
free( second );
return( 0 );
}

该程序存在一个堆溢出漏洞:如果用户输入的argv1的大小比first变量的666字节更大的话,那么输入的数据就有可能覆盖掉下一个chunk的chunk header——这可以导致任意代码执行。而攻击的核心思路就是利用glibc malloc的unlink机制。

现在我们再来分析如果一个攻击者精心构造输入数据并通过strcpy覆盖了second chunk的chunk header后会发生什么情况。

假设被覆盖后的chunk header相关数据如下:

(1) 填充prev_size位为一个偶数

(2) size = -4 (64位下为-8)

(3) fd = free 函数的got表地址address – 12;

(4) bk = shellcode的地址

那么当程序调用free(first)后会发生什么呢?

我们一步一步分析,前面已经介绍过了free的流程,由于first前面无free的chunk,所以不会发生向后合并,因此来判断下面是向前合并,代码如下:

image-20200723164646772

本例中,next chunk就是second chunk,从上面代码可知chunk判断下一堆块是否free的方法,即通过nextchunk + nextsize计算得到指向下下一个chunk的指针,然后判断下下个chunk的size的PREV_INUSE标记位是否为0。

在本例中,此时nextsize被我们设置为了-4,这样glibc malloc就会将next chunk的prev_size字段看做是next-next chunk的size字段,而我们已经将next chunk的prev_size字段设置为了一个偶数,因此此时通过inuse_bit_at_offset宏获取到的nextinuse为0,即next chunk为free!

既然next chunk为free,那么就需要进行向前合并,所以就会调用unlink(nextchunk, bck, fwd)函数。

真正的重点就是这个unlink函数的利用,认真分析一下unlink的代码

打眼一看,很像数据结构中学过的删除链表中某一结点的操作,确实如此,unlink实现的功能正是在bins链表中删除掉已经被合并的块。

具体利用流程如下:

(1)首先FD = nextchunk->fd = free地址 – 12;

(2)然后BK = nextchunk->bk = shellcode起始地址;

(3)再将BK赋值给FD->bk,即(free地址 – 12)->bk = shellcode起始地址;

(4)最后将FD赋值给BK->fd,即(shellcode起始地址)->fd = free地址 – 12。

作图理解一下:

image-20200723165236574

结合图片应该很好理解,借助了unlink将free()的got表修改为shellcode地址,当再次free(second)时,就会转而运行我们写好的shellcode了。

这里的chunk头并不是真实的,只是我们在攻击中需要让glic malloc在进行unlink时将它们强制看作chunk结构体而伪造的,也就是我上面说的结构合法。


上面Unlink的方法有些过时,但是可以拿过来学习一下原理,有助于对堆有更好的理解,目前新式的unlink中加入了许多限制,其中最重要的一条是:

FD->bk !=p || BK->fd !=p;

也就是说由于有一个保护检查机制,它会检查这个 chunk 的前一个 chunk 的 bk 指针是不是指向这个 chunk(后一个也一样),直接导致很多利用方式难以满足这个条件,比较有效的是freenote的方式。

下面介绍freenote的主要利用思路。

Free chunk 的双链表结构如下所示:

FD = p->fd = *(&p + 2)

BK = p->bk = *(&p + 3)

但现在执行unlink(P,BK,FD)时,需要满足FD->bk = p && BK->fd = p的条件,即:

FD->bk = *(*(&p + 2) + 3 ) = *(p[2] + 3) == p

BK->fd = *(*(&p + 3) + 2 ) = *(p[3] + 2) == p

=>

p[2] = &p – 3, p[3] = &p – 2

这里比较绕,建议画图辅助理解。

这时如果存在一个全局变量G_P,其中存储的指针指向p的话,那么就可以通过设置p[2] = &p – 3, p[3] = &p – 2 进行伪造,来满足指针检查。

根据图所示,当构造的fake chunk溢出修改了下一个chunk的 prev_size和p位,就可以将fake chunk 伪造成free chunk,这时free掉下一个chunk,两个块便可合并,触发unlink。

FD = P->fd

BK = P->bk

FD->bk = BK

BK->fd = FD

最终执行:

FD->bk = BK; =>p = *(&p+3) = p[3] = &p-2

BK->fd = FD; =>p = *(&p+2) = p[2] = &p-3

使得:

p= &p – 3

最后,p指针指向全局变量G_P前面3个4(8字节)字节处。如果G_P是个管理结构,那么就可以实现任意地址读写了。

即可以通过修改p所指向的内容来修改p的指针了

需要满足如下条件:

  1. 存在堆覆盖,可以改写到即将要释放的堆块,将其prev_size改成所构造的堆块大小,size中p位改为0.

  2. 存在已知地址的指针(通常为全局变量)指向伪造的堆块头部。

  3. 能够释放后续堆块来触发unlink。


5.forgotten chunk

forgotten chunk主要是指chunk的申请释放中被遗忘的部分,虽然堆块的申请和释放逻辑相对来说比较完善,但是检查还是存在漏洞。

简单的情况是从前往后释放,构造出残留堆块。

image-20200723165755808

如图所示,如果存在缓冲区溢出,然后通过正常申请释放构造出重叠堆块。

对于已经使用的A,B,C三个堆块,在大小方面没有要求,其中如果A存在堆溢出,且能够覆盖到堆块B的size域(或存在其他改写方式也能达到相同目的),将堆块B的size域的部分改写成size(B)+size(C)的值(NMP位保持不变),然后对堆块B进行释放,这样是可以通过检查的,并且能将B,C识别成一个堆块进行处理。其中原本C后续堆块的prev_size域会被当成数据部分处理,不起标识作用,使检查能顺利通过。

在此基础上,可以结合最基本的堆利用方法、unlink、fastbin来对堆块进行利用。

(1)如果C块或者其上还有其他未知块部分存在变量指针,则采用最基本堆利用方法,直接构造指针数据即可。

(2)如果存在fastbin中的堆块,且其中想改写的目的地址符合fastbin利用条件,则采用fastbin的方法。

(3)直接申请新堆块,在其中构造unlink利用的条件,通过释放堆块来触发unlink。

具体利用什么方法视情况而定。


0x04 小结

堆的利用灵活多变,且情况比栈的利用复杂的多,我所记录的只是一些最基本且常见的方法,便于对堆溢出漏洞利用有一个比较客观的认识,至于更多技巧,则需要自己不断实践和学习积累来获得。

感觉没用的知识又增加了(●ˇ∀ˇ●)

没看过heap题,对它的exp真心恐惧,现在本菜鸡内心慌得一批….