1. 程式人生 > >導致DllMain中死鎖的關鍵隱藏因子

導致DllMain中死鎖的關鍵隱藏因子

        有了前面兩節的基礎,我們現在切入正題:研究下DllMain為什麼會因為不當操作導致死鎖的問題。首先我們看一段比較經典的“DllMain中死鎖”程式碼。

//主執行緒中  
HMODULE h = LoadLibraryA(strDllName.c_str());  
// DLL中程式碼  
static DWORD WINAPI ThreadCreateInDllMain(LPVOID) {  
    return 0;  
}  
  
BOOL APIENTRY DllMain( HMODULE hModule,  
                       DWORD  ul_reason_for_call,  
                       LPVOID lpReserved  
                     )  
{  
    DWORD tid = GetCurrentThreadId();  
    switch (ul_reason_for_call)     
    {  
    case DLL_PROCESS_ATTACH: {  
            printf("DLL DllWithoutDisableThreadLibraryCalls_A:\tProcess attach (tid = %d)\n", tid);  
            HANDLE hThread = CreateThread(NULL, 0, ThreadCreateInDllMain, NULL, 0, NULL);  
            WaitForSingleObject(hThread, INFINITE);  
            CloseHandle(hThread);  
        }break;  
    case DLL_PROCESS_DETACH:  
    case DLL_THREAD_ATTACH:  
    case DLL_THREAD_DETACH:  
        break;  
    }  
    return TRUE;  
}  

        簡要說下DLL中邏輯:設計該段程式碼的同學希望在DLL第一次被對映到程序記憶體空間時,建立一個工作執行緒,該工作執行緒內容可能很簡單。為了儘可能簡單,我們讓這個工作執行緒直接返回0。這樣從邏輯和效率上看,都不會因為我們的工作執行緒寫的有問題而導致死鎖。然後我們在DllMain中等待這個執行緒結束才從返回。

        粗略看這個問題,我們很難看出這個邏輯會導致死鎖。但是事實就是這樣發生了。我們跑一下程式,發現程式輸出一下結果 後就停住了,游標在閃動,貌似還是在等待我們輸入:

可是我們怎麼敲擊鍵盤都沒有用:它死鎖了。 我是在VS2005中除錯該程式,於是我們可以Debug->Break All來凍結所有執行緒。

我們先檢視主執行緒(3096)的堆疊 堆疊不長,

我全部列出來

17 	[email protected]()
16 	[email protected]()
15 	[email protected]()
14 	[email protected]()
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 	
[email protected]
() 9 [email protected]() 8 [email protected]() 7 [email protected]() 6 [email protected]() 5 [email protected]() 4 [email protected]() 3 DllMainSerial.exe!wmain(int argc=3, wchar_t * * argv=0x003b7000) 2 DllMainSerial.exe!__tmainCRTStartup() 1 DllMainSerial.exe!wmainCRTStartup() 0 [email protected]()

我們看下這個堆疊。大致我們可以將我們程式分為4段:

0 啟動啟動我們程式 1~6 我們載入Dll4。

7~10 系統為我們準備DLL的載入。

11~17 DLL內部程式碼執行。

        我們關注一下14~17這段對WaitForSingleObject的呼叫邏輯。15、16步這個過程顯示了Kernel32中的WaitForSingleObjectEx在底層是呼叫了NtDll中的NtWaitForSingleObject。在NtWaitForSingleObject內部,即17步,我們看到的“[email protected]”。這兒要說明下,這個並不是意味著我們程式執行到這個函式。我們看下這個函式的程式碼

        KiFastSystemCallRet函式是核心態(Ring0層)邏輯回到使用者態(Ring3層)的著陸點。與之相對應的KiFastSystemCall函式是使用者態進入核心態必要的呼叫方法。因為核心態程式碼我們是無法檢視的,所以動態斷點只能設定到KiFastSystemCallRet開始處。所以實際死鎖是因為NtWaitForSingleObject在底層呼叫了KiFastSystemCall進入核心,在核心態中死鎖的。 

 我們在《DllMain中不當操作導致死鎖問題的分析--死鎖介紹》中介紹過,死鎖存在的條件是相互等待。主執行緒中,我們發現其等待的是工作執行緒結束。那麼工作執行緒在等待主執行緒什麼呢?我們看下工作執行緒的呼叫堆疊

我們對這個堆疊進行編號 

6 	[email protected]()
5 	[email protected]()  + 0xc bytes
4 	[email protected]()  + 0x8c bytes
3 	[email protected]()  + 0x46 bytes
2 	[email protected]()  + 0xb4bf bytes
1 	[email protected]()  + 0x7 bytes
0 	[email protected]()  + 0x9b48 bytes

        我們看到倒數兩步(5、6)和主執行緒中最後兩步(16、17)是相同的,即工作執行緒也是在進入核心態後死鎖的。我們知道主執行緒在等工作執行緒結束,那麼工作執行緒在等什麼呢?我們追溯棧,請關注“[email protected]() + 0xb4bf bytes”處的程式碼

       我們看到,是因為_RtlEnterCriticalSection在底層呼叫了NtWaitForSingleObject。那麼我們關注下_RtlEnterCriticalSection的引數_LdrpLoaderLock,它是什麼?我們藉助下IDA檢視下LdrpInitialize反編譯程式碼

