调试实战 | DllMain 的陷阱:当 Windows 10 并行加载遇上线程等待引发的死锁

摘要

这篇文章分析了在 Win10 系统上 DLL 卸载时发生死锁的根本原因。通过调试发现,死锁是由于 Win10 引入了并行加载器机制,导致线程在退出时等待全局事件 LdrpLoadCompleteEvent,而该事件又因 DllMain() 在处理 DLL_PROCESS_DETACH 时调用 WaitForSingleObject() 无限等待工作线程结束而无法被触发。

缘起

我在上一篇文章 《调试实战 | 从无法复现到真相大白:DLL卸载死锁背后的版本陷阱》中弄清楚了为什么之前卡死的程序不卡死的原因—— crt 调整了 _beginthreadex() 的逻辑,在其内部会增加 dll 引用计数,导致 FreeLibrary() 的时候没有正常卸载 dll,进而导致卡死的现象未重现。调整为 vs2010 后,就可以重现了。虽然重现了卡死的现象,但是在 win10 系统上卡死时的调用栈跟 win7 系统不太一样。通过 !cs -l 命令并不能看任何结果。本着打破砂锅问到底的原则,便有了今天的这篇总结。

测试代码

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

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
74
75
//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;

// change INFINITE to some other value (e.g. 5000) to exit normally
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;
}

初步调查

待程序卡死后,使用 windbg 附加。使用 ~* kn 命令查看所有线程的调用栈,可以发现 0 号线程和 6 号线程是我们需要关注的线程,而且这两个线程都在等待。

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
0:000> ~*kn

. 0 Id: 5680.3434 Suspend: 1 Teb: 00000000`00d90000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`00eff388 00007ffc`745cb6ae ntdll!NtWaitForSingleObject+0x14
01 00000000`00eff390 00007ffc`4cbd1289 KERNELBASE!WaitForSingleObjectEx+0x8e
02 00000000`00eff430 00007ffc`4cbd1d4e DllUnload!DllMain+0x159 [d:\myblogstuff\debugging-deadlock-when-dll-unload-win10\dllunload\dllmain.cpp @ 66]
03 00000000`00eff5a0 00007ffc`4cbd1c91 DllUnload!__DllMainCRTStartup+0xae [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\crtdll.c @ 512]
04 00000000`00eff5f0 00007ffc`76d09a1d DllUnload!_DllMainCRTStartup+0x31 [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\crtdll.c @ 477]
05 00000000`00eff620 00007ffc`76d5badb ntdll!LdrpCallInitRoutine+0x61
06 00000000`00eff690 00007ffc`76d5b537 ntdll!LdrpProcessDetachNode+0x107
07 00000000`00eff760 00007ffc`76cffd0a ntdll!LdrpUnloadNode+0x3f
08 00000000`00eff7b0 00007ffc`76cffc84 ntdll!LdrpDecrementModuleLoadCountEx+0x72
09 00000000`00eff7e0 00007ffc`745d32de ntdll!LdrUnloadDll+0x94
0a 00000000`00eff810 00007ff7`536b1059 KERNELBASE!FreeLibrary+0x1e
0b 00000000`00eff840 00007ff7`536b13cc WaitDllUnloadExe!main+0x49 [d:\myblogstuff\debugging-deadlock-when-dll-unload-win10\waitdllunloadexe\waitdllunloadexe.cpp @ 8]
0c 00000000`00eff880 00007ff7`536b121e WaitDllUnloadExe!__tmainCRTStartup+0x19c [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\crtexe.c @ 555]
0d 00000000`00eff8f0 00007ffc`74f77374 WaitDllUnloadExe!mainCRTStartup+0xe [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\crtexe.c @ 371]
0e 00000000`00eff920 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
0f 00000000`00eff950 00000000`00000000 ntdll!RtlUserThreadStart+0x21

1 Id: 5680.5acc Suspend: 1 Teb: 00000000`00d92000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0128f4b8 00007ffc`76d3d407 ntdll!NtWaitForWorkViaWorkerFactory+0x14
01 00000000`0128f4c0 00007ffc`74f77374 ntdll!TppWorkerThread+0x2f7
02 00000000`0128f7c0 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
03 00000000`0128f7f0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

2 Id: 5680.9d0 Suspend: 1 Teb: 00000000`00d94000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0138f598 00007ffc`76d3d407 ntdll!NtWaitForWorkViaWorkerFactory+0x14
01 00000000`0138f5a0 00007ffc`74f77374 ntdll!TppWorkerThread+0x2f7
02 00000000`0138f8a0 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
03 00000000`0138f8d0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

3 Id: 5680.53ac Suspend: 1 Teb: 00000000`00d96000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0148f978 00007ffc`76d3d407 ntdll!NtWaitForWorkViaWorkerFactory+0x14
01 00000000`0148f980 00007ffc`74f77374 ntdll!TppWorkerThread+0x2f7
02 00000000`0148fc80 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
03 00000000`0148fcb0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

