深入分析win32堆结构与管理策略
1. 深入分析win32堆结构与管理策略
1.1. 前言
1.1.1. 堆与栈的区别
1、栈(stack)由操作系统自动分配释放,用于存放函数的参数值、局部变量等,在程序编译后就已经规定好如何使用,使用多少内存空间。栈总是成“线性”变化。栈向低地址空间增长。
2、堆(heap)由开发人员分配和释放,若开发人员不释放,程序结束时由OS回收,分配方式类似于链表。堆向高地址增长。
下图是经典的32位系统内存布局,暂时我们只需要记住栈和堆的增长方向即可,后面实验部分会用到。
1.2. 理论篇
现代操作系统的堆数据结构一般包括堆块
和堆表
两类。
堆表
:堆表一般位于堆区的起始位置
,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。堆表在设计时可能会考虑采用平衡二叉树等高级数据结构,用于优化查找效率。现代操作系统的堆表往往不止一种数据结构。
堆块
:出于性能的考虑,堆区的内存按不同大小组织成块,以堆块为单位进行标识,而不是传统的按字节标识。一个堆块包括两个部分:块首
和块身
。块首位于一个堆块头部的几个字节,用来标识这个堆块自身的信息,例如,本块的大小、本块空闲还是占用等信息;块身是紧跟在块首后面的部分,也是最终分配给用户使用的数据区。
在windows中占用态的堆块被应用程序自身所索引,堆表只索引空闲态的堆块。其中,堆表有两个重要的数据结构:空闲双向链表(freelist)
和快速单身链表(lookaside)
。
堆的内存组织如下图:
1.2.1. 堆块
根据堆块是否被占用分为占用态
堆块和空闲态
堆块。
占用态
堆块的数据结构如下:
空闲态
堆块的数据结构如下:
对比上面两图可知,空闲态堆块和占用态堆块的块首结构基本一致。相对于占用态的堆块来说,空闲态堆块的块首后8个字节存放了两个指针地址,分别指向前驱堆块和后向堆块。
1.2.2. 堆表
1.2.2.1. 空闲双向链表(空表)
堆区一开始的堆表区中有一个128
项的指针数组,被称做空表索引(Freelist array)
。该数组的每一项包括两个指针,用双向链表组织一条空表,如下图。
空表索引
的第二项(free[1]
)链接了堆中所有大小为8字节
的空闲堆块,之后每个索引项链接的空闲堆块大小递增8字节,例如,free[2]
链接大小为16字节的空闲堆块,free[3]
链接大小为24字节的空闲堆块,free[127]
标识大小为1016字节的空闲堆块。因此有:
空表索引
的第一项(free[0]
)所标识的空表相对比较特殊。这条双向链表链接了所有大于等于1024字节的堆块(小于512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去.把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索。
1.2.2.2. 快速单向表(快表)
快表是Windows用来加速堆块分配而采用的一种堆表。这里之所以把它叫做“快表”是因为这类单向链表中从来不会发生堆块合并,快表也有128条,组织结构与空表类似,只是其中的堆块按照单链表
组织。快表总是被初始化为空,而且每条快表最多只有4个结点,故很快就会被填满。
快表结构:
1.2.3. 堆块分配
堆中的操作可以分为堆块分配
、堆块释放
和堆块合并
(三种。其中,"分配"和"释放"是在程序提交申请执行的,而堆块合并则是由堆管理系统自动完成的。
注意:堆管理系统所返回的指针一般指向
块身
的起始位置,在程序中是感觉不到块首
的存在的。然而,连续地进行内存申请时,可能会发现返回的内存之间存在“空隙”,那就是块首!
堆块分配可以分为三类:快表分配
、普通空表分配
和零号空表(free[0])
分配。
快表分配
:找到大小匹配的空闲堆块、将其状态修改为占用态、把它从堆表中“卸下”、最后返回一个指向堆块块身的指针给程序使用;普通空表分配
: 首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配,即最小的能够满足要求的空闲块;零号空表(free[0])
:先从 free[0]反向查找最后一个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配
1.2.4. 堆块释放
释放堆块的操作包括将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾
,分配的时候也先从堆表末尾拿。
1.2.5. 堆块合并
当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作.
堆块合并
将两个块从空闲链表中“卸下”、合并堆块、调整合并后大块的块首信息(如大小等)、将新块重新链入空闲链表。
1.3. 实验篇
1.3.1. 实验环境
操作系统 | 编译环境 | build 版本 |
---|---|---|
win xp sp3 | vc6.0 | release |
1.3.2. 实验代码
#include <windows.h>
#include<stdio.h>
int main()
{
HLOCAL h1, h2, h3, h4, h5, h6;
HANDLE hp;
//LoadLibrary("ntdll.dll");
HANDLE hHeap = GetProcessHeap();
hp = HeapCreate(0, 0x1000, 0x10000);
//printf("%d", hp);
__asm int 3
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5);
h3 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 6);
h4 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h5 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 19);
h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
//free block and prevent coaleses
HeapFree(hp, 0, h1); //free to freelist[2]
HeapFree(hp, 0, h3); //free to freelist[2]
HeapFree(hp, 0, h5); //free to freelist[4]
HeapFree(hp, 0, h4); //coalese h3,h4,h5,link the large block to
//freelist[8]
printf("%s","xxx");
return 0;
}
注意,程序里包含一句int 3的中断指令,这里用来中断程序的执行的,因为我们不能直接把程序加载到调试器里面进行调试,否则堆管理函数会检测到有调试器存在会启用调试态堆管理策略,与常态的堆管理策略会存在些许差异:
- 调试堆不使用快表,只用空表分配
- 所有堆块都被加上了多余的16字节尾用来防止溢出(防止程序溢出而非堆溢出攻击)
- 包括8个字节的0xAB和8个字节的0x00
- 块首的标识位不同
1.3.3. 调试
编译成功后,我们直接双击运行,如下
此时,我们附加我们的及时调试器。根据源码可知,中断是发生在HeapCreate
函数执行完成后的,HeapCreate
执行后会返回堆地址,结果保存在eax
中,我们在调试器发现eax值是:0x003A0000
也就是说HeapCreate
创建的堆区起始位置在003A0000
.即堆表从此位置开始,堆表中依次为段表索引(Segment List)
、虚表索引(Virtual Allocation list)
、空表使用标识(freelist usage bitmap)
和空表索引区
。
此处我们只关心堆偏移0x178
处的空表索引区,这个偏移是堆表起始的位置(根据上次我们介绍的堆表结构,堆表包含128的8个字节的flink
和blink
地址。所以堆表的结束位置在:128*8=1024=0x400,加上偏移,0x178+0x400=0x578)
加上堆基址0x003A0000
+0x178
=0x0x003A0178
,我们来到这个地址。
如图,这个地址便是free[0],占8个字节,flink
和blink
指向的地址都是0x003a0688
。后面的依次是free[1]、free[2],依次类推,我们发现free[1]、free[2]...free[127]都指向自身,根据链表的特点可知,它们都是空链表。
所以当一个堆刚刚被初始化时,只包含一个空闲态的大块,这个块也叫为"尾块" free[0]指向这个"尾块"
我们转到"尾块"的位置去看看(因为这里只有一个堆块,即free[0]指向的地址,free[0]=0x003a0688
)
上面理论篇,我们讲过,空闲态的堆块有8个字节的flink与blink,分别指向前驱节点与后继节点,此处的值均为0x003a0178
,这个地址是堆表free[0]的地址,可知,实验与理论相符。
实际上。在上面我们有提到,堆管理系统返回的堆地址是指向块身
的。在其前面还有8个字节的块首
,所以这个堆块起始于0x003a0680
, 根据上面谈到的块首的结构。前2个字节为块大小,此处值是130
, 堆的计算单位是8字节,也就是980字节。
注意:堆大小包含块首在内。
1.3.3.1. 堆块分配
在继续之前, 我们需要先了解堆块的分配细节,
-
堆块的大小包括了块首在内,即如果请求32字节,实际会分配的堆块为40字节:8字节块首+32字节块身;
-
堆块的单位是8字节,不足8字节的部分按8字节分配;
-
初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配(这个“次优块”就是位于偏移 0x0688 处的尾块。)。
-
由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的size 信息,最后把 freelist[0]指向新的尾块位置。
内存请求分配情况
堆句柄 | 请求字节数 | 实际分配(堆单位) | 实际分配(字节) |
---|---|---|---|
h1 | 3 | 2 | 16 |
h2 | 5 | 2 | 16 |
h3 | 6 | 2 | 16 |
h4 | 8 | 2 | 16 |
h5 | 19 | 4 | 32 |
h6 | 24 | 4 | 32 |
在调试器中,我们单步走过第一个HeapAlloc
,然后观察内存空间。
tips: 对于我们主动设置的int 3指令,如果调试器忽略异常后仍无法步过的话,可以在下一行代码处右键,此处设为新的eip。
按上面的分析,执行完h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
后,会从0x3a0680
地址开始切出一块大小为2个单位(16字节)的空间分配给h1, 新的尾块起始地址则为0x003a0690
,flink与blink地址位于0x003a0698
和0x003a069c
,其值0x003a0178
指向freelist[0]
, freelist[0]
则指向新的起始地址0x003a0698
,(003a0690+8字节的块首,我们上面有提到过指向块身。)
尾块起始处,如下图,如我们所预期的一样
另外,尾块的大小为12e, 等于原来的130减去分配出去的2个单位,还剩下0x13-2=0x12e个单位(堆的单位,不是字节),如上图,也可以验证。
h1所指向的堆块起始位置则是0x003a0680,如上图可知,大小为2个单位
堆表freelist[0]处,如下图,如我们所预期的一样
接着,会执行h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5);
按分配原则,会从尾块中再切一块大小为2个单位(16字节)的空间给h2,然后freelist[0]指向新的尾块起始地址,新的尾块仍指向freelist[0],剩下的尾块大小为12e-2=12c个单位。
剩下的依次类推,
当执行完h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
后,堆分配情况如下图所示,
剩下的堆大小为 130-2-2-2-2-4-4=120
单位,尾块扔指向freelist[0](0x003a0178
),我们去看下freelist[0]的值,此时应该指向,尾块的块首0x003a0708
,如下图
到此,堆的分配则执行完了。根据上面的理论可知,堆表中仍只有一个尾块,不存在其它的堆块。
1.3.3.2. 堆块释放
根据堆块的大小,h1,h3为16字节,则会链入freelist[2], h5为32字节,会链入到freelist[4]。
当执行HeapFree(hp, 0, h1)
后,根据h1的大小为16字节,可知它会被链入到freelist[2]中,我们先看下freelist[2]的地址,如下图,freelist[2]的地址是0x003a0188
,根据链表规则freelist[2]会指向h1的地址,h1则会指向freelist[2],
执行后,原来h1所指向的堆块变为空闲态并指向freelist[2]。如下图,flink与blink都指向freelist[2],因为此时只有链表中就一个节点,
freelist[2]则指向原来的h1地址,如下图:
接着会释放h3,执行HeapFree(hp, 0, h3)
,执行完后,h3所指向的堆块会被链入到freelist[2],并插入到整个链表的末尾。如下图所示,原来h3所在的堆块的blink(地址0x003a06ac)指向前一个堆块,即原来的h1,h3的flink则指向freelist[2],因为它是最后一个元素。原来的h1的blink指向freelist[2],flink指向h3.
freelist[2]如下图所示
形成的链表大概如下,
freelist[2] <---> h1 <---> h3
注h3的flink与freelist[2]的blink未给出。
再下一步,执行HeapFree(hp, 0, h5);
,释放h5所在的堆块,并链入freelist[4]
1.3.3.3. 堆块合并
因为h1,h3,h5内存地址不相邻,所以并不会发生堆块合并,当释放h4后,堆管理系统会发生现h3,h4,h5相邻,则会进行堆块合并。
首先这 3 个空闲块都将从空表中摘下,然后重新计算合并后新堆块的大小,最后按照合并后的大小把新块链入空表。
h3、h4 的大小都是2个堆单位(8字节),h5是4个堆单位,合并后的新块为8个堆单位,将被链入 freelist[8]。
合并之前freelist[8]如下图,
合并之前h3,h4,h5如下图,
合并之后freelist[8]如下图,flink与blink都指向合并之后的地址
合并之后的堆块如下图,大小8个堆块,flink与blink都指向freelist[8]
另外,我们看合并之后的freelist[4]已经指向自己,为空链表(图中框选中的位置),freelis[2]中,只剩下原来的h1所在的一个堆块了。
1.4. 总结
- 堆的数据结构:
堆块
、堆表
- 堆块:包含
块首
、块身
堆表
:空闲双向链表(freelist)
、快速单向链表(lookaside)
- 占用态的堆块:
8字节的块首+块身
- 空闲态的堆块:
16字节的块首(多了flink与blink)+块身
。空闲态的堆块变为占用态时,flink与blink所在的空间将变为data区。
在win中,占用态的堆块被使用它的程序所索引,而堆表只索引所有空闲的堆块。
- 参考:《0day,软件安全漏洞分析技术》
</stdio.h></windows.h>