首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Windows C++堆破坏场景及分析

Windows C++堆破坏场景及分析

作者头像
河边一枝柳
发布2021-09-02 09:48:57
9890
发布2021-09-02 09:48:57
举报

一个堆破坏的老故事

还记得第一次碰到堆破坏的时候,大概十年前了,当时在学校开发一个Wireshark插件,可是有一个问题我久久未能解决: 二次开发后的Wireshark,启动的时候偶尔会出现程序崩溃,那时候也不会用Windbg, 后来用Visual Studio启动Wireshark, 也是偶尔报错,这个时候可以看到堆栈,只记得当时是在一个很正常的内存分配或者释放的时候出现崩溃。那么总结为两点:

  1. 偶尔重现,那么也就是我们常说的还能跑起来,跑不起来那么就重启进程,重启进程无效,那就万能方法重启机器。这里想到一个名词叫做SRE (Site Reliability Engineering),有时候又戏称为Software Restart Engineer 或者 System Restart Engineer
  2. 在内存充足的情况,居然在申请内存或者释放内存的时候报错, 而且并不是直接导致内存破坏的地方。

那时候对于一个开发经验还不是很丰富的学生来说,搜索这种错误都不知道如何搜索。后来一个偶然的机会,听到了Applicaton Verifier,于是我就使用了这个工具进行了相关进程的配置,然后用Visual Studio启动了Wireshark, 在一个内存溢出的操作的时候,中断了,断点停在,我写的一个strcpy语句处,原来内存溢出导致了堆的破坏。

似懂非懂的知道了堆被破坏了其他变量的内存,但是为什么会导致堆破坏?并且Application Verifier是通过什么原理检测到这种错误的?还有阅读这篇文章的读者,你是否也曾碰到这种诡异的场景呢?那么让我们一起来看看Windows中的堆破坏和分析方法。

堆破坏

<<谈一谈Windows中的堆>>中比较详细地讲解了堆的结构,这里我们简单说一说堆中对象存储的基本结构。

堆的结构主要分为三层层:

  1. 一个堆由若干个Segment组成,每个Segment是连续的空间
  2. 一个Segment一般由若干个连续的Entry构成。
  3. 我们每次使用malloc/new申请的内存就占用一个Entry,一个Entry中只有User Data部分是malloc/new返回给应用程序的使用的地址,其他的部分为当前Entry的元数据,用于堆管理,在调试模式下还有一些用于调试的信息。

下图展示的了堆是如何破坏的,假设有两处应用程序申请的内存,分别为Entry1Entry2管理, 并且是连续的内存。那么这个时候拷贝信息到Entry1User Data, 然而没有控制好拷贝的长度,覆盖了Entry2Meta和部分User Data

这里我们问一个问题, 当出现上述堆破坏的时候,堆会直接报错吗? 并不会,因为此时执行的是内存拷贝操作,并不会做堆的任何检查操作。而是下次在堆上分配或者释放内存的时候,和这个Entry相关联的操作检查到堆破坏,从而导致程序崩溃。

其实程序崩溃有时候反而是好事,如果在堆被破坏发生时,其他对象的数据被修改,并且不立即导致程序崩溃,而继续运行进入了错误逻辑,可能会导致严重的后果。

那么我们要去检测堆破坏,能够抓取到破坏时候的函数调用栈吗?可以的,但是在讲解这种方法之前,先讲解下: 如果非第一现场检测到堆破坏,如何进行分析。

堆破坏之分析堆块内容

为什么要先讲解这种方法,而不是直接使用终极绝招,抓取第一现场呢?

  1. 如果你的软件在客户的环境中,他们在收集Dump后,并不一定配合帮你在他们机器上调试。请你直接分析已经Crash的Dump。
  2. 这种方法利于读者对于堆结构的理解,并且提供了解决堆破坏的思路。记得前段时间看过罗翔说的一句话,记得大概含义侦探小说里,一个一个看似不起眼的碎片,终究连成了一条线,构成了真相。 我们学习的每一个方法也是,并不仅仅是茴香豆的几种写法,而是一种思路,一种启发,也许未来的某一个会起到作用。

首先来看一个样例程序:

#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的拷贝操作内存越界了。需要声明的有两点:

  1. 这个程序不一定是百分百必现,因为pStr1pStr2的内存不一定是连续的或者靠近的
  2. 这个程序是启动后,再用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,可以切换到相应的栈帧,查看到其正好为pStr2Entry地址 (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

第五步 那么现在我们关心的一个是当前内存是什么内容?一般出现堆破坏很大可能是堆的上溢,那么前一个堆块是什么?我们先来看看当前堆块的内容。使用了WindbgMemory窗口, 特意向前偏移了0x24个字节查看更多信息,可以看到0x001c3fe8这个Entry的元数据部分已经被字符串覆盖了。而此时我们可以看到这个字符串是This is a heap corruption test, 根据这个字符串信息就比较容易找到溢出的位置了。

第六步 虽然这个时候你可能已经找到了问题所在,那你是否还有刚才的疑问,那上一个堆块是什么呢?我们根据堆的地址0x001b0000查看其所有的申请的Entry, 找到被破坏的堆块最接近的Entry0x001c3ef0。然后用!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刚好为pStr1Entry起始地址(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中的为最小的内存管理单元,默认为4KBytesFull 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技术,测试自己的程序。

相关阅读

  1. <<谈一谈Windows中的堆>>
  2. <<C++常见的三种内存破的场景和分析>>
  3. <<Windows程序Dump收集>>

参考

Mario Hewardt / Daniel Pravat的<<Windows高级调试>>

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-08-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 一个程序员的修炼之路 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一个堆破坏的老故事
  • 堆破坏
  • 堆破坏之分析堆块内容
  • 堆破坏分析之填充模式
  • 堆破坏分析之完全页堆(Full Page)
  • 相关阅读
  • 参考
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档