Windows 堆管理机制 [1] 堆基础

声明:这篇文章在写的时候,是最开始学习这个堆管理机制,所以写得有些重复和琐碎,基于笔记的目的想写得全一些,这篇文章写的时候参考了很多前辈的文章,已在末尾标出,某些未提及到的可以在评论补充
基于分享的目的,之前把所有部分都放出来了,但是全篇有八万词,pdf版本长达两百多页,全部放出看着比较杂乱,所以我对笔记进行了分割,只放出了几个章节

1.堆基础

1.1 win32堆概述

​ 从操作系统的角度来看,堆是系统的内存管理功能向应用软件提供服务的一种方式。

​ 通过堆,内存管理器(Memory Manager)将一块较大的内存空间委托给堆管理器(Heap Manager)来管理。堆管理器将大块的内存分割成不同大小的很多个小块来满足应用程序的需要。应用程序的内存需求通常是频繁而且零散的,如果把这些请求都直接传递给位于内核中的内存管理器,那么必然会影响系统的性能。有了堆管理器,内存管理器就只需要处理大规模的分配请求

用户态

小内存:堆管理器分配堆。调用堆分配API从堆管理器分配堆。堆分配API包括LocalAlloc,GloabalAlloc,HeapAlloc,malloc等函数。

​ 大内存:内存管理器分配虚拟内存。调用虚拟内存分配API来从内存管理器分配内存。虚拟内存API包括VirtualAlloc,VirtualAllocEx,VirtualFree,VirtualFreeEx,VirtualLock,VirtualUnlock,VirtualProtect,VirtualQuery等

内核态

小内存:池管理器(Pool Manager)。池管理器公开了一组驱动程序接口(DDI)以向外提供服务,包括 ExAllocatePool,ExAllocatePoolwithTag,ExAllocatePoolWithTagPriority ,ExAllocatePoolwithQuota,ExFreePool,ExFreePoolwithTag等

​ 大内存:内存管理器分配虚拟内存。内核对应的API包括NtAllocatevirtualMemory、NtProtectvirtualMemory等。

1.2 堆管理机制的发展阶段

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

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

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

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

1.3 堆内存与栈内存的区别

栈内存 堆内存
典型用例 函数局部数组 动态增长的链表等数据结构
申请方式 在程序中直接声明即可,如char buffer[8] 需要用函数申请,通过返回的指针使用。如p=malloc(8)
释放方式 函数返回时,由系统自动回收 需要把指针传给专用的释放函数,如free
初始化 0xCCCCCCCC 0xFDFDFDFD
0xCDCDCDCD
0xFDFDFDFD
管理方式
所处位置
申请后直接使用,申请与释放由系统自动完成,最后达到栈区平衡 需要程序员处理申请与释放
变化范围很大,0x0012XXXX
增长方向 由内存高址向低址增加 由内存低址向高址排列
生命周期 生命周期在被调用的函数内,不调用函数就不生成栈 生命周期由程序员决定,new/malloc出现,delete/free消亡
栈数组与堆数组 1)栈数组内存在栈上:int v1[] = {1, 20, 3, -1};
2)栈数组名不能被修改
1)堆数组需要从栈数组上一个内存去访问堆上的内存:
int *v2 = new int[4]; //栈上4/8个字节,堆上16个字节
2)堆数组名可以被修改

1.4 堆管理器

程序中对堆的直接操作主要有三种

  1. 进程默认堆。每个进程启动的时候系统会创建一个默认堆。比如LocalAlloc或者GlobalAlloc也是从进程默认堆上分配内存。也可以使用GetProcessHeap获取进程默认堆的句柄,然后根据用这个句柄去调用HeapAlloc达到在系统默认堆上分配内存的效果。

  2. C++编程中常用的是malloc和new去申请内存,这些由CRT库提供方法。根据查看,在VS2010之前(包含),CRT库会使用HeapCreate去创建一个堆,供CRT库自己使用。在VS2015以后CRT库的实现,并不会再去创建一个单独的堆,而使用进程默认堆。

  3. 自建堆。这个泛指程序通过HeapCreate去创建的堆,然后利用HeapAlloc等API去操作堆,比如申请空间

    堆管理器结构

​ 堆管理器是通过调用虚拟管理器的一些方法进行堆管理的实现,比如VirtualAlloc之类的函数。同样应用程序也可以直接使用VirtualAlloc之类的函数对内存进行使用

