C#托管堆遭破壞問題溯源分析
一、背景
1. 講故事
年前遇到了好幾例托管堆被損壞的案例,有些運(yùn)氣好一些,從被破壞的托管堆內(nèi)存現(xiàn)場(chǎng)能觀測(cè)出大概是什么問題,但更多的情況下是無法做出準(zhǔn)確判斷的,原因就在于生成的dump是第二現(xiàn)場(chǎng),借用之前文章的一張圖,大家可以理解一下。
圖片
為了幫助更多受此問題困擾的朋友,這篇來整理一下如何 快狠準(zhǔn) 的抓取第一現(xiàn)場(chǎng)。
二、抓取第一現(xiàn)場(chǎng)
1. 思路分析
要想抓到第一現(xiàn)場(chǎng),只需要讓破壞托管堆的那個(gè)線程在修改完之后,回到 CLR Pinvoke 層的時(shí)候主動(dòng)觸發(fā)GC,因?yàn)檫@時(shí)候托管堆已經(jīng)是損壞狀態(tài)了,程序也就會(huì)立即崩潰,破壞線程也就被捉j(luò)ian在床,畫個(gè)圖如下:
圖片
那如何讓 CLR:PInvoke 主動(dòng)觸發(fā)GC呢?這就需要借助微軟的 MDA 托管調(diào)試助手,它有一個(gè) gcUnmanagedToManaged 配置項(xiàng)就是專門做這件事情的,參考網(wǎng)址:https://learn.microsoft.com/zh-cn/dotnet/framework/debug-trace-profile/gcunmanagedtomanaged-mda
2. 如何配置 MDA
MDA 的配置非常簡(jiǎn)單,大體上分兩步:
- 提交注冊(cè)表開啟MDA
這里使用注冊(cè)表的方式,需要注意的是,程序和操作系統(tǒng)位數(shù)一致的話采用如下方式。
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework]
"MDA"="1"
如果不一致,采用如下配置,比如 32bit 程序跑在 64bit 系統(tǒng)上。
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\.NETFramework]
"MDA"="1"
這里我用的是第二段內(nèi)容,按照官方文檔描述,將內(nèi)容保存到 MDAEnable.reg 中,然后在 注冊(cè)表編輯器 上導(dǎo)入即可。
圖片
開啟應(yīng)用程序級(jí)捕獲
為了能夠讓 gcUnmanagedToManaged 生效,需要新建應(yīng)用程序打頭的配置文件,比如:Example_16_1_2.exe.mda.config,內(nèi)容如下:
<mdaConfig>
<assistants>
<gcUnmanagedToManaged/>
</assistants>
</mdaConfig>
完整截圖:
圖片
這樣就算配置好了,當(dāng)程序在 PInvoke 時(shí),CLR 會(huì)讀取注冊(cè)表的 MDA 值,如果開啟的話就會(huì)讀取 config 中 gcUnmanagedToManaged 子節(jié)做相應(yīng)的邏輯。
tips:如果配置不生效,保守一點(diǎn)的話,建議重啟下機(jī)器。
3. 一個(gè)托管堆破壞的測(cè)試案例
為了演示托管堆損壞,我準(zhǔn)備將一個(gè) string 傳給 C++,然后讓 C++ 溢出它來實(shí)現(xiàn)托管堆破壞。
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) 的線程棧上觸發(fā)了 GC 就是第一現(xiàn)場(chǎng),Task 下的 GC.Collect(); 是第二現(xiàn)場(chǎng),如果是前者目的就達(dá)到了。
激動(dòng)人心的時(shí)刻到了,把程序跑起來后,由于程序崩潰,procdump 立即給我抓了一個(gè) crash dump,截圖如下:
圖片
接下來打開 windbg,從序幕信息看果然是 GC 清掃的時(shí)候出的問題,托管堆也是損壞狀態(tài),信息如下:
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.
那是不是主線程引發(fā)的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:\testdump\Example\Example_16_1_2\Program.cs @ 35]
00d3f2f0 02bc0900 Example_16_1_2.Program.Main(System.String[]) [D:\testdump\Example\Example_16_1_2\Program.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:\testdump\Example\Example_16_1_2\Program.cs @ 35]
0a 00d3f318 7996f036 Example_16_1_2!Example_16_1_2.Program.Main+0x30 [D:\testdump\Example\Example_16_1_2\Program.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 層果然主動(dòng)觸發(fā)了 GC,成功將 Program.Alloc 這個(gè)非托管方法捉j(luò)ian在床。