深入分析win32堆结构与管理策略

1. 深入分析win32堆结构与管理策略

1.1. 前言

1.1.1. 堆与栈的区别

1、栈(stack)由操作系统自动分配释放,用于存放函数的参数值、局部变量等,在程序编译后就已经规定好如何使用,使用多少内存空间。栈总是成“线性”变化。栈向低地址空间增长。

2、堆(heap)由开发人员分配和释放,若开发人员不释放,程序结束时由OS回收,分配方式类似于链表。堆向高地址增长。

下图是经典的32位系统内存布局,暂时我们只需要记住栈和堆的增长方向即可,后面实验部分会用到。

20210526171551


1.2. 理论篇

现代操作系统的堆数据结构一般包括堆块堆表两类。

堆表:堆表一般位于堆区的起始位置,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。堆表在设计时可能会考虑采用平衡二叉树等高级数据结构,用于优化查找效率。现代操作系统的堆表往往不止一种数据结构。

堆块:出于性能的考虑,堆区的内存按不同大小组织成块,以堆块为单位进行标识,而不是传统的按字节标识。一个堆块包括两个部分:块首块身。块首位于一个堆块头部的几个字节,用来标识这个堆块自身的信息,例如,本块的大小、本块空闲还是占用等信息;块身是紧跟在块首后面的部分,也是最终分配给用户使用的数据区。

在windows中占用态的堆块被应用程序自身所索引,堆表只索引空闲态的堆块。其中,堆表有两个重要的数据结构:空闲双向链表(freelist)快速单身链表(lookaside)

堆的内存组织如下图:
20210526172042

1.2.1. 堆块

根据堆块是否被占用分为占用态堆块和空闲态堆块。

占用态堆块的数据结构如下:
20210526173327

空闲态堆块的数据结构如下:

20210526173558

对比上面两图可知,空闲态堆块和占用态堆块的块首结构基本一致。相对于占用态的堆块来说,空闲态堆块的块首后8个字节存放了两个指针地址,分别指向前驱堆块和后向堆块。

1.2.2. 堆表

1.2.2.1. 空闲双向链表(空表)

堆区一开始的堆表区中有一个128项的指针数组,被称做空表索引(Freelist array)。该数组的每一项包括两个指针,用双向链表组织一条空表,如下图。

20210526175005

空表索引的第二项(free[1])链接了堆中所有大小为8字节的空闲堆块,之后每个索引项链接的空闲堆块大小递增8字节,例如,free[2]链接大小为16字节的空闲堆块,free[3]链接大小为24字节的空闲堆块,free[127]标识大小为1016字节的空闲堆块。因此有:

空闲堆块的大小=索引项(ID)×8(字节)