1.4.1 win32堆管理器

​ win32 堆管理器由 NTDLL.dll 实现,目的是为用户态的应用程序提供内存服务,从实现角度上来讲,内核态的池管理器和用户态的 win32 堆管理器默用的是同一套基础代码,它们以运行时的方式存在于 ntdll.dll 和 ntosknrl.exe 模块中。

1.4.2 CRT堆管理器

​ 为了支持C的内存分配函数和C++的内存分配运算符(new和delete,即CRT内存分配函数),编译器的C运行库会创建一个专门的堆供这些函数使用,即CRT堆。

  • CRT由C运行时库创建,CRT创建的堆有三种模式,分别是SBH(Small Block),ODLSBH和System Heap模式,CRT运行时库选择一种模式创建相应的堆。

  • 对于SBH和OLDSBH模式来说,CRT堆会从堆管理器中批发大块的内存,然后分割成小块的内存供程序使用,对于系统模式,CRT堆只是将堆分配请求转发给它基于的win32堆。因此处于系统模式的CRT堆只是对win32堆的简单封装。

1.5 堆的创建与销毁

1.5.1 进程默认堆

​ 创建进程时操作系统为进程创建的默认堆:ntdll! KiUserApcDispather-> ntdll! LdrpInitialize-> ntdll! LdrplInitializeProcess-> ntdll! RtlCreateHeap

​ 创建好的堆的句柄回保存在PEB结构的ProcessHeap字段中,PEB中关于堆的字段如下:

kd> dt _PEB
ntdll!_PEB
   +0x018 ProcessHeap      : Ptr32 Void
   +0x078 HeapSegmentReserve : Uint4B
   +0x07c HeapSegmentCommit : Uint4B
成员 含义
ProcessHeap 进程堆的句柄
HeapSegmentReserve 堆的默认保留大小,字节数,1MB(0x100000)
HeapSegmentCommit 堆的默认提交大小,其默认值为两个内存页大小;x86系统中普通内存页的大小为4KB,因此是0x2000,即8KB
  • 使用GetProcessHeap可以取得当前进程的进程堆句柄:HANDLE GetProcessHeap(void)

    内部实现:从PEB结构读出ProcessHeap字段的值

1.5.2 私有堆

1. 创建

​ 可以通过HeapCreate这个api来创建属于进程的私有堆,这个api实际上会调用RtlCreateHeap函数,创建完毕之后会将创建好的堆句柄保存到peb结构中。

函数调用栈

​ HeapCreate-> ntdll! RtlCreateHeap-> NtAllocateVirtualMemory

函数原型
HANDLE HeapCreate(
  [in] DWORD  flOptions,
  [in] SIZE_T dwInitialSize,
  [in] SIZE_T dwMaximumSize
);
参数
参数 含义
flOptions 该参数可以是如下标志中的0个或多个:

HEAP_GENERATE_EXCEPTIONS(0x00000004),通过异常来报告失败情况,如果没有该标志则通过返回NULL报告错误
HEAP_CREATE_ENABLE_EXECUTE(0X00040000),允许执行堆中内存块上的代码
HEAP_NO_SERIALIZE(0x00000001),当堆函数访问这个堆时,不需要进行串行化控制(加锁)。指定这一标志可以提高堆操作函数的速度,但应该在确保不会有多个线程操作同一个堆时才这样做,通常在将某个堆分配给某个线程专用时这么做。也可以在每次调用堆函数时指定该标志,告诉堆管理器u需要堆那次调用进行串行化控制
dwInitialSize 用来指定堆的初始提交大小
dwMaximumSize 用来指定堆空间的最大值(保留大小),如果为0,则创建的堆可以自动增加。尽管可以使用任意大小的整数作为dwInitialSize和dwMaximumSize参数,但是系统会自动将其取整为大于该值的临近页边界(即页大小的整数倍)
windbg命令

​ 在windbg中可以使用!heap -h指令来查看进程中的所有堆。!heap -h的查询结果也就是peb.ProcessHeaps中的值。

