HEAP的概念
堆栈堆栈,在操作系统内存中有两种存储空间,一个是堆,一个是栈。堆主要用于存储用户动态分配的变量,而栈呢,则是存储我们程序过程中的临时变量。当然栈的作用远不止用作存储变量,但这不是我们这篇文章的讨论内容。
堆(HEAP)的分配,使用,回收都是通过微软的API来管理的,最常见的API是malloc和new。在往底层走一点呢,这两个函数都会调用HeapAlloc(RtlAllocateHeap)。同样的相关函数还有HeapFree用来释放堆,HeapCreate用来创建自己的私有堆。下面是这些函数的调用链:
HeapCreate->RtlCreateHeap->ZwAllocateVirtualMemory (这里会直接申请一大片内存,至于申请多大内存,由进程PEB结构中的字段觉得,HeapSegmentReserve字段指出要申请多大的虚拟内存,HeapSegmentCommit指明要提交多大内存,对虚拟内存的申请和提交概念不清楚的童鞋,请参见windows核心编程相关内容~)
HeapAlloc->RtlAllocateHeap(至于这里申请的内存,由于HeapCreate已经申请了一大片内存,堆管理器这片内存中划分一块出来以满足申请的需要。这一步申请操作是堆管理器自己维护的,仅当申请内存不够的时候才会再次调用ZwAllocateVirtualMemory )
HeapFree->RtlFreeHeap (对于释放的内存,堆管理器只是简单的把这块内存标志位已释放让后加入到空闲列表中,仅当空闲的内存达到一定阀值的时候会调用ZwFreeVirtualMeMory )
HeapDestroy->RtlDestroyHeap->ZwFreeVirtualMeMory (销毁我们申请的堆)
如何找到我们的HEAP信息?
WINDBG观察堆
源码:
#include "windows.h"
int main()
{
HANDLE heap_handle = HeapCreate( NULL , 0x1000 , 0x2000 ) ;
char *buffer = (char*)HeapAlloc(heap_handle , NULL , 128) ;
char *buffer1 = (char*)HeapAlloc(heap_handle , NULL , 121) ;
HeapFree(heap_handle, 0 , buffer ) ;
HeapFree(heap_handle, 0 , buffer1 ) ;
HeapDestroy( heap_handle) ;
return 0 ;
}
该源码生成编译生成heap.exe,然后用windbg调试这个程序,在main函数下断,紧接着执行第五行语句,执行结果如下
0:000> p
eax=002e1ca0 ebx=00000000 ecx=6d29b6f0 edx=00000000 esi=00000001 edi=01033374
eip=01031012 esp=0022fe8c ebp=0022feac iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202
heap!main+0x12:
01031012 ff150c200301 call dword ptr [heap!_imp__HeapCreate (0103200c)] ds:0023:0103200c={kernel32!HeapCreateStub (769a29d7)}
0:000> p
eax=002c0000 ebx=00000000 ecx=77429897 edx=77498500 esi=00000001 edi=01033374
eip=01031018 esp=0022fe98 ebp=0022feac iopl=0 nv up ei pl nz na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000206
heap!main+0x18:
01031018 8945fc mov dword ptr [ebp-4],eax ss:0023:0022fea8=6d222201
0:000> !heap
Index Address Name Debugging options enabled
1: 00300000
2: 00010000
3: 00020000
4: 002e0000
5: 002c0000
HeapCreate执行的返回值存放在eax处,这个函数返回了一个堆句柄:0x002c0000。用!heap命令查看可以看到第五个堆就是我们创建的堆句柄了。
每个进程都存在多个堆,我们也可以通过PEB结构来得到进程中存在的堆,结果和!heap命令显示的内容是一样的。
!peb 命令得到peb地址xxxx,dt _PEB XXXXX命令查看peb结构
+0x018 ProcessHeap : 0x00300000 Void ; 进程的默认堆
+0x068 NtGlobalFlag : 0 ; 这个标志位记录了当前堆调试模式,0为普通调试模式
+0x078 HeapSegmentReserve : 0x100000 ; 进程在新建堆的时候默认申请的虚拟内存大小
+0x07c HeapSegmentCommit : 0x2000 ; 进程在每次申请提交的虚拟内存大小,在提交的内存用完后,进程会又在一次提交HeapSegmentCommit中指定的内存大小
+0x080 HeapDeCommitTotalFreeThreshold : 0x10000 ; 当释放的内存大小大于这个阀值,就进行内存解除提交操作
+0x084 HeapDeCommitFreeBlockThreshold : 0x1000 ; 当一次性释放的块大小超过这个阀值,就进行内存解除提交操作,只有当满足这两个条件时才会调用ZwFreeVirtualMeMory 释放物理内存
+0x088 NumberOfHeaps : 5 ; 当前进程的堆数目,这个数目对应着!heap命令的堆显示个数
+0x08c MaximumNumberOfHeaps : 0x10 ; 进程所能运行的最大堆数目,若堆数目超过这个值估计HeapCreate就失败了吧
+0x090 ProcessHeaps : 0x77498500 -> 0x00300000 Void ;存储堆句柄的数组,这里我们可以得到进程的所有堆句柄
我们可以输入如下命令来查看现有的堆句柄
0:000> dd 0x77498500
77498500 00300000 00010000 00020000 002e0000
77498510 002c0000 00000000 00000000 00000000
77498520 00000000 00000000 00000000 00000000
77498530 00000000 00000000 00000000 00000000
77498540 00000000 77498340 7749bb08 77498220
77498550 00000000 00000000 00000000 00000000
77498560 77498220 00317bd0 00000000 00000000
77498570 00000000 00000000 00000000 00000000
可以看得到这里面的内容和!heap命令的输出结果是一样的
而堆句柄的存放范围,从MaximumNumberOfHeaps 上来看,就是77498500-77498540这0x40个字节,因为每个堆句柄占4个字节,0x10个堆句柄的存放空间就是0x40。
HEAP的组织结构
堆的管理,我们可以理解为一个内存池,它申请一大块空间,然后负责接管应用程序的申请释放等请求。只有在创建堆,释放堆(注意!是释放堆,不是堆中的空间!)在这之前,我们需要对堆有关的数据结构做一些解释
我这里观察到的HEAP结构,HEAP_SEGMENT结构和HEAP_ENTRY结构都和软件调试里面描述的不一样,当年奎哥写软件调试的时候估计还没用上WIN7吧。。。我的演示系统是WIN7
HeapCreate函数返回的堆句柄其实就是一个指向堆管理结构的指针,每个堆都会涉及到这样三个结构:HEAP,HEAP_SEGMENT,HEAP_ENTRY
HEAP_ENTRY结构:
在堆管理中,每一块申请下来的内存都会有下面所示的固定模式:
HEAP_ENTRY(8 bytes) |
我们new或malloc分配的空间 |
固定填充空间 |
这个结构用来记录所分配的空间的信息,包括用户申请的空间,填充的空间,所在的段号等等信息。所以我们new或者malloc的地址减去8就指向该结构。第三部分的固定填充空间是为了内存对齐而生成的,当然这部分空间还有一部分是用来额外记录这块内存的其它信息,这里就不详细做介绍了。
HEAP_SEGMENT结构:
我们可以这么认为,堆申请内存的大小是以段为单位的,当新建一个堆的时候,系统会默认为这个堆分配一个段叫0号段,通过刚开始的new和malloc分配的空间都是在这个段上分配的,当这个段用完的时候,如果当初创建堆的时候指明了HEAP_GROWABLE这个标志,那么系统会为这个堆在再分配一个段,这个时候新分配的段就称为1号段了,以下以此类推。每个段的开始初便是HEAP_SEGMENT结构的首地址,由于这个结构也是申请的一块内存,所以它前面也会有个HEAP_ENTRY结构:
HEAP_ENTRY(8 bytes) |
HEAP_SEGMENT |
HEAP_ENTRY(8 bytes) |
我们new或malloc分配的空间 |
固定填充空间 |
HEAP_SEGMENT结构会记录段的一些基本信息,该段申请的大小,已经提交内存的大小,第一个HEAP_ENTRY结构的入口点。(我观察看貌似段申请的内存并不会一次性全部提交,而是每次提交一个页的大小,比如一个段大小2个页,那么它会先提交一个页内存,若用完了再提交一个页的内存,若内存还用完了那就新建一个段,这个新建的段也会是先提交一个页内存。)但是0号段很特别,这个段的起始地址就是堆句柄指针指向的值,也就是说,HeapCreate返回的堆句柄总是指向0号段,为什么呢?因为HEAP结构是HEAP_ENTRY,HEAP_SEGMENT的合体加长版~
HEAP结构:
HEAP结构则是记录了这个堆的信息,这个结构可以找到HEAP_SEGMENT链表入口,空闲内存链表的入口,内存分配粒度等等信息。HEAP的首地址便是堆句柄的值,但是堆句柄的值又是0号段的首地址也是堆句柄,何解?其实很简单,0号段的HEAP_SEGMENT就在HEAP结构里面,HEAP结构类定义如这样:
在我们看来,内存组织结构应该如下所示:
HEAP_ENTRY(8 bytes) |
HEAP_SEGMENT |
HEAP |
更确切的说,HEAP结构中本身就包含了HEAP_ENTRY和HEAP_SEGMENT,HEAP_ENTRY结构是HEAP的第一个数据成员,HEAP_SEGMENT是它第二个数据成员。而对于HEAP_SEGMENT,它的第一个数据成员便是HEAP_ENTRY。这里为了方便理解,才在内存组织结构中把它们拆开展示。(注:这里是win7的情况,和软件调试这本书中所描述的有一些差异,也属正常现象,毕竟这部分结构微软并未公开)
用WINDBG观察HEAP结构
在之前已经演示了如何从PEB结构中找到所有的堆句柄,可以看到002c0000便是我们创建的句柄。然后我们执示例程序的第7行代码。执行完后结果如下:
0:000> p
eax=002c0000 ebx=00000000 ecx=77429897 edx=77498500 esi=00000001 edi=01033374
eip=01031026 esp=0022fe8c ebp=0022feac iopl=0 nv up ei pl nz na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000206
heap!main+0x26:
01031026 ff1500200301 call dword ptr [heap!_imp__HeapAlloc (01032000)] ds:0023:01032000={ntdll!RtlAllocateHeap (774120b5)}
0:000> p
eax=002c0590 ebx=00000000 ecx=774134b4 edx=002c0180 esi=00000001 edi=01033374
eip=0103102c esp=0022fe98 ebp=0022feac iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
heap!main+0x2c:
0103102c 8945f0 mov dword ptr [ebp-10h],eax ss:0023:0022fe9c={heap!envp (0103301c)}
可以看到EAX保存的返回值为002c0590。我们通过两种途径来观察我们申请的内存,通过!heap命令观察和通过dt命令观察
通过!HEAP命令观察
0:000> !heap -a 2c0000
Index Address Name Debugging options enabled
5: 002c0000
Segment at 002c0000 to 002c2000 (00001000 bytes committed)
Flags: 00001000
ForceFlags: 00000000
Granularity: 8 bytes
Segment Reserve: 00100000
Segment Commit: 00002000
DeCommit Block Thres: 00000200
DeCommit Total Thres: 00002000
Total Free Size: 0000013a
Max. Allocation Size: 7ffdefff
Lock Variable at: 002c0138
Next TagIndex: 0000
Maximum TagIndex: 0000
Tag Entries: 00000000
PsuedoTag Entries: 00000000
Virtual Alloc List: 002c00a0
Uncommitted ranges: 002c0090
002c1000: 00001000 (4096 bytes)
FreeList[ 00 ] at 002c00c4: 002c0618 . 002c0618
002c0610: 00088 . 009d0 [100] - free
Segment00 at 002c0000:
Flags: 00000000
Base: 002c0000
First Entry: 002c0588
Last Entry: 002c2000
Total Pages: 00000002
Total UnCommit: 00000001
Largest UnCommit:00000000
UnCommitted Ranges: (1)
Heap entries for Segment00 in Heap 002c0000
002c0000: 00000 . 00588 [101] - busy (587)
002c0588: 00588 . 00088 [101] - busy (80)
002c0610: 00088 . 009d0 [100]
002c0fe0: 009d0 . 00020 [111] - busy (1d)
002c1000: 00001000 - uncommitted bytes.
这个命令分别提炼出了HEAP, HEAP_SEGMENT和HEAP_ENTRY结构中的信息。虽然在灰色区域中,我们找不到2c0590,但是找到了一个2c0588,这个正是2c0590-8的结果,也就是说最右边的地址是每个HEAP_ENTRY的首地址,接着00588这个字段表示了前面一个HEAP_ENTRY所占用的大小,后面的0088表示这个内存块的总大小,即我们申请的内存+HEAP_ENTRY(128+8=0x80+0x8=0x88),[101]是这块内存的标志位,最右边一位为1表示该内存块被占用。然后busy(80)就是解释说这块内存是被占用的(非空闲的),它申请的内存为0x80,转化成十进制正好就是我们申请的128字节大小。
dt _HEAP_ENTRY 2c0588命令查看对应的结构信息
通过DT命令观察
同样的,已知HEAP的首地址,那么先从HEAP下手好了,dt _HEAP 002c0000可以显示HEAP的数据结构
ntdll!_HEAP
+0x000 Entry : _HEAP_ENTRY
+0x008 SegmentSignature : 0xffeeffee
+0x00c SegmentFlags : 0
+0x010 SegmentListEntry : _LIST_ENTRY [ 0x2c00a8 - 0x2c00a8 ]
+0x018 Heap : 0x002c0000 _HEAP
+0x01c BaseAddress : 0x002c0000 Void
+0x020 NumberOfPages : 2
+0x024 FirstEntry : 0x002c0588 _HEAP_ENTRY
+0x028 LastValidEntry : 0x002c2000 _HEAP_ENTRY
+0x02c NumberOfUnCommittedPages : 1
+0x030 NumberOfUnCommittedRanges : 1
+0x034 SegmentAllocatorBackTraceIndex : 0
+0x036 Reserved : 0
+0x038 UCRSegmentList : _LIST_ENTRY [ 0x2c0ff0 - 0x2c0ff0 ]
+0x040 Flags : 0x1000
+0x044 ForceFlags : 0
+0x048 CompatibilityFlags : 0
+0x04c EncodeFlagMask : 0x100000
+0x050 Encoding : _HEAP_ENTRY
+0x058 PointerKey : 0x17c06e63
+0x05c Interceptor : 0
+0x060 VirtualMemoryThreshold : 0xfe00
+0x064 Signature : 0xeeffeeff
+0x068 SegmentReserve : 0x100000
+0x06c SegmentCommit : 0x2000
+0x070 DeCommitFreeBlockThreshold : 0x200
+0x074 DeCommitTotalFreeThreshold : 0x2000
+0x078 TotalFreeSize : 0x13a
+0x07c MaximumAllocationSize : 0x7ffdefff
+0x080 ProcessHeapsListIndex : 5
+0x082 HeaderValidateLength : 0x138
+0x084 HeaderValidateCopy : (null)
+0x088 NextAvailableTagIndex : 0
+0x08a MaximumTagIndex : 0
+0x08c TagEntries : (null)
+0x090 UCRList : _LIST_ENTRY [ 0x2c0fe8 - 0x2c0fe8 ]
+0x098 AlignRound : 0xf
+0x09c AlignMask : 0xfffffff8
+0x0a0 VirtualAllocdBlocks : _LIST_ENTRY [ 0x2c00a0 - 0x2c00a0 ]
+0x0a8 SegmentList : _LIST_ENTRY [ 0x2c0010 - 0x2c0010 ]
+0x0b0 AllocatorBackTraceIndex : 0
+0x0b4 NonDedicatedListLength : 0
+0x0b8 BlocksIndex : 0x002c0150 Void
+0x0bc UCRIndex : (null)
+0x0c0 PseudoTagEntries : (null)
+0x0c4 FreeLists : _LIST_ENTRY [ 0x2c0618 - 0x2c0618 ]
+0x0cc LockVariable : 0x002c0138 _HEAP_LOCK
+0x0d0 CommitRoutine : 0x17c06e63 long +17c06e63
+0x0d4 FrontEndHeap : (null)
+0x0d8 FrontHeapLockCount : 0
+0x0da FrontEndHeapType : 0 ''
+0x0dc Counters : _HEAP_COUNTERS
+0x130 TuningParameters : _HEAP_TUNING_PARAMETERS
就如本文前面所述的,第一个字段是HEAP_ENTRY结构,接着应该是HEAP_SEGMENT,这里只不过把HEAP_SEGMENT结构的字段展开了,可以dt _HEAP_SEGMENT来观察下这个结构的字段
0:000> 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结构中灰色部分是和HEAP_SEGMENT结构中的字段是重复的,也就是说灰色部分字段便是HEAP_SEGMENT结构。在HEAP_SEGMENT结构中,我们可以找到FirstEntry字段,这里指的便是我们的分配的内存,不过HEAP_ENTRY结构无法观察,这里便没办法枚举出所有的HEAP_ENTRY结构了,但是说一下思路:
每个HEAP_ENTRY和它对应的内存我们可以称为一个内存块,计算下一个内存块需要用到现有内存块中的2个字段,Size和UnsedBytes,Size的值乘上粒度(就是0:000> !heap -a 2c0000命令显示的信息中的Granularity: 8 bytes字段,这里是8字节),下一个内存块地址就是 本内存块地址+Size*8+UnsedBytes。当然这里的粒度可以通过HEAP字段中的AlignMask 字段算出来。
HEAP的分配粒度
在HEAP结构中指明了分配粒度,这个分配粒度是说每次堆分配的时候,都以这个粒度为最小单位,这里看到粒度为8字节。所以这里就有了第二次分配内存的实验,我们让程序执行第9行,然后用!heap -a 002c0000观察分配情况
Heap entries for Segment00 in Heap 002c0000
002c0000: 00000 . 00588 [101] - busy (587)
002c0588: 00588 . 00088 [101] - busy (80)
002c0610: 00088 . 00088 [101] - busy (79)
002c0698: 00088 . 00948 [100]
002c0fe0: 00948 . 00020 [111] - busy (1d)
002c1000: 00001000 - uncommitted bytes.
这里可以看出多出了一个占用块,大小是0x79(121) bytes,但是实际分配的大小还是0x 88 (128)bytes,这是因为系统是以8 bytes为粒度分配的,所以为这块121 bytes的内存自动填充了7个字节,可见申请121 bytes和申请128 bytes所使用的空间是一样的。
HEAP的释放和销毁
执行了11行和12行的代码后,堆中的内容分别如下:
执行11行代码的堆情况
FreeList[ 00 ] at 002c00c4: 002c06a0 . 002c0590
002c0588: 00588 . 00088 [100] – free ;空闲列表中多出了一块内存
002c0698: 00088 . 00948 [100] – free ;空闲内存,空闲空间为948
Heap entries for Segment00 in Heap 002c0000
002c0000: 00000 . 00588 [101] - busy (587)
002c0588: 00588 . 00088 [100] ;原先的这块内存释放掉了
002c0610: 00088 . 00088 [101] - busy (79)
002c0698: 00088 . 00948 [100] ; 空闲内存
002c0fe0: 00948 . 00020 [111] - busy (1d)
002c1000: 00001000 - uncommitted bytes.
执行12行代码的堆情况
FreeList[ 00 ] at 005c00c4: 005c0590 . 005c0590
005c0588: 00588 . 00a58 [100] – free ;回收了buffer1的内存后,由于由于空闲内存是连续的,所以直接合并成一块内存。可以看到之前内存free空间是948,现在合并了以后便是948+88+88=a58,也就是当前内存大小
Heap entries for Segment00 in Heap 005c0000
005c0000: 00000 . 00588 [101] - busy (587)
005c0588: 00588 . 00a58 [100]
005c0fe0: 00a58 . 00020 [111] - busy (1d)
005c1000: 00001000 - uncommitted bytes.
最后执行14行代码,对堆进行释放,释放后我们通过!heap也可以看到只有4个堆了,我们申请的堆被释放了.
0:000> !heap
Index Address Name Debugging options enabled
1: 00300000
2: 00010000
3: 00020000
4: 002e0000
至于HEAP_ENTRY结构的问题,有时间在调试看看是怎么回事吧~另外,这里说明下,new和malloc内部都会调用HeapAlloc来申请内存,但是堆句柄从哪来呢?它会检测_crtheap变量是否为空,若不为空则拿_crtheap变量来作为自己的堆句柄去调用HeapAlloc
此文会涉及到一些普通堆的知识,这些内容可以参见我之前的文章 WINDBG的堆调试--了解HEAP组织
堆破坏
所谓的堆破坏,是说没控制好自己的指针,把不属于你分配的那块内存给写覆盖了。这块内存可能是你程序的数据,也可能是堆的管理结构。那么这个会导致怎样的后果呢?可能的情况我们来yy下
- 把程序里的计算结果覆盖了,这也许会让你重复看了N次代码,校验了N次计算逻辑也搞不明白为何计算结果还是有问题
- 堆管理结构被破坏了,new/delete,或者malloc/free操作失败
- 等等等等~
堆破坏较为理想的情况是被修改的数据会马上导致程序crash,最差的情况是你的堆数据莫名其妙在今天被改了,但明天才crash。这个时候在去分析crash,就如我们的警察叔叔现在接手一桩10年前的案子一般----无从下手。老外称之为heap corruption是很贴切的,有时候咱堆数据被意外篡改是无声无息的,你也许没法从界面甚至日志文件中看到它被篡改的一点迹象,当到某一个时刻,这种错误会暴露出来,然而这个时候查看堆信息也许会是毫无头绪。所以对于堆破坏,咱的策略是尽早发现我们的堆被篡改了,最好能够在堆数据被意外篡改的那一时刻诱发一个异常来提醒我们----兄弟,你的堆被腐蚀了。
微软提供了一些方案,来帮助我们诊断堆破坏。一般来说,堆破坏往往都是写数据越界造成的(yy的第二种情况,如果是第一种情况其实还简单,下个内存断点就好),所以微软在堆分配上,给程序员门额外提供了2种堆分配模式--完全页堆(full page heap),准页堆(normal page heap),用来检测堆被写越界的情况。
完全页堆(full page heap)
检测原理
完全页堆的检测基本思路是通过分配相邻的一个页,并将其设为不可访问属性,然后用户数据块会被分配到内存页的最末端,从而实现越界访问的检测。当我们对堆中分配的内存读写越界后便会访问到那个不可读的页,系统捕获到改次异常后会试图中断执行并将该异常上报给debugger,或者崩溃。具体的内存组织结构如下图
摘自《软件调试》
与普通堆不同的是,内存块前面的HEAP_ENTRY结构被DPH_BLOCK_INFORMATION结构取代,这个结构内部记录了页堆模式下这个内存块的一些基本信息。如果用户数据区前面的数据,也就是DPH_BLOCK_INFORMATION结构被破坏了,那么在释放内存块的时候系统会报错,如果编程者对这块内存块读写越界了,当然,这里越界有几种情况:
- 读越界,但只是访问了块尾填充部分数据,那么系统不会报错
- 写越界,但只篡改了图中块尾填充的部分,那么在堆块释放的时候会报错
- 读越界,且超过了块尾填充的部分,访问到了栅栏页,那么系统会立即抛出一个异常并中断执行
- 写越界,且超过了块尾填充部分,写到了栅栏页,那么系统会立即抛出一个异常并中断执行
这里需要注意的还是块尾填充不一定存在,块尾填充是因为要满足堆内存的最小分配粒度,如果本身内存块的分配粒度就已经是最小分配粒度的倍数了,那么块尾填充就不存在了,比如堆内存分配粒度是是8 bytes,那么如果申请了14 bytes的话会有2 bytes的大徐小的块尾填充块,如果申请了24bytes,那么就没有块尾填充了,因为24正好是8的倍数。
示例
开启全页堆(用windbg目录下的gflags或者装一个appverifier都可以开启),通过自己写的一个heap.exe来看一下如何使用全页堆检测堆破坏情况heap.exe代码如下:
在第14行向buffer写入138字节,这显然越界了,然后在用windbg启动heap.exe,直接运行,会发现报错如下
0:000> g
(1f50.1f54): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000080 ebx=00000000 ecx=02596000 edx=02596000 esi=00000001 edi=00193374
eip=00191068 esp=0016fdc8 ebp=0016fddc iopl=0 nv up ei ng nz ac pe cy
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010297
heap!main+0x68:
00191068 c60161 mov byte ptr [ecx],61h ds:0023:02596000=??
报了一个内存访问错误,然后看一下调用堆栈
0:000> kb
ChildEBP RetAddr Args to Child
0016fddc 0019120f 00000001 023fbfd0 0239df48 heap!main+0x68 [d:\projects\heap\main.cpp @ 14]
0016fe20 765b1114 7ffd3000 0016fe6c 778eb429 heap!__tmainCRTStartup+0x10f [f:\dd\vctools\crt_bld\self_x86\crt\src\crtexe.c @ 582]
0016fe2c 778eb429 7ffd3000 757369d8 00000000 kernel32!BaseThreadInitThunk+0xe
0016fe6c 778eb3fc 00191357 7ffd3000 00000000 ntdll!__RtlUserThreadStart+0x70
0016fe84 00000000 00191357 7ffd3000 00000000 ntdll!_RtlUserThreadStart+0x1b
可以看到是第14行报的错,但是14行的代码运行了那么多次,我们再看一下这个时候变量i的值是多少
0:000> dv i
i = 0n128
显然,在填充第128字节的时候,我们的temp指针访问到了栅栏页,从而报出了一个内存违规的异常。
这里顺带看一下如果我们分配的内存不是8 bytes的情况(一般堆内存分配粒度是8 bytes,所以申请128 bytes的内存时是不会有块尾填充部分的)
那我们接下来看另外一段代码
我们把第10行的temp = buffer改成temp = buffer1
因为buffer1申请了121 bytes,也就是说它有7 bytes的填充字节
0:000> g
(1ba0.1ba4): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000080 ebx=00000000 ecx=024c8000 edx=024c8000 esi=00000001 edi=00033374
eip=00031068 esp=002cfb80 ebp=002cfb94 iopl=0 nv up ei ng nz ac pe cy
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010297
heap!main+0x68:
00031068 c60161 mov byte ptr [ecx],61h ds:0023:024c8000=??
0:000> dv i
i = 0n128
可以看到变量i还是128,也就是说我们还是在访问到第128字节后才引发访问异常,而不是我们期望的121字节后就引发异常。
这里也就是说如果我们的代码中对申请的堆内存写越界了,写数据覆盖块尾填充部分的时候并不会引发异常!
但是,这并不代表我们的写越界问题不会被发现。块尾填充部分是会被填充上固定数据的,系统在适合的时机(比如销毁堆的时候)会校验块尾填充块,如果发现块尾填充块数据有变,那么便会报一个verifier异常,比如我们把代码中的for循环次数改为124
那么windbg会中断在第19行
提示内容如下
=======================================
VERIFIER STOP 0000000F: pid 0x1E3C: Corrupted suffix pattern for heap block.
025A1000 : Heap handle used in the call.
025A7F80 : Heap block involved in the operation.
00000079 : Size of the heap block.
025A7FF9 : Corruption address.
=======================================
This verifier stop is not continuable. Process will be terminated
when you use the `go' debugger command.
=======================================
(1e3c.143c): Break instruction exception - code 80000003 (first chance)
eax=6c75e994 ebx=6c75cf58 ecx=00000002 edx=002bf461 esi=00000000 edi=000001ff
eip=6c753c38 esp=002bf6b4 ebp=002bf8b8 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202
vrfcore!VerifierStopMessageEx+0x543:
6c753c38 cc int 3
提示说的很清楚了,appverifier指出了堆和具体的内存块,我们这个时候查看buffer1的值是0x025a7f80 ,正好就是出问题的堆块,出问题的地址是0x025a7ff79,正好就是buffer1内存块的边界,错误原因是Corrupted suffix pattern for heap block,也就是说咱块尾填充部分(suffix pattern for heap block)被破坏(corrupted)了
结论:只要写越界,系统都能够检测出来,只不过如果写越界写到了栅栏页会理解触发异常中断,而写越界只写了块尾填充部分,那么系统在适当时机(比如堆被销毁,或者这块内存被重新分配等时机)会对块尾填充部分做完整性检测,如果发现被破坏了,就会报错。当然,你可以根据错误号(蓝色字体部分)信息去appverifier的帮助文档中查找更详细的错误说明。
结构详解
这次咱来倒叙,先从最基本的内存堆块结构DPH_BLOCK_INFORMATION开始介绍,DPH_BLOCK_INFORMATION结构微软也有对应文档介绍
(摘自MSDN)
其中prefix start magic和prefix end magic是校验块,用来检测DPH_BLOCK_INFORMATION是否被破坏,这些检测部分属于DPH_BLOCK_INFORMATION结构。我们先来用windbg探究下DPH_BLOCK_INFORMATION这个最基本的结构.再一次,我们打开windbg调试heap.exe.运行到第10行,这个时候变量的值是
0:000> dv heap_handle
heap_handle = 0x024a0000
0:000> dv buffer
buffer = 0x024a5f80 "???"
0:000> dv buffer1
buffer1 = 0x024a7f80 "???"
这里可以看到一个很有趣的现象,buffer1和buffer的地址正好相差8K,也就是两个页的大小.这当然是因为页堆的原因啦,其实这两块内存分配是相邻着的,虚拟内存结构如下图所示
buffer内存块(4K) | 栅栏页(4K) | buffer1内存块(4K) | 栅栏页(4K) |
由于buffer和buffer1分配的大小是一样的(buffer1加上尾部填充块和buffer的大小相同),所以这两块内存正好相差8K
而DPH_BLOCK_INFORMATION就在我们申请的内存块指针的前0x20字节处,用dt命令看的结果如下:
0:000> dt _DPH_BLOCK_INFORMATION 0x024a5f80-0x20
verifier!_DPH_BLOCK_INFORMATION
+0x000 StartStamp : 0xabcdbbbb
+0x004 Heap : 0x024a1000 Void
+0x008 RequestedSize : 0x80
+0x00c ActualSize : 0x1000
+0x010 Internal : _DPH_BLOCK_INTERNAL_INFORMATION
+0x018 StackTrace : 0x003d9854 Void
+0x01c EndStamp : 0xdcbabbbb
0x024a5f80-0x20就是DPH_BLOCK_INFORMATION结构的地址。DPH_BLOCK_INFORMATION结构在已分配和已释放的状态下,StartStamp和EndStamp(也就是MSDN图中的prefix start magic和prefix end magic)是不同的,显然dt输出的结果看来,这个内存块是已分配状态。StackTrace记录了分配这个内存块时的调用栈,可以用dds来看一下这个内存块被分配时候的调用栈
0:000> dds 0x003d9854
003d9854 00000000
003d9858 00004001
003d985c 00090000
003d9860 5b3b8e89 verifier!AVrfDebugPageHeapAllocate+0x229
003d9864 776d5c4e ntdll!RtlDebugAllocateHeap+0x30
003d9868 77697e5e ntdll!RtlpAllocateHeap+0xc4
003d986c 776634df ntdll!RtlAllocateHeap+0x23a
003d9870 003b1030 heap!main+0x30 [d:\projects\heap\main.cpp @ 8]
003d9874 003b120c heap!__tmainCRTStartup+0x10f [f:\dd\vctools\crt_bld\self_x86\crt\src\crtexe.c @ 582]
003d9878 76451114 kernel32!BaseThreadInitThunk+0xe
003d987c 7766b429 ntdll!__RtlUserThreadStart+0x70
003d9880 7766b3fc ntdll!_RtlUserThreadStart+0x1b
输出结果我们可以看到这个内存块是在main.cpp,也就是我们的示例代码的第8行分配的,第8行是char *buffer = (char*)HeapAlloc(heap_handle , NULL , 128) 正好就是分配buffer内存的那条语句。这个结构的其它字段,顾名思义,ActualSize指明了实际分配字节数,0x1000 bytes也就是4K大小,Internal这个字段保存了个内部结构,用windbg也看不出这个结构信息。
当然为了防止内存块前面的数据被冲刷掉,除了DPH_BLOCK_INFORMATION外,系统还通过DPH_HEAP_BLOCK保存了所分配内存块的信息,
通过!heap –p –h [address] 可以查看到页堆的信息
0:000> !heap -p -h 0x024a0000 //heap_handle的值
_DPH_HEAP_ROOT @ 24a1000
Freed and decommitted blocks
DPH_HEAP_BLOCK : VirtAddr VirtSize
Busy allocations
DPH_HEAP_BLOCK : UserAddr UserSize - VirtAddr VirtSize
024a1f6c : 024a5f80 00000080 - 024a5000 00002000
024a1f38 : 024a7f80 00000079 - 024a7000 00002000
可以看到,buffer内存块对应的DPH_HEAP_BLOCK结构地址是024a1f6c
0:000> dt _DPH_HEAP_BLOCK 024a1f6c
verifier!_DPH_HEAP_BLOCK
+0x000 NextFullPageHeapDelayedNode : 0x024a1020 _DPH_HEAP_BLOCK
+0x004 DelayQueueEntry : _DPH_DELAY_FREE_QUEUE_ENTRY
+0x000 LookasideEntry : _LIST_ENTRY [ 0x24a1020 - 0x0 ]
+0x000 UnusedListEntry : _LIST_ENTRY [ 0x24a1020 - 0x0 ]
+0x000 VirtualListEntry : _LIST_ENTRY [ 0x24a1020 - 0x0 ]
+0x000 FreeListEntry : _LIST_ENTRY [ 0x24a1020 - 0x0 ]
+0x000 TableLinks : _RTL_BALANCED_LINKS
+0x010 pUserAllocation : 0x024a5f80 "???"
+0x014 pVirtualBlock : 0x024a5000 "???"
+0x018 nVirtualBlockSize : 0x2000
+0x01c Flags : _DPH_HEAP_BLOCK_FLAGS
+0x020 nUserRequestedSize : 0x80
+0x024 AdjacencyEntry : _LIST_ENTRY [ 0x24a1f5c - 0x24a1fc4 ]
+0x02c ThreadId : 0x3f4
+0x030 StackTrace : 0x003d9854 Void
从dt的数据看来,这个结构大小为0x34,buffer和buffer1的DPH_HEAP_BLOCK结构首地址正好也是相差0x34,说明这两个结构是紧挨着的,下一步在让我们来看看DPH_HEAP_BLOCK结构是如何组织的。
摘自《软件调试》
这个是整个的页堆结构图,我们先来说说DPH_HEAP_BLOCK的组织吧,在图中0x16d00000是页堆的首地址,也就是页堆的句柄,我们调试器中,页堆首地址则是0x024a0000,为了数据统一,我还是拿0x024a0000作为堆句柄来讲解。我们的DPH_HEAP_BLOCK其实就在堆块节点池里边,我们可以近似把这个节点池看成一个大型的DPH_HEAP_BLOCK数组,但有个地方在软件调试中没有提到,就是在win7下,运行时这些DPH_HEAP_BLOCK结构都是以二叉平衡数的结构来组织的,这个树的结构的入口正是在TableLinks字段内,这么做的原因也大概是因为能够在分配时更快的索。我们再看看DPH_HEAP_ROOT结构,这个结构储存了整个页堆的必要信息,它就相当于普通堆的_HEAP结构。
0:000> dt _dph_heap_root 24a1000
verifier!_DPH_HEAP_ROOT
+0x000 Signature : 0xffeeddcc
+0x004 HeapFlags : 0x1002
+0x008 HeapCritSect : 0x024a16cc _RTL_CRITICAL_SECTION
+0x00c NodesCount : 0x2c
+0x010 VirtualStorageList : _LIST_ENTRY [ 0x24a1fa0 - 0x24a1fa0 ]
+0x018 VirtualStorageCount : 1
+0x01c PoolReservedLimit : 0x024a5000 Void
+0x020 BusyNodesTable : _RTL_AVL_TABLE
+0x058 NodeToAllocate : (null)
+0x05c nBusyAllocations : 2
+0x060 nBusyAllocationBytesCommitted : 0x4000
+0x064 pFreeAllocationListHead : (null)
+0x068 FullPageHeapDelayedListTail : (null)
+0x06c DelayFreeQueueHead : (null)
+0x070 DelayFreeQueueTail : (null)
+0x074 DelayFreeCount : 0
+0x078 LookasideList : _LIST_ENTRY [ 0x24a1078 - 0x24a1078 ]
+0x080 LookasideCount : 0
+0x084 UnusedNodeList : _LIST_ENTRY [ 0x24a1ed0 - 0x24a16e4 ]
+0x08c UnusedNodeCount : 0x28
+0x090 nBusyAllocationBytesAccessible : 0x2000
+0x094 GeneralizedFreeList : _LIST_ENTRY [ 0x24a1f04 - 0x24a1f04 ]
+0x09c FreeCount : 1
+0x0a0 PoolCommitLimit : 0x024a2000 Void
+0x0a4 NextHeap : _LIST_ENTRY [ 0x5b3e9a58 - 0x23a10a4 ]
+0x0ac ExtraFlags : 3
+0x0b0 Seed : 0xfed6f13a
+0x0b4 NormalHeap : 0x027d0000 Void
+0x0b8 CreateStackTrace : 0x003d9824 _RTL_TRACE_BLOCK
+0x0bc ThreadInHeap : (null)
+0x0c0 BusyListHead : _LIST_ENTRY [ 0x24a10c0 - 0x24a10c0 ]
+0x0c8 SpecializedFreeList : [64] _LIST_ENTRY [ 0x24a10c8 - 0x24a10c8 ]
+0x2c8 DelayFreeListLookup : [257] (null)
+0x6cc HeapCritSectionStorage : _RTL_CRITICAL_SECTION
这里边维护了很多运行时信息,比如说DPH_BLOCK_INFORMATION中的那个二叉树入口其实就是保存在BusyNodesTable 字段,这里面记录了所有被分配了的内存块所对应的DPH_BLOCK_INFORMATION。当然,这里面一些信息软件调试里面都有介绍,很多看名字也能够猜到大概意思,看名字猜不到啥意思的字段,其实我也猜不到。。。-_-|||在创建页堆后,所有内存分配都分配在页堆中,通过分配的地址也能看得出来(我们分配的内存都是024a打头),而非普通页堆中,普通页堆也仅仅只是保存一些系统内部使用的数据。一般来说,堆块节点池加上DPH_HEAP_ROOT结构大小正好是4个内存页,也就是16K。
优缺点
缺点:消耗大量虚拟内存,每块内存的分配粒度是2个页(8K),
优点:能够立即捕获越界读写操作,通过调用栈就可以追溯到问题源头。能够快速定位问题代码。
使用建议:32位下不适宜跑配置文件结构比较复杂的软件,让我们来假设一个xml配置文件下有3000个节点,每个节点有5个字符串描述属性,如果把这些配置文件信息转化为stl结构来保存,那么每个节点则需要为此分配5*8K的空间,3000项配置则需要3000*5*8K=117MB虚拟内存,如果每个节点信息再多一些呢?这样会导致虚拟内存耗尽从而出现一系列内存问题(比如,new失败)。当然64位就不存在这种问题了7T的虚拟内存空间,现在看来应该是够用了。
对于调试堆破坏来说,其实我们只要了解DPH_BLOCK_INFORMATION结构和DPH_HEAP_BLOCK中的基本字段就差不多了,这样更方便我们定位出错源头。比如在appverifier报错后(或者你程序自己莫名其妙崩溃或者数据被篡改后,要知道appverifier并不总是可信的),我们可以自己手动调试出错的堆块结构(DPH_BLOCK_INFORMATION,DPH_HEAP_BLOCK和DPH_HEAP_ROOT),检测以下这些点:
- 检测堆块管理结构的校验字段是否完整
- 是否块尾填充部分有被修改过
- 检测到未释放或者重复释放堆资源时,查看问题的堆块被分配时的调用栈
其实页堆还好,它有较强的实时性,所以并不需要太多手工调试的操作,越界读写都会立即触发异常并且中断,所以从这点看来,它是一些软件用来检测堆资源是否正确使用的必备良药~ 但是相对于页堆,准页堆的调试则需要更好的去了解准页堆工作原理了,因为它提供的堆块检测不是实时的,所以发现问题后,需要咱“精湛的调试内功“去找出源头,关于准页堆的东西,下回再说吧,敬请期待~