一:背景
1. 讲故事
年前遇到了好几例托管堆被损坏的案例,有些运气好一些,从被破坏的托管堆内存现场能观测出大概是什么问题,但更多的情况下是无法做出准确判断的,原因就在于生成的dump是第二现场,借用之前文章的一张图,大家可以理解一下。
为了帮助更多受此问题困扰的朋友,这篇来整理一下如何 快狠准
的抓取第一现场。
二:抓取第一现场
1. 思路分析
要想抓到第一现场,只需要让破坏托管堆的那个线程在修改完之后,回到 CLR Pinvoke 层的时候主动触发GC,因为这时候托管堆已经是损坏状态了,程序也就会立即崩溃,破坏线程也就被捉jian在床,画个图如下:
那如何让 CLR:PInvoke
主动触发GC呢? 这就需要借助微软的 MDA
托管调试助手,它有一个 gcUnmanagedToManaged
配置项就是专门做这件事情的,参考网址:https://learn.microsoft.com/zh-cn/dotnet/framework/debug-trace-profile/gcunmanagedtomanaged-mda
2. 如何配置 MDA
MDA 的配置非常简单,大体上分两步:
- 提交注册表开启MDA
这里使用注册表的方式,需要注意的是,程序和操作系统位数一致的话采用如下方式。
Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINESOFTWAREMicrosoft.NETFramework] "MDA"="1"
如果不一致,采用如下配置,比如 32bit 程序跑在 64bit 系统上。
Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINESOFTWAREWow6432NodeMicrosoft.NETFramework] "MDA"="1"
这里我用的是第二段内容,按照官方文档描述,将内容保存到 MDAEnable.reg
中,然后在 注册表编辑器
上导入即可。
- 开启应用程序级捕获
为了能够让 gcUnmanagedToManaged
生效,需要新建应用程序打头的配置文件,比如: Example_16_1_2.exe.mda.config
,内容如下:
<mdaConfig> <assistants> <gcUnmanagedToManaged/> </assistants> </mdaConfig>
完整截图:
这样就算配置好了,当程序在 PInvoke 时,CLR 会读取注册表的 MDA 值,如果开启的话就会读取 config
中 gcUnmanagedToManaged
子节做相应的逻辑。
tips:如果配置不生效,保守一点的话,建议重启下机器。
3. 一个托管堆破坏的测试案例
为了演示托管堆损坏,我准备将一个 string 传给 C++,然后让 C++ 溢出它来实现托管堆破坏。
C# 代码如下:
namespace Example_16_1_2 { internal class Program { [DllImport("Example_16_1_3.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] public extern static void Alloc(string str); static void Main(string[] args) { Test(); Task.Factory.StartNew(() => { Thread.Sleep(3000); GC.Collect(); }); Console.ReadLine(); } static void Test() { var str = "hello"; var str2 = "world!"; Alloc(str); } } }
C++ 代码如下:
extern "C" { _declspec(dllexport) void Alloc(wchar_t* c); } #include "iostream" #include <Windows.h> using namespace std; void Alloc(wchar_t* c) { for (size_t i = 0; i < 10; i++) { *c++ = 'a'; } wprintf(L"%s n", c); }
从代码逻辑看,只要 Alloc(str)
的线程栈上触发了 GC 就是第一现场,Task 下的 GC.Collect();
是第二现场,如果是前者目的就达到了。
激动人心的时刻到了,把程序跑起来后,由于程序崩溃,procdump 立即给我抓了一个 crash dump,截图如下:
接下来打开 windbg,从序幕信息看果然是 GC 清扫的时候出的问题,托管堆也是损坏状态,信息如下:
Debug session time: Sun Jan 29 10:14:21.000 2023 (UTC + 8:00) System Uptime: 0 days 1:14:11.423 Process Uptime: not available ................................. Loading unloaded module list .. This dump file has an exception of interest stored in it. The stored exception information can be accessed via .ecxr. (4460.52ac): Access violation - code c0000005 (first/second chance not available) For analysis of this file, run !analyze -v eax=00610060 ebx=00000000 ecx=02da23a4 edx=00000001 esi=02da2370 edi=02da2388 eip=79a6f2d1 esp=00d3ef64 ebp=00d3f104 iopl=0 nv up ei pl nz na pe nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010206 clr!WKS::gc_heap::plan_phase+0x79b: 79a6f2d1 f70000000080 test dword ptr [eax],80000000h ds:002b:00610060=???????? 0:000> !VerifyHeap Could not request method table data for object 02DA1228 (MethodTable: 0000000C). Last good object: 02DA121C. object 03da1020: bad member 02DA1228 at 03DA1098 Last good object: 03DA1010. object 03da2338: bad member 02DA1228 at 03DA2340 Last good object: 03DA2328. object 03da3568: bad member 02DA2364 at 03DA357C Last good object: 03DA3558. Failed to request SyncBlk at index 1.
那是不是主线程
引发的GC呢?切过去便知。
0:000> ~0s eax=00610060 ebx=00000000 ecx=02da23a4 edx=00000001 esi=02da2370 edi=02da2388 eip=79a6f2d1 esp=00d3ef64 ebp=00d3f104 iopl=0 nv up ei pl nz na pe nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010206 clr!WKS::gc_heap::plan_phase+0x79b: 79a6f2d1 f70000000080 test dword ptr [eax],80000000h ds:002b:00610060=???????? 0:000> !clrstack OS Thread Id: 0x52ac (0) Child SP IP Call Site 00d3f220 79a6f2d1 [HelperMethodFrame: 00d3f220] System.StubHelpers.StubHelpers.TriggerGCForMDA() 00d3f294 02bc0aa7 DomainBoundILStubClass.IL_STUB_PInvoke(System.String) 00d3f298 02bc09c9 [InlinedCallFrame: 00d3f298] Example_16_1_2.Program.Alloc(System.String) 00d3f2e0 02bc09c9 Example_16_1_2.Program.Test() [D:testdumpExampleExample_16_1_2Program.cs @ 35] 00d3f2f0 02bc0900 Example_16_1_2.Program.Main(System.String[]) [D:testdumpExampleExample_16_1_2Program.cs @ 19] 00d3f490 7996f036 [GCFrame: 00d3f490] 0:000> k 10 # ChildEBP RetAddr 00 00d3f104 79a68153 clr!WKS::gc_heap::plan_phase+0x79b 01 00d3f124 79a6847b clr!WKS::gc_heap::gc1+0xbc 02 00d3f13c 79a68585 clr!WKS::gc_heap::garbage_collect+0x367 03 00d3f15c 79b1ddbd clr!WKS::GCHeap::GarbageCollectGeneration+0x1bd 04 00d3f16c 79b1de34 clr!WKS::GCHeap::GarbageCollectTry+0x71 05 00d3f198 79d20aed clr!WKS::GCHeap::GarbageCollect+0xac 06 00d3f204 79d066c0 clr!TriggerGCForMDAInternal+0x7d 07 00d3f28c 02bc0aa7 clr!StubHelpers::TriggerGCForMDA+0x61 WARNING: Frame IP not in any known module. Following frames may be wrong. 08 00d3f2d8 02bc09c9 0x2bc0aa7 09 00d3f2e8 02bc0900 Example_16_1_2!Example_16_1_2.Program.Test+0x39 [D:testdumpExampleExample_16_1_2Program.cs @ 35] 0a 00d3f318 7996f036 Example_16_1_2!Example_16_1_2.Program.Main+0x30 [D:testdumpExampleExample_16_1_2Program.cs @ 19] 0b 00d3f324 799722da clr!CallDescrWorkerInternal+0x34 0c 00d3f378 7997859b clr!CallDescrWorkerWithHandler+0x6b 0d 00d3f3ec 79b1b11b clr!MethodDescCallSite::CallTargetWorker+0x16a 0e 00d3f510 79b1b7fa clr!RunMain+0x1b3 0f 00d3f77c 79b1b727 clr!Assembly::ExecuteMainMethod+0xf7
从线程栈上的 clr!StubHelpers::TriggerGCForMDA
来看,在 Pinvoke 层果然主动触发了 GC,成功将 Program.Alloc
这个非托管方法捉jian在床。
三:总结
在此之前很多朋友都会困惑于托管堆破坏导致的程序崩溃,希望这篇文章能够让后来者少走弯路。