DllMain()限入死锁问题分析 (一)

这个经典的同步问题,有很多文章讲过,写这篇博客,权当是疏理一下相关的技术概念和分析方法。

问题的现象:

如果在一个DLL的DllMain()函数中创建一个线程,那么当该DLL被引导时,就会导致主(host)进程陷入死锁。


问题重现方法:

用VS生成一个DLL工程,在DllMain()函数里创建一个线程,并等待该进程结束。简单起见,子线程函数不必包含任何工作逻辑,直接返回即可。

DLL工程源代码:

#include <windows.h>
#include <stdio.h>
WINAPI InitDllProc LPVOID lpParam )
{
	return 1;
}
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
	BOOL bRet=FALSE;
	switch(fdwReason)
	{
	case DLL_PROCESS_ATTACH:
		{
			DWORD dwId=0;
			HANDLE hThread=NULL;
			hThread = CreateThread(NULL, 0, InitDllProc, NULL, 0, &dwId);
			if(hThread)
			{
				WaitForSingleObject(hThread, INFINITE);
				CloseHandle(hThread);
				bRet=TRUE;
			}
		}
		break;
	}
	return bRet;
}

另生成一个普通的console程序工程,在main()函数里调用LoadLibrary()引导DLL工程生成的DLL。

console程序源代码。

#include <windows.h>
#include <stdio.h>
int _tmain(int argc, _TCHAR* argv[])
{
	_tprintf(L"Load library ...\n");
	LoadLibrary(L"DllMain.dll");
	_tprintf(L"Load library done.\n");
	return 0;
}


把创建的DllMain.dll和LoadDll.exe放在同一个目录下,并双击运行LoadDll.exe。可以看到主进程输出"Load library ..."之后就再也没有动作。



用任务管理器看到该进程的CPU使用率一直保持0不变。



据此可以判断,LoadDll.exe进程陷入了某种死锁状态。


问题调试

调试环境:
OS: Windows 7 SP1 Build 7601 (X86)
WinDbg 6.3.9600.16384 X86

在LoadDll.exe限入死锁状态后,用WinDbg attach到该进程。用!cs -l命令查看锁的状态。
0:002> !cs -l
-----------------------------------------
DebugInfo          = 0x779f7540
Critical section   = 0x779f7340 (ntdll!LdrpLoaderLock+0x0)
LOCKED
LockCount          = 0x1
WaiterWoken        = No
OwningThread       = 0x00000fa0
RecursionCount     = 0x1
LockSemaphore      = 0x30
SpinCount          = 0x00000000

可以看到有一个名为ntdll!LdrpLoaderlock的关键区(Critical Section)正处于锁状态,对应的句柄号是0x30,拥有这个关键区的线程是fa0。
0:002> ~* kv
   0  Id: f9c. fa0 Suspend: 1 Teb: 7ffde000 Unfrozen
ChildEBP RetAddr  Args to Child              
0015f934 77966a24 75af179c 0000002c 00000000 ntdll!KiFastSystemCallRet (FPO: [0,0,0])
0015f938 75af179c 0000002c 00000000 00000000 ntdll!ZwWaitForSingleObject+0xc (FPO: [3,0,0])
0015f9a4 7739baf3 0000002c ffffffff 00000000 KERNELBASE!WaitForSingleObjectEx+0x98 (FPO: [Non-Fpo])
0015f9bc 7739baa2 0000002c ffffffff 00000000 kernel32!WaitForSingleObjectExImplementation+0x75 (FPO: [Non-Fpo])
0015f9d0 70731044 0000002c ffffffff 00000001 kernel32!WaitForSingleObject+0x12 (FPO: [Non-Fpo])
0015f9e4 7073135e 70730000 0000008c 00000000 DllMain!DllMain+0x34 (FPO: [3,0,1]) (CONV: stdcall) [d:\for_work\taskforce\sharing\etp_couse\advancedwindowsdebugging\slides\multi-thread analyze materials\sourcecode\dllmain\dllmain\dllmain.cpp @ 25]
0015fa28 70731418 70730000 0015fa54 779789d8 DllMain!__DllMainCRTStartup+0x7a (FPO: [Non-Fpo]) (CONV: cdecl) [f:\dd\vctools\crt_bld\self_x86\crt\src\crtdll.c @ 543]
: : :
0015fd34 00e5101c 00e52118 00000001 00e51193 kernel32!LoadLibraryW+0x11 (FPO: [Non-Fpo])
0015fd40 00e51193 00000001 00472b78 00471228 LoadDll!wmain+0x1c (FPO: [2,0,1]) (CONV: cdecl) [d:\programming\vc++\loaddll\loaddll\loaddll.cpp @ 12]

   1  Id: f9c.8c Suspend: 1 Teb: 7ffdd000 Unfrozen
