Windows編程總結之 DLL


+-- 版本 --+-- 編輯日期 --+-- 作者 -------------+
|   V1.0   |   2014.9.16  | yin_caoyuan@126.com |
+----------+--------------+---------------------+


這篇文章是對 《Windows核心編程(第五版)》 19,20,22 這三章的總結。
這篇文章共有 12 小節:
    1. Dll 和 進程的地址空間;
    2. 隱式載入時鏈接 和 顯式運行時鏈接;
    3. 構建 dll;
    4. 構建 可執行模塊;
    5. 運行 可執行模塊;
    6. 入口點函數;
    7. 函數轉發器;
    8. DLL 重定向;
    9. 模塊的基地址重定位;
    10. 模塊的綁定;
    11. DLL 注入;
    12. API 攔截;
以下是這 12 小節的概要:
    1:     介紹 dll 與 進程地址空間 之間的關系;
    2~5:   介紹 dll 載入 進程地址空間 的方法,這些方法是如何起作用的;
    6~8:   介紹 dll 中涉及到的其它知識點;
    9~10:  介紹 dll 載入速度的優化方法,基地址重定位和綁定技術;
    11~12: 介紹 dll 注入 技術,和基於 DLL注入 的 API攔截 技術; 
其中,1~8 節是基礎知識,9~12 節的知識依賴於 1~8 節;



1. DLL 和 進程的地址空間:
在 可執行模塊 能夠調用一個 dll 中的函數之前,必須將該 dll 的文件映像映射到進程的 地址空間中。
注意:
在 dll 中預定地址空間或者分配內存,這段內存是從進程地址空間中分配的,因此當 dll 被卸載時,之前由 dll 分配的內存並不會被清理掉。比如在 dll 中的一個函數 new 了一塊內存,如果稍后將這個 dll 卸載,這塊內存並不會被清理。
當 dll 被卸載時,dll 中的“全局變量”也將被卸載。
多個可執行文件共享一個 dll 時,dll 中的全局變量和靜態變量並不會共享,當一個進程將一個 dll 映像文件映射到自己的地址空間時,系統會為全局變量和靜態變量創建新的實例。
可執行文件和 dll 有可能使用不同的 C運行時庫,比如在 dll 中使用 malloc 分配一塊內存,在可執行文件中使用 free 去釋放內存,可能因為兩者使用了不同的 C運行時庫,free 不能正確釋放 malloc 分配的內存。針對這種問題,當一個模塊提供一個內存分配函數時,必須同時提供另一個用來釋放內存的函數。
void* DllAllocMem()
{
    return (malloc(100));
}
void DllFreeMem(void* pv)
{
    free(pv);
}


2. 隱式載入時鏈接 和 顯式運行時鏈接
將 dll 文件載入到 進程地址空間中,有兩種方法, 隱式載入時鏈接 和 顯式運行時鏈接。
隱式載入時鏈接,是指在可執行模塊載入的時候,把這個可執行模塊需要用到的 dll 載入到進程地址空間中。
顯式運行時鏈接,是指在可執行模塊運行的時候,動態載入指定的 dll,然后設法獲取導出內容的地址,進行調用。
使用 隱式載入時鏈接,需要在可執行模塊編譯的時候,傳入一個 .lib 文件,這個 .lib 文件稱為導入庫文件。
使用 顯式運行時鏈接,不需要 .lib 文件,僅從 .dll 文件中就可以解析出導出的內容。
隱式鏈接 dll,需要在工程的設置面板中設置 附加庫文件,或者使用 #pragma comment(lib,"xxx.lib") 命令,告知鏈接器去鏈接指定的 .lib 文件。
顯式鏈接 dll 需要用到的函數:
LoadLibrary(pszDllPathName);        // 載入 dll 到進程地址空間中 
LoadLibraryEx(pszDllPathName, hFile, dwFlags);      // 提供額外參數
FreeLibrary(hInstDll);              // 從進程地址空間中卸載 dll 
GetModuleHandle(pszModuleName);     // 可以用來檢查一個 模塊 是否被載入 
FARPROC GetProcAddress(hInstDll, pszSymbolName);    // 得到 dll 中的指定導出函數。
使用 隱式載入時鏈接 可以在代碼中直接引用 dll 中的符號,非常方便, 顯式運行時鏈接 不能直接引用,但是可以在程序運行時動態地去加載 dll。
隱式載入時鏈接 為何能直接在代碼中引用 dll 的符號(編譯時並不知道 dll 的符號存在於哪里),會在下面的構建過程中給出解釋。


3. 構建 dll
dll 模塊的 構建過程:
       testdll.h
a.cpp    b.cpp    c.cpp
  |        |        |
  V        V        V
編譯器   編譯器   編譯器
  |        |        |
  V        V        V
