Windows堆初探
2024-2-4 17:56:11 Author: mp.weixin.qq.com(查看原文) 阅读量:12 收藏

Windows堆概述

很多应用程序都会有内存需求,而这些应用程序的内存需求通常是频繁且零散的,如果应用程序每次有内存需求都通过内存管理器来申请,势必会有很大资源开销,影响系统性能。因此,用户层的堆管理方式就出现了。堆可以向内存管理器一次性申请一块比较大的内存,然后根据应用程序的需求分割成对应小块。申请和释放的操作可以由堆管理器来处理,当应用程序的生命周期结束时堆也随之销毁,堆申请的内存全部还给内存管理器。

Windows堆的发展阶段:

堆管理机制的发展大致可以分为三个阶段:

1.Windows 2000~Windows XP SP1:堆管理系统只考虑了完成分配任务和性能因素,没有考虑安全因素,可以比较容易发被攻击者利用。

2.Windows XP 2~Windows 2003:加入了安全因素,比如修改了块首的格式并加入安全 cookie,双向链表结点在删除时会做指针验证等。这些安全防护措施使堆溢出攻击变得非常困 难,但利用一些高级的攻击技术在一定情况下还是有可能利用成功。

3.Windows Vista~Windows 7:不论在堆分配效率上还是安全与稳定性上,都是堆管理算法的一个里程碑。

堆的数据结构(_HEAP)

Windows通过HEAP结构来记录和维护堆的管理信息,该结构位于堆的开始处。当使用HeapCreate函数创建堆时,该函数会返回一个堆句柄。这个句柄不同于窗口对象、内核对象的句柄需要结合句柄表使用,而是独立存在可以直接使用去访问堆,也就是说这个句柄实质上也是一个指向堆的指针。该地址所指向的数据结构就是HEAP,HEAP结构的定义如下:

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)

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结构描述,具体数据结构如下:

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进行映射。

虚拟内存空间中的虚拟地址有三种状态:

1.空闲的:没有被申请预定也没有映射到物理内存。

2.保留的:地址被预定了,但是也还没有映射到物理内存。

3.提交的:这个虚拟地址已经和物理内存产生了映射。所有提交的内存有三种映射方式:

  1. private 堆栈

  2. mapped 文件映射

  3. 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.快表被激活启用。
2.释放的内存符合快表1-127的准确大小(即1X8bytes、2X8bytes...127*8bytes.)。
3.快表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函数会对申请的堆大小进行更改,加上标准堆头的大小,并对申请的大小进行位与运算后进行对齐补全。最后便如上面在堆表处所提到的,从堆表中寻找一个合适的可以分配的堆块返回。

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);
}

找到合适的堆表后进行断链取出(堆溢出任意地址写的利用点)。

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起始地址。

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,堆的内存分配算法和安全机制已经改变了很多,仅通过构造堆块头已经无法完成利用,但是最基础的框架和原理还是相通,剩下的就是不同版本的新结构分析和安全机制绕过的技术学习了。

本文参考了以下内容:

1.《0day安全软件漏洞分析技术》

2.看雪论坛-Windows平台下堆溢出漏洞学习笔记

(https://bbs.kanxue.com/thread-271213.htm)

3.https://github.com/reactos/wine/blob/master/dlls/ntdll/heap.c

4.博客园-Windows堆管理机制-修竹Kirakira

(https://www.cnblogs.com/XiuzhuKirakira/p/16986744.html)

5.CSDN-磨刀砍柴Debug-堆(heap)系列_0x04

(https://blog.csdn.net/weixin_44531336/article/details/125283387)

看雪ID:time.time

https://bbs.kanxue.com/user-home-967913.htm

*本文为看雪论坛优秀文章,由 time.time 原创,转载请注明来自看雪社区

# 往期推荐

1、区块链智能合约逆向-合约创建-调用执行流程分析

2、在Windows平台使用VS2022的MSVC编译LLVM16

3、神挡杀神——揭开世界第一手游保护nProtect的神秘面纱

4、为什么在ASLR机制下DLL文件在不同进程中加载的基址相同

5、2022QWB final RDP

6、华为杯研究生国赛 adv_lua

球分享

球点赞

球在看


文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458540955&idx=1&sn=fda886bf39bd805342c92d188cd4cb6f&chksm=b18d6b1186fae2076b2aeb5b297b8b5ed17ba10021d41b95302e3dacf35e5279b3a16ad6124f&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh