有了前面两节的基础,我们现在切入正题:研究下DllMain为什么会因为不当操作导致死锁的问题。首先我们看一段比较经典的“DllMain中死锁”代码。(转载请指明出于breaksoftware的csdn博客)
粗略看这个问题,我们很难看出这个逻辑会导致死锁。但是事实就是这样发生了。我们跑一下程序,发现程序输出一下结果
后就停住了,光标在闪动,貌似还是在等待我们输入。可是我们怎么敲击键盘都没有用:它死锁了。我是在VS2005中调试该程序,于是我们可以Debug->Break All来冻结所有线程。
我们先查看主线程(3096)的堆栈
堆栈不长,我全部列出来
17 | ntdll.dll!_KiFastSystemCallRet@0() |
16 | ntdll.dll!_NtWaitForSingleObject@12() |
15 | kernel32.dll!_WaitForSingleObjectEx@12() |
14 | kernel32.dll!_WaitForSingleObject@8() |
13 | DllWithoutDisableThreadLibraryCalls_A.dll!DllMain(HINSTANCE__ * hModule=0x10000000, unsigned long ul_reason_for_call=1, void * lpReserved=0x00000000) |
12 | DllWithoutDisableThreadLibraryCalls_A.dll!__DllMainCRTStartup(void * hDllHandle=0x10000000, unsigned long dwReason=1, void * lpreserved=0x00000000) |
11 | DllWithoutDisableThreadLibraryCalls_A.dll!_DllMainCRTStartup(void * hDllHandle=0x10000000, unsigned long dwReason=1, void * lpreserved=0x00000000) |
10 | ntdll.dll!_LdrpCallInitRoutine@16() |
9 | ntdll.dll!_LdrpRunInitializeRoutines@4() |
8 | ntdll.dll!_LdrpLoadDll@24() |
7 | ntdll.dll!_LdrLoadDll@16() |
6 | kernel32.dll!_LoadLibraryExW@12() |
5 | kernel32.dll!_LoadLibraryExA@12() |
4 | kernel32.dll!_LoadLibraryA@4() |
3 | DllMainSerial.exe!wmain(int argc=3, wchar_t * * argv=0x003b7000) |
2 | DllMainSerial.exe!__tmainCRTStartup() |
1 | DllMainSerial.exe!wmainCRTStartup() |
0 | kernel32.dll!_BaseProcessStart@4() |
我们看下这个堆栈。大致我们可以将我们程序分为4段:
0 启动启动我们程序
1~6 我们加载Dll。
7~10 系统为我们准备DLL的加载。
11~17 DLL内部代码执行。
我们关注一下14~17这段对WaitForSingleObject的调用逻辑。15、16步这个过程显示了Kernel32中的WaitForSingleObjectEx在底层是调用了NtDll中的NtWaitForSingleObject。在NtWaitForSingleObject内部,即17步,我们看到的“_KiFastSystemCallRet@0”。这儿要说明下,这个并不是意味着我们程序执行到这个函数。我们看下这个函数的代码
KiFastSystemCallRet函数是内核态(Ring0层)逻辑回到用户态(Ring3层)的着陆点。与之相对应的KiFastSystemCall函数是用户态进入内核态必要的调用方法。因为内核态代码我们是无法查看的,所以动态断点只能设置到KiFastSystemCallRet开始处。所以实际死锁是因为NtWaitForSingleObject在底层调用了KiFastSystemCall进入内核,在内核态中死锁的。
我们在《DllMain中不当操作导致死锁问题的分析--死锁介绍》中介绍过,死锁存在的条件是相互等待。主线程中,我们发现其等待的是工作线程结束。那么工作线程在等待主线程什么呢?我们看下工作线程的调用堆栈
我们对这个堆栈进行编号
6 | ntdll.dll!_KiFastSystemCallRet@0() |
5 | ntdll.dll!_NtWaitForSingleObject@12() + 0xc bytes |
4 | ntdll.dll!_RtlpWaitForCriticalSection@4() + 0x8c bytes |
3 | ntdll.dll!_RtlEnterCriticalSection@4() + 0x46 bytes |
2 | ntdll.dll!__LdrpInitialize@12() + 0xb4bf bytes |
1 | ntdll.dll!_KiUserApcDispatcher@20() + 0x7 bytes |
0 | ntdll.dll!_RtlAllocateHeap@12() + 0x9b48 bytes |
我们看到倒数两步(5、6)和主线程中最后两步(16、17)是相同的,即工作线程也是在进入内核态后死锁的。我们知道主线程在等工作线程结束,那么工作线程在等什么呢?我们追溯栈,请关注“ntdll.dll!__LdrpInitialize@12() + 0xb4bf bytes”处的代码
我们看到,是因为_RtlEnterCriticalSection在底层调用了NtWaitForSingleObject。那么我们关注下_RtlEnterCriticalSection的参数_LdrpLoaderLock,它是什么?我们借助下IDA查看下LdrpInitialize反编译代码
由RtlTryEnterCriticalSection 可知LdrpLoaderLock是_RTL_CRITICAL_SECTION类型。在尝试进入临界区之前,LdrpLoaderLock将被保存到某个结构体变量v4的某个字段(偏移0xA0)中。那么v4是什么类型呢?这儿可能要科普下windows x86操作系统的一些知识:
在windows系统中每个用户态线程都有一个记录其执行环境的结构体TEB(Thread Environment Block)。TEB结构体中第一个字段是一个TIB(ThreadInformation Block)结构体,该结构体中保存着异常登记链表等信息。在x86系统中,段寄存器FS总是指向TEB结构。于是FS:[0]指向TEB起始字段,也就是指向TIB结构体。我们用Windbg查看下TEB的结构体,该结构体很大,我只列出我们目前关心的字段
NtTib就是TIB结构体对象名。 我们再看下TIB结构体
该结构体其他字段不解释,我们只看最后一个字段(FS:[18])指向_NT_TIB结构体的指针Self。正如其名,该字段指向的是TIB结构体在进程空间中的虚拟地址。为什么要指向自己?那我们是否可以直接使用FS:[0]地址?不可以。举个例子:我用windbg挂载到我电脑上一个运行中的calc(计算器)。我们查看fs:[0]指向空间保存的值,7ffdb000是TIB的Self字段。
介绍完这些后,我们再回到IDA反汇编的代码中。v4 = *(_DWORD*)(*MK_FP(__FS__, 0x18) + 0x30);这段中MK_FP不是一个函数,是一个宏。它的作用是在基址上加上偏移得出一个地址。于是MK_FP(__FS__, 0x18)就是FS:[0x18],即TIB的Self字段。在该地址再加上0x30得到的地址已经超过了TIB空间,于是我们继续查看TEB结构体
可以发现该结构体偏移0xa0处是一个名字为LoaderLock的变量。
《windows核心编程》中有关于DllMain序列化执行的讲解,大致意思是:线程在调用DllMain之前,要先获取锁,等DllMain执行完再解开这个锁。这样不同线程加载DLL就可以实现序列化操作。而在微软官方文档《Best Practices for Creating DLLs》中也有对这个说法的佐证
其中还有段关于这个锁的介绍
在该文中多处对这个锁的说明值暗示这个锁是PEB中的LoaderLock。
那么刚才为什么要*(_DWORD *)(v4 + 0xa0) = &LdrpLoaderLock;?因为该LdrpLoaderLock是进程内共享的变量。这样每个线程在执行初期,会先进入该临界区,从而实现在进程内DllMain的执行是序列化的。于是我们得出以下结论:进程内所有线程共用了同一个临界区来序列化DllMain的执行。
结合《DllMain中不当操作导致死锁问题的分析--进程对DllMain函数的调用规律的研究和分析》中介绍的规律
二 线程创建后会调用已经加载了的DLL的DllMain,且调用原因是DLL_THREAD_ATTACH。
我们发现