RtlCreateHeap函数流程
  1. 计算得到最大堆块大小:最大堆块大小实际上是 0x7f000,即 0x80000 减去一个页面。 最大块大小为 0xfe00,粒度偏移为 3

  2. 获取传入的堆参数块,根据PEB设置堆参数块的值,根据PEB->NtGlobalFlag设置堆块的标志

  3. 根据传入的ReserveSize和CommitSize设置堆块的保留页面和提交页面

  4. 如果调用者提供的堆基地址不为0

    1)如果调用者提供了CommitRoutine,设置提交的基地址和不提交的基地址

    2)如果调用者未提供CommitRoutine,查询提供的地址处的信息,获得该内存区域的大小;查询未提交处地址的信息,获得保留内存的大小

  5. 如果调用者提供的堆基地址为0,调用ZwAllocateVirtualMemory从内存管理器分配内存

  6. 此时,已获得一个堆指针、已提交的基址、未提交的基址、段标志、提交大小和保留大小。 如果已提交和未提交的基地址相同,那么我们需要调用ZwAllocateVirtualMemory提交由ComitSize指定的数量

  7. 计算堆头的末尾并为 8 个未提交的范围结构腾出空间。 一旦我们为它们腾出空间,然后将它们链接在一起并 以null 终止链

  8. 填写堆结构体,并将堆结构体插入进程的堆列表

    1)初始化堆前面的元素

    2)初始化空表

    3)初始化VirtualAllocdBlocks虚拟内存块

    4)初始化临界区

    5)初始化初始化堆结构体的第一个堆段

    6)初始化堆结构体的其他元素,将堆结构体按16或8字节对齐

    7)将新建好的堆插入进程的堆列表中

    8)初始化堆的快表:为快表分配空间,对其进行初始化

2. 销毁

​ 可以通过HeapDestory这个api来销毁属于进程的私有堆,这个api实际上会调用RtlDestroyHeap函数,会将PEB堆列表中的堆要销毁堆的堆句柄移除掉。

函数调用栈

​ HeapDestory -> ntdll! RtlDestroyHeap -> NtFreeVirtualMemory

函数原型
BOOL HeapDestroy(
  [in] HANDLE hHeap
);
RtlDestroyHeap函数流程
  1. 如果被销毁的堆是进程默认堆,不允许销毁
  2. 如果是低碎片堆,调用RtlpDestroyLowFragHeap销毁低碎片堆
  3. 调用RtlpHeapFreeVirtualMemory释放堆里面的巨块(VirtualAllocdBlocks)
  4. 销毁设置的堆标记,并将该堆从进程的堆列表中移除
  5. 销毁临界区
  6. 释放未提交的堆段的虚拟内存
  7. 如果堆中有大块的索引,释放大块的内存
  8. 调用RtlpDestroyHeapSegment销毁每个堆中的段
注意

​ 应用程序不应该也不需要销毁默认堆,因为进程内的很多系统函数会使用这个堆,这并不会导致内存泄漏。因为当进程退出和销毁进程对象时,系统会两次调用内存管理器的MmCleanProcessAddressSpace函数来释放清理进程的内存空间

第一次:在退出进程中执行。当NtTerminateProcess函数调用PspExitThread退出线程时,如果退出的是最后一个线程,则PspExitThread会调用MmCleanProcessAddressSpace(该函数先删除进程用户空间中的文件映射和虚拟地址,释放虚拟地址描述符,然后删除进程空间的系统部分,最后删除进程的页表和页目录设施)

第二次:当系统的工作线程删除进程对象时会再次调用MmCleanProcessAddressSpace函数

1.5.3 堆列表

​ 每个进程的PEB结构以列表的形式记录了当前进程的所有堆句柄,包括进程的默认堆。以下是PEB结构中,用来记录这些堆句柄的字段:

kd> dt _PEB
ntdll!_PEB
   +0x018 ProcessHeap      : Ptr32 Void
   +0x088 NumberOfHeaps    : Uint4B
   +0x08c MaximumNumberOfHeaps : Uint4B
   +0x090 ProcessHeaps     : Ptr32 Ptr32 Void
成员 含义
ProcessHeap 进程默认堆句柄
NumberOfHeaps 记录堆的总数
MaximumNumberOfHeaps 指定ProcessHeaps数组最大个数,当NumberOfHeaps达到该值的大小,那么堆管理器就会增大MaximumNumberOfHeaps的值,并重新分配ProcessHeaps数组
ProcessHeaps 记录每个堆的句柄,是一个数组,这个数组可以容纳的句柄数记录在MaximumNumberOfHeaps中