……  
v4 = *(_DWORD *)(*MK_FP(__FS__, 0x18) + 0x30);  
v3 = *MK_FP(__FS__,0x18);  
 ……  
  *(_DWORD *)(v4 + 0xa0) = &LdrpLoaderLock;  
  if ( !(unsigned __int8)RtlTryEnterCriticalSection(&LdrpLoaderLock) )  
  {  
  ……  
    RtlEnterCriticalSection(&LdrpLoaderLock);  
  }  
  ……  
  if ( *(_DWORD *)(v4 + 0xc) )  
  {  
    ……  
    LdrpInitializeThread(a1);  
  }  
  else  
  {  
……  
    v17 = LdrpInitializeProcess(a1, a2, &v11, v14, v15);  
……  
  }  
……  

   由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的結構體,該結構體很大,我只列出我們目前關心的欄位

lkd> dt _TEB  
nt!_TEB  
   +0x000 NtTib            : _NT_TIB  
   +0x01c EnvironmentPointer : Ptr32 Void  
   +0x020 ClientId         : _CLIENT_ID  
……  

NtTib就是TIB結構體物件名。 我們再看下TIB結構體B

lkd> dt _NT_TIB  
nt!_NT_TIB  
   +0x000 ExceptionList    : Ptr32 _EXCEPTION_REGISTRATION_RECORD  
   +0x004 StackBase        : Ptr32 Void  
   +0x008 StackLimit       : Ptr32 Void  
   +0x00c SubSystemTib     : Ptr32 Void  
   +0x010 FiberData        : Ptr32 Void  
   +0x010 Version          : Uint4B  
   +0x014 ArbitraryUserPointer : Ptr32 Void  
   +0x018 Self             : Ptr32 _NT_TIB  

該結構體其他欄位不解釋,我們只看最後一個欄位(FS:[18])指向_NT_TIB結構體的指標Self。正如其名,該欄位指向的是TIB結構體在程序空間中的虛擬地址。為什麼要指向自己?那我們是否可以直接使用FS:[0]地址?不可以。舉個例子:我用windbg掛載到我電腦上一個執行中的calc(計算器)。我們檢視fs:[0]指向空間儲存的值,7ffdb000是TIB的Self欄位。

我們檢視TIB結構體去匹配該地址指向的空間的。

可以看到7ffdb000所指向的空間的各欄位的值和FS:[0]指向的空間的值一致。但是如果我們這樣輸入就會失敗

介紹完這些後,我們再回到IDA反彙編的程式碼中。v4 = *(_DWORD*)(*MK_FP(__FS__, 0x18) + 0x30);這段中MK_FP不是一個函式,是一個巨集。它的作用是在基址上加上偏移得出一個地址。於是MK_FP(__FS__, 0x18)就是FS:[0x18],即TIB的Self欄位。在該地址再加上0x30得到的地址已經超過了TIB空間,於是我們繼續檢視TEB結構體

發現0x30偏移的是PEB(Process Environment Block)。

lkd> dt _PEB  
nt!_PEB  
   +0x000 InheritedAddressSpace : UChar  
   +0x001 ReadImageFileExecOptions : UChar  
……  
+0x09c GdiDCAttributeList : Uint4B  
   +0x0a0 LoaderLock       : Ptr32 Void  
   +0x0a4 OSMajorVersion   : Uint4B  

可以發現該結構體偏移0xa0處是一個名字為LoaderLock的變數。 《windows核心程式設計》中有關於DllMain序列化執行的講解,大致意思是:執行緒在呼叫DllMain之前,要先獲取鎖,等DllMain執行完再解開這個鎖。這樣不同執行緒載入DLL就可以實現序列化操作。而在微軟官方文件《Best Practices for Creating DLLs》中也有對這個說法的佐證

The DllMain entry-point function. This function is called by the loader when it loads or unloads a DLL. 
The loader serializes calls to DllMain so that only a single DllMain function is run at a time .  

其中還有段關於這個鎖的介紹

The loader lock. This is a process-wide synchronization primitive that the loader uses to ensure serialized loading of DLLs. 
Any function that must read or modify the per-process library-loader data structures must acquire this lock 
before performing such an operation. 
The loader lock is recursive, which means that it can be acquired again by the same thread. 

在該文中多處對這個鎖的說明值暗示這個鎖是PEB中的LoaderLock。

        那麼剛才為什麼要*(_DWORD *)(v4 + 0xa0) = &LdrpLoaderLock;?因為該LdrpLoaderLock是程序內共享的變數。這樣每個執行緒在執行初期,會先進入該 臨界區,從而實現在程序內DllMain的執行是序列化的。於是我們得出以下結論: 程序內所有執行緒共用了同一個臨界區來序列化DllMain的執行。

        結合《DllMain中不當操作導致死鎖問題的分析--程序對DllMain函式的呼叫規律的研究和分析》中介紹的規律 二 執行緒建立後會呼叫已經載入了的DLL的DllMain,且呼叫原因是DLL_THREAD_ATTACH。 我們發現

HANDLE hThread = CreateThread(NULL, 0, ThreadCreateInDllMain, NULL, 0, NULL);  
WaitForSingleObject(hThread, INFINITE);  

主執行緒進入臨界區去呼叫DllMain時進入了臨界區,而工作執行緒也要進入臨界區去執行DllMain。但是此時臨界區被主執行緒佔用,工作執行緒便進入等待狀態。而主執行緒卻等待工作執行緒退出才退出臨界區。於是這就是死鎖產生的原因。