a.obj    b.obj    c.obj
     \     |     /
      V    V    V
         鏈接器 <-- (.def)
           |
           V
       .dll, .lib
編譯期:
在編譯器編譯各個 .cpp 文件的時候,如果發現 __declspec(dllexport) 修飾符修飾的 變量、函數、或C++類 的話,就會在生成的 .obj 文件里嵌入這些要導出的內容的信息
鏈接期:
鏈接器會檢測 .obj 中嵌入的導出信息,並利用這些信息生成一個 導出段(export section),這個導出段中列出了導出的變量、函數和類的符號名,鏈接器還會保存相對虛擬地址RVA(relative virtual address),表示每個符號可以在 dll 中的何處找到。
此外,鏈接器還會用導出信息 生成一個 .lib 文件,這個 .lib 文件列出了這個 dll 導出的符號。
我們可以使用 dumpbin.exe (加上-exports 選項)來查看一個 .dll 文件的導出段:
...
ordinal hint  RVA      name
   1    0     00001010 ReadBinaryFileToBuffer = ReadBinaryFileToBuffer
   2    1     00001090 WriteBinaryFileWithBuffer = WriteBinaryFileWithBuffer
...
如上,hint表示序號,也可以使用這個序號來訪問 dll 中導出的內容,name 表示導出符號的名字,而 RVA 表示一個偏移量,導出的符號位於 dll 映像文件的這個位置。
注意:
    1. 為什么要有 __declspec(dllexport):
        我們必須告訴編譯器和鏈接器,哪些函數、變量、C++類是需要導出的。因此需要 __declspec(dllexport) 這個修飾符來修飾那些需要導出的內容。
    2. 生成的 .dll 里包含了哪些信息?
        一個導出段,標識了這個 dll 里有哪些導出符號,如何尋找這些符號。
        這個導出段記錄了訪問導出內容所需要的全部信息。借助於導出段,只需要一個 dll 文件就足以訪問 dll 中導出的所有內容。
    3. 生成的 .lib 里包含了哪些信息?
        既然只要有 .dll 就可以訪問到 dll 中導出的內容,為什么還需要一個 .lib 呢?
        .lib 文件是專門為了隱式鏈接 dll 而創建的,其中僅包含了 dll 導出的符號。使用 .def 文件也可以產生出 .lib 文件,可見 .lib 中的信息有多簡單。
    4. C++代碼的名稱粉碎問題:
        C++ 編譯器在編譯 C++ 代碼的時候,會對 C++ 代碼進行名稱粉碎,比如 ReadBinaryFileToBuffer 被重命名為 ?ReadBinaryFileToBuffer@@YGKPB_WPAEI@Z, 這是為了實現函數重載,不同的參數調用不同的函數。
        因為 C++ 會有名稱粉碎,而 C 沒有,所以使用 C++ 編寫的 dll 被 C模塊 調用的時候,C 模塊無法使用 ReadBinaryFileToBuffer 找到正確的函數。
        C++ 為了解決這個問題,引入了一個修飾符: extern "C" ,這要求編譯器不要對指定的符號進行 名稱粉碎。注意: extern "C" 是 C++ 的特性,C 語言中是沒有這個修飾符的。
        如果使用 C++ 編寫 dll,那么要在聲明導出函數的時候加上 extern "C"
    5. 導出 C++類 的名稱粉碎問題:
        針對導出 C++類,對於類來說,名稱粉碎是必須的,不能通過 extern "C" 來消除,這就要求只有當導出 C++ 類的模塊使用的編譯器與導入 C++ 類的模塊使用的編譯器由同一廠商提供時,我們才可以導出 C++ 類,這樣才可以保證C++類名稱粉碎之后的結果是一致的。因此,除非知道可執行模塊的開發人員與 dll 模塊的開發人員使用的是相同的工具包,否則我們應該避免從 dll 中導出類。
    6. C 代碼的名稱粉碎問題:
        之前說過 C 編譯器不會進行名稱粉碎,但是不知道為啥,即使根本沒有用到 C++,Microsoft 的 C 編譯器也會對 C 函數進行名稱粉碎,和 C++編譯器粉碎的結果不大一樣, ReadBinaryFileToBuffer 被粉碎為 _ReadBinaryFileToBuffer@12
        因此如果我們在VC上使用 C 語言編寫 dll 模塊,然后這個 dll 模塊要給別的廠商使用的話,名稱粉碎問題仍然會可執行模塊不能正確找到 ReadBinaryFileToBuffer
        解決的辦法是使用 模塊定義文件 .def 。
        當鏈接器鏈接各個 .obj 文件的時候,會從 .obj 里找到對應的導出信息,比如 _ReadBinaryFileToBuffer@12,如果有 .def 文件,又從 .def 里找到了 ReadBinaryFileToBuffer,這兩個函數是匹配的,鏈接器就會使用 .def 里定義的名字來作為導出的函數名。
        注意:即使在 .cpp 文件里沒有使用 __declspec(dllexport) 修飾導出函數,在 .def 里聲明了導出函數的話,也一樣是可以的。
        
        