​ 和其他的句柄不同的是,堆句柄的值实际上就是这个堆的起始地址。和其他函数创建的对象保存在内核空间中不同,应用程序创建的堆是在用户空间保存的,因此应用程序可以直接通过该地址来操作堆,而不用担心操作失误造成蓝屏错误。所以,此时的句柄值就可以直接是堆的起始地址

1.6 堆的调试支持

1.6.1 堆管理器提供的调试支持

​ 下面是一些常见的调试选项,可通过WinDbg提供的gflags.exe(默认位于C:\Program Files\Debugging Tools for Windows (x86)目录下)或者!gflag命令来设置:

  • htc - 堆尾检查(Heap Tail Checking,HTC),在堆块末尾附加额外的标记信息(通常为 8 字节的0xabababab),用于检查堆块是否发生溢出
  • hfc - 堆释放检查(Heap Free Checking,HFC),在释放堆块时对堆进行各种检查,防止多次释放同一个堆块
  • hpc - 堆参数检查,对传递给堆管理的参数进行更多的检查
  • ust - 用户态栈回溯(User mode Stack Trace,UST),即将每次调用堆函数的函数调用信息记录到一个数据库中
  • htg - 堆标志(heap tagging),为堆块增加附加标记,以记录堆块的使用情况或其他信息
  • hvc - 调用时验证(Heap Validation on Call,HVC),即每次调用堆函数时都对整个堆进行验证和检查
  • hpa - 启用页堆(Debug Page Heap),在堆块后增加专门用于检测溢出的栅栏页,若发生堆溢出触及栅栏页便会立刻触发异常。

举例:要针对app.exe程序添加堆尾检查功能和页堆,去掉堆标志,可以执行以下命令

!gflag -i app.exe +htc +hpa -htg
gflags.exe -i app.exe +htc +hpa -htg

1.6.2 启用堆调试功能的方法原理

​ 操作系统的进程加载器在加载一个进程时会从注册表中读取进程的全局标志值:

​ 1)在 HKEY_ LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options 表键下寻找以该程序名(如 MyApp.EXE,不区分大小写)命名的子键

​ 2)如果存在这样的子键, 那么读取下面的 GlobalFlag 键值(REG_DWORD 类型)。

​ 可以使用 gflags 工具(gflags.exe)来编辑系统的全局标志或某个程序文件的全局标志(将标志信息保存在上述注册表表键下,如果不存在,就会创建)

1. 与堆有关的全局标志

标 志 缩 写 描 述
FLG_HEAP_ENABLE_FREE_CHECK 0x20 hfc 释放检查
FLG_HEAP_VALIDATE_PARAMETERS 0x40 hpc 参数检查
FLG_HEAP_ENABLE_TAGGING 0x800 htg 附加标记
FLG_HEAP_ENABLE_TAG_BY_DLL 0x8000 htd 通过DLL附加标记
FLG_HEAP_ENABLE_TAIL_CHECK 0x10 htc 堆尾检查
FLG_HEAP_VALIDATE_ALL 0x80 hvc 全面验证
FLG_HEAP_PAGE_ALLOCS 0x02000000 hpa DPH
FLG_USER_STACK_TRACE_DB 0x1000 ust 用户态栈回溯
FLG_HEAP_DISABLE_COALESCING 0x00200000 dhc 禁用合并空闲块

2. 调试器中运行一个程序默认的全局标志

​ 如果是在调试器中运行一个程序,而且注册表中没有设置 GlobalFlag 键值,那么操作系统 的加载器会默认将全局标志设置为 0x70,也就是启用 htc、hfc 和 hpc 这 3 项堆调试功能。如果 注册表中设置了,那么会使用注册表中的设置

1.6.3 栈回溯数据库

1. 工作原理

​ 如果当前进程的全局标志中包含了 UST 标志(FLG_USER_STACK_TRACE_DB,0x1000), 那么堆管理器会为当前进程分配一块大的内存区,并建立一个 STACK_TRACE_DATABASE 结构来管理这个内存区,然后使用全局变量 ntdll!RtlpStackTraceDataBase 指向这个内存结构。这个内存区称为用户态栈回溯数据库(User-Mode Stack Trace Database),简称栈回溯数据库或 UST 数据库

1)UST 数据库的头结构

​ 通过查看RtlpStackTraceDataBase全局变量获得UST数据库的起始地址:

0:001> dd ntdll!RtlpStackTraceDataBase l1 
7c97c0d0 00410000

