首页
社区
课程
招聘
Windows堆初探
2023-11-21 10:38 5761

Windows堆初探

2023-11-21 10:38
5761

Windows堆初探

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结构的定义如下:

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。对此比较合理的解释就是堆段的内存结构本身就在一个内存堆块中,因此也需要维护一个堆块头的数据结构。一个堆段的内存布局大致参考如下:

堆段内存布局

111

需要补充理解的知识:一个堆段中的虚拟内存并非全部映射到了物理内存中,而是部分提交映射,还有部分当使用到时会触发缺页中断,由专门的内存映射单元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表指向该堆块。
111

在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最底层的函数。

111

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

本文参考了以下内容:

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

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

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

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

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


[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

收藏
点赞3
打赏
分享
最新回复 (1)
雪    币: 19389
活跃值: (29037)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2023-11-22 09:31
2
1
感谢分享
游客
登录 | 注册 方可回帖
返回