Windows Hook經驗總結之一:API Hook方法彙總

HOOK的目的是用我們自己的代碼取代一些函數的代碼以改變程序的行爲。
靜態HOOK:在進程運行前掛鉤,採用用戶級進程即可完成。比如:有些程序會在啓動時需要原光盤,如果我們修改獲取驅動類型的函數則可以從硬盤啓動。
動態HOOK:掛鉤系統進程(如服務)時要動態掛鉤(運行時掛鉤)。

靜態HOOK即運行前掛鉤

這裏修改我們想要修改函數來自的物理模塊(大多數時候是.exe或.dll)。在這裏我們至少有3種可能的做法。
第一種可能是找到函數的入口點然後重寫它的代碼這會因爲函數的大小而受限制,但只要能動態加載其它一些模塊(API LoadLibrary)就足夠了。內核函數(kernel32.dll)是通用的,Windows中每個進程都有其拷貝,如果知道哪些模塊在某版本中會修改,就可以在一些API如LoadLibraryA中直接使用指針(因爲相同Windows版本中kernel模塊在內存中地址是固定的)。
第二種可能是在模塊中被代替的函數只是原函數的擴展。可以選擇修改開始的5個字節(CPU跳轉指令長度)爲跳轉指令或者改寫IAT。如果改跳轉指令,那麼將會改變指令執行流程轉爲執行我們的代碼。如果調用了IAT記錄被修改的函數,我們的代碼能在調用結束後被執行。
第三種是修改整個模塊。即創建自己的模塊版本,它能夠加載原始的模塊並調用原始的函數,當然我們對這個不感興趣,但重要的函數都是被更新的。這種方法對於有的模塊過大有幾百個導出函數的很不方便。

動態HOOK即運行時掛鉤

在運行前掛鉤通常都非常特殊,並且是在內部面向具體的應用程序(或模塊)。如果我們更換了kernel32.dll或ntdll.dll裏的函數(只在NT操作系統裏),我們就能完美地做到在所有將要運行的進程中替換這個函數。但說來容易做起來卻非常難,不但得考慮精確性和需要編寫比較完善的新函數或新模塊,更主要的問題是隻有即將運行的進程才能被掛鉤(要掛鉤所有進程只能重啓電腦)。另一個問題是如何進入這些文件,因爲NT操作系統保護了它們。比較好的解決方法是在進程運行時掛鉤(只針對能夠寫入它們內存的進程)。爲了能寫入進程可使用API函數WriteProcessMemory

使用IAT掛鉤本進程

這裏有很多種可能性。首先介紹如何用改寫IAT掛鉤函數的方法。接下來這張圖描述了PE文件的結構:
這裏寫圖片描述

這裏比較重要的是.idata部分的導入地址表(IAT:Import Address Table)。這個部分包含了導入的相關信息和函數地址。有一點很重要,我們必須知道PE文件是如何創建的。當在編程語言裏間接調用任意API(這意味着我們是用函數的名字來調用它,而不是用它的地址),編譯器並不直接把調用連接到模塊,而是用jmp指令連接調用到IAT,IAT在系統把進程調入內存時時會由進程載入器填滿。這就是我們可以在兩個不同版本的Windows裏使用相同的二進制代碼的原因,雖然模塊可能會加載到不同的地址。進程載入器會在程序代碼裏調用所使用的IAT裏填入直接跳轉的jmp指令。所以只要能在IAT裏找到想要掛鉤的指定函數,就能很容易改變那裏的jmp指令並重定向代碼到新的地址。完成之後每次調用都會執行新的代碼了。這種方法的缺點是經常有很多函數要被掛鉤(比如:要在搜索文件的API中改變程序的行爲就得修改函數FindFirstFileFindNextFile,但這些函數都有ANSI和UNICODE版本,所以就不得不修改FindFirstFileAFindFirstFileWFindNextFileAFileNextFileW的IAT地址。但還有其它類似的函數如FindFirstFileExA和它的UNICODE版本FindFirstFileExW,也都是由前面提到的函數調用的。我們知道FindFirstFileW調用FindFirstFileExW,但這是直接調用,而不是使用IAT。再比如說ShellAPI的函數SHGetDesktopFolder也會直接調用FindFirstFilwWFindFirstFileExW)
我們通過使用imagehlp.dll裏的ImageDirectoryEntryToData來很容易地找到IAT。

    PVOID ImageDirectoryEntryToData(
        IN LPVOID           Base,    
        IN BOOLEAN      MappedAsImage,    
        IN USHORT           DirectoryEntry,    
        OUT PULONG      Size    
    );