​ UST 数据库头结构如下:

0:001> dt ntdll!_STACK_TRACE_DATABASE 00410000 
 +0x000 Lock : _ _ unnamed //同步对象
 +0x038 AcquireLockRoutine : 0x7c901005 ntdll!RtlEnterCriticalSection+0 
 +0x03c ReleaseLockRoutine : 0x7c9010ed ntdll!RtlLeaveCriticalSection+0 
 +0x040 OkayToLockRoutine : 0x7c952080 ntdll!NtdllOkayToLockRoutine+0 
 +0x044 PreCommitted : 0 '' //数据库提交标志
 +0x045 DumpInProgress : 0 '' //转储标志
 +0x048 CommitBase : 0x00410000 //数据库的基地址
 +0x04c CurrentLowerCommitLimit : 0x00422000 
 +0x050 CurrentUpperCommitLimit : 0x0140f000 
 +0x054 NextFreeLowerMemory : 0x00421acc "" //下一空闲位置的低地址
 +0x058 NextFreeUpperMemory : 0x0140f4fc "???" //下一空闲位置的高地址
 +0x05c NumberOfEntriesLookedUp : 0x3fb 
 +0x060 NumberOfEntriesAdded : 0x2c1 //已加入的表项数
 +0x064 EntryIndexArray : 0x01410000 -> (null) 
 +0x068 NumberOfBuckets : 0x89 //Buckets 数组的元素数
 +0x06c Buckets : [1] 0x00410a50 _RTL_STACK_TRACE_ENTRY // Buckets 数组。指针数组,数组的每个元素指向的是一个桶位。
     								//堆管理器在存放栈回溯记录时,先计算这个记录的散列值,然后对桶位数(NumberOfBuckets)求余(%)
     								//将得到的值作为这个记录所在的桶位。位于同一个桶位的多个记录是以链表方式链接在一起的
     								//每个栈回溯记录是一个 RTL_STACK_TRACE_ENTRY 结构

2)UST 数据库的回溯记录

0:001> dt _RTL_STACK_TRACE_ENTRY 0x00410a50 
 +0x000 HashChain : 0x00410e9c _RTL_STACK_TRACE_ENTRY //指向属于同一桶位的下一个记录的地址。
     												  //因为BackTrace数组长度是32字节,所以栈回溯最大深度为 32 字节
 +0x004 TraceCount : 1 //本回溯发生的次数
 +0x008 Index : 0x23 //记录的索引号
 +0x00a Depth : 0xe //栈回溯的深度,即 BackTrace 的元素数
 +0x00c BackTrace : [32] 0x7c96d6dc //从栈帧中得到的函数返回地址数组
3)堆管理器将当前的栈回溯信息记录到 UST 数据库中的过程

​ 建立 UST 数据库后,当堆块分配函数再被调用的时候,堆管理器便会将当前的栈回溯信息记录到 UST 数据库中,其过程如下

  1. 堆分配函数调用 RtlLogStackBackTrace 发起记录请求

  2. RtlLogStackBackTrace 判断 ntdll!RtlpStackTraceDataBase 指针是否为 NULL。如果是, 则返回;否则,调用 RtlCaptureStackBackTrace

  3. RtlCaptureStackBackTrace 调用 RtlWalkFrameChain 遍历各个栈帧并将每个栈帧中的函数返回地址以数组的形式返回

  4. RtlLogStackBackTrace 将得到的信息放入一个 RTL_STACK_TRACE_ENTRY 结构中, 然后根据新数据的散列值搜索是否已记录过这样的回溯记录

    如果搜索到,则返回该项的索引值; 如果没有找到,则调用 RtlpExtendStackTraceDataBase 将新的记录加入数据库中,然后将新加入项的索引值返回。每个 UST 记录都有一个索引值,称为 UST 记录索引号。 RTL_STACK_TRACE_ENTRY 结构中的 TraceCount 字段用来记录这个栈回溯发生的次数,如果它的值大于 1,便说明这样的函数调用过程发生了多次

  5. 堆分配函数(RtlDebugAllocateHeap)将 RtlLogStackBackTrace 函数返回的索引号放入堆块末尾一个名为 HEAP_ENTRY_EXTRA 的数据结构中,这个数据结构是在分配堆块时就分配好的,它的长度是 8 字节,依次为 2 字节的 UST 记录索引号,2 字节的堆块标记(Tag)号, 最后 4 字节用来存储用户设置的数值

