堆溢出
本文章首先介绍堆的基本单位的结构(chunk),之后介绍了堆的分配和释放函数,通过一个实例分析了堆溢出漏洞的危害(DWORD SHOOT),最后介绍了另外两种利用堆溢出的漏洞(Double free和Unlink)
1.堆的结构
堆的基本单位是chunk,堆从低地址向高地址,需要用户自己分配。

堆、栈区别总结:
1.堆栈空间分配
①栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
②堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
2.堆栈缓存方式
①栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放。
②堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。
3.堆栈数据结构区别
①堆(数据结构):堆可以被看成是一棵树,如:堆排序。
②栈(数据结构):一种先进后出的数据结构。
chunk的结构
1 | /* |
每个字段的具体的解释如下
prev_size
, 如果该 chunk 的物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk 。size
,该 chunk 的大小,包括用户申请的大小和头部的大小。 该字段的低三个比特位对 chunk 的大小没有影响,是三个标志位,它们从高到低分别表示:(重点关注PREV_INUSE
位)NON_MAIN_ARENA
,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。IS_MAPPED
,记录当前 chunk 是否是由 mmap 分配的。PREV_INUSE
,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
fd,bk
, chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下fd
指向下一个(非物理相邻)空闲的 chunkbk
指向上一个(非物理相邻)空闲的 chunk- 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理
fd_nextsize,bk_nextsize
, 也是只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。
一个已经分配的 chunk
的样子如下。我们称前两个字段称为 chunk header
,pre_size与size都是4字节数据。后面的部分称为 user data
。每次 malloc 申请得到的内存指针,其实指向 user data
的起始处。
1 | chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size
域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。
bin链的分类与构成
堆块释放之后会添加到相应的bins中进行管理
https://blog.csdn.net/qq_41453285/article/details/96865321
根据bin链成员的大小不同,分为以下几类:
fast bin是单链表,其他都是双向链表
Fast bin:拥有10个元素的数组,用户存放每个fast chunk的头指针。fastbin是最多包含十个fast chunk的单向链表。主要是为了提高小内存的分配效率,对于size_sz为8B的平台,小于128B的chunk分配,首先会在fastbin中查找是否有所需大小的chunk存在。
Unsorted bin:1个。是smallbin和largebin的cache,所有空闲的chunk在回收时都要先放到unsortbin中,分配是如果unsortbin中没有合适的chunk,就会把unsortbin中所有的chunk加入到所属的bin中,在从bin中分配出合适的chunk。
Small bin:62个
Large bin:63个

2. 堆的分配与释放
- 分配:malloc函数
- 释放:free函数

上图的情形是,当前chunk的上一chunk被free()释放,容易发现,当前chunk的PREV_ISUSE标志位置0,表示前一chunk已经被释放。
被释放的chunk中,原先data的位置的低地址处被填入两个指针,分别是fd和bk,分别表示前一个free chunk和后一个free chunk的地址。这样所有通过free()释放的内存chunk会组成一个双向链表。也因此一个chunk最小长度为16字节:2个size和2个指针。
当一个chunk被释放时,还有一件事情要做,就是检查相邻chunk的是否处于释放状态,如果相邻chunk空闲的话,就会进行chunk合并操作。由于每个chunk中都存放了size信息,所以很容易就找到当前chunk前后chunk的状态。

3. 堆溢出漏洞利用
代码
1 |
|
流程分析
(1)程序首先创建了一个大小为 0x1000 的堆区,并从其中连续申请了 6 个大小为 8 字节的堆块(加上块首实际上是 16 字节),这应
(2)释放奇数次申请的堆块是为了防止堆块合并的发生。
(3)三次释放结束后,freelist[2]所标识的空表中应该链入了 3 个空闲堆块,它们依次是 h1、 h3、h5。
(4)再次申请 8 字节的堆块,应该从 freelist[2]所标识的空表中分配,这意味着第一个堆块 h1 被从空表中“拆下”。(下面解释)
(5)如果我们手动修改 h1 块首中的指针,应该能够观察到 DWORD SHOOT 的发生。
分配
ollydbg调试,在第一个断点处中断,即分配完成。发现pmemoey的值为00390688,该值是堆分配函数的返回值,代表的是块头下用户可用区域的开始地址,因此分配的第一个堆块的地址为0x00390680。查看该地址的值:

下图显示了分配的6个块,例如0x390680-0x390690是一个块(两行是一个块)。注意到出了堆结构外还有16字节(ABAB… 0000…)这是调试堆的特征,增加16字节数据防止程序溢出,分析时可忽略。

释放
接下来程序继续运行,释放三块h1,h3,h5,释放之后堆中内存值如下图,可以看出h1,h3,h5的fd和bk指针均发生了改变,不再是0。

将fd(向前指针),bk(向后指针)的值列出,可以看出h1,h3,h5形成了一条双向链表,链在freelist[2],因为块大小是16比特,freelist[2]管理所有大小为16比特的空闲块。
NAME | Flag | 向前指针 | 向后指针 |
---|---|---|---|
h1 | 1 占用态 | 0x003906c8 (h3) | 0x00390198(Freelist[2]) |
h2 | 0 空闲态 | NULL | NULL |
h3 | 1 占用态 | 0x00390708 (h5) | 0x00390688(h1) |
h4 | 0 空闲态 | NULL | NULL |
h5 | 1 占用态 | 0x00390198(Freelist[2]) | 0x003906c8 (h3) |
h6 | 0 空闲态 | NULL | NULL |
freelist
0x00390680-0x00390730是分配的6个堆块,下面0x00390040开始是未分配的空堆,fd,bk的值相同,指向的是freelist[0],根据上表可知0x00390198是freelist[2]的地址

freelist的值如下如图:除了 freelist[0]和 freelist[2]之外,所有的空表索引都为空(指向自身)

再分配
最后一次 8 字节的内存请求会把 freelist[2]的第一项(原来的 h1)分配出去,这意味着将第一个结点从双向链表中“卸下”。为什么是第一个而不是最后一个呢,因为unsortbin采用先进先出的原则,添加/取走bin链上的一个freechunk时,是通过bin头的bk指针所操作的。

由上图可以看出0x00390198处链接的第一个块是0x00390688,即h1。观察最后一次申请内存时h1前后向指针的变化 非常重要。未申请内存之前 h1处的前向指针指向h3, 后向指针指向指向freelist[2]。当h1被卸下后,h3的前向指针不变为h5-0x390708 ,后向指针变为freelist[2]-0x390198(也就是将原来h1的后向指针的值,写入(h1前向指针地址+0x04)的地址的相对应的值中)
????:教程写的都是去掉左后一个h5,然后相当于将原来h5的前向指针的值,写入h5后向指针地址的相对应的值中
截图来看确实是去掉了h1,h1的fd,bk的值都被置为0
![]()
如果在程序中修改上述的 h1的前向指针为一个合法地址的时候,h1的后向指针就会被写入这个合法的地址当中,那么应该能够观察到 DWORD SHOOT 现象。所以可以直接在调试器中手动将h1(0x00390688) 处的后向指针改为shellcode的地址,前向指针改为任意地址-0x04,可以是系统调用。当最后一个分配函数被调用后,这条指令执行后,shellcode 将会被写入目标地址。
上面的例子只是引发 DWORD SHOOT 的一种情况。堆块的分配、释放、合并操作都能引发 DWORD SHOOT(因为都涉及链表操作)。
攻击失败分析
DWORD SHOOT攻击需要利用一个正常的调用,以实现恶意代码的执行,一般是利用RtlEnterCriticalSection() 和RtLeaveCritcalSection()。每个进程的PEB 中都存放这一对线程同步函数指针,指向RtlEnterCriticalSection() 和RtLeaveCritcalSection(),并且在进程退出的时候会被ExitProcess()调用,如果能够通过DWORD SHOOT 修改这对指针中的任意一个,那么在程序退出的时候ExitProcess()将会被骗去执行我们的shellcode。由于PEB的位置始终不会变化,所以这对指针相对于peb的偏移之中不变,这使得开发通用的exploit成为可能。
本次采用peb中线程同步函数的入口地址 即 PEB(进程环境块) RtlEnterCriticalSection()。ExitProcess()函数在程序退出的时候会调用临界区函数,但是调用方法独特,是通过线程环境块中偏移 0x20处存放的函数指针来间接完成的。也就是偏移 0x7ffdf020处存放的指向RtlEnterCriticalSection()的指针。在0x7ffdf024处存放着RtlCriticalSection()的指针。但是Windows XP SP2 之后改写了实现方法,对这种攻击进行了防护。微软不再使用固定的 PEB 基址 0x7FFDF000,而是使用具有一定随机性的基址,从而影响了 DWORD SHOOT 对 PEB 中函数的攻击,无法准确利用该地址进行攻击,这种防御措施称为PEB Random。因此该种攻击方式仅可以在windows2000版本下进行复现,存在一定的局限性。
此外微软也改写了操作双向链表的代码,在卸载 free list 中的堆块时更加小心。SP2 在进行删除操作时,提前验证堆块的完整性,以防止 DWORD SHOOT,称为Safe Unlink。
4. 其他堆溢出漏洞利用
常见漏洞
堆溢出:chunk1的数据溢出到chunk2,典型的攻击方式就是DWORD SHOOT(上面已经详细介绍)利用漏洞获取一个可以任意写的chunk,修改下一个chunk的关键信息如chunk头中的信息,如果下一个chunk是free状态,还可以修改BK和FD字段进行攻击。
double free:在释放chunk之后,没有对chunk指针清零。对同一地址调用两次free()。潜在导致对未知内存位置的修改。double free的原理其实和堆溢出的原理差不多,都是通过unlink这个双向链表删除的宏来利用的。只是double free需要由自己来伪造整个chunk并且欺骗操作系统。为了让系统进行unlink的操作,达到篡改指针的目的。但是一般的情况下,我们两次释放同一块内存会被操作系统给检测出来,怎么欺骗过操作系统才是最重要的。
分配两个 chunk, chunk1 和 chunk2,然后
free(chunk1);free(chunk2);free(chunk1)
,假设进入fastbin管理,结构如下图:表头指向chunk1,chunk1指向chunk2,chunk2又指回chunk1。那么这时按照如下步骤:
- 申请一个new chunk 1:
将new chunk 1的指针域修改为要修改的地址-0x10以上:
申请一个new chunk 2:
再申请一个new chunk 3(new chunk 3是和new chunk 1重合的)
这时可以发现,fastbin链表已经指向了我们想要修改的地址了,只要再申请一个堆块就会申请到想要修改的地址,然后只要编辑这个堆块便可完成任意地址写。
unlink:低版本libc2.23以下存在。使得攻击者拥有可以在任意可读写地址进行读写的能力。
原理概述
当堆块free时,会检查相邻的后面的堆块,或者前面的堆块,是否空闲,如果空闲,那么需要进行堆块合并操作。空闲的堆块一般以双向链表的形式组织(fast bin是单向链表,此攻击不适用),如果刚刚释放的堆块要与前面或者后面空闲的堆块进行合并操作,那么需要将前或后的堆块从双向链表中摘下来,合并成更大的堆块插入到unsort bin链表中。空闲堆块从(small bin)双向链表中摘下来的操作就是unlink。
首先需要两个相邻的堆块,其中一个堆块空闲,一个堆块占用,释放占用的堆块,引发两个堆块合并。正常的空闲堆块链接在空闲链表中,我们无法控制其中的fd和bk指针,所以方法是伪造一个空闲的堆块。
伪造空闲堆块:libc判断相邻堆块空闲的方法是通过本堆块的size字段,size字段最后3位是标志位。其中最后一位如果为0,说明相邻的堆块是free的,为1说明正在使用。pre_size字段指明前一个堆块的起始位置。这两个字段可以判断相邻的后面堆块是否分配和堆块的位置。
ps. 如果系统还会判断这个相邻的堆块是否在某个未分配的链表中,那么unlink攻击便实现不了。因为如果相邻堆块在某个空闲链表中,那么就无法修改其中的bk和fd指针。所以我们需要利用一个分配的堆块,来伪造出一个空闲堆块。
具体操作:
分配两个堆块,但不要过小,大于fastbin的限制。
示意图:chunkA->chunkB
ChunkA = prev_size | chunksize&flag | content
ChunkB = prev_size | chunkSize&flag | content
如果此时chunkA存在堆溢出漏洞,那么我们便可将精心构造的数据写入chunkB. 或者直接修改后面分配的堆块的头部,即pre_size和size字段,pre_size需要设置成前面分配的堆块的用户区大小,并且设置size字段最后一位标志位为0,表示chunkA伪造堆块是空闲的。
那么在我们free(chunkB)时,堆管理器就会将chunkB和我们的fakeChunk合并。这是unlink会摘除chunkA,因此chunkA的fd,bk需要被伪造。
相应的也可以通过堆溢出,构造chunkB为伪造的空闲堆块。
假设被覆盖后的chunk header相关数据如下:
1
2
3
41) prev_size = 一个偶数,这样其PREV_INUSE 位就是0 了,即表示前一个chunk为free。
2) size = -4
3) fd = target addr – 24;(后文统一简称为“free addr – 24”)
4) bk = shellcode的地址这时free(chunkA),记nextchunk为chunkB。从上面代码可以知道,它是通过将nextchunk + nextsize计算得到指向下下一个chunk的指针,然后判断下下个chunk的size的PREV_INUSE标记位。在本例中,此时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,那么就需要进行和chunkA合并,所以就会调用unlink(nextchunk, bck, fwd);函数,将chunkB取下。
绕过glibc的安全防护
unlink之前需要进行一些简单的检查,设伪空闲堆块的堆块头指针是p,那么需要检查:
1
p->bk->fd==p && p->fd->bk==p
这个检查是可以欺骗的:fd的偏移是3个机器位数,bk的偏移是4个机器位数。即在64位机器上,fd是8*3=24字节,bk是8*4=32字节;进行以下构造:
1
fd = ptr - 3*size(int); bk = ptr - 2*size(int)
1
2
3
4
5
6
7
8因为,FD = p-->fd = ptr - 24; BK = p-->bk = ptr - 16,
所以,FD-->bk = FD + 3*8 = ptr - 24 + 24 = p,
BK-->fd = BK + 16 = ptr - 16 + 16 = p.
FD->bk = BK; ==> p = ptr-16
BK->fd = FD; ==> p = ptr-24
==> p=ptr - 3*size(int)
即P 的指针指向了比自己低 12 的地址处恶意代码的构造
构造恶意的fd与bk实现攻击,根据上面chunkB的构造,关注fd和bk
1
2
3
4FD=P->fd = target addr -24
BK=P->bk = shellcode起始地址
FD->bk = BK,即 *(target addr-24 + 24)=BK=shellcode起始地址
BK->fd = FD,即 *(shellcode起始地址 +16) = FD = target addr-24(图中的12是32位系统下,4*3=12,与上文保持统一的话,12就是24)
这样,通过 unlink 直接实现任意地址读写的目的,但是我们还是需要确保 shellcode起始地址 +16地址具有可写的权限。
比如说我们将 target addr 设置为某个 got 表项,那么当程序调用对应的 libc 函数时,就会直接执行我们设置的值shellcode处的代码。需要注意的是,shellcode+16 处的值被破坏了,需要想办法绕过.
参考资料:
https://www.jianshu.com/p/484926468136
https://www.cnblogs.com/gm-201705/p/9901548.html
https://zhuanlan.zhihu.com/p/26981039
https://blog.csdn.net/weixin_50287973/article/details/108438564
https://blog.csdn.net/zh_explorer/article/details/80307005
https://blog.csdn.net/qq_25201379/article/details/81545128
https://www.dazhuanlan.com/2019/12/24/5e01fc2754796/