@作者: ay @文章出处: cnss-ay的博客@Notice: 转载请注明出处!若文章显示不完整,可以到文章出处阅读。
此文会涉及到一些普通堆的知识,这些内容可以参见我之前的文章 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代码如下:
#include "windows.h"
int main()
{
HANDLE heap_handle = HeapCreate( NULL , 1024 , 0 ) ;
char *temp = NULL ;
char *buffer = (char*)HeapAlloc(heap_handle , NULL , 128) ;
char *buffer1 = (char*)HeapAlloc(heap_handle , NULL , 121) ;
temp = buffer ;
for( int i = 0 ; i < 138 ; ++i )
{
*(temp++) = 'a' ;
}
HeapFree(heap_handle, 0 , buffer ) ;
HeapFree(heap_handle, 0 , buffer1 ) ;
HeapDestroy( heap_handle) ;
return 0 ;
}
在第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
for( int i = 0 ; i < 124 ; ++i )
那么windbg会中断在第19行
HeapDestroy( heap_handle) ;
提示内容如下
=======================================
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),检测以下这些点:
- 检测堆块管理结构的校验字段是否完整
- 是否块尾填充部分有被修改过
- 检测到未释放或者重复释放堆资源时,查看问题的堆块被分配时的调用栈
其实页堆还好,它有较强的实时性,所以并不需要太多手工调试的操作,越界读写都会立即触发异常并且中断,所以从这点看来,它是一些软件用来检测堆资源是否正确使用的必备良药~ 但是相对于页堆,准页堆的调试则需要更好的去了解准页堆工作原理了,因为它提供的堆块检测不是实时的,所以发现问题后,需要咱“精湛的调试内功“去找出源头,关于准页堆的东西,下回再说吧,敬请期待~