4 Id: 5680.23a4 Suspend: 1 Teb: 00000000`00d98000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`03a0fbc8 00007ffc`745f62ce ntdll!NtDelayExecution+0x14
01 00000000`03a0fbd0 00007ffc`60e798ab KERNELBASE!SleepEx+0x9e
02 00000000`03a0fc70 00007ffc`74f77374 ObjectVirtualizeDll_x64!ProxyHttpApiHook+0x52cdb
03 00000000`03a0fca0 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
04 00000000`03a0fcd0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

5 Id: 5680.4914 Suspend: 1 Teb: 00000000`00d9a000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`03b0ee48 00007ffc`74600d00 ntdll!NtWaitForMultipleObjects+0x14
01 00000000`03b0ee50 00007ffc`74600bfe KERNELBASE!WaitForMultipleObjectsEx+0xf0
02 00000000`03b0f140 00007ffc`5e5754fb KERNELBASE!WaitForMultipleObjects+0xe
03 00000000`03b0f180 00007ffc`5e5827ba cpbgrd64+0x154fb
04 00000000`03b0f710 00007ffc`74f77374 cpbgrd64+0x227ba
05 00000000`03b0fc70 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
06 00000000`03b0fca0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

6 Id: 5680.4474 Suspend: 1 Teb: 00000000`00d9c000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0406f8b8 00007ffc`76d50022 ntdll!NtWaitForSingleObject+0x14
01 00000000`0406f8c0 00007ffc`76d074ed ntdll!LdrpDrainWorkQueue+0x15e
02 00000000`0406f900 00007ffc`76d3ec6e ntdll!LdrShutdownThread+0x9d
03 00000000`0406fa00 00000000`6c8c733e ntdll!RtlExitUserThread+0x3e
04 00000000`0406fa40 00000000`6c8c72ec MSVCR100D!_endthreadex+0x2e [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\threadex.c @ 367]
05 00000000`0406fa80 00000000`6c8c72a4 MSVCR100D!_callthreadstartex+0x2c [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\threadex.c @ 315]
06 00000000`0406fad0 00007ffc`74f77374 MSVCR100D!_threadstartex+0xb4 [f:\dd\vctools\crt_bld\self_64_amd64\crt\src\threadex.c @ 297]
07 00000000`0406fb10 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
08 00000000`0406fb40 00000000`00000000 ntdll!RtlUserThreadStart+0x21

7 Id: 5680.4194 Suspend: 1 Teb: 00000000`00da0000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0426f778 00007ffc`745cb6ae ntdll!NtWaitForSingleObject+0x14
01 00000000`0426f780 00007ffc`5e577e80 KERNELBASE!WaitForSingleObjectEx+0x8e
02 00000000`0426f820 00007ffc`74f77374 cpbgrd64+0x17e80
03 00000000`0426f850 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
04 00000000`0426f880 00000000`00000000 ntdll!RtlUserThreadStart+0x21

# 8 Id: 5680.3e34 Suspend: 1 Teb: 00000000`00da2000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0416f738 00007ffc`76dbcb3e ntdll!DbgBreakPoint
01 00000000`0416f740 00007ffc`74f77374 ntdll!DbgUiRemoteBreakin+0x4e
02 00000000`0416f770 00007ffc`76d3cc91 kernel32!BaseThreadInitThunk+0x14
03 00000000`0416f7a0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

9 Id: 5680.50bc Suspend: 0 Teb: 00000000`00da4000 Unfrozen
# Child-SP RetAddr Call Site
00 00000000`0436fcf8 00000000`00000000 ntdll!RtlUserThreadStart