配置 UST 数据库的大小

  1. 使用 gflags 工具:如下命令便将 heapmfc.exe 程序的 UST 数 据库设置为 24MB:

    !gflags -i heapmfc.exe /tracedb 24 
    
  2. 在注册表中 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\Current Version\Image File Execution Options\heapmfc.exe 键下直接修改 StackTraceDatabaseSizeInMb 表项 (REG_DWORD)

2. DH 和 UMDH 工具

​ 可以使用 DH.EXE(Display Heap)和 UMDH.EXE(User-Mode Dump Heap)工具来查询包 括 UST 数据库在内的堆信息:利用堆管理器的调试功能将堆信息显示出来或转储(dump)到文件中

​ 这两个工具都是在命令行运行的,通过-p 开关指定要观察的进程。如果要转储 UST 数据 库,那么应该先设置好符号文件的路径

举例

​ 通过以下命令可以将进程 5622 的堆信息转储到文件 DH_5622.dmp 中,DH 生成的文件是文本文件。内部包含了进程中所有堆的列表 和 UST 数据库中的所有栈回溯记录(称为 Hogs)

C:\>set _NT_SYMBOL_PATH=D:\symbols 
C:\>dh -p 5622

3. 定位内存泄漏

1)使用 UMDH 来定位内存泄漏的基本步骤

  1. 使用 gflags 工具启用 ust 功能,也就是在 HeapMfc.exe 所在的目录中执行 gflags /i HeapMfc.exe +ust
  2. 运行 HeapMfc 程序,并使用 UMDH 工具对其进行第一次采样,即执行 c:\windbg\umdh -p:1228 -d -f:u1.log –v
  3. 单击 HeapMfc 对话框中的 New 按钮,这会导致 HeapMfc 程序分配内存,但是并不释放,也就是模拟一个内存泄漏情况
  4. 再次执行 UMDH 对程序进行采样,即 c:\windbg\umdh -p:1228 -d -f:u2.log –v
  5. 使用 UMDH 比较两个采样文件,即 c:\windbg\umdh -d u1.log u2.log –v

2)命令的执行结果和注释

​ UMDH 会比较两次采样中的每个 UST 记录,并将存在差异的记录以如下格式显示出来:

+ 字节差异 (新字节数 –旧字节数) 新的发生次数 allocs BackTrace UST 记录的索引号
+ 发生次数差异 (新次数值 – 旧次数值) BackTrace UST 记录的索引号 allocations 栈回溯列表

利用 UMDH 工具定位内存泄漏如下:

c:\dig\dbg\author\code\bin\release>c:\windbg\umdh -d u1.log u2.log -v 
// Debug library initialized ... 				//加载和初始化符号库,即 DBGHELP.DLL 
DBGHELP: HeapMfc - private symbols & lines 		//加载 HeapMfc 程序的符号文件
 .\HeapMfc.pdb 									//符号文件路径和名称
DBGHELP: ntdll - public symbols 				//加载 NTDLL.DLL 的符号文件
 d:\symbols\ntdll.pdb\36515FB5D04345E491F672FA2E2878C02\ntdll.pdb 
… 												//省略加载其他符号文件的信息
// 以下是 UMDH 发现的两次采样间的差异,即可能的内存泄漏线索

//索引号为 A2(BackTraceA2)的 UST 记录在两次采样中新增 100 字节(用户数据区大小),新的字节数为 11308,上次的字节数为 11208。这一记录所代表函数调用过程的发生次数是 20
+ 100 ( 11308 - 11208) 20 allocs BackTraceA2

//BackTraceA2 所代表的调用过程在两次采样间新增 1 次,新的发生次数是 20,旧的发生次数是 19
+ 1 ( 20 - 19) BackTraceA2 allocations

 ntdll!RtlDebugAllocateHeap+000000E1 
 ntdll!RtlAllocateHeapSlowly+00000044 
 ntdll!RtlAllocateHeap+00000E64 
 msvcrt!_heap_alloc+000000E0 
 msvcrt!_nh_malloc+00000013 
 msvcrt!malloc+00000027 
 MFC42!operator new+00000031 
//归纳结果
     