4. 構建可執行模塊
可執行文件的構建過程:
        testexe.h
a.cpp    b.cpp    c.cpp
  |        |        |
  V        V        V
編譯器   編譯器   編譯器
  |        |        |
a.obj    b.obj    c.obj
     \     |     /
      V    V    V
         鏈接器  <-- (.lib)
           |
           V
       testexe.exe
編譯期:
編譯各個 .cpp 文件產生出 .obj文件,如果使用到了 dll 中的符號(隱式載入時鏈接 dll),把它當作外部符號暫不處理。
鏈接期:
將各個 .obj 文件合並,並使用 .lib 來解析對導入的函數、變量的引用,.lib 中只是包含了 dll 中導出的符號,鏈接器只是想知道被引用的符號確實存在,以及符號來自於哪個 dll。
如果鏈接器能夠解決對所有外部符號的引用,就能鏈接成功生成可執行模塊。如果沒有包含 .lib 但是引用了 dll 中的符號的話,將會出現 error Link2091: 無法解析的外部符號 xxxFunc,因為鏈接器無法知道這個外部符號是否存在。
對於引用了外部符號的代碼,鏈接器將這段代碼編譯為跳轉到一個地址表中,在鏈接期不知道導入函數的地址所以這個地址表是空的,當可執行模塊載入的時候,這個地址表將被導入函數的地址填充起來。
鏈接器解決導入符號的時候,會在生成的可執行模塊中嵌入一個特殊的段,稱為 導入段(import section)。導入段中列出了需要使用的 dll 模塊,以及從每個 dll 模塊中引用的符號。
我們可以使用 dumpbin.exe (加上 -imports 選項)來查看一個模塊的導入段:
...
TestDll.dll
    4020B4 Import Address Table
    40242C Import Name Table
        0 time date stamp
        0 Index of first forwarder reference
        1 _WriteBinaryFileWithBuffer@12
        0 _ReadBinaryFileToBuffer@12
...
如上,TestDll.dll 是該可執行模塊所依賴的 dll 的名稱,Import Address Table 是 導入內容地址表,在 TestDll.dll 被加載之后,dll 中導出函數的地址將被填充到這個表里,此時為空。Import Name Table 是導入內容名稱表,其中記錄了從 TestDll.dll 導入的函數名稱。
注意:
    1. 可執行文件構建完成后,只是知道依賴於哪些 dll,哪些dll中存在着外部符號。它的 Import Address Table 是空的,可執行模塊和 dll 被加載的時候, Import Address Table 將被填充起來。
    2. .lib 文件並沒有什么神奇的,它只是包含了 dll 導出函數的名稱,鏈接器使用它只是為了確認被引用的外部符號存在與哪個dll中,根據這一條信息,鏈接器就可以產生針對某個 dll 的導入段。
    3. 我們可以使用 pexports.exe 工具由 .dll 產生出 .def, 然后使用 VC 的 lib.exe 工具由 .def 產生出 .lib 文件。
    
    
5. 運行可執行模塊
運行過程:
    1. 為進程創建虛擬地址空間;
    2. 把可執行模塊映射到進程地址空間中;
    3. 檢查可執行模塊的導入段,根據規則搜索程序路徑和系統路徑,找到所需的 dll 並加載;
    4. 檢查 dll 的導入段,如果這個 dll 還依賴別的 dll,那么繼續去定位所需的 dll 並加載;
    5. 開始修復所有對導入符號的引用,此時會再次查看所有模塊的導入段。對導入段中列出的每個符號,加載程序會檢查對應 dll 的導出段,看符號是否存在,如果符號存在,就從 dll 的導出段中取出 RVA 並加上模塊的虛擬地址,這樣就得到了這個符號在進程地址空間中的地址。
    6. 得到符號的地址后,加載程序會把這個虛擬地址保存到可執行模塊的導入段中,此時 Import Address Table 將被填充起來。
    7. 當代碼引用到一個導入符號的時候,會查看 Import Address Table 得到導入符號的地址,這樣就能訪問被導入的 變量、函數、C++類了。
注意:
    1. 在第三步定位 dll 的時候,如果沒有找到所需要的 dll,則會彈出錯誤提示:“無法啟動,因為計算機中缺失 xxx.dll”
    2. 在第五步修復導入符號引用的時候,如果在 dll 的導出段中沒有找到對應的導出符號,則會彈出錯誤提示:“程序入口點 xxxFunc 無法定位到動態鏈接庫 xxx.dll 上”
    
    