ChildEBP RetAddr  Args to Child              
005af624 77966a24 77952264 00000030 00000000 ntdll!KiFastSystemCallRet (FPO: [0,0,0])
005af628 77952264 00000030 00000000 00000000 ntdll!ZwWaitForSingleObject+0xc (FPO: [3,0,0])
005af68c 77952148 00000000 00000000 00000000 ntdll!RtlpWaitOnCriticalSection+0x13e (FPO: [Non-Fpo])
005af6b4 77983795 779f7340 77c6147b 7ffdd000 ntdll!RtlEnterCriticalSection+0x150 (FPO: [Non-Fpo])
005af748 77983636 005af7b8 77c614a7 00000000 ntdll!LdrpInitializeThread+0xc6 (FPO: [Non-Fpo])
005af794 77983663 005af7b8 77920000 00000000 ntdll!_LdrpInitialize+0x1ad (FPO: [Non-Fpo])
005af7a4 00000000 005af7b8 77920000 00000000 ntdll!LdrInitializeThunk+0x10 (FPO: [Non-Fpo])

根据栈回溯的结果可以知道,fa0线程正在等待句柄2c,而8c线程在等待句柄30,正是与处于LOCKED状态的关键区相关的句柄。那么2c和30句柄分别是什么呢?
0:002> !handle 30
Handle 30
  Type         Event

其中30的类型是Event,与!cs命令给出的LockSemphore这个名字并不相符。很不明白微软为什么要给它起这么一个迷惑人的名字。这样我们就知道原来关键区这个用户态的锁机制的背后,还是要有内核态的支持,那就是当发现已经有其它人进入关键区后,后申请进入的线程必须等待一个相应的事件;当前的访问者退出关键区时,会触发该事件,从而唤醒等待进入的访问者。
0:002> !handle 2c f
Handle 2c
  Type         Thread
  Attributes   0
  GrantedAccess0x1fffff:
         Delete,ReadControl,WriteDac,WriteOwner,Synch
         Terminate,Suspend,Alert,GetContext,SetContext,SetInfo,QueryInfo,SetToken,Impersonate,DirectImpersonate
  HandleCount  4
  PointerCount 7
  Name         <none>
  Object Specific Information
    Thread Id   f9c.8c
    Priority    10
    Base Priority 0
    Start Address 70731000 DllMain!InitDllProc

另一个句柄2c的类型则是线程,对应于线程8c,线程入口地址是InitDllProc,这个正是子线程的函数。

至此,我们只是用WinDbg证实了程序确实是进入了死锁状态:DllMain线程已经进入名为LdrpLoaderLock的关键区,它正等待子线程退出;而子线程又在等待DllMain线程退出该关键区。下面的问题是:为什么它们都需要进入这个关键区?创建一个线程这样一个看似简单的动作为什么会导致限入死锁?

因为目前子线程正等待进入该关键区,我们先来看它进入关键区的目的何在。从当前的栈回溯可以看到它是在ntdll!LdrpInitializeThread函数里陷入等待的,查看该函数的汇编码,发现它在关键区代码里要访问的是一个叫ntdll!PebLdr的指针所指向的一片区域。
ntdll!LdrpInitializeThread+0xbc:
7798378b 6840739f77      push    offset ntdll!LdrpLoaderLock (779f7340)
77983790 e80b40feff      call    ntdll!RtlEnterCriticalSection (779677a0)
77983795 895dfc          mov     dword ptr [ebp-4],ebx
77983798 a194789f77      mov     eax,dword ptr [ntdll!PebLdr+0x14 (779f7894)]
7798379d 8945e4          mov     dword ptr [ebp-1Ch],eax
779837a0 33db            xor     ebx,ebx
779837a2 43              inc     ebx