在這裏Base參數可以用我們程序的Instance(Instance通過調用GetModuleHandle獲得):
hInstance = GetModuleHandleA(NULL);
DirectoryEntry我們可以使用恆量IMAGE_DIRECTORY_ENTRY_IMPORT

 #define IMAGE_DIRECTORY_ENTRY_IMPORT 1

函數的結果是指向第一個IAT記錄指針。IAT的所有記錄是由IMAGE_IMPORT_DESCRIPTOR定義的結構。所以函數結果是指向IMAGE_IMPORT_DESCRIPTOR的指針。

 typedef struct _IMAGE_THUNK_DATA {
        union {
            PBYTE                       ForwarderString;
            PDWORD                  Function;
            DWORD                   Ordinal;
            PIMAGE_IMPORT_BY_NAME   AddressOfData;
        } ;
    } IMAGE_THUNK_DATA,*PIMAGE_THUNK_DATA;

    typedef struct _IMAGE_IMPORT_DESCRIPTOR {
        union {
            DWORD               Characteristics;
            PIMAGE_THUNK_DATA   OriginalFirstThunk;
        } ;
        DWORD                   TimeDateStamp;
        DWORD                   ForwarderChain;
        DWORD                   Name;
        PIMAGE_THUNK_DATA       FirstThunk;
    } IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR;

IMAGE_IMPORT_DESCRIPTOR裏的Name成員變量是模塊名字的指針。如果想要掛鉤某個函數比如是來自kernel32.dll就在導入表裏找屬於名字kernel32.dll的描述符號。先調用ImageDirectoryEntryToData然後找到名字是”kernel32.dll”的描述符號,最後在這個模塊記錄的函數列表裏找到想要的函數(函數地址通過GetProcAddress函數獲得)。如果找到了就必須用VirtualProtect函數來改變其內存頁面的保護屬性,然後就可以在內存中的這部分寫入代碼了。在改寫了地址之後還要把保護屬性改回來。在調用VirtualProtect之前要先知道有關頁面的信息,可以通過VirtualQuery來實現。還有必要加入一些測試以防某些函數會失敗(如果第一次調用VirtualProctect失敗了,就沒辦法繼續)。

PCSTR pszHookModName = "kernel32.dll", pszSleepName = "Sleep";
HMODULE hKernel = GetModuleHandle(pszHookModName);
PROC pfnNew = (PROC)0x12345678,       //這裏存放新地址
pfnHookAPIAddr = GetProcAddress(hKernel,pszSleepName);