6. 入口點函數
BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:    // dll 被映射到地址空間時,會調用 DllMain 並傳入 DLL_PROCESS_ATTACH
        break;
    case DLL_THREAD_ATTACH:     // 進程中有線程創建時,新創建的線程會調用 DllMain 並傳入 DLL_THREAD_ATTACH, DllMain 可以根據這個消息執行與線程相關的初始化
        break;
    case DLL_THREAD_DETACH:     // 當線程函數返回時,會調用 ExitThread 函數,即將終止的線程會在 ExitThread 中調用 DllMain 並傳入 DLL_THREAD_DETACH
        break;
    case DLL_PROCESS_DETACH:    // 當 dll 從進程地址空間中卸載時,發出卸載 dll 指令的線程會調用 DllMain 並傳入 DLL_PROCESS_DETACH
        break;
    }
    return TRUE;
}
參數 hModule 是這個 dll 實例的句柄;
參數 lpReserved 表示 dll 是如何載入的,如果是隱式載入 lpReserved 不為零,如果是顯式載入 lpReserved 將為零;
參數 ul_reason_for_call 是 DllMain 被調用的原因,可能是下列 4 個值之一: DLL_PROCESS_ATTACH,DLL_THREAD_ATTACH,DLL_THREAD_DETACH,DLL_PROCESS_DETACH
注意:
    1. DLL 使用 DllMain 函數來對自己初始化,當 DllMain 執行的時候,其它 DLL 的 DllMain 可能還沒有被執行,此時如果我們在 DllMain 中調用其它 DLL 中的導出函數,就可能出現問題。Platform SDK 文檔中說 DllMain 函數只應該執行簡單的初始化,比如設置線程局部存儲區,創建內核對象,打開文件等等。我們必須避免調用 User,Shell,ODBC,COM,RPC以及套接字函數,這是因為包含這些函數的 DLL 可能尚未初始化完畢。
    2. 如果要在 Dll 中創建全局或者靜態 C++ 對象,會存在同樣的問題,因為在 DllMain 被調用的同時,這些對象的構造函數和析構函數也會被調用。
    3. 當 DllMain 處理 DLL_PROCESS_ATTACH 的時候, DllMain 的返回值用來表示該 DLL 是否初始化成功,如果在這個時候 DllMain 返回了 FALSE ,則會彈出窗口,程序無法啟動。
    4. 如果在 DLL_PROCESS_DETACH 中存在無限循環,有可能會導致進程無法終止,只有所有 DLL 的 DLL_PROCESS_DETACH 消息都被處理完,進程才會終止。除非使用 TerminateProcess 強行中止進程,這種情況下 DllMain 不會收到 DLL_PROCESS_DETACH 消息。
    5. 如果在 DLL_THREAD_DETACH 中存在無限循環,有可能會導致線程無法終止,除非使用 TerminateThread 強行終止線程。


7. 函數轉發器
函數轉發器(function forwarder)是 DLL 輸出段中的一個條目,用來將一個函數調用轉發到另一個 DLL 中的另一個函數。例如,用 dumpbin.exe(-exports) 工具查看 kernel32.dll 我們會看到類似下面的輸出:
1486  5CD  WaitForThreadpoolIoCallbacks (forwarded to NTDLL.TpWaitForIoCompletion)
1487  5CE  WaitForThreadpoolTimerCallbacks (forwarded to NTDLL.TpWaitForTimer)
1488  5CF  WaitForThreadpoolWaitCallbacks (forwarded to NTDLL.TpWaitForWait)
1489  5D0  WaitForThreadpoolWorkCallbacks (forwarded to NTDLL.TpWaitForWork)
這個輸出顯示了4個被轉發的函數。
如果使用隱式載入時鏈接 kernel32.dll ,當可執行文件運行的時候,加載程序會載入 kernel32.dll 並發現被轉發的函數實際上是在 NTDLL.dll 中,然后它會將 NTDLL.dll 模塊也一並載入。當可執行文件調用 WaitForThreadpoolIoCallbacks 的時候,實際上調用的是 NTDLL 的 TpWaitForIoCompletion 函數。
如果使用顯式運行時鏈接 kernel32.dll ,如果在可執行文件運行的時候調用 WaitForThreadpoolIoCallbacks ,那么 GetProcAddress 會先在 kernel32.dll 的導出段中查找,並發現 WaitForThreadpoolIoCallbacks 是一個轉發器函數,於是它會遞歸調用 GetProcAddress ,在 NTDLL.dll 的導出段中查找 TpWaitForIoCompletion
使用 pragma 指示符,我們可以在自己的 dll 模塊中使用函數轉發器。如下所示:
#pragma comment(linker, "/export:SomeFunc=DllWork.SomeOtherFunc")
這個 pragma 告訴鏈接器,正在編譯的 DLL 應該輸出一個名為 SomeFunc 的函數,但實際實現 SomeFunc 的是另一個名為 SomeOtherFunc 的函數,該函數被包含在另一個名為 DllWork.dll 的模塊中。我們必須為每一個想要轉發的函數單獨創建一行 pragma。
注意:
pragma 語句要放在 函數定義的后面:
DLLTEST_LIB int DllTestFunc()
{
    return 10;
}
#pragma comment(linker, "/export:DllTestFunc=FileSystem.fnFileSystem")
利用這種技術,可以通過偽造 dll 的方式對目標 dll 中的函數進行攔截。使用偽造 dll 轉發目標 dll 中的大部分函數,而想要攔截的函數則不轉發。


