调试实战 | 从无法复现到真相大白:DLL卸载死锁背后的版本陷阱

摘要

这篇文章记录了我对 DLL 卸载时死锁问题的进一步探索。多年前,我曾在看雪论坛上发表过一篇关于 DLL 卸载死锁的分析文章,但最近有网友反馈无法复现这个问题。通过深入调试,我发现问题根源在于不同版本的 VS 运行时库对引用计数的处理方式不同。本文详细展示了如何使用 Windbg 追踪 DLL 引用计数的变化,解释了为什么在 VS2022 环境下原问题不再出现,最终定位到不同版本的 _beginthreadex_endthreadex 的内部实现差异是关键所在。

缘起

前一阵子,网友跟我说我之前写的一篇文章有问题,他那里不能重现。文章是很糟之前发在看雪上的一篇关于 dll 卸载时死锁的文章。链接为 https://bbs.kanxue.com/thread-255547-1.htm。我的第一感觉是是不是操作系统不一致导致的加载/卸载行为发生变化导致的。但是这位网友给出的分析很可信。于是我自己也快速用 vs2022 建了一个测试工程,发现没死锁。虽然网友已经给了他的结论,但还是很有必要整体梳理一下。

测试代码

这里再简单贴一下关键代码

1
2
3
4
5
6
7
8
9
10
//WaitDllUnloadExe.cpp
#include "windows.h"

int main(int argc, char* argv[])
{
HMODULE module = LoadLibraryA(".\\DllUnload.dll");
Sleep(1000);
FreeLibrary(module);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
//dllmain.cpp
#include "pch.h"

#include "process.h"
#include <stdio.h>

HANDLE g_hThread;

volatile bool g_quit = false;

unsigned __stdcall procThread(void*)
{
while (!g_quit)
{
OutputDebugStringA("====procThread running.\n");
Sleep(100);
}

OutputDebugStringA("====procThread quitting.\n");

return 0;
}

unsigned __stdcall quitDemoProc(void*)
{
int idx = 0;
while (idx++ < 5)
{
OutputDebugStringA("====quitDemoProc running!!!!!!!!\n");
Sleep(10);
}
OutputDebugStringA("====quitDemoProc quitting!!!\n");
return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
{
g_hThread = (HANDLE)_beginthreadex(NULL, 0, &procThread, NULL, 0, NULL);
CloseHandle((HANDLE)_beginthreadex(NULL, 0, &quitDemoProc, NULL, 0, NULL));
}
break;
case DLL_THREAD_ATTACH:
{
OutputDebugStringA("====DLL_THREAD_ATTACH called.\n");
}
break;
case DLL_THREAD_DETACH:
{
OutputDebugStringA("====DLL_THREAD_DETACH called.\n");
}
break;
case DLL_PROCESS_DETACH:
{
char buffer[256] = "";
sprintf_s(buffer, "====DLL_PROCESS_DETACH begin wait for thread=%x\n", (int)g_hThread);
OutputDebugStringA(buffer);
g_quit = true;
DWORD waitResult = WaitForSingleObject(g_hThread, INFINITE);

sprintf_s(buffer, "====DLL_PROCESS_DETACH end wait for thread=%x, result=%x\n", (int)g_hThread, waitResult);
OutputDebugStringA(buffer);
}
break;
}
return TRUE;
}

初步观察

运行新编译出来的程序,简单观察了一下程序行为,可以发现执行完 FreeLibrary(module); 后,DllUnload.dll 并没有被卸载,引用计数是 1

view-dll-loadcount

继续执行,main() 函数结束后,如果还有其它线程在运行,这些线程会被自动杀掉。所以并没有出现卡死的现象。

那为什么引用计数不是 0 呢?按理说 LoadLibraryA()FreeLibrary() 是成对儿出现的,引用计数应该归零才对。

追踪引用计数

从上篇文章中可知,在 win10 系统下,每个模块的信息存储在 Peb.Ldr.InLoadOrderModuleList 中,引用计数存储在 _LDR_DATA_TABLE_ENTRYDdagNode->LoadCount 中。可以在 LoadCount 上设置一个内存写断点。但是等一下,如果模块信息还没添加到 InLoadOrderModuleList 中,该如何设置断点呢?很简单,从 InLoadOrderModuleList 的名字可知,链表里存储的模块信息是按加载顺序来的。最新加载的模块会放到链表的末尾的位置,也就是 BLink 的位置,可以先对此字段设置内存写断点,有新模块被加载时,会修改此字段的值。

监视添加模块