因为DllMain的主线程现在已经在关键区里,所以从现在的现场环境无法确定主线程为什么要进入关键区。我们不得不用WinDbg重新执行该程序,并在合适的地方加上断点,以确保我们能在主线程进入关键区前停在当场。

用.restart命令指示WinDbg终止当前调试,并重新运行该程序。WinDbg在创建进程后,自动断在初始调试断点。这时我们来看一下LdrpLoaderLock的状态。OwningThread和LockSemaphore都还没有赋值。
0:000> x ntdll!LdrpLoaderLock
779f7340
         ntdll!LdrpLoaderLock = <no type information>
0:000> dt _RTL_CRITICAL_SECTION 779f7340
ntdll!_RTL_CRITICAL_SECTION
   +0x000 DebugInfo        : 0x779f7540 _RTL_CRITICAL_SECTION_DEBUG
   +0x004 LockCount        : 0n-1
   +0x008 RecursionCount   : 0n0
   +0x00c OwningThread     : (null) 
   +0x010 LockSemaphore    : (null) 
   +0x014 SpinCount        : 0


主线程进入该关键区一定会修改OwningThread这个值,所以我们为它加一个硬件访问断点。实际操作中,需要先在LoadDll!wmain打个断点并触发该断点后,才能加物理访问断点。
0:000> bp LoadDll!wmain
0:000> g
Breakpoint 0 hit
: : :
0:000> ba w4 ntdll!LdrpLoaderLock+0x0c