8. DLL 重定向
DLL 重定向指的是我們可以通過某種手段強制系統在加載 DLL 的時候首先從應用程序的目錄中載入模塊。
這需要介紹 DLL 加載時候的搜索順序。
// TODO 介紹加載 DLL 的搜索順序,貌似這個和系統有關系。
DLL 重定向是在 Windows2000 之后添加的一項特性,在這之前,出於節約內存和磁盤的原因,dll 盡量被放在 系統目錄中,當多個程序使用同一個 dll 的時候,就可能出現 DLL Hell 的問題。
因此微軟提供了 DLL 重定向技術,強制先從應用程序目錄中載入 dll 模塊,也就是不同程序使用各自的 dll 互不影響。
// TODO 介紹如何使用 DLL 重定向,並確定 DLL 重定向在不同的系統中是否默認打開。


9. 模塊的基地址重定位:
每個可執行文件和 DLL 模塊都有一個首選基地址(preferred base address),它表示在將模塊映射到進程的地址空間中時的首選位置。當我們在構建一個可執行文件的時候,鏈接器會將模塊的首選基地址設為 0x00400000。對 DLL 模塊來說,鏈接器會將首選基地址設為 0x10000000 。
我們可以用 dumpbin.exe(/headers) 來查看模塊的首選基地址:
OPTIONAL HEADER VALUES
         10B magic # (PE32)
        9.00 linker version
        1200 size of code
         C00 size of initialized data
           0 size of uninitialized data
        1630 entry point (00401630)
        1000 base of code
        3000 base of data
      400000 image base (00400000 to 00405FFF)    <-- 首選基地址是 0x00400000
編譯器和鏈接器會依據首選基地址來產生機器碼:
在可執行文件中的一段機器碼:(首選基地址為 0x00400000)
MOV    [0x00414540],5
在 DLL 中的一段機器碼:(首選基地址為 0x10000000)
MOV    [0X10014540],5
如果可執行文件只依賴於一個 dll,那么不會有啥問題,dll 被加載時會正確載入到它的首選基地址 0x10000000 上;
如果可執行文件依賴於多個 dll,問題來了,默認情況下每個 dll 的首選基地址都是 0x10000000,第一個 dll 被正確載入到了 0x10000000 上,第二個 dll 就不可能載入到 0x10000000 上了。這種情況下,加載程序會對第二個 dll 進行基地址重定位,把它放到別的地方。
在 dll 加載時對其進行基地址重定位是個非常痛苦的過程。假如 dll 被重定位到了 0x20000000 處,那么這個 dll 中所有的機器碼都應該改成 0x2xxxxxxx,這樣才能正確運行這些機器碼。
基地址重定位會損害應用程序的初始化時間。因此如果要將多個模塊載入到同一個進程地址空間中,我們必須給每個模塊指定不同的首選基地址。
指定首選基地址的方法:
1. 可以在 VS 的配置項中修改首選基地址:配置屬性 --> 鏈接器 --> 高級 --> 基址
2. 在所有的 dll 編譯完成者后,使用 Rebase.exe 工具,可以對需要載入到進程地址空間的所有模塊進行基地址重定位,使每個 dll 都使用不同的基地址,彼此互不干擾。
Rebase.exe 運行的時候會模擬所有模塊被加載時進行的基地址重定位操作,重定位后各個模塊之間彼此互不干擾,然后將模擬的結果寫入到各個模塊的磁盤文件中。(0x1xxxxxxx 被修改為 0x2xxxxxxx)