ULONG ulSize;
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = 
        (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData(
            hKernel, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize );
while (pImportDesc->Name)
{
    PSTR pszModName = (PSTR)((PBYTE) hKernel + pImportDesc->Name);
    if (stricmp(pszModName, pszHookModName) == 0) 
        break;   
    pImportDesc++;
}

PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((PBYTE) hKernel + pImportDesc->FirstThunk);
while (pThunk->u1.Function)
{
    PROC* ppfn = (PROC*) &pThunk->u1.Function;
    BOOL bFound = (*ppfn == pfnHookAPIAddr); //API Address match
    if (bFound) 
    {
        MEMORY_BASIC_INFORMATION mbi;
        VirtualQuery(ppfn, &mbi, sizeof(MEMORY_BASIC_INFORMATION) );
        VirtualProtect(mbi.BaseAddress, mbi.RegionSize, PAGE_READWRITE, &mbi.Protect);

        *ppfn = *pfnNew; // modify address and Hooked

        DWORD dwOldProtect;
        VirtualProtect(mbi.BaseAddress, mbi.RegionSize, mbi.Protect, &dwOldProtect );
        break;
    }
    pThunk++;
}

調用Sleep(1000)的結果如例子

0407BD8: 68E8030000    push 0000003E8h
00407BDD: E812FAFFFF    call Sleep

Sleep: ;這是跳轉到IAT裏的地址

004075F4: FF25BCA14000    jmp dword ptr [00040A1BCh]

0040A1BC: 79 67 E8 77 00 00 00 00    ;原始表
0040A1BC: 78 56 34 12 00 00 00 00    ;新表

最後會跳轉到0x12345678。

改寫入口點掛鉤本進程

改寫函數入口點開始的一些字節這種方法相對簡單。就象改變IAT裏的地址一樣,也要先修改頁面屬性。在這裏對想要掛鉤的函數是一開始的5個字節。使用動態分配MEMORY_BASIC_INFORMATION結構,函數的起始地址也是用GetProcAddress來獲得。我們在這個地址裏插入指向我們代碼的跳轉指令。接下來程序調用Sleep(5000)(所以它會等待5秒鐘),然後Sleep函數被掛鉤並重定向到new_sleep,最後它再次調用Sleep(5000)。因爲新的函數new_sleep什麼都不做並直接返回,所以整個程序只需要5秒鐘而不是10秒種。

.386p
.model flat, stdcall
includelib lib\kernel32.lib
Sleep                   PROTO :DWORD
GetModuleHandleA        PROTO :DWORD
GetProcAddress          PROTO :DWORD,:DWORD
VirtualQuery                PROTO :DWORD,:DWORD,:DWORD
VirtualProtect              PROTO :DWORD,:DWORD,:DWORD,:DWORD
VirtualAlloc                PROTO :DWORD,:DWORD,:DWORD,:DWORD
VirtualFree             PROTO :DWORD,:DWORD,:DWORD
FlushInstructionCache       PROTO :DWORD,:DWORD,:DWORD
GetCurrentProcess           PROTO
ExitProcess                 PROTO :DWORD

.data
kernel_name             db "kernel32.dll",0
sleep_name          db "Sleep",0
old_protect         dd ?

MEMORY_BASIC_INFORMATION_SIZE    equ 28

PAGE_READWRITE              dd 000000004h
PAGE_EXECUTE_READWRITE  dd 000000040h
MEM_COMMIT                  dd 000001000h
MEM_RELEASE                 dd 000008000h

.code
start:
    push    5000
    call    Sleep

do_hook:
    push   offset kernel_name
    call    GetModuleHandleA
    push   offset sleep_name
    push   eax
    call    GetProcAddress
    mov   edi,eax            ;最後獲得Sleep地址

    push   PAGE_READWRITE
    push   MEM_COMMIT
    push   MEMORY_BASIC_INFORMATION_SIZE
    push   0
    call    VirtualAlloc
    test    eax,eax
    jz     do_sleep
    mov   esi,eax            ;爲MBI結構分配內存

    push  MEMORY_BASIC_INFORMATION_SIZE
    push  esi
    push  edi
    call   VirtualQuery        ;內存頁的信息
    test   eax,eax
    jz    free_mem

    call   GetCurrentProcess
    push  5
    push  edi
    push  eax
    call   FlushInstructionCache    ;只是爲了確定一下:)

    lea   eax,[esi+014h]
    push  eax
    push  PAGE_EXECUTE_READWRITE
    lea   eax,[esi+00Ch]
    push  [eax]
    push  [esi]
    call   VirtualProtect          ;我們要修改保護屬性,這樣才能夠寫入代碼
    test   eax,eax
    jz    free_mem        

    mov  byte ptr [edi],0E9h    ;寫入跳轉指令
    mov  eax,offset new_sleep
    sub   eax,edi
    sub   eax,5
    inc   edi
    stosd                ;這裏是跳轉地址

    push  offset old_protect
    lea    eax,[esi+014h]
    push  [eax]
    lea   eax,[esi+00Ch]
    push  [eax]
    push  [esi]
    call   VirtualProtect        ;恢復頁保護屬性