先看一下 0 号线程在等待什么?函数 ntdll!NtWaitForSingleObject() 的第一个参数是等待的句柄,在 64 位程序中函数的第一个参数一般是通过 rcx 传递的。一般寄存器的值是在调用过程中发生变化的,不能直接查看。但是 ntdll!NtWaitForSingleObject() 会直接进内核,用户态的寄存器会被保存起来,不会发生变化,因此可以直接通过 r 命令查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0:000> uf ntdll!NtWaitForSingleObject
ntdll!NtWaitForSingleObject:
00007ffc`76d8d610 4c8bd1 mov r10,rcx
00007ffc`76d8d613 b804000000 mov eax,4
00007ffc`76d8d618 f604250803fe7f01 test byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffc`76d8d620 7503 jne ntdll!NtWaitForSingleObject+0x15 (00007ffc`76d8d625) Branch

ntdll!NtWaitForSingleObject+0x12:
00007ffc`76d8d622 0f05 syscall //<----
00007ffc`76d8d624 c3 ret

ntdll!NtWaitForSingleObject+0x15:
00007ffc`76d8d625 cd2e int 2Eh
00007ffc`76d8d627 c3 ret

执行 r rcx 命令查看 rcx 寄存器的值,如下:

1
2
0:000> r rcx
rcx=00000000000002b8

说明: 执行 r 命令时需要确保当前线程是我们关注的线程,0:000 冒号后面的 000windbg 中的线程编号,不是操作系统中的线程 ID

我们可以进一步通过 !handle 命令验证 0x2b8 是否是合法的句柄值。在 windbg 中执行 !handle 0x2b8 f

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0:000> !handle 0x2b8 f
Handle 00000000000002b8
Type Thread //<----
Attributes 0
GrantedAccess 0x1fffff:
Delete,ReadControl,WriteDac,WriteOwner,Synch
Terminate,Suspend,Alert,GetContext,SetContext,SetInfo,QueryInfo,SetToken,Impersonate,DirectImpersonate
HandleCount 4
PointerCount 163744
Name <none>
Object specific information
Thread Id 5680.4474 //<----
Priority 10
Base Priority 0

可以发现 0x2b8 是线程句柄,对应的线程 ID4474,进程 ID5680。使用 ~~[4474]s 切换线程,可以发现这个线程正是 6 号线程。(可以通过底部命令行左侧的 0:006 确认)

switch-t0-thread-no6

这与我们的代码完全匹配,我们的代码在 DllMain() 中会无限等待创建的线程结束,对应的关键代码如下:

1
DWORD waitResult = WaitForSingleObject(g_hThread, INFINITE);

知道 0 号线程在等待什么,接下来使用同样的方法查看 6 号线程在等待什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0:006> r rcx
rcx=0000000000000038
0:006> !handle 0x38 f
Handle 0000000000000038
Type Event //<----
Attributes 0
GrantedAccess 0x1f0003:
Delete,ReadControl,WriteDac,WriteOwner,Synch
QueryState,ModifyState
HandleCount 2
PointerCount 65519
Name <none>
Object specific information
Event Type Auto Reset
Event is Waiting

可以发现 6 号线程在等待一个事件,句柄值是 0x38。通过调用栈可知,6 号线程正在退出,在退出过程中调用了 LdrpDrainWorkQueue() ,进而导致了等待。至此已经从调用栈上看不出更多的信息了。为什么 6 号线程会陷入等待呢?谁又会触发这个事件呢?

查看反汇编

windbg 中执行 ub 00007ffc76d50022 L50,查看 ntdll!LdrpDrainWorkQueue() 调用 ntdll!NtWaitForSingleObject() 的相关代码,输出结果如下(输出结果有省略)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
0:006> ub 00007ffc`76d50022 L50
//...
ntdll!LdrpDrainWorkQueue:
00007ffc`76d4fec4 48895c2408 mov qword ptr [rsp+8],rbx
00007ffc`76d4fec9 48896c2410 mov qword ptr [rsp+10h],rbp
00007ffc`76d4fece 4889742418 mov qword ptr [rsp+18h],rsi
00007ffc`76d4fed3 57 push rdi
00007ffc`76d4fed4 4154 push r12
00007ffc`76d4fed6 4156 push r14
00007ffc`76d4fed8 4883ec20 sub rsp,20h
00007ffc`76d4fedc 4c8b35cdc41000 mov r14,qword ptr [ntdll!LdrpWorkCompleteEvent (00007ffc`76e5c3b0)] //<---
00007ffc`76d4fee3 4c8d2506c51000 lea r12,[ntdll!LdrpWorkQueue (00007ffc`76e5c3f0)]
00007ffc`76d4feea 4032f6 xor sil,sil
00007ffc`76d4feed 8bf9 mov edi,ecx
00007ffc`76d4feef 85c9 test ecx,ecx
00007ffc`76d4fef1 4c0f443587c41000 cmove r14,qword ptr [ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)] //<---
00007ffc`76d4fef9 488d0dc0c41000 lea rcx,[ntdll!LdrpWorkQueueLock (00007ffc`76e5c3c0)]
00007ffc`76d4ff00 e89bfbfcff call ntdll!RtlEnterCriticalSection (00007ffc`76d1faa0)
//...
00007ffc`76d50015 4533c0 xor r8d,r8d
00007ffc`76d50018 33d2 xor edx,edx
00007ffc`76d5001a 498bce mov rcx,r14 //<----
00007ffc`76d5001d e8eed50300 call ntdll!NtWaitForSingleObject (00007ffc`76d8d610)

注意查看 //<---- 对应的汇编代码,ntdll!NtWaitForSingleObject() 的参数来源自 r14,而 r14 的值可能来源于两个地方,一个是 ntdll!LdrpWorkCompleteEvent,一个是 ntdll!LdrpLoadCompleteEvent。大概逻辑如下:

1
2
3
4
5
r14 = ntdll!LdrpWorkCompleteEvent;
if (param == 0)
{
r14 = ntdll!LdrpLoadCompleteEvent;
}

再看一下调用 ntdll!LdrpDrainWorkQueue() 的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
10
0:006> ub 00007ffc`76d074ed 
ntdll!LdrShutdownThread+0x70:
00007ffc`76d074c0 65488b042530000000 mov rax,qword ptr gs:[30h]
00007ffc`76d074c9 b900100000 mov ecx,1000h
00007ffc`76d074ce 668588ee170000 test word ptr [rax+17EEh],cx
00007ffc`76d074d5 0f85e0690900 jne ntdll!LdrShutdownThread+0x96a6b (00007ffc`76d9debb)
00007ffc`76d074db 418af4 mov sil,r12b
00007ffc`76d074de 4488a42400010000 mov byte ptr [rsp+100h],r12b
00007ffc`76d074e6 33c9 xor ecx,ecx //<----
00007ffc`76d074e8 e8d7890400 call ntdll!LdrpDrainWorkQueue (00007ffc`76d4fec4)

可以发现,ntdll!LdrShutdownThread() 调用 ntdll!LdrpDrainWorkQueue() 时的参数是 0。所以最终等待的句柄是 ntdll!LdrpLoadCompleteEvent

使用 dd ntdll!LdrpLoadCompleteEvent L1 查看其值,果然是 0x38

1
2
0:006> dd ntdll!LdrpLoadCompleteEvent L1
00007ffc`76e5c380 00000038

看来 6 号线程在等待 ntdll!LdrpLoadCompleteEvent。那谁会触发这个事件呢?

继续查看反汇编

如果能有办法找到 ntdll!LdrpLoadCompleteEvent 所有的引用,就可以进一步查看相关代码。是时候请出 IDA 了,因为 IDA 的静态分析能力简直太香了。用 64 位的 IDA 打开 ntdll.dll,找到所有引用 ntdll!LdrpLoadCompleteEvent 的地方,如下图:

view-loadcompleteevent-reference-in-ida

可以发现,一共就三个地方:LdrpDropLastInProgressCount+38↑rLdrpDrainWorkQueue+2D↑rLdrpCreateLoaderEvents+12↑o

小提示: 还可以在 windbg 中使用 # 命令查找所有用到 LdrpLoadCompleteEvent 的代码。# 命令的大概语法是 # [Pattern] [Address [ L Size ]]。可以在 windbg 中输入 .hh # 查看该命令的详细解释。

1
2
3
4
5
6
7
8
9
10
11
12
> 0:006> lmm ntdll
> Browse full module list
> start end module name
> 00007ffc`76cf0000 00007ffc`76ee8000 ntdll (pdb symbols) d:\mssyms\ntdll.pdb\180BF1B90AA75697D0EFEA5E5630AC7E1\ntdll.pdb
> 0:006> # "ntdll!LdrpLoadCompleteEvent" 00007ffc`76cf0000 L99999
> ntdll!LdrpDropLastInProgressCount+0x38:
> 00007ffc`76d4eeb4 488b0dc5d41000 mov rcx,qword ptr [ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)]
> ntdll!LdrpDrainWorkQueue+0x2d:
> 00007ffc`76d4fef1 4c0f443587c41000 cmove r14,qword ptr [ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)]
> ntdll!LdrpCreateLoaderEvents+0x12:
> 00007ffc`76d6eb22 488d0d57d80e00 lea rcx,[ntdll!LdrpLoadCompleteEvent (00007ffc`76e5c380)]
>

IDA 中查看另外两个函数的反汇编,F5 结果如下:

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
__int64 LdrpCreateLoaderEvents()
{
__int64 result; // rax
char v1; // [rsp+20h] [rbp-18h]
int v2; // [rsp+20h] [rbp-18h]

v1 = 0;
result = ZwCreateEvent(&LdrpLoadCompleteEvent, 2031619i64, 0i64, 1i64, v1);
if ( (int)result >= 0 )
{
LOBYTE(v2) = 0;
return ZwCreateEvent(&LdrpWorkCompleteEvent, 2031619i64, 0i64, 1i64, v2);
}
return result;
}

__int64 LdrpDropLastInProgressCount()
{
struct _TEB *v0; // rax

v0 = NtCurrentTeb();
v0->SameTebFlags &= ~0x1000u;
RtlEnterCriticalSection(&LdrpWorkQueueLock);
LdrpWorkInProgress = 0;
RtlLeaveCriticalSection(&LdrpWorkQueueLock);
return ZwSetEvent(LdrpLoadCompleteEvent, 0i64);
}

从伪代码可知,函数 LdrpCreateLoaderEvents() 会创建对应的事件,而函数 LdrpDropLastInProgressCount() 会触发事件。那谁会调用 LdrpDropLastInProgressCount() 呢?继续在 IDA 中查找引用,如下图:

view-droplastinprogresscount-reference-in-ida

从图中可知,有很多函数都引用了 LdrpDropLastInProgressCount(),静态分析有点太费事了,如果能动态调试就再好不过了。

继续调试

我们之前在 DllMain() 中是无限等待的,如果只等待几秒,程序是可以正常退出的,也就是说 DllMain() 执行后应该是有人调用了 SetEvent() 的。修改代码为只等待 5 秒,等待结束后在 ntdll!NtSetEvent() 函数上设置断点,应该可以看到是谁触发了事件。整个过程比较简单,就省略了,直接给出结果,如下图:

setevent-called-in-LdrUnloadDll

看来,LdrUnloadDll() 函数会先调用 DllMain()DllMain() 执行完成后,LdrUnloadDll() 会继续调用 LdrpDropLastInProgressCount() ,进而触发事件。如果我们在 DllMain() 中一直等待,LdrUnloadDll() 就没机会调用 LdrpDropLastInProgressCount() ,也就不会触发事件,最终就会导致死锁。

函数 LdrUnloadDll() 对应的伪代码如下(来自 IDAF5,有删减):

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
__int64 __fastcall LdrUnloadDll(__int64 a1)
{
LoadedDllByHandle = 0;
if ( !byte_7FFC76E5C508 )
{
LoadedDllByHandle = LdrpFindLoadedDllByHandle(a1, &v6, &v5);
if ( LoadedDllByHandle >= 0 )
{
v2 = v6;
LoadedDllByHandle = LdrpDecrementModuleLoadCountEx(v6, 1u);
if ( LoadedDllByHandle == 0xC000022D )
{
loadOwnerFlag = NtCurrentTeb()->SameTebFlags & 0x1000;
if ( !loadOwnerFlag )
LdrpDrainWorkQueue(0); //<----
v2 = v6;

// 内部会调用 DllMain()
LdrpDecrementModuleLoadCountEx(v6, 0);

if ( !loadOwnerFlag )
LdrpDropLastInProgressCount(); //<----

LoadedDllByHandle = 0;
}
LdrpDereferenceModule(v2);
}
}
return (unsigned int)LoadedDllByHandle;
}

函数 LdrUnloadDll() 会先调用 LdrpDrainWorkQueue(),然后调用 LdrpDecrementModuleLoadCountEx()(其内部会调用 DllMain()),最后调用 LdrpDropLastInProgressCount()

死锁的原因找到了,但是为什么第一个线程可以顺利退出呢?难道第一个线程没有等待吗?

继续深挖

在继续深挖之前,非常有必要对相关代码有个了解,以下是 LdrShutdownThread()LdrpDrainWorkQueue() 的伪代码。

  • LdrShutdownThread() 的伪代码如下(来自 IDAF5,有删减):
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
__int64 __fastcall LdrShutdownThread(struct _RTLP_FLS_CONTEXT *a1)
{
// 省略若干代码
if ( ((NtCurrentTeb()->SameTebFlags & 8) == 0 || (NtCurrentTeb()->SameTebFlags & 0x20) != 0)
&& (v1->SameTebFlags & 0x2000) == 0 )
{
if ( (NtCurrentTeb()->SameTebFlags & 0x1000) != 0 )
{
loadOwner = 1;
}
else
{
loadOwner = 0;
LdrpDrainWorkQueue(0); //<----
}
LdrpAcquireLoaderLock();

// 循环调用 LdrpCallInitRoutine(),内部会调用 DllMain()
// 省略若干代码

LdrpReleaseLoaderLock(v5, 19i64);

if ( !loadOwner )
LdrpDropLastInProgressCount(); //<----

LdrpFreeTls(v9);
}

// 省略若干代码

return result;
}

根据以上伪代码可知,函数 LdrShutdownThread() 调用 LdrpDrainWorkQueue() 时传递的 tastType 参数的值是 0

  • LdrpDrainWorkQueue() 的伪代码如下(来自 IDAF5,有删减):
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
74
75
76
77
78
struct _TEB *__fastcall LdrpDrainWorkQueue(int taskType)
{
HANDLE hEvent; // r14
hEvent = (HANDLE)LdrpWorkCompleteEvent;
if ( !taskType )
hEvent = LdrpLoadCompleteEvent;

while ( 1 )
{
while ( 1 )
{
RtlEnterCriticalSection(&LdrpWorkQueueLock);
v4 = LdrpDetourExist;
// 我机器上有 hook,LdrpDetourExist 的值是 1,
// 而且 LdrShutdownThread 调用此函数的时候传递的是 0,
// 也就是 taskType 的值是 0,我机器上永远会走 else
if ( !LdrpDetourExist || taskType == 1 )
{
v5 = (__int64 *)LdrpWorkQueue;
if ( *(__int64 **)(LdrpWorkQueue + 8) != &LdrpWorkQueue
|| (v6 = *(_QWORD *)LdrpWorkQueue, *(_QWORD *)(*(_QWORD *)LdrpWorkQueue + 8i64) != LdrpWorkQueue) )
{
__fastfail(3u);
}
LdrpWorkQueue = *(_QWORD *)LdrpWorkQueue;
*(_QWORD *)(v6 + 8) = &LdrpWorkQueue;
if ( &LdrpWorkQueue == v5 )
{
if ( LdrpWorkInProgress == taskType )
{
LdrpWorkInProgress = 1;
v2 = 1;
}
}
else
{
if ( !v4 )
++LdrpWorkInProgress;
LdrpUpdateStatistics();
}
}
else
{
// LdrpWorkInProgress 如果是 0(表明没有工作在进行中),
// 那么 v2 会被设置成 1 (因为 taskType 的值是 0)
if ( LdrpWorkInProgress == taskType )
{
LdrpWorkInProgress = 1;
v2 = 1;
}
v5 = &LdrpWorkQueue;
}
RtlLeaveCriticalSection(&LdrpWorkQueueLock);

// 关键判断,如果 v2 不是 0,则跳出内层循环,不会等待
if ( v2 ) //<----
break;

// 如果队列中无需要处理的项,则等待,否则处理
if ( &LdrpWorkQueue == v5 )
NtWaitForSingleObject(hEvent, 0, 0i64); //<----
else
LdrpProcessWork((__int64)(v5 - 8), v4);
}

// 如果 taskType 是 0,跳出外层循环
if ( !taskType || (__int64 *)LdrpRetryQueue == &LdrpRetryQueue )
break;

// 外层循环,处理 retry 逻辑,省略相关代码
v2 = 0;
}

// 设置 LoadOwner 标记,后续调用 LdrpDropLastInProgressCount() 的时候会清除
result = NtCurrentTeb();
result->SameTebFlags |= 0x1000u;
return result;
}

如果 LdrpWorkInProgress 的值是 0,则 LdrpWorkInProgress 会被设置成 1。在执行等待代码的上方会跳出循环,因此不会执行等待逻辑。这也就解释了为什么第一个线程退出的时候为什么可以正常退出。

说明: 仔细阅读这个变量的名字,LdrpWorkInProgress 表示正在处理的工作数量,如果没有工作,则是 0,有工作则非零。

LdrpWorkInProgress 什么时候会被改变呢?

LdrpWorkInProgress

可以在 windbg 中使用 # "LdrpWorkInProgress" 00007ffc76cf0000 L99999 查看 LdrpWorkInProgress 的使用情况,如下:

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
0:007> # "LdrpWorkInProgress" 00007ffc`76cf0000 L99999
ntdll!LdrpDropLastInProgressCount+0x25:
00007ffc`76d4eea1 832540d5100000 and dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],0
ntdll!LdrpDrainWorkQueue+0x83:
00007ffc`76d4ff47 393d9bc41000 cmp dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],edi
ntdll!LdrpDrainWorkQueue+0x8b:
00007ffc`76d4ff4f c7058fc4100001000000 mov dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],1
ntdll!LdrpDrainWorkQueue+0x168:
00007ffc`76d5002c ff05b6c31000 inc dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)]
ntdll!LdrpWorkCallback+0x4c:
00007ffc`76d500ac ff0536c31000 inc dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)]
ntdll!LdrpProcessWork+0x1a0:
00007ffc`76d5028c 8b0556c11000 mov eax,dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)]
ntdll!LdrpProcessWork+0x1a8:
00007ffc`76d50294 89054ec11000 mov dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],eax
ntdll!LdrpUpdateStatistics+0x6:
00007ffc`76d502ea 3b0df8c01000 cmp ecx,dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)]
ntdll!LdrpUpdateStatistics+0x15:
00007ffc`76d502f9 0f420de8c01000 cmovb ecx,dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)]
ntdll!LdrpProcessWork$fin$0+0x41:
00007ffc`76d96085 8b055d630c00 mov eax,dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)]
ntdll!LdrpProcessWork$fin$0+0x49:
00007ffc`76d9608d 890555630c00 mov dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],eax
ntdll!LdrpDrainWorkQueue+0x5d3bd:
00007ffc`76dad281 393d61f10a00 cmp dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],edi
ntdll!LdrpDrainWorkQueue+0x5d3c5:
00007ffc`76dad289 c70555f10a0001000000 mov dword ptr [ntdll!LdrpWorkInProgress (00007ffc`76e5c3e8)],1