//第 2 次采样比第 1 次总增加 128 字节,其中 100 字节属于用户数据区(请求长度),28 字节属于堆的管理信息,8 字节为 HEAP_ENTRY结构,另 20 字节为堆块末尾的自动填充和 HEAP_ENTRY_EXTRA 结构
Total increase == 100 requested + 28 overhead = 128 

1.6.4 页堆

​ Windows 2000 引入了专门用于调试的页堆(Debug Page Heap,DPH)。一旦启用该机制,堆管理器会在堆块后增加专门用于检测溢出的栅栏页(fense page),这样一旦用户数据区溢出并触及栅栏页便会立刻触发异常

​ DPH包含在 Windows 2000 之后的所有 Windows 版本中,也加入 NT 4.0 的 Service Pack 6 中

1. 页堆总体结构

​ 下图中:左侧的矩形是页堆的主体部分,右侧是附属的普通堆

​ 创建每个页堆时,堆管理器都会创建一个附属的 普通堆,其主要目的是满足系统代码的分配需要,以节约页堆上的空间

1)页堆上的结构
  • 第 1 个内存页(起始 4KB):用来伪装普通堆的 HEAP 结构,大多空间被填充为 0xeeeeeeee,只有少数字段(Flags 和 ForceFlags)是有效的,这个内存页的属性是只读的,因此可以用于检测应用程序意外写 HEAP 结构的错误

  • 第 2 个内存页:

    1. 开始处是一个 DPH_HEAP_ROOT 结构,该结构包含了 DPH 的基本信息和各种链表,是描述和管理页堆的重要资料

      1)第一个字段是这个结构的签名(signature),固定为 0xffeeddcc,与普通堆结构的签名 0xeeffeeff 不同

      2)NormalHeap 字段记录着附属普通堆的句柄。

    2. DPH_HEAP_ROOT 结构之后的一段空间用来存储堆块节点,称为堆块节点池(node pool)

      为了防止堆块的管理信息被覆盖,除了在堆块的用户数据区前面存储堆块信息,页堆还会在节点池为每个堆块记录一个 DPH_HEAP_BLOCK 结构,简称 DPH 节点结构。多个节点是以链表的形式链接在一起的:

      1)DPH_HEAP_BLOCK 结构的 pNodePoolListHead 字段用来记录这个链表的开头

      2)pNodePoolListTail 字段用来记录链表的结尾

      第一个节点描述 DPH_HEAP_ROOT 结构和节点池本身所占用的空间。节点池的典型大小是 4 个内存页(16KB)减去 DPH_HEAP_ROOT 结构的大小

  • 节点池后的一个内存页:用来存放同步用的关键区对象,即_RTL_CRITICAL_SECTION 结构。 这个结构之外的空间被填充为 0。DPH_HEAP_BLOCK 结构的 HeapCritSect 字段记录着关键区对象的地址。

页堆结构

2)堆块结构

​ 每个堆块至少占用两个内存页,在用于存放用户数据的内存页后,堆管理器总会多分配一个内存页,这个内存页专门用来检测溢出, 即栅栏页(fense page)。

​ 栅栏页的页属性被设置为不可访问(PAGE_NOACCESS),因此一旦用户数据 区发生溢出并触及栅栏页,便会引发异常,如果程序在被调试,那么调试器便会立刻收到异常, 使调试人员可以在第一现场发现问题,从而迅速定位到导致溢出的代码

​ 为了及时检测溢出,堆块的数据区是按照紧邻栅栏页的原则来布置的,以一个用户数据大小远小于一个内存页的堆块为例,这个堆块会占据两个内存页,数据区在第一个内存页的末尾,第二个内存页紧邻在数据区的后面,以下为一个页堆堆块(DPH_HEAP_BLOCK)的数据布局:

页堆堆块

① 页堆堆块的数据区

  • DPH_BLOCK_ INFORMATION 结构,即页堆堆块的头结构
  • 用户数据区
  • 用于满足分配粒度要求而多分配的额外字节。如果应用程序申请的长度(即用户数据区的长度)正好是分配粒度的倍数, 比如 16 字节,那么这部分就不存在了

② 页堆堆头的头结构