free_mem:
    push   MEM_RELEASE
    push   0
    push   esi
    call    VirtualFree        ;釋放內存
do_sleep:
    push   5000
    call    Sleep
    push   0
    call    ExitProcess
new_sleep:                
    ret    004h
end start

第二次調用Sleep的結果是這樣:

004010A4: 6888130000    push 000001388h
004010A9: E80A000000    call Sleep
Sleep:                  ;這裏是跳轉到IAT裏的地址 
004010B8: FF2514204000  jmp dword ptr [000402014h]

tabulka:

00402014: 79 67 E8 77 6C 7D E8 77

Kernel32.Sleep:

77E86779: E937A95788    jmp 0004010B5h
new_sleep:
004010B5: C20400        ret 004h   

保存原始函數

更多時候需要的不僅僅是掛鉤函數,比如某些時候並不想取代指定函數而只是想檢查一下它的結果,或者僅當使用特定的參數調用時才取代原函數。比如之前提過的通過取代FindXXXFile函數來完成隱藏文件,如果想要隱藏指定的文件並且不想被注意的話,就得對其它所有文件只調用沒有被修改過的原始函數。此時採用修改IAT的方法是很簡單的,爲調用原始函數可以用GetProcAddress獲得原始地址,然後直接調用。但修改入口點的方法就會有問題,因爲修改了函數入口點的5個字節,破壞了原函數,所以就必須保存開始的那些指令。這將用到以下的技術。
我們知道要修改開始的5個字節,但不知道里麪包含多少條指令以及指令的長度,所以得爲那些指令保留足夠的內存空間。16個字節應該足夠了,因爲函數開始時通常沒有多長的指令,很可能根本就用不到16個字節。整個被保留的內存用0x90(0x90=nop)來填滿。之後的5個字節預留給即將填入的跳轉指令。至於如何獲取開始的指令長度另作討論。假定現在已經知道了長度,就可以在原始函數的下條指令填入跳轉地址。
下一個問題就是諸如ntdll.DbgBreakPoint這樣的API,它們太短了,所以不能用這種掛鉤方法,並且它是由Kernel32.DebugBreak調用,所以也不能通過修改IAT來掛鉤。雖然說沒有誰會去掛鉤這個只有int 3的函數,但只要認真想想也能找到解決的方法,如掛鉤它之後的那個函數(它可能會因爲修改了前一個函數的開始5個字節而被破壞),DbgBreakPoint函數長度爲2個字節,可以先設置一些標誌,然後嘗試着在第二個函數的開始寫入條件跳轉指令。
保存原始函數的問題已經敘述完了,就到解除掛鉤(unhook)。解除掛鉤就是把被修改的字節恢復爲原始狀態。修改IAT的方法裏,想解除掛鉤只需要在表裏恢復原始的地址;修改入口點的方法裏,則只需要把原始函數的開始指令拷貝回去。

掛鉤其它進程

試想,誰會想只掛鉤自己進程?顯然是非常不實用的。
先介紹CreateRemoteThread,它只在使用了NT技術的Windows版本里有效。。如幫助裏所說,這個函數可以在任意進程裏創建新線程並運行它的代碼。

    HANDLE CreateRemoteThread(
        HANDLE                      hProcess,
        LPSECURITY_ATTRIBUTES       lpThreadAttributes,
        DWORD                       dwStackSize,
        LPTHREAD_START_ROUTINE      lpStartAddress,
        LPVOID                          lpParameter,
        DWORD                       dwCreationFlags,
        LPDWORD                     lpThreadId
    );

