赞
踩
一个堆破坏的老故事
还记得第一次碰到堆破坏的时候,大概十年前了,当时在学校开发一个Wireshark插件,可是有一个问题我久久未能解决: 我修改后的Wireshark运行的时候偶尔启动的时候会出现程序崩溃,那时候也不会用Windbg, 后来用Visual Studio启动Wireshark, 也是偶尔报错,这个时候可以看到堆栈,只记得当时是在一个很正常的内存分配或者释放的时候出现崩溃。那么总结为两点:
偶尔重现,那么也就是我们常说的还能跑起来,跑不起来那么就重启进程,重启进程无效,那就万能方法重启机器。这里想到一个名词叫做SRE (Site Reliability Engineering),有时候又戏称为Software Restart Engineer 或者 System Restart Engineer。
在内存充足的情况,居然在申请内存或者释放内存的时候报错, 而且并不是直接导致内存破坏的地方。
那时候对于一个开发经验还不是很丰富的学生来说,搜索这种错误都不知道如何搜索。 后来一个偶然的机会,听到了Applicaton Verifier,于是我就使用了这个工具进行了相关进程的配置,然后用Visual Studio启动了Wireshark, 在一个内存溢出的操作的时候,中断了,断点停在,我写的一个strcpy语句处,原来内存溢出导致了堆的破坏。
似懂非懂的知道了堆被破坏了其他变量的内存,但是为什么会导致堆破坏? 并且Application Verifier是通过什么原理检测到这种错误的? 还有阅读这篇文章的读者,你是否也曾碰到这种诡异的场景呢? 那么让我们一起来看看Windows中的堆破坏和分析方法。
堆破坏
在<<谈一谈Windows中的堆>>中比较详细地讲解了堆的结构,这里我们简单说一说堆中对象存储的基本结构。
堆的结构主要分为三层层:
一个堆由若干个Segment组成,每个Segment是连续的空间
一个Segment一般由若干个连续的Entry构成。
我们每次使用malloc/new申请的内存就占用一个Entry,一个Entry中只有User Data部分是malloc/new返回给应用程序的使用的地址,其他的部分为当前Entry的元数据,用于堆管理,在调试模式下还有一些用于调试的信息。
下图展示的了堆是如何破坏的,假设有两处应用程序申请的内存,分别为Entry1和Entry2管理, 并且是连续的内存。那么这个时候拷贝信息到Entry1的User Data, 然而没有控制好拷贝的长度,覆盖了Entry2的Meta和部分User Data。
这里我们问一个问题, 当出现上述堆破坏的时候,堆会直接报错吗? 并不会,因为此时执行的是内存拷贝操作,并不会做堆的任何检查操作。而是下次在堆上分配或者释放内存的时候,和这个Entry相关联的操作检查到堆破坏,从而导致程序崩溃。
其实程序崩溃有时候反而是好事,如果在堆被破坏发生时,其他对象的数据被修改,并且不立即导致程序崩溃,而继续运行进入了错误逻辑,可能会导致严重的后果。
那么我们要去检测堆破坏,能够抓取到破坏时候的函数调用栈吗?可以的,但是在讲解这种方法之前,先讲解下: 如果非第一现场检测到堆破坏,如何进行分析。
堆破坏之分析堆块内容
为什么要先讲解这种方法,而不是直接使用终极绝招,抓取第一现场呢?
如果你的软件在客户的环境中,他们在收集Dump后,并不一定配合帮你在他们机器上调试。请你直接分析已经Crash的Dump。
这种方法利于读者对于堆结构的理解,并且提供了解决堆破坏的思路。记得前段时间看过罗翔说的一句话,记得大概含义侦探小说里,一个一个看似不起眼的碎片,终究连成了一条线,构成了真相。 我们学习的每一个方法也是,并不仅仅是茴香豆的几种写法,而是一种思路,一种启发,也许未来的某一个会起到作用。
首先来看一个样例程序:
#include <iostream>
void HeapCorruptionFunction()
{
char * pStr1 = new char[5];
char * pStr2 = new char[5];
printf("%p %p\n", pStr1, pStr2);
strcpy(pStr1, "This is a heap corruption test");
delete[]pStr2;
delete[]pStr1;
}
int main()
{
getchar();
HeapCorruptionFunction();
return 0;
这个程序比较简单, 对pStr1的拷贝操作内存越界了。需要声明的有两点:
这个程序不一定是百分百必现,因为pStr1和pStr2的内存不一定是连续的或者靠近的
这个程序是启动后,再用Windbg附加调试,或者产生Dump。如果直接用调试器启动,那么堆的Entry分配会增加填充块用于调试,而直接启动进程后,再用调试器附加进程,这样堆的管理模式和实际发布版本运行时候的效果一样,接近在发布环境运行的状态。
接下来将讲解详细的分析步骤:
第一步 查看堆栈,对照代码,可以看到在delete[]pStr2;,正常的内存释放的地方出现了堆错误。那么这个时候我们可以联想到,是不是出现了堆破坏呢?
0:000> k
# ChildEBP RetAddr
00 0079f84c 772716c0 ntdll!RtlReportCriticalFailure+0x4b
01 0079f858 7726f5cf ntdll!RtlpReportHeapFailure+0x2f
02 0079f88c 77279e6e ntdll!RtlpHpHeapHandleError+0x6e
03 0079f8a0 772201ea ntdll!RtlpLogHeapFailure+0x41
04 0079f8fc 758af43b ntdll!RtlFreeHeap+0x4ccda
05 0079f910 758af408 ucrtbase!_free_base+0x1b
06 0079f920 00121061 ucrtbase!free+0x18
07 0079f94c 0012110e HeapCorruption!HeapCorruptionFunction+0x61 [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 8]
08 0079f954 00121328 HeapCorruption!main+0xe [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 16]
09 (Inline) -------- HeapCorruption!invoke_main+0x1c [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
0a 0079f99c 75f38494 HeapCorruption!__scrt_common_main_seh+0xfa [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
0b 0079f9b0 771f40e8 KERNEL32!BaseThreadInitThunk+0x24
0c 0079f9f8 771f40b8 ntdll!__RtlUserThreadStart+0x2f
0d 0079fa08 00000000 ntdll!_RtlUserThreadStart+0x1b
第二步 确认堆破坏, 可以看到HEAP ERROR DETECTED, 说明出现了堆破坏操作。
0:000> !heap -s
......
**************************************************************
* *
* HEAP ERROR DETECTED *
* *
**************************************************************
Details:
Heap address: 001b0000
Error address: 001c3fe8
Last known valid blocks: before - 001c3ef0, after - 001c40f8
Error type: HEAP_FAILURE_MULTIPLE_ENTRIES_CORRUPTION
Details: The heap manager detected multiple corrupt heap entries.
Follow-up: Enable pageheap.
Stack trace:
77279e6e: ntdll!RtlpLogHeapFailure+0x00000041
772201ea: ntdll!RtlFreeHeap+0x0004ccda
758af43b: ucrtbase!_free_base+0x0000001b
758af408: ucrtbase!free+0x00000018
00121061: HeapCorruption!HeapCorruptionFunction+0x00000061
0012110e: HeapCorruption!main+0x0000000e
00121328: HeapCorruption!__scrt_common_main_seh+0x000000fa
75f38494: KERNEL32!BaseThreadInitThunk+0x00000024
771f40e8: ntdll!__RtlUserThreadStart+0x0000002f
771f40b8: ntdll!_RtlUserThreadStart+0x0000001b
LFH Key : 0xc159285b
Termination on corruption : ENABLED
Heap Flags Reserv Commit Virt Free List UCR Virt Lock Fast
(k) (k) (k) (k) length blocks cont. heap
-------------------------------------------------------------
001b0000 00000002 1128 528 1020 154 21 1 1 0 LFH
00520000 00001002 60 12 60 1 2 1 0 0
02500000 00001002 1188 88 1080 1 5 2 0 0 LFH
-------------------------------------------------------------
第三步 根据堆破坏错误地址001c3fe8,可以切换到相应的栈帧,查看到其正好为pStr2的Entry地址 (0x001c3ff0-0x8, 因为这个是32位程序,_HEAP_ENTRY元数据占用8个字节)。这个程序比较简单,也许你此时通过代码审查已经可以找到问题所在了。我们还要继续看看更多的信息,便于我们在相对复杂的场景做出分析。
0:000> .frame 0n7;dv /t /v
eax=00000000 ebx=772a58d0 ecx=c0000374 edx=0079f651 esi=00000002 edi=001b0000
eip=7726845c esp=0079f7b8 ebp=0079f84c iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000244
ntdll!RtlReportCriticalFailure+0x4b:
7726845c cc int 3
07 0079f94c 0012110e HeapCorruption!HeapCorruptionFunction+0x61 [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 8]
0079f948 char * pStr1 = 0x001c3fd0 "This is a heap corruption test"
0079f944 char * pStr2 = 0x001c3ff0 "???"
第四步 查看Entry对应的信息,Size显示为0,这个明显也是不对的,印证了堆破坏。
0:000> !heap -x 001c3fe8
Entry User Heap Segment Size PrevSize Unused Flags
-----------------------------------------------------------------------------
001c3fe8 001c3ff0 001b0000 ffffffff 0 - b LFH;busy
第五步 那么现在我们关心的一个是当前内存是什么内容?一般出现堆破坏很大可能是堆的上溢,那么前一个堆块是什么? 我们先来看看当前堆块的内容。使用了Windbg的Memory窗口, 特意向前偏移了0x24个字节查看更多信息,可以看到0x001c3fe8这个Entry的元数据部分已经被字符串覆盖了。而此时我们可以看到这个字符串是This is a heap corruption test, 根据这个字符串信息就比较容易找到溢出的位置了。
第六步 虽然这个时候你可能已经找到了问题所在,那你是否还有刚才的疑问,那上一个堆块是什么呢?我们根据堆的地址0x001b0000查看其所有的申请的Entry, 找到被破坏的堆块最接近的Entry为0x001c3ef0。然后用!heap -x 001c3ef0查看这个地址,居然得不到任何堆块信息,然而根据我们的样例来看,Windbg显示的这个结果也是不正确的,所以也不要完全相信调试器的结果。
还有一个方法,那就是我们可以用当前堆块减去1个字节去查看, 但是很明显这个Entry也不对,因为其起始地址加上大小后,超过了下一个Entry(即被破坏的Entry)的位置。
0:000> !heap -x 0x001C3FE8-0x1
Entry User Heap Segment Size PrevSize Unused Flags
-----------------------------------------------------------------------------
001c3fd8 001c40f8 001b0000 001b0000 10308 108b8 35d8 busy user_flag internal
0:000> ? 001c3fd8+0x10308
Evaluate expression: 1917664 = 001d42e0
这个时候可能是Windbg显示错误了,还有一种可能就是当前的Entry也被破坏了,调试过程中有时候也离不开猜测,而这些猜测也是你已有技术为基础的。那么我们再向前看一个Entry呢? 这下看上去有点正确了,而且可以看到0x001c3fc8刚好为pStr1的Entry起始地址(001C3FD0-0x8, 因为这个是32位程序,_HEAP_ENTRY元数据占用8个字节),原来如此,刚好和我们之前能够判断的pStr1的拷贝操作导致了接下来的连续两个Entry被覆盖了。
0:000> !heap -x 001c3fd8-0x1
Entry User Heap Segment Size PrevSize Unused Flags
-----------------------------------------------------------------------------
001c3fc8 001c3fd0 001b0000 001b9a10 10 - b LFH;busy
往往真实的分析比这个要复杂许多,我们要从内存的内容中不放过任何的蛛丝马迹。
堆破坏分析之填充模式
在讲填充模式之前,我们先来想一想,如果你来想查看一个堆块是否被破坏会怎么做? 这样的思考有利于自己更好的理解和加深这种方法的本质,可以运用在其他的地方。
填充模式,本质就是一种标记, 比如在下图中,在应用程序申请的内存之后填充一个叫做Post Pattern的部分,本人在Win10中测试32位程序填充的是8个字节的0xab。
如果你使用调试器启动程序,比如Windbg, 当你操作内存溢出的时候会覆盖Post Pattern部分,而这个部分被覆盖后,当释放这块内存的时候,会校验是否这块内容发生了变化,如果发生了变化,则说明这块内存出现了溢出, 并且中断调试器。 实际上如果用调试器启动在堆块的头部也会加上Pre Pattern,检测程序的下溢问题。
这个方法可以帮大家找出一些内存溢出问题,比如查看当前出现错误的堆块对应的操作代码进行审查,但是具有滞后性,无法在堆破坏的时刻保留第一现场,在有些场景分析堆破坏问题仍然非常困难: 比如当前被破坏的堆块,可能是由前面的堆块溢出而导致的破坏。
堆破坏分析之完全页堆(Full Page)
这种方法就是文中开头那个故事中用到的方法。使用Application Verifier, 配置如下图,开启对进程HeapCorruption.exe的Heap配置(其实也是一些操作系统的注册表配置),那么这个时候启动进程,将启用了完全页堆(Full Page)的调试技术。
当然你也可以使用Windows调试工具集中的gflag开启使用命令对进程开启Full Page调试技术: gflags /i ImageFileName +hpa 或者gflags /p /enable ImageFileName /full。
在做了如上配置后,开启Dump收集(参考<<Windows程序Dump收集>>), 或者使用调试器直接启动进程。如果有内存的溢出则产生Dump,或者调试器中断程序。 比如之前的测试代码,使用了Full Page后,用调试器运行则会出现Access Violation的错误。
查看此时的函数调用栈, 对比代码则可以看出来正是strcpy(pStr1, "This is a heap corruption test"); 这个函数的调用导致了异常的发生,也就是说,在内存溢出的时候,就直接检测到了这个第一现场。
0:000> k
# ChildEBP RetAddr
00 00f0fbf0 00e0104f ucrtbase!strcat+0x89
01 00f0fc20 00e01108 HeapCorruption!HeapCorruptionFunction+0x4f [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 7]
02 00f0fc28 00e01318 HeapCorruption!main+0x8 [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 16]
03 (Inline) -------- HeapCorruption!invoke_main+0x1c [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
04 00f0fc70 75f38494 HeapCorruption!__scrt_common_main_seh+0xfa [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
05 00f0fc84 771f40e8 kernel32!BaseThreadInitThunk+0x24
06 00f0fccc 771f40b8 ntdll!__RtlUserThreadStart+0x2f
07 00f0fcdc 00000000 ntdll!_RtlUserThreadStart+0x1b
那么Full Page是如何做到的呢?Windows中的页为最小的内存管理单元,默认为4KBytes。Full Page技术,使得应用程序申请的每个内存,对应的Entry后面,紧跟着一个PAGE_NOACCESS的页。那么存储的模式如下图:
那么当你访问上述Entry越界的时候,将会访问到下一个PAGE_NOACCESS的页面,此时将会有Access violation - code c0000005异常产生,直接中断程序,找到内存溢出的第一现场。
我们来看一看,刚刚的程序中断的时候pStr1的地址为0x0b29cff8。
0:000> k
# ChildEBP RetAddr
00 00f0fbf0 00e0104f ucrtbase!strcat+0x89
01 00f0fc20 00e01108 HeapCorruption!HeapCorruptionFunction+0x4f [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 7]
02 00f0fc28 00e01318 HeapCorruption!main+0x8 [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 16]
03 (Inline) -------- HeapCorruption!invoke_main+0x1c [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
04 00f0fc70 75f38494 HeapCorruption!__scrt_common_main_seh+0xfa [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
05 00f0fc84 771f40e8 kernel32!BaseThreadInitThunk+0x24
06 00f0fccc 771f40b8 ntdll!__RtlUserThreadStart+0x2f
07 00f0fcdc 00000000 ntdll!_RtlUserThreadStart+0x1b
0:000> .frame 0n1;dv /t /v
01 00f0fc20 00e01108 HeapCorruption!HeapCorruptionFunction+0x4f [c:\personal\sync\beyourbest\cpp\windbgsample\heapcorruption\source.cpp @ 7]
00f0fc1c char * pStr1 = 0x0b29cff8 "This is "
00f0fc18 char * pStr2 = 0x0b2a0ff8 "???"
然后查看当前页, 可以看到当前页已经提交,并且属性为PAGE_READWRITE,可以读写。并且页的边界地址与申请的内存之间相隔了8个字节(0x0b29d000-0x0b29cff8=0x8)的大小,那么也就是说虽然我们申请了5个字节的数据,但是32位中的最小分配粒度为8个字节。
0:000> !address 0x0b29cff8
Usage: PageHeap
Base Address: 0b29c000
End Address: 0b29d000
Region Size: 00001000 ( 4.000 kB)
State: 00001000 MEM_COMMIT
Protect: 00000004 PAGE_READWRITE
Type: 00020000 MEM_PRIVATE
Allocation Base: 0b1a0000
Allocation Protect: 00000001 PAGE_NOACCESS
More info: !heap -p 0x5b31000
More info: !heap -p -a 0xb29cff8
Content source: 1 (target), length: 8
0:000> ? 0b29d000-0x0b29cff8
Evaluate expression: 8 = 00000008
接着我们看当前页的紧邻的页的状态, 当前页的状态还处于MEM_RESERVE,还没有提交页,那么这个页是无法读写数据的,一旦访问,就会出现Access violation - code c0000005。
0:000> !address 0b29d000+0x1
Usage: PageHeap
Base Address: 0b29d000
End Address: 0b29e000
Region Size: 00001000 ( 4.000 kB)
State: 00002000 MEM_RESERVE
Protect: <info not present at the target>
Type: 00020000 MEM_PRIVATE
Allocation Base: 0b1a0000
Allocation Protect: 00000001 PAGE_NOACCESS
More info: !heap -p 0x5b31000
More info: !heap -p -a 0xb29d001
Content source: 0 (invalid), length: fff
完全页堆这种方法用于追踪堆的破坏问题,确实是利器,这个技术确实能够有效地找出产品或者是第三方模块的堆破坏问题。尤其是第三方模块的第一现场尤为重要,我们知道堆可能在第三方模块破坏了,但是在产品模块中才开始报错,这就导致了责任不明确。如果抓取了第三方模块内的第一现场,第三方模块的负责人们才会承认和他们关联,也避免了责任推脱。
当然这种方法也有一点小小的缺陷,基本上一个页就存放了一个Entry,那就导致了内存消耗的巨大,要注意内存稍微大一些。
另外一个值得提醒的就是,可能你的调试环境中,配置了Full Page, 但是久而久之你忘记了,后来又在你测试环境运行程序,发现内存消耗比以前很多,而且程序运行效率下降,这个时候也要注意看看,是否是自己开启了一些调试选项哈:)
另外一个建议就是,希望在产品发布之前,都开启Full Page技术,测试自己的程序。
相关阅读
<<谈一谈Windows中的堆>>
<<C++常见的三种内存破的场景和分析>>
<<Windows程序Dump收集>>
参考
Mario Hewardt / Daniel Pravat的<<Windows高级调试>>
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。