0:000> dt ntdll!_DPH_BLOCK_INFORMATION 016d6ff0-20 
 +0x000 StartStamp : 0xabcdbbbb 		//头结构的起始签名,固定为这个值
 +0x004 Heap : 0x016d1000 				//DPH_HEAP_ROOT 结构的地址
 +0x008 RequestedSize : 9 				//堆块的请求大小(字节数)
 +0x00c ActualSize : 0x1000 			//堆块的实际字节数,不包括栅栏页
 +0x010 FreeQueue : _LIST_ENTRY [ 0x12 - 0x0 ] //释放后使用的链表结构
 +0x010 TraceIndex : 0x12 				//在 UST 数据库中的追踪记录序号
 +0x018 StackTrace : 0x00346a60 		//指向 RTL_TRACE_BLOCK 结构的指针
 +0x01c EndStamp : 0xdcbabbbb 			//头结构的结束签名,固定为这个值

2. 启用页堆

1)对某个应用程序启用页堆

​ 在命令行中输入如下命令便对这个程序启用了 DPH:

!gflag /p /enable frecheck.exe /full 或 !gflag -i +hpa 

image-20220919105144491

​ 以上两个命令都会在注册表中建立子键 HKEY_LOCAL_MACHINE \SOFTWARE\Microsoft\Windows NT\CurrentVersion\ Image File Execution Options\frecheck.exe,并加入如下两个键值:

GlobalFlag (REG_SZ) = 0x02000000 
PageHeapFlags (REG_SZ) = 0x00000003 

​ 如果使用第一个命令,那么还会加入以下键值

VerifierFlags (REG_DWORD) = 1 
2)查看是否启用页堆

使用!gflag 命令

​ 在 WinDBG 中打开目标程序,输入!gflag 命令确认已经启用完全的 DPH:

0:000> !gflag \p
Current NtGlobalFlag contents: 0x02000000
    hpa - Place heap allocations at ends of pages

image-20220919105014006

查看全局变量 ntdll!RtlpDebugPageHeap 的值

​ 如果启用,那么它的值应该为 1

① 查看当前进程的堆列表:

0:000> !heap -p 
 Active GlobalFlag bits: 
 	hpa - Place heap allocations at ends of pages 
 StackTraceDataBase @ 00430000 of size 01000000 with 00000011 traces 
 PageHeap enabled with options: 
 	ENABLE_PAGE_HEAP COLLECT_STACK_TRACES 
 active heaps: 
 + 140000 ENABLE_PAGE_HEAP COLLECT_STACK_TRACES // DPH 
 	NormalHeap – 240000 						// 附属的普通堆
 		HEAP_GROWABLE 
……[省略数行] 
 + 16d0000 ENABLE_PAGE_HEAP COLLECT_STACK_TRACES 
 	NormalHeap - 17d0000 
 		HEAP_GROWABLE HEAP_CLASS_1

② “+”号后面的是页堆句柄,对于每个 DPH,堆管理器还会为其创建一个普通的堆,比如 16d0000 堆的普通堆是 17d0000

​ 如果!heap 命令中不包含/p 参数,那么列出的堆中只包含每个 DPH 的普通堆,不包含 DPH。如果要观察某个 DPH 的详细信息,那么应该在!heap 命令中加入 -p 开关,并用-h 来指定 DPH 的句柄

③ 查看当前进程的页堆

0:000> !heap -p -h 16d0000 
 _DPH_HEAP_ROOT @ 16d1000 							//DPH_HEAP_ROOT 结构的地址
 Freed and decommitted blocks 						//释放和已经归还给系统的块列表
 	DPH_HEAP_BLOCK : VirtAddr VirtSize 				//列表的标题行,目前内容为空
 Busy allocations 									//占用(已分配)的块
 	DPH_HEAP_BLOCK : UserAddr UserSize - VirtAddr VirtSize //列表的标题行
 _HEAP @ 17d0000 									//普通堆的句柄,亦即 HEAP 结构的地址
 	_HEAP_LOOKASIDE @ 17d0688 						//旁视列表("前端堆")的地址
 	_HEAP_SEGMENT @ 17d0640 						//段结构的地址
 	CommittedRange @ 17d0680 						//已提交区域的起始地址
 	HEAP_ENTRY Size Prev Flags UserPtr UserSize – state //普通堆上的堆块列表
 	* 017d0680 0301 0008 [01] 017d0688 01800 - (busy) 
 	  017d1e88 022f 0301 [10] 017d1e90 01170 - (free) 
 	  VirtualAllocdBlocks @ 17d0050 				//直接分配的大虚拟内存块列表头
posted @ 2024-01-28 19:40  修竹Kirakira  阅读(66)  评论(0编辑  收藏  举报