继续执行,该物理访问断点被触发。栈回溯显示主线程停在了要进入关键区的地方。从时间线上来看,这个事件发生在LoadDll程序调用LoadLibrary()函数引导DllMain.Dll的过程中,当前的函数是LdrpLoadDll().
0:000> g
Breakpoint 1 hit
:::
ntdll!RtlEnterCriticalSection+0x1b9:
779677c9 c7470801000000  mov     dword ptr [edi+8],1  ds:0023:779f7348=00000000
0:000> ~* kv
.  0  Id: 4b4.a58 Suspend: 1 Teb: 7ffdf000 Unfrozen
ChildEBP RetAddr  Args to Child              
001ff698 7797fea3 779f7340 779cdfb9 77967c9a ntdll!RtlEnterCriticalSection+0x1b9 (FPO: [Non-Fpo])
001ff804 7798232c 001ff864 001ff830 00000000 ntdll!LdrpLoadDll+0x287 (FPO: [Non-Fpo])
001ff838 75af88ee 0025374c 001ff878 001ff864 ntdll!LdrLoadDll+0x92 (FPO: [Non-Fpo])
001ff870 773a3c12 00000000 00000000 00000001 KERNELBASE!LoadLibraryExW+0x15a (FPO: [Non-Fpo])
001ff884 0023101c 00232118 00000001 00231193 kernel32!LoadLibraryW+0x11 (FPO: [Non-Fpo])
001ff890 00231193 00000001 004a2b78 004a1228 LoadDll!wmain+0x1c (FPO: [2,0,1]) (CONV: cdecl) [d:\programming\vc++\loaddll\loaddll\loaddll.cpp @ 12]
001ff8d4 773a3c45 7ffd3000 001ff920 779837f5 LoadDll!__tmainCRTStartup+0x10f (FPO: [Non-Fpo]) (CONV: cdecl) [f:\dd\vctools\crt_bld\self_x86\crt\src\crtexe.c @ 579]
001ff8e0 779837f5 7ffd3000 779cde9d 00000000 kernel32!BaseThreadInitThunk+0xe (FPO: [Non-Fpo])
001ff920 779837c8 002312db 7ffd3000 00000000 ntdll!__RtlUserThreadStart+0x70 (FPO: [Non-Fpo])
001ff938 00000000 002312db 7ffd3000 00000000 ntdll!_RtlUserThreadStart+0x1b (FPO: [Non-Fpo])
查看LdrpLoadDll函数的代码。
: : :
ntdll!LdrpLoadDll+0x27d:
7797fe99 6840739f77      push    offset ntdll!LdrpLoaderLock (779f7340)
7797fe9e e8fd78feff      call    ntdll!RtlEnterCriticalSection (779677a0)
7797fea3 e9eefcffff      jmp     ntdll!LdrpLoadDll+0x287 (7797fb96)
ntdll!LdrpLoadDll+0x287:
7797fb96 897dfc          mov     dword ptr [ebp-4],edi
7797fb99 8d85d7feffff    lea     eax,[ebp-129h]
7797fb9f 50              push    eax
7797fba0 8d85e0feffff    lea     eax,[ebp-120h]
7797fba6 50              push    eax
7797fba7 ffb5bcfeffff    push    dword ptr [ebp-144h]
7797fbad ff7510          push    dword ptr [ebp+10h]
7797fbb0 ffb5c8feffff    push    dword ptr [ebp-138h]
7797fbb6 ffb5d8feffff    push    dword ptr [ebp-128h]
7797fbbc e8d6010000      call    ntdll!LdrpFindOrMapDll (7797fd97)
7797fbc1 8985dcfeffff    mov     dword ptr [ebp-124h],eax
: : :
这个函数中并没有访问到之前提及的PebLdr变量,它调用了另一个函数LdrpFindOrMapDll,在这个函数中有使用到PebLdr。
ntdll!LdrpFindOrMapDll+0x53e:
779808bd 53              push    ebx
779808be 8d7e3c          lea     edi,[esi+3Ch]
779808c1 e86d74ffff      call    ntdll!LdrpHashUnicodeString (77977d33)
779808c6 83e01f          and     eax,1Fh
779808c9 8d04c5c0a59f77  lea     eax,ntdll!LdrpHashTable (779fa5c0)[eax*8]
779808d0 8b4804          mov     ecx,dword ptr [eax+4]
779808d3 8907            mov     dword ptr [edi],eax
779808d5 894f04          mov     dword ptr [edi+4],ecx
779808d8 8939            mov     dword ptr [ecx],edi
779808da 897804          mov     dword ptr [eax+4],edi
779808dd a190789f77      mov     eax,dword ptr [ntdll!PebLdr+0x10 (779f7890)]
779808e2 894604          mov     dword ptr [esi+4],eax
779808e5 c7068c789f77    mov     dword ptr [esi],offset ntdll!PebLdr+0xc (779f788c)
779808eb 8930            mov     dword ptr [eax],esi
779808ed 8b0d98789f77    mov     ecx,dword ptr [ntdll!PebLdr+0x18 (779f7898)]
779808f3 893590789f77    mov     dword ptr [ntdll!PebLdr+0x10 (779f7890)],esi
779808f9 8d4608          lea     eax,[esi+8]
779808fc c70094789f77    mov     dword ptr [eax],offset ntdll!PebLdr+0x14 (779f7894)
77980902 894804          mov     dword ptr [eax+4],ecx
77980905 8901            mov     dword ptr [ecx],eax
77980907 a398789f77      mov     dword ptr [ntdll!PebLdr+0x18 (779f7898)],eax
7798090c ff7620          push    dword ptr [esi+20h]
7798090f ff7618          push    dword ptr [esi+18h]
77980912 6800ac9f77      push    offset ntdll!LdrpInvertedFunctionTable (779fac00)
77980917 e863000000      call    ntdll!RtlInsertInvertedFunctionTable (7798097f)
7798091c 56              push    esi
7798091d e8a5fdffff      call    ntdll!RtlpStkMarkDllRange (779806c7)
77980922 837d0c00        cmp     dword ptr [ebp+0Ch],0
77980926 0f85474cfcff    jne     ntdll!LdrpFindOrMapDll+0x5a9 (77945573)

至此,我们知道了主线程在LoadLibrary函数里会进入LdrpLoaderLock这个关键区,以保护后续对ntdll!PebLdr的访问。而DllMain里创建的子线程在初始化时,也需要进入该关键区以访问PebLdr。

在下一个章节,我们将分析PebLdr是什么?为什么LoadLibrary和线程初始化都需要访问它?还有哪些操作需要用到它?



版权声明:本文为happylaodu原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。