10. 模塊的綁定
回想一下隱式載入時鏈接的原理:
可執行模塊的導入段中有一個 Import Address Table , 載入之前這個表是空的,可執行模塊載入的時候,載入程序會加載需要的 dll ,獲取 dll 的基地址,獲取導出符號的 RVA ,基地址加上 RVA 就是導出符號的真實地址,每個導出符號的真實地址都會被加載程序填充到 Import Address Table 表中;
可執行模塊運行期間如果調用了某個dll的導出函數,那么會跳轉到 Import Address Table 來得到這個導出函數的地址,然后進行調用。
Import Address Table 中填充的是 dll 載入到地址空間的基地址+RVA。RVA在 dll 的導出段中已經有了,而通過基地址重定位技術可以確定 dll 載入的基地址是多少。也就是說如果一個 dll 已經進行過重定位,就可以直接推算出它的 Import Address Table 應該填充哪些內容。如果一開始就把 Import Address Table 填充在 dll 文件里的話,載入程序就不需要進行填充工作了,這可以加快應用程序的初始化速度。
進行這種填充類似於將所需的 dll 與可執行文件綁定在一起,可執行文件的 Import Address Table 與 dll 的基地址和導出符號 RVA 一一對應,所以這種技術被稱為模塊綁定技術。
VS 提供的 Bind.exe 提供了綁定可執行文件與dll的功能。
Bind.exe 的工作原理正如上面所寫的那樣,讀取所有 dll 的基地址和RVA,將其填充到可執行文件的 Import Address Table 中。
注意:
    1. 如果要使用 Bind.exe 必須保證 dll 已經進行過重定位,dll 會被加載到首選基地址上。
    2. 何時使用 Bind.exe 進行模塊綁定呢?因為不同的 Windows 版本系統 dll 可能會不同,所以針對不同版本的 Windows需要分別進行綁定,我們可以在應用程序的安裝過程中來進行綁定。
    