可以发现在 LdrpDrainWorkQueue()LdrpProcessWork()LdrpDropLastInProgressCount()LdrpWorkCallback() 中会修改它的值。

结合 LdrUnloadDll()LdrpDrainWorkQueue()LdrpDropLastInProgressCount() 的代码可知,其在调用 DllMain() 之前会通过 LdrpDrainWorkQueue() 设置 LdrpWorkInProgress 标志,在调用完 DllMain() 之后,又会通过调用 LdrpDropLastInProgressCount() 把这个值设置为 0

其实,LdrShutdownThread() 也有类似的逻辑,也是会先调用 LdrpDrainWorkQueue() ,然后执行一些业务代码,最后会调用 LdrpDropLastInProgressCount() 。我们再看观察其它几个会调用 LdrpDropLastInProgressCount() 的函数,注意观察以下代码中的 //<---- 标记。

相关函数

  • LdrpLoadDllInternal() 的伪代码如下(来自 IDAF5,有删减):
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
__int64 __fastcall LdrpLoadDllInternal(__int64 a1, int a2, unsigned int a3, int a4, __int64 a5, __int64 a6, __int64 *a7, int *a8)
{
result = LdrpFastpthReloadedDll(a1, a3, a6, a7);
if ( (int)result < 0 )
{
if ( (NtCurrentTeb()->SameTebFlags & 0x1000) != 0 )
{
loadOwnerFlag = 1;
}
else
{
loadOwnerFlag = 0;
LdrpDrainWorkQueue(0); //<----
}

if ( !a6 || loadOwnerFlag || *(_DWORD *)(*(_QWORD *)(a6 + 152) + 24i64) )
{
LdrpDetectDetour();
v12 = a8;
v14 = LdrpFindOrPrepareLoadingModule(a1, a2, a3, a4, a5, (__int64)&v19, (__int64)a8);
if ( v14 == -1073741515 )
{
LdrpProcessWork(*(_QWORD *)(v19 + 176), 1);
}
else if ( v14 != -1073741267 && v14 < 0 )
{
*a8 = v14;
}
}
else
{
v12 = a8;
*a8 = -1073741515;
}

if ( !loadOwnerFlag )
result = LdrpDropLastInProgressCount(); //<----
}
else
{
v12 = a8;
*a8 = result;
}

return result;
}
  • LdrpInitializeThread() 的伪代码如下(来自 IDAF5,有删减):
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
__int64 __fastcall LdrpInitializeThread(__int64 a1, __int64 a2, __int64 a3)
{
RtlpInitializeThreadActivationContextStack(v3, a2, a3, a1);
if ( (NtCurrentTeb()->SameTebFlags & 8) == 0
|| (result = (__int64)NtCurrentTeb(), (*(_BYTE *)(result + 0x17EE) & 0x20) != 0) )
{
result = 0x2000i64;
if ( (*(_WORD *)(v5 + 6126) & 0x2000) == 0 )
{
LdrpDrainWorkQueue(0); //<----
LdrpAcquireLoaderLock();
for ( i = qword_7FFC76E5C4D0; (__int64 *)i != &qword_7FFC76E5C4D0; i = *(_QWORD *)i )
{
if ( *(int *)(*(_QWORD *)(i + 152) + 56i64) >= 9
&& ProcessEnvironmentBlock->ImageBaseAddress != *(void **)(i + 48) )
{
v11 = *(_DWORD *)(i + 104);
if ( (v11 & 0x40000) == 0 )
{
v12 = *(__int64 (__fastcall **)(__int64, _QWORD, __int64))(i + 56);
if ( v12 )
{
if ( (v11 & 0x80004) == 524292 )
{
if ( byte_7FFC76E5C508 )
goto LABEL_22;
RtlActivateActivationContextUnsafeFast(&v13, *(_QWORD *)(i + 136));
if ( *(_WORD *)(i + 110) )
LdrpCallTlsInitializers(2i64, i);
LdrpCallInitRoutine(v12, *(_QWORD *)(i + 48), 2u, 0i64);
RtlDeactivateActivationContextUnsafeFast((__int64)&v13);
}
}
}
}
}
if ( *(_WORD *)(LdrpImageEntry + 110) && !byte_7FFC76E5C508 )
{
RtlActivateActivationContextUnsafeFast(&v19, *(_QWORD *)(LdrpImageEntry + 136));
LdrpCallTlsInitializers(2i64, LdrpImageEntry);
RtlDeactivateActivationContextUnsafeFast((__int64)&v19);
}
LABEL_22:
LdrpReleaseLoaderLock(v9, 21i64);
return LdrpDropLastInProgressCount(); //<----
}
}
return result;
}
  • LdrEnumerateLoadedModules() 的伪代码如下(来自 IDAF5,有删减):
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
__int64 __fastcall LdrEnumerateLoadedModules(int a1, void (__fastcall *a2)(__int64 *, __int64, char *), __int64 a3)
{
if ( (NtCurrentTeb()->SameTebFlags & 0x1000) != 0 )
{
loadOwnerFlag = 1;
v10 = 1;
}
else
{
loadOwnerFlag = 0;
v10 = 0;
LdrpDrainWorkQueue(0); //<----
}
LdrpAcquireLoaderLock();
for ( i = (__int64 *)qword_7FFC76E5C4D0; i != &qword_7FFC76E5C4D0; i = (__int64 *)*i )
{
a2(i, a3, &v9);
if ( v9 )
break;
}
LdrpReleaseLoaderLock(v6, 15i64);
if ( !loadOwnerFlag )
LdrpDropLastInProgressCount(); //<----
return 0i64;
}