空表索引的第一项(free[0])所标识的空表相对比较特殊。这条双向链表链接了所有大于等于1024字节的堆块(小于512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去.把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索。

1.2.2.2. 快速单向表(快表)

快表是Windows用来加速堆块分配而采用的一种堆表。这里之所以把它叫做“快表”是因为这类单向链表中从来不会发生堆块合并,快表也有128条,组织结构与空表类似,只是其中的堆块按照单链表组织。快表总是被初始化为空,而且每条快表最多只有4个结点,故很快就会被填满。

快表结构:

20210530193208

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. 调试

编译成功后,我们直接双击运行,如下

20210526202207

此时,我们附加我们的及时调试器。根据源码可知,中断是发生在HeapCreate函数执行完成后的,HeapCreate执行后会返回堆地址,结果保存在eax中,我们在调试器发现eax值是:0x003A0000

20210526202421

也就是说HeapCreate创建的堆区起始位置在003A0000.即堆表从此位置开始,堆表中依次为段表索引(Segment List)虚表索引(Virtual Allocation list)空表使用标识(freelist usage bitmap)空表索引区

此处我们只关心堆偏移0x178 处的空表索引区,这个偏移是堆表起始的位置(根据上次我们介绍的堆表结构,堆表包含128的8个字节的flinkblink地址。所以堆表的结束位置在:128*8=1024=0x400,加上偏移,0x178+0x400=0x578)

加上堆基址0x003A0000+0x178=0x0x003A0178,我们来到这个地址。

20210526204446

如图,这个地址便是free[0],占8个字节,flinkblink指向的地址都是0x003a0688。后面的依次是free[1]、free[2],依次类推,我们发现free[1]、free[2]...free[127]都指向自身,根据链表的特点可知,它们都是空链表。

所以当一个堆刚刚被初始化时,只包含一个空闲态的大块,这个块也叫为"尾块" free[0]指向这个"尾块"

我们转到"尾块"的位置去看看(因为这里只有一个堆块,即free[0]指向的地址,free[0]=0x003a0688

20210526205608

上面理论篇,我们讲过,空闲态的堆块有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地址位于0x003a06980x003a069c,其值0x003a0178指向freelist[0], freelist[0]则指向新的起始地址0x003a0698,(003a0690+8字节的块首,我们上面有提到过指向块身。)

尾块起始处,如下图,如我们所预期的一样

20210527144557

另外,尾块的大小为12e, 等于原来的130减去分配出去的2个单位,还剩下0x13-2=0x12e个单位(堆的单位,不是字节),如上图,也可以验证。
h1所指向的堆块起始位置则是0x003a0680,如上图可知,大小为2个单位

堆表freelist[0]处,如下图,如我们所预期的一样

20210527150546

接着,会执行h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5); 按分配原则,会从尾块中再切一块大小为2个单位(16字节)的空间给h2,然后freelist[0]指向新的尾块起始地址,新的尾块仍指向freelist[0],剩下的尾块大小为12e-2=12c个单位。
剩下的依次类推,

当执行完h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);后,堆分配情况如下图所示,

20210527153057

剩下的堆大小为 130-2-2-2-2-4-4=120单位,尾块扔指向freelist[0](0x003a0178),我们去看下freelist[0]的值,此时应该指向,尾块的块首0x003a0708,如下图

20210527153549

到此,堆的分配则执行完了。根据上面的理论可知,堆表中仍只有一个尾块,不存在其它的堆块。

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],

20210527154543

执行后,原来h1所指向的堆块变为空闲态并指向freelist[2]。如下图,flink与blink都指向freelist[2],因为此时只有链表中就一个节点,

20210527154826

freelist[2]则指向原来的h1地址,如下图:

20210527155022

接着会释放h3,执行HeapFree(hp, 0, h3),执行完后,h3所指向的堆块会被链入到freelist[2],并插入到整个链表的末尾。如下图所示,原来h3所在的堆块的blink(地址0x003a06ac)指向前一个堆块,即原来的h1,h3的flink则指向freelist[2],因为它是最后一个元素。原来的h1的blink指向freelist[2],flink指向h3.

20210527160356

freelist[2]如下图所示

20210527163803

形成的链表大概如下,

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]如下图,

20210527164622

合并之前h3,h4,h5如下图,

20210527164645

合并之后freelist[8]如下图,flink与blink都指向合并之后的地址

20210527164719

合并之后的堆块如下图,大小8个堆块,flink与blink都指向freelist[8]

20210527164813

另外,我们看合并之后的freelist[4]已经指向自己,为空链表(图中框选中的位置),freelis[2]中,只剩下原来的h1所在的一个堆块了。

20210527165122

1.4. 总结

  • 堆的数据结构: 堆块堆表
  • 堆块:包含块首块身
  • 堆表空闲双向链表(freelist)快速单向链表(lookaside)
  • 占用态的堆块:8字节的块首+块身
  • 空闲态的堆块:16字节的块首(多了flink与blink)+块身。空闲态的堆块变为占用态时,flink与blink所在的空间将变为data区。

在win中,占用态的堆块被使用它的程序所索引,而堆表只索引所有空闲的堆块。


  • 参考:《0day,软件安全漏洞分析技术》
    </stdio.h></windows.h>
posted @ 2021-11-09 21:18  Hslim  阅读(572)  评论(0编辑  收藏  举报