11. DLL 注入
指將一個 DLL 注入到另外一個進程的地址空間中,從而跨越進程地址邊界來訪問另外一個進程的地址空間。
    1. 使用注冊表來注入 DLL:
    HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\ 在這個注冊表項中可以找到兩個注冊表值: AppInit_Dlls,LoadAppInit_Dlls, 將 AppInit_Dlls 的值設定為我們要注入的 dll 路徑,將 LoadAppInit_Dlls 的值設定為 1。當 User32.dll 被映射到一個新的進程的時候,會收到 DLL_PROCESS_ATTACH 通知,當 User32.dll 對這個通知進行處理的時候,會取得 AppInit_Dlls 中的值,並調用 LoadLibrary 來載入指定的 dll。
    這種方法利用 User32.dll 被加載時檢索注冊表的特性來實現 DLL 注入,這種方法有局限性,所有基於 GUI 的程序都會被注入,無法注入到指定的程序中。不過可以借鑒這種思路,如果我們知道目標程序會在加載的時候從注冊表中加載 dll,就可以把自己的 dll 也添加到注冊表里面。
    
    2. 使用 Windows 掛鈎來注入 DLL:
    我們可以為另外一個進程安裝掛鈎,監聽另外一個進程的消息,當另外一個進程的窗口即將處理一條消息的時候,將會引起掛鈎函數的調用。安裝掛鈎使用 SetWindowsHookEx 函數:
    HHOOK SetWindowsHookEx(
        int idHook,         // 要安裝的掛鈎類型,比如 WH_KEYBOARD 用於監聽鍵盤事件,WH_GETMESSAGE 用於監聽消息被 Post 進窗口消息隊列事件
        HOOKPROC lpfn,      // 函數地址,監聽的事件發生時,系統會調用這個函數,如果我們要把掛鈎安裝到另外一個進程中,這個函數必須被放到一個 dll 中;如果只是安裝到本進程,則不需要放到 dll 中。
        HINSTANCE hMod,     // 標識一個 dll, 這個 dll 中包含了 lpfn 函數。
        DWORD dwThreadId    // 要給哪個線程安裝掛鈎,如果指定為 0 的話,系統會為系統中所有的線程安裝掛鈎。
        )
    進程 A 調用 SetWindowsHookEx(WH_GETMESSAGE, GetMsgProc, hInstDll, 0) 將會發生的事情:
        0. 進程 A 在調用 SetWindowsHookEx 之前,已經把 hInstDll 映射到了自己的進程地址空間中, GetMsgProc 是函數在進程 A 的地址空間中的地址;
        1. 某進程 B 中的一個線程准備向窗口 Post 一條消息;
        2. 系統檢查該線程是否安裝了 WH_GETMESSAGE 掛鈎;
        3. 系統檢查 GetMsgProc 所在的 DLL 是否被映射到進程 B 的地址空間中;
        4. 如果 DLL 沒有被映射, 系統將會強制將該 DLL 映射到進程 B 的地址空間中;
        5. 系統必須確定 GetMsgProc 在進程 B 的地址空間中的地址,可以用以下公式計算得來: GetMsgProc B = hInstDll B + (GetMsgProc A - hInstDll A)
        7. 系統在進程 B 的地址空間中調用 GetMsgProc 函數;
    通過安裝掛鈎的方式, hInstDll 這個 DLL 被注入到了進程 B 中,由於整個 dll 都被加載到了進程 B 的地址空間中, dll 中的所有函數都可以被進程 B 中的任何線程調用。
    問題:
        1. hInstDll 被注入到了所有進程中嗎?
        2. GetMsgProc 是在進程 B 中被調用的,進程 A 可以通過這種方法直接獲取到進程 B 的信息嗎?
        
    3. 使用 遠程線程 來注入 DLL
    利用遠程線程,我們可以在目標進程中創建一個自己的線程,這個線程是自己創建的,但在目標進程的地址空間中執行,我們可以設置自己的線程函數,只要在線程函數里調用 LoadLibrary ,就可以將 DLL 注入到目標進程中。
    Windows 提供了 CreateRemoteThread 函數:
    HANDLE CreateRemoteThread(
        HANDLE hProcess,                     // 目標進程句柄
        LPSECURITY_ATTRIBUTES psa,           // 安全屬性
        SIZE_T dwStackSize,                  // 線程棧大小
        LPTHREAD_START_ROUTINE lpStartAddr,  // 線程函數地址,這個地址應該在目標進程的地址空間中,因為線程函數的代碼不能在我們自己進程的地址空間中執行。
        LPVOID lpParameter,                  // 傳遞給線程函數的參數
        DWORD dwCreationFlags                // 控制創建出來的線程,比如: CREATE_SUSPENDED
        LPDWORD lpThreadId                   // 線程 Id
    )
    我們可以把 LoadLibrary 作為線程函數傳給 CreateRemoteThread, 讓 LoadLibrary 載入指定的 dll ,來實現注入 DLL 的目的:
    HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, LoadLibraryW, L"C:\\MyLib.dll", 0, NULL)
    上面的代碼有幾個問題:
        1. LoadLibraryW 這個函數是 kernel32.dll 的導出函數,我們的進程載入 kernel32.dll 的時候,會把 LoadLibraryW 的實際地址放入到進程的導入段中,我們給 CreateRemoteThread 傳入的 LoadLibraryW ,實際上是 LoadLibraryW 的導入段地址,並不是 LoadLibraryW 的實際地址。而 lpStartAddr 要求這個地址是在目標進程的地址空間中,如果把導入段地址傳過去的話,遠程線程並不能執行到 LoadLibraryW。所以我們應該設法獲取到 LoadLibraryW 在目標進程中的地址,然后再傳給 CreateRemoteThread 。
        解決的辦法使用 GetProcAddress 獲取 LoadLibraryW 在 kernel32.dll 中的地址,因為進程每次加載 dll 的時候系統都會把 kernel32.dll 映射到相同的地址上面,所以不同進程間, LoadLibraryW 的地址都等於 LoadLibraryW 在 kernel32.dll 中的地址。
        PTHREAD_START_ROUTINE pfnThreadRtn = (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "LoadLibraryW")
        HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, pfnThreadRtn, L"C:\\MyLib.dll", 0, NULL)
        2. CreateRemoteThread 的參數 "C:\\MyLib.dll" 這個字符串位於當前進程而不是目標進程的地址空間中。目標進程訪問它的時候就會引起訪問違規,程序崩潰。所以我們需要設法在目標進程中分配一塊內存,存儲這個字符串。
        解決的辦法是使用 VirtualAllocEx 函數在目標進程中分配內存,使用 ReadProcessMemory 和 WriteProcessMemory 來讀寫目標進程的內存,把 "C:\\MyLib.dll" 這個字符串寫入其中。
        LPVOID lpRemoteMemory = VirtualAllocEx(hProcessRemote, 0, sizeof(L"C:\\MyLib.dll"), MEM_COMMIT, PAGE_READWRITE)
        BOOL bRet = WriteProcessMemory(hProcessRemote, lpRemoteMemory, L"C:\\MyLib.dll", sizeof(L"C:\\MyLib.dll"), 0)
        HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, pfnThreadRtn, lpRemoteMemory, 0, NULL)
        在遠程線程執行結束后,我們需要調用 VirtualFreeEx 來釋放之前在目標進城中分配的內存。
        3. 在遠程線程執行結束后,注入的 dll 仍然存在與目標進程中,我們需要再次使用 CreateRemoteThread ,在遠程函數中執行 FreeLibrary ,將之前注入的 dll 從目標進程中卸載。
    參考文章:http://blog.csdn.net/g710710/article/details/7303081
    
    4. 使用木馬 DLL 來注入 DLL
    這種方法是說,把我們知道的進程必然會載入的一個 DLL 給替換掉。我們的 dll 需要導出原有 dll 中的所有導出符號,這可以使用之前講過的函數轉發器來實現。
    如果只想把這個方法應用在某個應用程序中,則可以給我們的 dll 起一個獨一無二的名稱,並修改應用程序 .exe 模塊的導入段。這要求我們要非常熟悉 .exe 和 .dll 的文件格式。
    
    