句柄hProcess可以通過OpenProcess獲得。這裏必須獲得足夠的權限。lpStartAddress是指向目標進程地址空間裏存放新線程第一條指令地址的指針,因爲新線程是在目標進程裏創建,所以它存在於目標進程的地址空間裏。lpParameter是指向提交給新線程的參數的指針。

DLL注入

可以在目標進程地址空間裏任意地方運行我們的新線程。這看起來沒什麼用,除非在裏面有我們完整的代碼。第一種方法就是這麼實現。它調用GetProcAddress獲取LoadLibrary地址,然後把LoadLibrary賦值給參數lpStartAddress。LoadLibrary函數只有一個參數,就和目標進程裏新線程的函數

HINSTANCE LoadLibrary(LPCTSTR lpLibFileName);

用這點相似性,把lpParameter參數賦爲我們的DLL庫的名字,在新線程運行後lpParameter的位置就是lpLibFileName的位置,在加載了新的模塊到目標進程後就開始執行初始化部分。如果在這裏放置了能夠掛鉤其它函數的特殊函數就OK了。在執行了初始化部分後,這個線程就什麼都不做並被關閉,但我們的模塊仍然在地址空間中。這種方法很不錯而且很容易實現,它的名字叫DLL注入。

char *lpLibFileName = "my.dll"; 
PTHREAD_START_ROUTINE lpStartAddress = (PTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle("Kernel32"), "LoadLibraryA"); 
CreateRemoteThread( hRemoteProcess, NULL, 0, lpStartAddress, lpLibFileName, 0, NULL); 

但是這是遠線程,不是在自己的進程裏,而lpLibFileName指向的是自己進程裏的數據,到了目標進程,這個指針都不知道指向哪兒去了,同樣lpStartAddress這個地址上的代碼到了目標進程裏也不知道是什麼了,不知道是不是想要的LoadLibraryA了(其實Windows已經解決了這個問題,Kernel32.dll很特殊,對所有的進程,Windows總是把它加載到相同的地址上去。因此LoadLibraryA及Kernel32.dll內其它所有函數在不同進程中的地址始終相同)。所以要用到在VirtualAllocEx函數在目標進程裏分配內存,並調用WriteProcessMemory將自己進程中的數據拷貝到目標進程中。
如果不介意多個DLL庫的話,DLL注入是最快的方法(從程序員的角度來看)。

獨立的代碼

實現獨立的代碼比較困難,但也容易給人深刻印象。獨立的代碼是不需要任何靜態地址的代碼。它裏面所有東西都是互相聯繫地指向代碼裏面某些特定的地方,即使不知道這段代碼開始執行的地址它也能自己完成 。當然,也有可能先獲得地址然後重新鏈接我們的代碼,這樣它可以完全正常地在新地址工作,但這比編寫獨立的代碼更困難。這如病毒的代碼,病毒通過這種方法感染可執行文件,它把它自己的代碼加入到可執行文件中的某個地方。在不同的可執行文件中放置病毒代碼的位置也不一樣,這取決於比方說文件結構的長度。
首先將我們的代碼插入目標進程,然後CreateRemoteThread函數就會負責運行我們的代碼。所以第一步我們要做的就是通過OpenProcess函數獲取目標進程的信息和句柄,接着調用VirtualAllocEx在目標進程地址空間裏分配一些內存給我們的代碼,最後調用WriteProcessMemory把我們的代碼寫入分配的內存裏並運行它。調用CreateRemoteThread的參數lpStartAddress設置爲分配的內存地址,lpParameter可以隨便設置。

原始修改

在非NT內核的老版本Windows裏是沒有CreateRemoteThread函數的,所以不能用以上的方法。其實根本不需要把我們代碼放到目標進程裏來掛鉤它的函數。有兩個函數WriteProcessMemoryOpenProcess,它們在所有版本的Windows中都有效。我們還需要的函數是VirtualProtectEx,用來修改進入目標進程的內存頁。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章