从以上代码中可以发现一个通用的模式,基本上会分成以下三个部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
// part 1
if ( (NtCurrentTeb()->SameTebFlags & 0x1000) == 0 )
{
loadOwnerFlag = 0;
LdrpDrainWorkQueue(0); //<----
}

// part 2
// 业务处理代码

// part 3
if ( !loadOwnerFlag )
LdrpDropLastInProgressCount(); //<----

有点类似于用全局变量 LdrpWorkInProgress 做了一个锁。part1 先检查这个全局变量,如果已经设置了标记就等待,否则就设置标记。然后在 part2 执行业务代码,最后在 part3 清除这个标记。如果在 part2 中卡住了,其它线程就要执行等待逻辑。

问题总结

至此,整个卡死的前因后果都非常清晰了。

第一个线程结束的时候,我们没有执行 FreeLibrary(),也就不会执行 LdrUnloadDll()LdrpWorkInProgress 的值是 0LdrShutdownThread() 内部调用 LdrpDrainWorkQueue() 的时候,由于 LdrpWorkInProgress 的值是 0,所以不会等待。

当第二个线程结束的时候,LdrUnloadDll() 正在 DllMain() 中等待其结束,而在此之前 LdrpWorkInProgress 已经被 LdrUnloadDll() 设置成 1 了。所以第二个线程对应的 LdrShutdownThread() 内部调用 LdrpDrainWorkQueue() 的时候,对应的 LdrpWorkInProgress 的值是 1,因此需要等待。而 DllMain() 由于在等待线程结束,但是线程永远不会结束,LdrUnloadDll() 也就没机会调用后面的 LdrpDropLastInProgressCount() 触发事件,于是死锁了。