12. API 攔截
DLL 注入可以讓我們訪問另外一個進程的地址空間,獲取其它進程內部的各種信息。但是,我們無法知道其它進程中的線程具體是怎么調用各種函數的,API 攔截指的是攔截 Windows 系統函數,並修改這些函數的行為。
在對另一個進程進行 API 攔截前,我們必須先進行 DLL 注入,這樣另外的進程才能夠執行我們的攔截代碼。
通過修改模塊的導入段來攔截 API:
之前關於dll的內容中說過, 可執行模塊的導入段中包含了一個符號表,其中列出了該模塊從各個 dll 中導入的符號,當可執行模塊調用一個導入函數的時候,會先從導入段的符號表中獲取到導入函數的地址,然后再跳轉到那個地址。
因此,為了攔截一個特定的函數,我們所需要做的就是修改這個函數在模塊的導入段中的地址。要達到這個目的,需要如下步驟:
    1. 獲取可執行模塊的導入段;
    2. 遍歷導入段中導入了哪些 dll, 通過比對找到 目標API 所屬 dll 在導入段中的信息;
    3. 遍歷從這個 dll 中導入的函數,通過比對找到 目標API 在導入段中的位置。在這之前我們需要先獲取 目標API 的實際地址,然后跟導入段中記錄的導入函數地址相比對,才能確認這個導入函數是不是我們要找的函數;
    4. 修改這個導入段,將其所指向的地址改成 攔截API 的地址;
要實現上述步驟,需要以下的函數和數據結構:
    1. PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR) ImageDirectoryEntryToData(hExeMod, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize);  // IMAGE_DIRECTORY_ENTRY_IMPORT 標記會讓這個函數返回導入段的地址;返回的結果是若干個  PIMAGE_IMPORT_DESCRIPTOR 結構體構成的數組;
    2. PIMAGE_IMPORT_DESCRIPTOR: 這個結構體描述了某一個 dll 的導入段信息, Name 字段標識這個 dll 的名字, FirstThunk 是一個 PIMAGE_THUNK_DATA 結構體,代表了從這個 dll 中導入的第一個函數的信息;
    3. PIMAGE_THUNK_DATA: PIMAGE_THUNK_DATA.u1.Function 就是這個導入函數的實際地址了;將這個地址與 GetProcAddress 獲取的地址相比對,就能確定這個地址是不是我們要找的那個函數的地址;
    4. WriteProcessMemory 可以將這個地址替換為我們的 攔截函數 的地址;
通過上述函數和數據結構,我們可以構造如下的代碼:
    PROC pfnOri = GetProcAddress(GetModuleHandle("kernel32"), "ExitProcess");    // 獲取 ExitProcess 函數在進程中的地址;
    HMODULE hExeMod = GetModuleHandle("Database.exe");                           // 獲取可執行模塊的句柄;
    ULONG ulSize;                                                                // 獲取可執行模塊的導入段
    PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData(hExeMod, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize);
    for(;pImportDesc->Name;pImportDesc++){                                       // 遍歷導入段,找 dll 模塊
        PSTR pszModName = (PSTR)((PBYTE)hExeMod + pImportDesc->Name);            // 獲取 dll 模塊的模塊名
        if(lstrcmpiA("kernel32.dll", pszModName) == 0 ){                         // 找到模塊名為 kernel32.dll 的模塊
            PIMAGE_THUNK_DATA pTrunk = (PIMAGE_THUNK_DATA)((PBYTE)hExeMod + pImportDesc->FirstThunk);
            for(;pTrunk->u1.Function;pTrunk++) {                                 // 遍歷從 kernel32.dll 中導入的函數
                PROC* ppfn = (PROC*)&pTrunk->u1.Function;
                if(ppfn == pfnOri){                                              // 比對 pfnOri 和 ppfn,如果一樣,說明 pTrunk 中記錄了 ExitProcess 在導入段中的信息
                    WriteProcessMemory(GetCurrentProcess(), ppfn, &pfnNew, sizeof(pfnNew), NULL)    // 使用新的地址 pfnNew 替換 ppfn
                    return;
                }
            }
        }
    }
    上面的代碼攔截了 Database.exe 模塊對 ExitProcess 函數的調用,Database.exe 對 ExitProcess 的調用,將被我們自己的函數 pfnNew 所替代;
    注意:上面的代碼運行在目標進程的地址空間中,在進行 API 注入前,必須把包含這段代碼的 dll 注入到目標進程中;
    上面的代碼沒有考慮安全性,原始代碼參考《Windows核心編程》的相關章節;

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM