-
-
Windows堆初探
-
2023-11-21 10:38 6495
-
Windows堆初探
Windows堆概述
很多应用程序都会有内存需求,而这些应用程序的内存需求通常是频繁且零散的,如果应用程序每次有内存需求都通过内存管理器来申请,势必会有很大资源开销,影响系统性能。因此,用户层的堆管理方式就出现了。堆可以向内存管理器一次性申请一块比较大的内存,然后根据应用程序的需求分割成对应小块。申请和释放的操作可以由堆管理器来处理,当应用程序的生命周期结束时堆也随之销毁,堆申请的内存全部还给内存管理器。
Windows堆的发展阶段:
堆管理机制的发展大致可以分为三个阶段
- Windows 2000~Windows XP SP1:堆管理系统只考虑了完成分配任务和性能因素,没有考虑安全因素,可以比较容易发被攻击者利用。
- Windows XP 2~Windows 2003:加入了安全因素,比如修改了块首的格式并加入安全 cookie,双向链表结点在删除时会做指针验证等。这些安全防护措施使堆溢出攻击变得非常困 难,但利用一些高级的攻击技术在一定情况下还是有可能利用成功。
- Windows Vista~Windows 7:不论在堆分配效率上还是安全与稳定性上,都是堆管理算法的一个里程碑
堆的数据结构(_HEAP)
Windows通过HEAP结构来记录和维护堆的管理信息,该结构位于堆的开始处。当使用HeapCreate函数创建堆时,该函数会返回一个堆句柄。这个句柄不同于窗口对象、内核对象的句柄需要结合句柄表使用,而是独立存在可以直接使用去访问堆,也就是说这个句柄实质上也是一个指向堆的指针。该地址所指向的数据结构就是HEAP,HEAP结构的定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 | kd> dt _HEAP ntdll!_HEAP +0x000 Entry : _HEAP_ENTRY +0x008 Signature : Uint4B +0x00c Flags : Uint4B +0x010 ForceFlags : Uint4B +0x014 VirtualMemoryThreshold : Uint4B +0x018 SegmentReserve : Uint4B +0x01c SegmentCommit : Uint4B +0x020 DeCommitFreeBlockThreshold : Uint4B +0x024 DeCommitTotalFreeThreshold : Uint4B +0x028 TotalFreeSize : Uint4B +0x02c MaximumAllocationSize : Uint4B +0x030 ProcessHeapsListIndex : Uint2B +0x032 HeaderValidateLength : Uint2B +0x034 HeaderValidateCopy : Ptr32 Void +0x038 NextAvailableTagIndex : Uint2B +0x03a MaximumTagIndex : Uint2B +0x03c TagEntries : Ptr32 _HEAP_TAG_ENTRY +0x040 UCRSegments : Ptr32 _HEAP_UCR_SEGMENT +0x044 UnusedUnCommittedRanges : Ptr32 _HEAP_UNCOMMMTTED_RANGE +0x048 AlignRound : Uint4B +0x04c AlignMask : Uint4B +0x050 VirtualAllocdBlocks : _LIST_ENTRY +0x058 Segments : [64] Ptr32 _HEAP_SEGMENT +0x158 u : __unnamed +0x168 u2 : __unnamed +0x16a AllocatorBackTraceIndex : Uint2B +0x16c NonDedicatedListLength : Uint4B +0x170 LargeBlocksIndex : Ptr32 Void +0x174 PseudoTagEntries : Ptr32 _HEAP_PSEUDO_TAG_ENTRY +0x178 FreeLists : [128] _LIST_ENTRY +0x578 LockVariable : Ptr32 _HEAP_LOCK +0x57c CommitRoutine : Ptr32 long +0x580 FrontEndHeap : Ptr32 Void +0x584 FrontHeapLockCount : Uint2B +0x586 FrontEndHeapType : UChar +0x587 LastSegmentIndex : UChar //上面是win2000-xp的堆结构 //下面是win7之后的堆结构 kd> dt _HEAP ntdll!_HEAP +0x000 Entry : _HEAP_ENTRY +0x008 SegmentSignature : Uint4B +0x00c SegmentFlags : Uint4B +0x010 SegmentListEntry : _LIST_ENTRY +0x018 Heap : Ptr32 _HEAP +0x01c BaseAddress : Ptr32 Void +0x020 NumberOfPages : Uint4B +0x024 FirstEntry : Ptr32 _HEAP_ENTRY +0x028 LastValidEntry : Ptr32 _HEAP_ENTRY +0x02c NumberOfUnCommittedPages : Uint4B +0x030 NumberOfUnCommittedRanges : Uint4B +0x034 SegmentAllocatorBackTraceIndex : Uint2B +0x036 Reserved : Uint2B +0x038 UCRSegmentList : _LIST_ENTRY +0x040 Flags : Uint4B +0x044 ForceFlags : Uint4B +0x048 CompatibilityFlags : Uint4B +0x04c EncodeFlagMask : Uint4B +0x050 Encoding : _HEAP_ENTRY +0x058 PointerKey : Uint4B +0x05c Interceptor : Uint4B +0x060 VirtualMemoryThreshold : Uint4B +0x064 Signature : Uint4B +0x068 SegmentReserve : Uint4B +0x06c SegmentCommit : Uint4B +0x070 DeCommitFreeBlockThreshold : Uint4B +0x074 DeCommitTotalFreeThreshold : Uint4B +0x078 TotalFreeSize : Uint4B +0x07c MaximumAllocationSize : Uint4B +0x080 ProcessHeapsListIndex : Uint2B +0x082 HeaderValidateLength : Uint2B +0x084 HeaderValidateCopy : Ptr32 Void +0x088 NextAvailableTagIndex : Uint2B +0x08a MaximumTagIndex : Uint2B +0x08c TagEntries : Ptr32 _HEAP_TAG_ENTRY +0x090 UCRList : _LIST_ENTRY +0x098 AlignRound : Uint4B +0x09c AlignMask : Uint4B +0x0a0 VirtualAllocdBlocks : _LIST_ENTRY +0x0a8 SegmentList : _LIST_ENTRY +0x0b0 AllocatorBackTraceIndex : Uint2B +0x0b4 NonDedicatedListLength : Uint4B +0x0b8 BlocksIndex : Ptr32 Void +0x0bc UCRIndex : Ptr32 Void +0x0c0 PseudoTagEntries : Ptr32 _HEAP_PSEUDO_TAG_ENTRY +0x0c4 FreeLists : _LIST_ENTRY +0x0cc LockVariable : Ptr32 _HEAP_LOCK +0x0d0 CommitRoutine : Ptr32 long +0x0d4 FrontEndHeap : Ptr32 Void +0x0d8 FrontHeapLockCount : Uint2B +0x0da FrontEndHeapType : UChar +0x0dc Counters : _HEAP_COUNTERS +0x130 TuningParameters : _HEAP_TUNING_PARAMETERS |
以winxp为例,其偏移0x00c的Flags表示是什么堆,值为2是可增长堆,0x014的VirtualMemoryThreshold是内存申请阈值,超过这个值就会用ZwAllocVirtualMemory进行虚拟分配。0x018的SegmentReserve 是堆段保留大小。0x01c的SegmentCommit是堆段提交大小
0x058的Segments是一个堆段指针数组。0x178的FreeLists是一个包含128个元素的双向链表数组,用来记录堆中空闲堆块链表的表头。当有新的分配请求时,堆管理器会遍历这个链表寻找可以满足请求大小的最接近堆块。如果找到了,便将这个块分配出去;否则,便要考虑为这次请求提交新的内存页和建立新的堆块。当释放一个堆块的时候,除非这个堆块满足解除提交的条件,直接释放给内存管理器,大多数情况下对其修改属性并加入空闲链表中。
不管是xp还是win7亦或是win10,在堆结构中都有3个非常重要的数据结构:HEAP_ENTRY--堆头结构、LIST_ENTRY--堆表结构、HEAP_SEGMENT--堆段结构。
堆块
堆块是堆实际操作的最小单位,堆块大小=堆头(描述堆块的结构)+堆块体(用户可用空间)。
在空闲状态和使用状态,堆块的结构是不一样的,更准确的说是堆头的结构不一样。
占用状态下,堆头是HEAP_ENTRY结构,占用8字节空间,用于描述当前堆块的信息。
空闲状态下,堆头是HEAP_FREE_ENTRY结构,不仅包含当前堆块的信息,还会占用8字节堆块体空间,用于记录上一个堆块和下一个堆块的地址(双向链表**_LIST_ENTRY**),具体结构信息如下:
堆块头的结构(HEAP_ENTRY)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | kd> dt _HEAP_ENTRY ntdll!_HEAP_ENTRY +0x000 Size : Uint2B //堆块的大小,以分配粒度为单位 +0x002 PreviousSize : Uint2B //前一个堆块的大小 +0x004 SmallTagIndex : UChar //用于检查堆溢出的Cookie +0x005 Flags : UChar //标志 +0x006 UnusedBytes : UChar //为了对齐而多分配的字节数 +0x007 SegmentIndex : UChar //这个堆块所在堆段的序号 //上面是使用状态的堆头 //下面是空闲状态的堆头 kd> dt _HEAP_FREE_ENTRY ntdll!_HEAP_FREE_ENTRY +0x000 Size : Uint2B +0x002 PreviousSize : Uint2B +0x000 SubSegmentCode : Ptr32 Void +0x004 SmallTagIndex : UChar +0x005 Flags : UChar +0x006 UnusedBytes : UChar +0x007 SegmentIndex : UChar +0x008 FreeList : _LIST_ENTRY |
其中的Flags字段代表了堆块的状态,其值由以下这些标志的组合:
- HEAP_ENTRY_BUSY(0x1):该块处于占用状态
- HEAP_ENTRY_EXTRA_PRESENT(0x2):这个块存在额外描述
- HEAP_ENTRY_FILL_PATTERN(0x4):使用固定模式填充堆块
- HEAP_ENTRY_VIRTUAL_ALLOC(0x8):虚拟分配
- HEAP_ENTRY_LAST_ENTRY(0x10):该堆段的最后一个块
堆段(HEAP_SEGMENT)
堆段是多个堆块的集合,每个堆至少拥有一个00号段(Segment),一个可扩展的堆最多可以拥有64个堆段。每个堆段使用_HEAP_SEGMENT结构描述,具体数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | kd> dt _HEAP_SEGMENT ntdll!_HEAP_SEGMENT +0x000 Entry : _HEAP_ENTRY +0x008 SegmentSignature : Uint4B +0x00c SegmentFlags : Uint4B +0x010 SegmentListEntry : _LIST_ENTRY +0x018 Heap : Ptr32 _HEAP +0x01c BaseAddress : Ptr32 Void //段的基地址 +0x020 NumberOfPages : Uint4B //段的内存页数 +0x024 FirstEntry : Ptr32 _HEAP_ENTRY //第一个堆块地址 +0x028 LastValidEntry : Ptr32 _HEAP_ENTRY //堆块的边界 +0x02c NumberOfUnCommittedPages : Uint4B //尚未提交的内存页数 +0x030 NumberOfUnCommittedRanges : Uint4B +0x034 SegmentAllocatorBackTraceIndex : Uint2B +0x036 Reserved : Uint2B +0x038 UCRSegmentList : _LIST_ENTRY |
仔细看上面的结构会发现第一个字段是一个HEAP_ENTRY结构,当我们回头看最顶层的堆结构--HEAP发现它的第一个字段也是HEAP_ENTRY。对此比较合理的解释就是堆和堆段的内存结构本身就在一个内存堆块中,因此也需要维护一个堆块头的数据结构。一个堆段的内存布局大致参考如下:
堆段内存布局
需要补充理解的知识:一个堆段中的虚拟内存并非全部映射到了物理内存中,而是部分提交映射,还有部分当使用到时会触发缺页中断,由专门的内存映射单元mmu进行映射。
虚拟内存空间中的虚拟地址有三种状态:
- 空闲的:没有被申请预定也没有映射到物理内存
- 保留的:地址被预定了,但是也还没有映射到物理内存
- 提交的:这个虚拟地址已经和物理内存产生了映射。所有提交的内存有三种映射方式:
- private 堆栈
- mapped 文件映射
- image PE文件的映像区域
堆表(LIST_ENTRY)
上面提到在申请堆内存时,会在堆表中查找适合大小的内存块。那么堆表(freeList列表)是什么样的,查找方式是什么?
首先,堆管理器会根据申请的大小+8字节分为:小块(<1kb)、大块(>=1kb,<512kb)、巨大快(>=512kb),算出表的表的index然后在下图freeList列表中寻找。申请时32位以8字节对齐,64位以16字节对齐。以32位为例,申请的实际内存为申请内存大小+8bytes,然后进行8字节对齐。这加上的8字节就是上面提到的堆头大小,用于描述申请的堆体(内存块)。
堆表的种类:
1.快表(指向单链表),精准查找,先进后出。固定大小的块,不可以拆分和合并,最大的1016byte,最多4个节点。
2.空闲表(指向双链表)1-127,无正好大小但相近的,可以把原始快拆出一个正好大小的块和一个多余块。最大也是1016byte
3.空闲表0(指向双链表),大块都在这个里面找,可以拆分和合并。堆创建之初只有一个超大的堆块,由0表指向该堆块。
在32位系统中一个进程中一般会有2个初始化的堆,一个是进程的默认堆,堆块起始地址一般在0x130000。另一个是libc的堆块地址,在VS2015以后CRT库的实现,并不会再去创建一个单独的堆,而使用进程默认堆。
一个新的堆在最开始只有空表第0项时有值的(FreeList[0]),其他所有表的地址都指向自己。当我们申请内存时会从堆块尾(FreeList[0]指向队尾)申请固定大小的堆块,linux中这个部分也叫Top chunk。
申请的堆内存释放时会优先释放到快表中,但有3个前提:
- 快表被激活启用
- 释放的内存符合快表1-127的准确大小(即1X8bytes、2X8bytes...127*8bytes.)
- 快表1-127的空间没有满,每层快表只有4个节点。比如快表3,能存放24字节的块只有4个,多余的就会并入空表中。
这里要说明一下,在之前的windows版本中,前端分配器一直使用的是look aside表(快表)但在win7以后就换成了LFH。
堆的申请和释放
win32提供的堆申请的api函数很多,包括LocalAlloc,GloabalAlloc,HeapAlloc,malloc等函数。这些函数都殊途同归,最终调用Ntdll.dll中的RtlAllocateHeap()。同样的所有的释放函数最终都会调用Ntdll.dll中的RtlDestoryHeap()函数,这两个函数都是ring3最底层的函数。
在申请堆内存时,RtlAllocateHeap函数会对申请的堆大小进行更改,加上标准堆头的大小,并对申请的大小进行位与运算后进行对齐补全。最后便如上面在堆表处所提到的,从堆表中寻找一个合适的可以分配的堆块返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | static ARENA_FREE* HEAP_FindFreeBlock(HEAP * heap, SIZE_T size, SUBHEAP * *ppSubHeap) { SUBHEAP* subheap; struct list* ptr; SIZE_T total_size; FREE_LIST_ENTRY* pEntry = heap->freeList + get_freelist_index(size + sizeof (ARENA_INUSE)); /* Find a suitable free list, and in it find a block large enough */ ptr = &pEntry->arena.entry; while ((ptr = list_next(&heap->freeList[0].arena.entry, ptr))) { ARENA_FREE* pArena = LIST_ENTRY(ptr, ARENA_FREE, entry); SIZE_T arena_size = (pArena->size & ARENA_SIZE_MASK) + sizeof (ARENA_FREE) - sizeof (ARENA_INUSE); if (arena_size >= size) { subheap = HEAP_FindSubHeap(heap, pArena); if (!HEAP_Commit(subheap, (ARENA_INUSE*)pArena, size)) return NULL; *ppSubHeap = subheap; return pArena; } } /* If no block was found, attempt to grow the heap */ if (!(heap->flags & HEAP_GROWABLE)) { WARN( "Not enough space in heap %p for %08lx bytes\n" , heap, size); return NULL; } total_size = size + ROUND_SIZE( sizeof (SUBHEAP)) + sizeof (ARENA_INUSE) + sizeof (ARENA_FREE); if (total_size < size) return NULL; /* overflow */ if ((subheap = HEAP_CreateSubHeap(heap, NULL, heap->flags, total_size, max(heap->grow_size, total_size)))) { if (heap->grow_size < 128 * 1024 * 1024) heap->grow_size *= 2; } else while (!subheap) /* shrink the grow size again if we are running out of space */ { if (heap->grow_size <= total_size || heap->grow_size <= 4 * 1024 * 1024) return NULL; heap->grow_size /= 2; subheap = HEAP_CreateSubHeap(heap, NULL, heap->flags, total_size, max(heap->grow_size, total_size)); } TRACE( "created new sub-heap %p of %08lx bytes for heap %p\n" , subheap, subheap->size, heap); *ppSubHeap = subheap; return (ARENA_FREE*)(( char *)subheap->base + subheap->headerSize); } |
找到合适的堆表后进行断链取出(堆溢出任意地址写的利用点)
1 2 3 4 5 6 | int remove (ListNode * node) { node -> blink -> flink = node -> flink; node -> flink -> blink = node -> blink; return 0; } |
堆溢出的原理
在前置知识了解后我们就可以尝试理解堆溢出的利用方式。
举个例子,当我们申请了一个8字节大小的堆内存,但在使用时写入了16字节的数据,此时超出的8字节数据就会将后面堆块的堆块头淹没。如果后面的堆块是一个空闲堆块(可以构造出来),我们可以再写入8字节数据就可以将它的LIST_ENTRY也淹没掉。
构造要溢出的空闲堆块:申请同样大小的3个堆块,将中间的第二个释放掉,由于附近没有其他闲置堆块所以不会被合并,当下次再申请同样大小的内存就可以把它拿出来。
因为溢出的内容是可控的,所以我们可以尝试伪造一个正常的堆块头和2个任意的4字节数据。溢出后我们进行相同大小的内存申请,堆管理器就会将后面的堆块进行断链取出,此时就会一次触发任意地址写操作。
下面我们以栈空间指向函数返回地址所在的栈地址为目标,将栈空间的原始返回地址改写为目标shellcode起始地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | node0是已申请的内存,假设node0用户空间为0x8字节 node0溢出8字节到空闲node1的堆头,再溢出8字节将node1的Flink和blink覆盖成目标地址和内容 node1的next覆盖成栈地址 node0(占用的) |node1(空闲的) node0的8字节可用空间 | node1堆头 |flink |blink 0x 41 41 41 41 41 41 41 41 32 32 32 32 32 32 32 32 12 ff 2b 00 40 10 00 00 |栈地址 |函数返回地址(shellcode地址) 把node1申请走时堆管理器进行断链,执行了两步 1.node1->flink->blink = node1->blink 原本是将node2 的blink指针改为指向nodo1 现在node1->flink被覆盖成12 ff 2b 00栈地址,因此执行完这行代码后该栈地址的内容就回被替换成node1->blink,即函数返回地址。 2.node1->blink->flink = node1->flink 此时函数返回地址,也就是shellcode处前4个字节会被破坏。 |
部分被破环的4字节数据不会执行流程造成影响,但是为了shellcode能够稳定执行还是需要配合一些跳板技术来使用,有兴趣的可以搜索相关的内容。
除了攻击改写栈的函数返回地址,还可以攻击windows异常处理的seh函数地址、其他虚函数的指针、关键内存变量、PEB 中线程同步函数的入口地址等,有点像hook。
结语
windows堆的内存结构和管理机制基本就是这样了,从win2000到现在的win11,堆的内存分配算法和安全机制已经改变了很多,仅通过构造堆块头已经无法完成利用,但是最基础的框架和原理还是相通,剩下的就是不同版本的新结构分析和安全机制绕过的技术学习了。
本文参考了以下内容:
《0day安全软件漏洞分析技术》
https://github.com/reactos/wine/blob/master/dlls/ntdll/heap.c