TEB.SameTebFlags

关于 SameTebFlags 可能的标志位信息,我问了一下 AI,看着比较靠谱,未经证实,各位谨慎参考。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 标志位掩码(共 16 位)
typedef enum _TEB_SAME_TEB_FLAGS {

SafeThunkCall = 0x0001, // 0x0001
InDebugPrint = 0x0002, // 0x0002
HasFiberData = 0x0004, // 0x0004
SkipThreadAttach = 0x0008, // 0x0008

WerInShipAssertCode = 0x0010, // 0x0010
RanProcessInit = 0x0020, // 0x0020
ClonedThread = 0x0040, // 0x0040
SuppressDebugMsg = 0x0080, // 0x0080

DisableUserStackWalk = 0x0100, // 0x0100
RtlExceptionAttached = 0x0200, // 0x0200
InitialThread = 0x0400, // 0x0400
SessionAware = 0x0800, // 0x0800

LoadOwner = 0x1000, // 0x1000
LoaderWorker = 0x2000, // 0x2000

SkipLoaderInit = 0x4000, // 0x4000
SkipFileAPIBrokering = 0x8000, // 0x8000
} TEB_SAME_TEB_FLAGS;

主要关注 LoadOwnerLoaderWorker 标志。

Parallel Loader

在调查整个问题的过程中,查询了各种资料,才对这个问题有了一个粗浅的认识。原来在 win10 之前,所有涉及模块的操作都是串行完成的,通过加载器锁进行保护,如果遇到死锁,基本上可以通过 !cs -l 查看出来。而在 win10 中引入了并行加载器,加载工作改由多个工作线程并行执行。强烈建议阅读参考资料中的前两篇文章,以便对并行加载有个更深入的了解。

总结

  • win10 开启了并行加载,提高了加载速度,但是增大了调试难度。
  • 永远不要在 DllMain 中做一些复杂操作,尤其是执行等待操作。
  • !handle handle flag 命令可以查看句柄的信息,具体参考 windbg 帮助文档
  • 可以在 windbg 中执行 # "assembly_string" memory_range 搜索相关指令
  • win10 下查看加载器相关的逻辑可以关注 ntdll!LdrpLoadCompleteEventLdrpWorkInProgress 等全局变量

参考资料

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%