重新运行程序,在调用 LoadLibrary() 的那行代码处设置断点,中断后,对 Peb.Ldr.InLoadOrderModuleListBLink 设置写断点,然后让程序重新运行起来。很快断点便命中了,

1
2
3
Breakpoint 1 hit
ntdll!LdrpInsertDataTableEntry+0x7f:
00007ffc`e16e469f 488d0d3a7e1500 lea rcx,[ntdll!PebLdr+0x20 (00007ffc`e183c4e0)]

执行 ub rip L3 查看最近的三条汇编指令,可以发现正在把 rbx 的值写入 rcx 指向的地址,因此触发了断点。执行 r rbx 命令查看寄存器的值。可以发现 rbx 的值是 00000217843326f0,也就是说 ntdll!_LDR_DATA_TABLE_ENTRY 对象的地址是 00000217843326f0

1
2
3
4
5
6
7
8
0:000> ub rip L3
ntdll!LdrpInsertDataTableEntry+0x74:
00007ffc`e16e4694 488d4310 lea rax,[rbx+10h]
00007ffc`e16e4698 48894b08 mov qword ptr [rbx+8],rcx
00007ffc`e16e469c 488919 mov qword ptr [rcx],rbx

0:000> r rbx
rbx=00000217843326f0

执行以下命令查看 dll 名称及引用计数。可以发现正是我们关注的 dll,而且当前的引用计数是 1

1
2
3
4
0:000> dt ntdll!_LDR_DATA_TABLE_ENTRY 00000217843326f0 -y BaseDllName DdagNode->LoadCount
+0x058 BaseDllName : _UNICODE_STRING "DllUnload.dll"
+0x098 DdagNode :
+0x018 LoadCount : 1

可以执行 k 命令查看调用栈,可以知道是 ntdll!LdrpInsertDataTableEntry() 执行的插入操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0:000> k
# Child-SP RetAddr Call Site
00 0000004b`86efe880 00007ffc`e16e4b73 ntdll!LdrpInsertDataTableEntry+0x7f
01 0000004b`86efe8b0 00007ffc`e1731243 ntdll!LdrpMapDllWithSectionHandle+0xe3
02 0000004b`86efe900 00007ffc`e1730ca0 ntdll!LdrpMapDllNtFileName+0x19f
03 0000004b`86efea00 00007ffc`e1730160 ntdll!LdrpMapDllSearchPath+0x1d0
04 0000004b`86efec60 00007ffc`e16efb53 ntdll!LdrpProcessWork+0x74
05 0000004b`86efecc0 00007ffc`e16e73e4 ntdll!LdrpLoadDllInternal+0x13f
06 0000004b`86efed40 00007ffc`e16e6af4 ntdll!LdrpLoadDll+0xa8
07 0000004b`86efeef0 00007ffc`df1bdb72 ntdll!LdrLoadDll+0xe4
08 0000004b`86efefe0 00007ffc`cb58f266 KERNELBASE!LoadLibraryExW+0x162
09 0000004b`86eff050 00007ffc`df1b6af1 ObjectVirtualizeDll_x64!ProxyHttpApiHook+0x48696
0a 0000004b`86eff4e0 00007ffc`df20a05f KERNELBASE!LoadLibraryExA+0x31
0b 0000004b`86eff520 00007ff7`bfc317c0 KERNELBASE!LoadLibraryA+0x3f
0c 0000004b`86eff550 00007ff7`bfc31cc9 WaitDllUnloadExe!main+0x30
0d 0000004b`86eff670 00007ff7`bfc31b6e WaitDllUnloadExe!invoke_main+0x39
0e 0000004b`86eff6c0 00007ff7`bfc31a2e WaitDllUnloadExe!__scrt_common_main_seh+0x12e
0f 0000004b`86eff730 00007ff7`bfc31d5e WaitDllUnloadExe!__scrt_common_main+0xe
10 0000004b`86eff760 00007ffc`e15d7374 WaitDllUnloadExe!mainCRTStartup+0xe
11 0000004b`86eff790 00007ffc`e171cc91 KERNEL32!BaseThreadInitThunk+0x14
12 0000004b`86eff7c0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

监视引用计数修改

从上面的分析可知,DllUnload.dll 模块对应的 ntdll!_LDR_DATA_TABLE_ENTRY 的地址是 00000217843326f0,其 LoadCount 的地址是 poi(00000217843326f0 + 0x98) + 0x18 也就是 0000021784338e28。可以执行以下命令对此地址设置内存写断点。

