Ay's Blog@CNSSUESTC

WINDBG的堆调试—full page heap的堆破坏检测原理

@作者: ay @文章出处: cnss-ay的博客@Notice: 转载请注明出处!若文章显示不完整,可以到文章出处阅读。

 

此文会涉及到一些普通堆的知识,这些内容可以参见我之前的文章 WINDBG的堆调试--了解HEAP组织

堆破坏

所谓的堆破坏,是说没控制好自己的指针,把不属于你分配的那块内存给写覆盖了。这块内存可能是你程序的数据,也可能是堆的管理结构。那么这个会导致怎样的后果呢?可能的情况我们来yy下

  1. 把程序里的计算结果覆盖了,这也许会让你重复看了N次代码,校验了N次计算逻辑也搞不明白为何计算结果还是有问题
  2. 堆管理结构被破坏了,new/delete,或者malloc/free操作失败
  3. 等等等等~

堆破坏较为理想的情况是被修改的数据会马上导致程序crash,最差的情况是你的堆数据莫名其妙在今天被改了,但明天才crash。这个时候在去分析crash,就如我们的警察叔叔现在接手一桩10年前的案子一般----无从下手。老外称之为heap corruption是很贴切的,有时候咱堆数据被意外篡改是无声无息的,你也许没法从界面甚至日志文件中看到它被篡改的一点迹象,当到某一个时刻,这种错误会暴露出来,然而这个时候查看堆信息也许会是毫无头绪。所以对于堆破坏,咱的策略是尽早发现我们的堆被篡改了,最好能够在堆数据被意外篡改的那一时刻诱发一个异常来提醒我们----兄弟,你的堆被腐蚀了。

微软提供了一些方案,来帮助我们诊断堆破坏。一般来说,堆破坏往往都是写数据越界造成的(yy的第二种情况,如果是第一种情况其实还简单,下个内存断点就好),所以微软在堆分配上,给程序员门额外提供了2种堆分配模式--完全页堆(full page heap),准页堆(normal page heap),用来检测堆被写越界的情况。

完全页堆(full page heap)

检测原理

完全页堆的检测基本思路是通过分配相邻的一个页,并将其设为不可访问属性,然后用户数据块会被分配到内存页的最末端,从而实现越界访问的检测。当我们对堆中分配的内存读写越界后便会访问到那个不可读的页,系统捕获到改次异常后会试图中断执行并将该异常上报给debugger,或者崩溃。具体的内存组织结构如下图

image

摘自《软件调试》

 

与普通堆不同的是,内存块前面的HEAP_ENTRY结构被DPH_BLOCK_INFORMATION结构取代,这个结构内部记录了页堆模式下这个内存块的一些基本信息。如果用户数据区前面的数据,也就是DPH_BLOCK_INFORMATION结构被破坏了,那么在释放内存块的时候系统会报错,如果编程者对这块内存块读写越界了,当然,这里越界有几种情况:

  1. 读越界,但只是访问了块尾填充部分数据,那么系统不会报错
  2. 写越界,但只篡改了图中块尾填充的部分,那么在堆块释放的时候会报错
  3. 读越界,且超过了块尾填充的部分,访问到了栅栏页,那么系统会立即抛出一个异常并中断执行
  4. 写越界,且超过了块尾填充部分,写到了栅栏页,那么系统会立即抛出一个异常并中断执行

这里需要注意的还是块尾填充不一定存在,块尾填充是因为要满足堆内存的最小分配粒度,如果本身内存块的分配粒度就已经是最小分配粒度的倍数了,那么块尾填充就不存在了,比如堆内存分配粒度是是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结构微软也有对应文档介绍

ms220938.Local_-1265171613_fphbs(en-US,VS.80).gif

(摘自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结构是如何组织的。

image

摘自《软件调试》

 

这个是整个的页堆结构图,我们先来说说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),检测以下这些点:

  1. 检测堆块管理结构的校验字段是否完整
  2. 是否块尾填充部分有被修改过
  3. 检测到未释放或者重复释放堆资源时,查看问题的堆块被分配时的调用栈

其实页堆还好,它有较强的实时性,所以并不需要太多手工调试的操作,越界读写都会立即触发异常并且中断,所以从这点看来,它是一些软件用来检测堆资源是否正确使用的必备良药~ 但是相对于页堆,准页堆的调试则需要更好的去了解准页堆工作原理了,因为它提供的堆块检测不是实时的,所以发现问题后,需要咱“精湛的调试内功“去找出源头,关于准页堆的东西,下回再说吧,敬请期待~

posted on 2012-01-05 09:17 __ay 阅读(10832) 评论(0)  编辑 收藏 引用 所属分类: Debugging


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理