首页 > 代码库 > OD: Heap Overflow
OD: Heap Overflow
Windows 堆溢出
MS 没有完全公开 Windows 的堆管理细节,目前对 Windows 堆的了解主要基于技术狂热者、黑客、安全专家、逆向工程师等的个人研究成果。
目前 Windows NT4/2000 SP4 上的堆管理策略基本(与攻击相关的数据结构和算法)研究清楚。
堆溢出的重要研究者:
Halvar Flake:2002 年 Blach Hat 上首次挑战 Windows 的堆溢出,揭秘了堆中一些重要的 Data Structure 和算法。演讲“Third Generation Exploitation”。
David Litchfield:安全界传奇人物。发现了横扫世界的蠕虫所利用的 0day,著名安全咨询公司 NGS(Next Generation Security)创始人。2004 年在 Black Hat 上演讲“Windows Heap Overflows”首次较全面地介绍 Windows 2000 下的堆溢出技术细节,包括 Data Structure、堆分配算法、利用思路、劫持进程的方法、执行 shellcode 时会遇到的问题等。那次演讲的白皮书是研究 Windows 堆溢出人员的必读文献。
Matt Conover:演讲的“XP SP2 Heap Exploitation”全面提示了 Windows 堆中溢出相关的所有数据结构和分配策略,提出突破 Windows XP SP2 平台下重要安全机制的防护进行堆溢出的方法。
堆管理机制需要兼顾内存有效利用、分配决策速度、健壮性、安全性。MS 操作系统堆管理机制发展大致分为三个阶段:
1. Windows 2000 - Windows XP sp1:只考虑了完成分配任务和性能因素,没有考虑安全因素,较容易利用。
2. Windows XP 2 - Windows 2003:加入安全因素,如修改了快首的格式、加入安全 cookie、双向链表结点在删除时会做指针验证等。利用非常困难,需要高级攻击技术才可能利用成功。
3. Vista:效率和安全性上的里程碑。
原书主要讨论 Windows 2000 - Windows XP sp1 平台的堆管理策略。
堆与栈的区别
栈空间在程序设计时已经规定好怎么使用、使用多少内存空间。典型的栈变量包括函数内部的普通变量、数组等。使用时不需要申请,栈空间由系统维护,其分配和回收由系统完成。这些对程序员透明。
堆具备以下特性:
1. 堆在程序运行时动态内存分配。
2. 使用时需要程序员用专用函数进行申请,如 C 中的 malloc、C++ 中的 new 等。堆内存申请可能成功,也可能失败,与内存大小、机器性能和当前运行环境有关。
3. 一般使用一个指针来使用申请得到的内存(包括读、写、释放)。
4. 使用后需要将堆指针传给释放函数回收内存,否则会造成内存泄露。
5. 堆的增长方向为内存低址向高址排列(不考虑碎片),地址变化范围很大;栈由内存高址向低址增加。
与整齐的栈不同,堆往往显得“杂乱无章”,堆溢出利用是内存利用技术的一个转折点。
堆的数据结构与管理策略
OS 一般会提供一套 API 把复杂的堆管理机制屏蔽掉,但堆溢出利用需要了解这些知识。
现代操作系统的堆数据结构一般包括堆快和堆表两类。
堆块:堆区内存按照不同大小组织成块,以堆块为单位标识。一个堆块包括块首和块身。块首是堆块头部的几个字节,用来标识堆块信息,如大小、空闲/占用等;块身是分配给用户使用的数据区。进行连续内存申请时,可以发现内存之间存在“空隙”,“空隙”即是被块首占用的内存。
堆表:位于堆区的起始位置,用于索引堆区所有堆块的信息。堆表的数据结构决定了堆区的组织方式和效率,往往会采用平衡二叉树等高级数据结构。现代操作系统的堆表往往不只用一种数据结构。
Windows 中占用态的堆块被使用它的程序索引,而堆表只索引所有空闲态的堆块。最重要的堆表有空闲双向链表(空表)Freelist 和快速单向链表(快表)Lookaside。
空表
空闲堆块的块首包含一对重要的指针——将堆块组织成双向链表。按照堆块的大小,空表被分为 128 条。
堆区一开始的堆表区中有一个空表索引(Freelist Array),是一个 128 项的指针数组,数组的每一项包括两个指针,用于标识一条空表。
空表索引的第 i+1 项(free[i],i>0)标识了所有大小为 8*i 字节的空闲堆块(free[127] 标识的堆块大小是 1016);空表第一项(free[0])标识了所有大于等于 1024 字节的堆块(大于等于 512 字节),这些堆场按照大小的升序在 free[0] 中依次链接下去。
快表
快表是 Windows 用来加速堆块分配而采用的堆表。快表是单向链表,其中的堆块从来不会发生合并(空闲块块首被设置为占用态,防止堆块合并)。快表也有 128 条,组织结构与空表类似。快表问题被初始化为空,而且每条快表最多只有 4 个结点。块表是单链表,插入删除都比空表所用的指令少。
堆块操作可以分为堆块分配、堆块释放和堆块合并三种。分配和释放在程序提交内存申请和释放操作时执行,合并则由系统自动完成。
堆块分配
这里不讨论堆缓存、低碎片堆和虚分配,只考虑以下三种情况:
1. 快表分配:寻找大小匹配的空闲堆块,将其状态改为占用态并从堆表中卸下,最后返回指向堆块的指针。
2. 普通空表(小于 512 字节):先寻找最优的空闲块分配,若失败,则寻找次优(最小的并能满足要求的)空闲堆块分配。
3. 零号分配(free[0]):先从 free[0] 反射查出最大块的大小,若能满足要求,则正向寻找最小能满足要求的空闲块分配。
当发生次优分配时,一个大于所需空间的空闲堆块会被使用,这时会从这个空闲堆块中精确分配所需大小的空间,剩余空间重新标注块首并链入空表。快表只有在精确匹配时才分配,故不存在这个问题。
堆块释放
将堆块状态改为空闲,链入相应堆表。所有释放快都链入堆表末尾,分配时也先从末尾拿。
堆块合并
当堆块管理系统发现两个空闲堆块彼此相邻时,就会进行堆块合并操作。堆块合并会将两个块从空闲链表中卸下、合并堆块、调整合并块的块首信息并重新链入空闲链表。实际上,堆区还有一种内存紧缩操作(shrink the compact),由 RtlCompactHeap 执行,操作效果与磁盘碎片整理差不多,会对整个堆进行调整,尽量合并可用的碎片。
Windows 将堆内存大小分为三类,小块(size<1kb)、大块(1kb<=size<512kb)、巨块(size>=512kb)。对应的分配和释放算法也有三类:
堆中漫游
Windows 下众多的堆分配函数最终都将使用 ntdll.dll 中的 RtlAllocateHeap() 实现,这也是用户态能看到的最底层的堆分配函数。研究 Windows 堆只要研究这个函数。
漂亮的堆溢出 exploit 需要对堆中的重要数据结构掌握到字节级别。下面是一段调试堆的程序:
1 /***************************************************************************** 2 To be the apostrophe which changed "Impossible" into "I‘m possible"! 3 4 POC code of chapter 6.2 in book "Vulnerability Exploit and Analysis Technique" 5 6 file name : heap_debug.c 7 author : failwest 8 date : 2007.04.04 9 description : demo show of how heap works 10 Noticed : 1 only run on windows 2000 11 2 complied with VC 6.0 12 3 build into release version 13 4 only used for run time debugging 14 version : 1.0 15 E-mail : failwest@gmail.com 16 17 Only for educational purposes enjoy the fun from exploiting :) 18 ******************************************************************************/ 19 20 #include <windows.h> 21 main() 22 { 23 HLOCAL h1,h2,h3,h4,h5,h6; 24 HANDLE hp; 25 hp = HeapCreate(0,0x1000,0x10000); 26 __asm int 3 27 28 h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,3); 29 h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,5); 30 h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,6); 31 h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 32 h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,19); 33 h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24); 34 35 //free block and prevent coaleses 36 HeapFree(hp,0,h1); //free to freelist[2] 37 HeapFree(hp,0,h3); //free to freelist[2] 38 HeapFree(hp,0,h5); //free to freelist[4] 39 40 HeapFree(hp,0,h4); //coalese h3,h4,h5,link the large block to freelist[8] 41 42 43 return 0; 44 }
其中 API 函数 HeapCreate() 定义如下:
HANDLE WINAPI HeapCreate( __in DWORD flOptions, // 指定如何在堆上执行各种操作:是否独占、分配失败是否抛异常、内容是否可执行 __in SIZE_T dwInitialSize, // 初始大小 __in SIZE_T dwMaximumSize ); // 最大容量
对堆的调试比栈调试要求要高:堆分配算法依赖于操作系统、编译器版本、编译选项、build 类型、甚至 VM 版本等等。
调试堆时不能直接用 OllyDbg、WinDbg 等加载程序,否则堆管理函数会检测到当前进程处于调试状态,从而使用调试态的管理策略。
调试态的堆管理策略和常态管理策略差异很大:
1. 调试态不使用快表,只用空表分配。
2. 调试态堆块都被加上多余的 16 字节尾部(8 字节的 0xAB 和 8 字节的 0x00),用来防止程序发生溢出。
3. 块首的标志位不同。
如果堆溢出时发现调试器中能正常执行的 shellcode 在单独运行时发生错误,很可能是因为调试堆和常态堆之间的差异造成的。
为避免程序检测出调试器而使用调试态堆管理策略,如上代码中人工加入了 int 3 调试中断(机器码为 0xCC,又称 CC 中断,这个可以继续挖掘信息来学习),设置 OllyDbg 为系统默认调试器后(Options -> Just-In-Time Debugging -> Make OllyDbg Just-In-Time Debugger),手动执行 release 程序,程序会产生 int 3 中断,系统自动调用 OllyDbg 载入进程进行调试。
如上代码的高度环境为:Win2000 虚拟机、VC++6.0、默认编译选项、release 编译版本。
所有的堆块分配函数都要指明堆区的句柄,然后在堆区内进行堆表的修改等操作并完成分配工作。malloc() 虽然在使用时不用程序员明确指出使用哪个堆区进行分配,这是因为它已经使用 HeapCreate() 函数为自己创建了堆区(可以逆向去看看 malloc 的实现)。
通常一个进程有多个堆块:本例中位于 0x00130000 的堆块是进程堆,可以用 GetProcessHeap() 获取;malloc() 的堆区一般紧接着程序镜像。单击 OllyDbg 的 M 按键可以看到相应的映射关系。
HeapCreate() 创建堆后,会将创建的堆区的地址给 EAX,上述代码示例中创建的堆区在 0x00520000。
从 0x00520000 开始,堆表中包含的信息依次是段表索引(Segment List)、虚表索引(Virtual Allocation List)、空表使用标识(16 字节,freelist usage bitmap)和空表索引区。
从 Matt Conover & Oded Horovitz 在 CanSecWest2004 上发表的《Reliable Windows Heap Exploits》中得知,Freelist Usage Bitmap 是用来快速索引 Freelist 的,一共包括 128 个位而不是原书中说的 32 字节。
偏移 0x178(实际地址 0x00520178)处的空表索引与堆溢出的关系密切,这个也是关心的重点区域。
当一个堆刚被初始化时,情况比较简单:
1. 只有一个空闲态的大块:尾块。 2. 尾块位于偏移 0x668 处(如果启用了快表,这个位置将会是快表)。 3. Freelist[0] 指向尾块。 4. 除 Freelist[i] (i>0) 都指向自己,即为空。
空闲态堆块的数据结构和占用态的基本一致,只是块首后的 8 个字节被用来存放空表指针了。其中前 4 个字节代表 Flink 指向下一个堆块的地址,后 4 个字节代表 Blink 指向前一个堆块的地址。
通过调试可以发现:
1. 本例中初始的尾块不是开始于 0x00520668,而是开始于 0x00520660,因为一般引用堆块的指针都会跃过堆块的前 8 字节块首,直接指向堆块的数据区。 2. 堆块大小的计量单位是 8 字节,本例中尾块的大小显示为 0x130,实际堆块大小为 0x130 * 0x8 = 0x980 字节 3. 堆块大小包含块首的 8 字节。
调试可以看到,堆块分配时,实际分配的堆块大小会在申请的数量上加上块首所需的 8 个字节,并在这个结果上向前取整,使分配的大小为 8 的倍数。初始时只有尾块一个空闲块,这时对申请的堆将使用次优分配。
按照堆表数据结构的规定,这时 快表位于偏移 0x584 的位置(0x00520584),但在以上调试中,快表始终为空。这是因为只有在堆可扩展时,快表才会启用,要想创建可扩展的堆区,要使用 HeapCreate(0,0,0),调试快表的示例代码如下:
1 /***************************************************************************** 2 To be the apostrophe which changed "Impossible" into "I‘m possible"! 3 4 POC code of chapter 6.2 in book "Vulnerability Exploit and Analysis Technique" 5 6 file name : heap_debug.c 7 author : failwest 8 date : 2007.04.04 9 description : demo show of how heap works 10 Noticed : 1 only run on windows 2000 11 2 complied with VC 6.0 12 3 build into release version 13 4 only used for run time debugging 14 version : 1.0 15 E-mail : failwest@gmail.com 16 17 Only for educational purposes enjoy the fun from exploiting :) 18 ******************************************************************************/ 19 20 #include <windows.h> 21 main() 22 { 23 HLOCAL h1,h2,h3,h4; 24 HANDLE hp; 25 hp = HeapCreate(0,0,0); 26 __asm int 3 27 28 h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 29 h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 30 h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16); 31 h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24); 32 33 HeapFree(hp,0,h1); 34 HeapFree(hp,0,h2); 35 HeapFree(hp,0,h3); 36 HeapFree(hp,0,h4); 37 38 h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16); 39 HeapFree(hp,0,h2); 40 41 return 0; 42 }
这时偏移 0x688 处被快表占用,从偏移 0x178 处的空表也可以看到尾块不在偏移 0x688 处了。
调试可以看到堆块申请、释放的效果与之前表述一致。
对以上两段代码的调试,存在如下疑问:
1. Freelist[1] 指向大小为 1*8=8 字节的堆块,但根据堆块大小的定义,8 字节大小的堆块数据区大小为 0,实际使用中会产生数据区大小为 0 的堆块吗?
2. Freelist 中每个元素比较好理解,为 16 个字节,分别为 Flink 和 Blink(链表中前后节点的指针);但Lookaside(快表)每个元素大小为 48 个字节,除了元素开关的 8 个字节指向链表的下一节点,元素的其他 40 字节的含义还不清楚。