1
ba w4 00000217`84338e28

设置好内存写断点后,重新运行程序,很快就断下来了。执行 k 命令,可以发现引用计数是被谁修改的。调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
0:000> g
Breakpoint 2 hit
ntdll!LdrpIncrementModuleLoadCount+0x37:
00007ffc`e16ef57b 488d0dfedc1400 lea rcx,[ntdll!LdrpModuleDatatableLock (00007ffc`e183d280)]
0:000> k
# Child-SP RetAddr Call Site
00 0000004b`86efe630 00007ffc`e16e0182 ntdll!LdrpIncrementModuleLoadCount+0x37
01 0000004b`86efe660 00007ffc`df1f082d ntdll!LdrAddRefDll+0x42
02 0000004b`86efe690 00007ffb`a6be33bf KERNELBASE!GetModuleHandleExW+0xad
03 0000004b`86efe6d0 00007ffb`a6be3804 ucrtbased!create_thread_parameter+0xcf
04 0000004b`86efe730 00007ffb`aadc19b0 ucrtbased!_beginthreadex+0xd4
05 0000004b`86efe7b0 00007ffb`aadc2598 DllUnload!DllMain+0xb0
06 0000004b`86efea00 00007ffb`aadc2771 DllUnload!dllmain_dispatch+0x98
07 0000004b`86efea50 00007ffc`e16e9a1d DllUnload!_DllMainCRTStartup+0x31
08 0000004b`86efea80 00007ffc`e173d2f7 ntdll!LdrpCallInitRoutine+0x61
09 0000004b`86efeaf0 00007ffc`e173d08a ntdll!LdrpInitializeNode+0x1d3
0a 0000004b`86efec40 00007ffc`e170d947 ntdll!LdrpInitializeGraphRecurse+0x42
0b 0000004b`86efec80 00007ffc`e16efbae ntdll!LdrpPrepareModuleForExecution+0xbf
0c 0000004b`86efecc0 00007ffc`e16e73e4 ntdll!LdrpLoadDllInternal+0x19a
0d 0000004b`86efed40 00007ffc`e16e6af4 ntdll!LdrpLoadDll+0xa8
0e 0000004b`86efeef0 00007ffc`df1bdb72 ntdll!LdrLoadDll+0xe4
0f 0000004b`86efefe0 00007ffc`cb58f266 KERNELBASE!LoadLibraryExW+0x162
10 0000004b`86eff050 00007ffc`df1b6af1 ObjectVirtualizeDll_x64!ProxyHttpApiHook+0x48696
11 0000004b`86eff4e0 00007ffc`df20a05f KERNELBASE!LoadLibraryExA+0x31
12 0000004b`86eff520 00007ff7`bfc317c0 KERNELBASE!LoadLibraryA+0x3f
13 0000004b`86eff550 00007ff7`bfc31cc9 WaitDllUnloadExe!main+0x30
14 0000004b`86eff670 00007ff7`bfc31b6e WaitDllUnloadExe!invoke_main+0x39
15 0000004b`86eff6c0 00007ff7`bfc31a2e WaitDllUnloadExe!__scrt_common_main_seh+0x12e
16 0000004b`86eff730 00007ff7`bfc31d5e WaitDllUnloadExe!__scrt_common_main+0xe
17 0000004b`86eff760 00007ffc`e15d7374 WaitDllUnloadExe!mainCRTStartup+0xe
18 0000004b`86eff790 00007ffc`e171cc91 KERNEL32!BaseThreadInitThunk+0x14
19 0000004b`86eff7c0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

可以发现是 _beginthreadex() 内部会间接调用 KERNELBASE!GetModuleHandleExW() 进而调用 ntdll!LdrpIncrementModuleLoadCount() 增加了引用计数。再次执行 g 命令,让程序重新运行起来。很快断点又再次命中。因为我们的 DllMain() 中创建了两个线程。

callstack-of-modify-dll-loadcount-second-time

查看 _beginthreadex()_beginthread() 的源码可知,这两个函数内部都会调用这段代码 unique_thread_parameter parameter(create_thread_parameter(procedure, context)); 这段代码内部会调用 GetModuleHandleExW() 间接增加引用计数。

view-beginthread-source

再次让程序运行,可以发现断点又触发了,引用计数变成了 2。这次是 _endthreadex() 调用触发的,入口函数是 quitDemoProc 对应的线程结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Breakpoint 2 hit
ntdll!LdrpDecrementNodeLoadCountLockHeld+0x58:
00007ffc`e16dfd80 418bd1 mov edx,r9d
0:010> k
# Child-SP RetAddr Call Site
00 0000004b`878ffa58 00007ffc`e16dfcda ntdll!LdrpDecrementNodeLoadCountLockHeld+0x58
01 0000004b`878ffa60 00007ffc`e16dfc30 ntdll!LdrpDecrementModuleLoadCountEx+0x42
02 0000004b`878ffa90 00007ffc`df2098ac ntdll!LdrUnloadDll+0x40
03 0000004b`878ffac0 00007ffb`a6be32cb KERNELBASE!FreeLibraryAndExitThread+0x3c
04 0000004b`878ffaf0 00007ffb`a6be3931 ucrtbased!common_end_thread+0xab
05 0000004b`878ffb30 00007ffb`a6be3017 ucrtbased!_endthreadex+0x11
06 0000004b`878ffb60 00007ffc`e15d7374 ucrtbased!thread_start<unsigned int (__cdecl*)(void *),1>+0xb7
07 0000004b`878ffbc0 00007ffc`e171cc91 KERNEL32!BaseThreadInitThunk+0x14
08 0000004b`878ffbf0 00000000`00000000 ntdll!RtlUserThreadStart+0x21
0:010> !dlls -c UnloadDll
This is Win8 with the loader DAG.

0x217842d6d80: C:\Windows\SYSTEM32\ucrtbased.dll
Base 0x7ffba6b30000 EntryPoint 0x7ffba6b88350 Size 0x00221000 DdagNode 0x217842d6ed0
Flags 0x0008a2ec TlsIndex 0x00000000 LoadCount 0x00000002 NodeRefCount 0x00000000
<unknown>
LDRP_LOAD_NOTIFICATIONS_SENT
LDRP_IMAGE_DLL
LDRP_PROCESS_ATTACH_CALLED

再次让程序运行,断点再次被触发,这次是 FreeLibrary() 触发的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0:010> g
Breakpoint 2 hit
ntdll!LdrpDecrementNodeLoadCountLockHeld+0x58:
00007ffc`e16dfd80 418bd1 mov edx,r9d
0:000> k
# Child-SP RetAddr Call Site
00 0000004b`86eff4b8 00007ffc`e16dfcda ntdll!LdrpDecrementNodeLoadCountLockHeld+0x58
01 0000004b`86eff4c0 00007ffc`e16dfc30 ntdll!LdrpDecrementModuleLoadCountEx+0x42
02 0000004b`86eff4f0 00007ffc`df1b32de ntdll!LdrUnloadDll+0x40
03 0000004b`86eff520 00007ff7`bfc317d9 KERNELBASE!FreeLibrary+0x1e
04 0000004b`86eff550 00007ff7`bfc31cc9 WaitDllUnloadExe!main+0x49
05 0000004b`86eff670 00007ff7`bfc31b6e WaitDllUnloadExe!invoke_main+0x39
06 0000004b`86eff6c0 00007ff7`bfc31a2e WaitDllUnloadExe!__scrt_common_main_seh+0x12e
07 0000004b`86eff730 00007ff7`bfc31d5e WaitDllUnloadExe!__scrt_common_main+0xe
08 0000004b`86eff760 00007ffc`e15d7374 WaitDllUnloadExe!mainCRTStartup+0xe
09 0000004b`86eff790 00007ffc`e171cc91 KERNEL32!BaseThreadInitThunk+0x14
0a 0000004b`86eff7c0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

解释

原来是,vs2022 编译时使用的运行时库中的 _beginthreadex() 会增加引用计数,_endthreadex() 会减少引用计数。两个线程中的一个会及时退出,而另外一个没有退出,导致引用计数没有归零,进而导致模块没有被卸载。所以就没出现卡死的情况。

vs2010 编译时使用的运行时库中的 _endthreadex() 不会修改引用计数(具体参考 vs2010 目录下的 threadex.c 文件)。当调用 FreeLibrary() 时,会触发模块卸载行为,进而会遇到卡死的情况。

手动实验

我已经把相关测试工程上传到了这里 todo,感兴趣的小伙伴可以自行测试验证。

说明: 如果想重现卡死问题,只需要修改【平台工具集】属性的值为 Visual Studio 2010 (v100)

总结

  • VS2022 对应的运行时库中,_beginthreadex() 内部会调用 GetModuleHandleExW() 增加 DLL 的引用计数,而 _endthreadex() 则会减少引用计数。而 VS2010 对应的运行时库不会增加 DLL 的引用计数。
  • 内存断点是必须要掌握的一种断点,适用于监视已知地址的内存变化。
  • !dlls -c module 命令可以查看 module 对应的引用计数。
BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%