Windows下x86和x64平臺的Inline Hook介紹

Zomboss發表於2023-02-17

前言

我在之前研究文明6的聯網機制並試圖用Hook技術來攔截socket函式的時候,熟悉了簡單的Inline Hook方法,但是由於之前的方法存在缺陷,所以進行了深入的研究,總結出了一些有關Windows下x86和x64架構程式的Inline Hook方法。

本文使用的方法並非最優,也沒有保證安全,但是用較少的程式碼實現了所需的功能,非常適合用來學習Inline Hook的基本原理和一般的使用方法。

由於本文是在Windows平臺下的,所以你需要對Windows系統的機制需要有一定的瞭解;同時本文的程式碼基於C語言(當然C++編譯器也可以編譯),所以你應該要有C語言的基礎(尤其是對指標的理解);此外,你還需要有一定的8086彙編(如果x86和x64更好)基礎,因為本文涉及到部分彙編指令。

本文假定你對以上這些內容有一定基礎,但並不非常熟悉,如果你完全瞭解,可以適當跳過部分內容。

如果你對更高階的內容有興趣,本文後面也會對這些東西做一個介紹,有興趣可以進一步瞭解。

在開始之前,先說明一下本文所有提到的完整程式碼都可以在這個連結找到:https://gitcode.net/PeaZomboss/miscellaneous/-/tree/main/230131-inlinehook

正文

Windows下的Hook機制,最早是用來在提供類似於DOS下的中斷機制,當然還有更多其他功能。Hook技術有許許多多的分類,本文所用的就是其中一種:Inline Hook。

所謂Inline Hook,一般是修改一個函式頭部的程式碼,使其跳轉到指定的地址。這樣當呼叫這個函式的時候,實際上執行的是我們設定的程式碼。

正因為如此,我們可以用Hook技術來攔截作業系統的API,或者某個軟體的關鍵函式,然後攔截獲取資訊或者修改其內容,從而達到我們的目的。比如微信QQ的防撤回就是這樣實現的,遊戲對戰平臺也一般是這樣做的。

後面要介紹兩種Inline Hook的方法。其中第一種比較簡單,但效果較差,尤其是在x64和多執行緒的情況下;而第二種效果好,尤其是x64以及多執行緒的情況下,但是操作較為複雜。

而許多更高階的功能基本就是在第二種方法的基礎上擴充套件的。

為了方便演示,我選擇了kernelbase.dll的函式WriteConsoleA,因為這個函式可以直接在控制檯輸出一段指定字串,便於我們檢視Hook的效果。

如果你透過windows.h標頭檔案匯入WriteConsoleA這個函式,會發現它呼叫了kernel32.dll的WriteConsoleA而不是kernelbase.dll的,這個你可以去反彙編看看,但是在kernel32.dll內部,你會發現函式頭部就是一句jmp指令,而真正執行的是kernelbase.dll裡的函式,所以一般選擇要Hook的函式的時候,如果這個函式頭部是一句跳轉指令,則去修改跳轉過去的地址。

簡單的Hook

這部分Hook方法是最簡單的,對於x86和x64僅有彙編指令的不同,但根本邏輯是完全一樣的。

這種方法之所以簡單,是因為不需要什麼複雜的操作和概念,只要簡單修改函式的頭部程式碼,然後需要呼叫原來的程式碼的時候再給他改回去就行了。

但是因為要改來改去的,所以在多執行緒的情況下會遇到問題,這個在之後討論。

x86

對於x86的Hook,方法比較簡單,使用一句跳轉指令就可以了:

jmp addr_diff

由於jmp指令有好多種用法,我們這裡用的是定址範圍±2G的指令,所以編譯成機器碼有5個位元組,第一個位元組是0xE9,剩下4個位元組是目標地址相對當前EIP的差值。

比如被Hook的函式地址是7FF01000,我們就修改7FF01000處的程式碼,使其跳轉到我們00401000處程式碼,程式碼如下:

...
00401000  ???
...
7FF01000  E9 FBFF4F80  jmp 00401000
7FF01005  ???
...

注意這裡的FBFF4F80,實際上是用小端表示的0x804FFFFB,記得剛剛說的吧,是目標地址相對當前EIP的差值。在執行7FF01000這一句的時候,EIP已經不是7FF01000了,而是7FF01005,因為EIP始終指向當前執行指令的下一個指令。

我們可以計算得出0x7FF01005+0x804FFFFB=0x100401000,由於EIP是32位暫存器,所以實際上執行這一句後EIP就會被設為00401000,這樣就使得程式碼執行到了我們的地方了。

所以我們可以得出這樣一個計算公式,假定被我們Hook的程式碼地址是addr_hook,而我們替換的地址是addr_fake,那麼跳轉語句jmp addr_diff的addr_diff=addr_hook-addr_fake-5。

代入剛剛的資料,0x804FFFFB=0x00401000-0x7FF01000-0x5,只取低32位,可以發現這個等式成立。

那麼方法就很簡單了,我們只要知道被Hook函式的地址,用來替換的函式的地址,即可計算出修改的指令,當然修改之前要先儲存一下原來的指令,以便到時候改回去。具體操作在後面的例項講解會有說明。

x64

對於x64來說,除了頭部修改的位元組數和跳轉的指令不同,其餘和x86的情況完全一致。

不過這個彙編指令就不能再像x86一樣簡單用jmp指令了,因為似乎沒有一個jmp指令可以跨大於±2G的記憶體地址空間。

作為jmp的替代,我們可以用暫存器定址或者壓棧配合ret指令實現同樣的效果:

mov rax, address
jmp rax

或者

mov rax, address
push rax
ret

以上兩段程式碼效果一樣,而且都佔用12個位元組,但缺點一致——會改變暫存器的值。

由於改變暫存器的值可能會影響程式執行結果,我們可以用如下程式碼避免這種情況:

push address.low
mov dword [rsp+4], address.high
ret

注意這裡的address.low表示地址的低4位元組,address.high表示地址的高4位元組。

這段程式碼的原理是在x64彙編中,push指令只能處理4個位元組的立即數,但是由於棧是8位元組對齊的,所以執行第一句指令的時候,棧裡會壓入8位元組內容,其中低4位元組就是push的值,而高4位元組會補0,此時我們可以透過rsp暫存器間接定址再把那高4位元組立即數放入棧裡。

相對之前的兩段程式碼,這段程式碼的好處是不會修改暫存器,不過缺點是指令長度要多2個位元組。不過為了確保不會出現問題,我們就選擇這個方法。

例項

首先看一下微軟檔案關於WriteConsoleA這個函式的原型說明:

BOOL WINAPI WriteConsole(
  _In_             HANDLE  hConsoleOutput,
  _In_       const VOID    *lpBuffer,
  _In_             DWORD   nNumberOfCharsToWrite,
  _Out_opt_        LPDWORD lpNumberOfCharsWritten,
  _Reserved_       LPVOID  lpReserved
);

注意這個函式原型就是一個宏,在Unicode下實際呼叫的是WriteConsoleW,ANSI下則是WriteConsoleA。推薦是直接呼叫WriteConsoleA以免遇到不必要的麻煩。

第一個引數是輸出的控制檯控制程式碼,這個控制程式碼可以透過呼叫GetStdHandle(-11)來獲取。
第二個引數是要寫入到控制檯的字串緩衝區,在WriteConsoleA中用char陣列就行了。
第三個引數指示剛剛那個緩衝區裡的字元數量,不要超過緩衝區實際的長度。
第四個引數是一個DWORD型別的指標,返回實際寫入到控制檯的字元數量,可以為NULL。
第五個引數保留,傳入NULL即可。
返回值BOOL型別,我們並不關心。

所以我們可以這樣用:

HANDLE hstdout = GetStdHandle(-11);
char str[16] = "Hello World\n";
WriteConsoleA(hstdout, str, strlen(str), NULL, NULL);

就會在螢幕輸出一行Hello World和一個換行。

現在編寫一個替換原來函式的函式,注意呼叫約定和引數列表要一模一樣。

WINBOOL WINAPI fk_WriteConsoleA(HANDLE hConsoleOutput, CONST VOID *lpBuffer, DWORD nNumberOfCharsToWrite, LPDWORD lpNumberOfCharsWritten, LPVOID lpReserved)
{
    unhook(); // 後面說明
    char buf[128];
    strcpy(buf, (char *)lpBuffer);
    buf[nNumberOfCharsToWrite - 1] = '\0';
    strcat(buf, "\t[hook]\n");
    int len = nNumberOfCharsToWrite + 8;
    WINBOOL result = WriteConsoleA(hConsoleOutput, buf, len, NULL, NULL);
    dohook(); // 後面說明
    return result;
}

這段程式碼首先呼叫了unhook(),把被Hook函式的頭幾個位元組改回原來的程式碼,這樣就可以重新呼叫原來的這個函式了。

之後一段程式碼很簡單,就是把原來想要輸出的字串後面的'\n'去掉,並加上了"\t[hook]\n",然後再呼叫WriteConsoleA函式輸出被替換的字串。

最後再呼叫dohook()把頭部的函式改成跳轉程式碼,這樣又可以繼續Hook這個函式了。


對於Hook的程式碼,x86和x64基本一樣,除了硬編碼部分存在差異,所以我們可以用條件編譯的方法來區分二者。

這裡我們可以用如下方法來確定編譯結果是x86還是x64:

#if defined(__x86_64__) || defined(__amd64__) || defined(_M_X64) || defined(_M_AMD64)
#define _CPU_X64
#elif defined(__i386__) || defined(_M_IX86)
#define _CPU_X86
#else
#error "Unsupported CPU"
#endif

其中__x86_64____amd64__是gcc定義的,表明這是x64,同理_M_X64_M_AMD64則是由微軟vc編譯器定義的。而__i386__是gcc定義的x86下的宏,_M_IX86是微軟定義的。

我們在此基礎上重新定義了_CPU_X64_CPU_X86這兩個宏,用來方便後續的使用。

接下來需要定義被Hook函式頭部需要替換的位元組數,那麼按照前面的方法,我們如下定義:

#ifdef _CPU_X64
#define HOOK_JUMP_LEN 14
#endif
#ifdef _CPU_X86
#define HOOK_JUMP_LEN 5
#endif

然後定義如下全域性變數

HANDLE hstdout = NULL; // 標準輸出控制程式碼
void *hook_func = NULL; // 被Hook函式的地址
char hook_jump[HOOK_JUMP_LEN]; // 用於替換的跳轉程式碼
char old_entry[HOOK_JUMP_LEN]; // 被Hook函式原來的程式碼

然後是初始化程式碼,請仔細看註釋的說明:

void inithook()
{
    HMODULE hmodule = GetModuleHandleA("kernelbase.dll"); // 獲取模組控制程式碼
    hook_func = (void *)GetProcAddress(hmodule, "WriteConsoleA"); // 找到函式地址
    VirtualProtect(hook_func, HOOK_JUMP_LEN, PAGE_EXECUTE_READWRITE, NULL); // 允許函式頭部記憶體可讀寫
#ifdef _CPU_X64
    union
    {
        void *ptr;
        struct
        {
            long lo;
            long hi;
        };
    } ptr64; // 便於獲取指標變數的高4位元組和低4位元組
    ptr64.ptr = (void *)fk_WriteConsoleA;
    hook_jump[0] = 0x68; // push xxx
    *(long *)&hook_jump[1] = ptr64.lo; // xxx,即地址的低4位元組
    hook_jump[5] = 0xC7;
    hook_jump[6] = 0x44;
    hook_jump[7] = 0x24;
    hook_jump[8] = 0x04; // mov dword [rsp+4], yyy
    *(long *)&hook_jump[9] = ptr64.hi; // yyy,即地址的高4位元組
    hook_jump[13] = 0xC3; // ret
#endif
#ifdef _CPU_X86
    hook_jump[0] = 0xE9; // jmp
    *(long *)&hook_jump[1] = (BYTE *)fk_WriteConsoleA - (BYTE *)hook_func - 5; // 計算指令內容
#endif
    memcpy(&old_entry, hook_func, HOOK_JUMP_LEN); // 儲存原來的指令
}

這裡呼叫了VirtualProtect函式,把目標函式的指定位元組記憶體設為可讀可寫,實際上不論設定與否,讀取的時候可以直接用指標或者memcpy函式,但是如果不設定,則無法寫入,而且寫入的時候還必須要透過WriteProcessMemory函式。

前面提到的dohook()unhook()其實很簡單了:

void dohook()
{
    WriteProcessMemory(GetCurrentProcess(), hook_func, hook_jump, HOOK_JUMP_LEN, NULL);
}

void unhook()
{
    WriteProcessMemory(GetCurrentProcess(), hook_func, old_entry, HOOK_JUMP_LEN, NULL);
}

第一個引數從GetCurrentProcess()獲得,表示當前程式,最後一個引數設為NULL就行了,其餘3個引數內容和memcpy是基本一樣的。


為了直觀展示此方法的侷限性,我特地設計了一個多執行緒的情況:

DWORD WINAPI thread_writehello(void *stdh)
{
    DWORD id = GetCurrentThreadId();
    char str[64];
    for (int i = 0; i < 10; i++) {
        int len = sprintf(str, "%d:\t Hello World %d\n", id, i);
        WriteConsoleA(stdh, str, len, NULL, NULL);
    }
    return 0;
}

#define THREAD_COUNT 5

int main()
{
    inithook();
    dohook();
    hstdout = GetStdHandle(-11);
    HANDLE hthreads[THREAD_COUNT];
    for (int i = 0; i < THREAD_COUNT; i++)
        hthreads[i] = CreateThread(NULL, 0, thread_writehello, hstdout, CREATE_SUSPENDED, NULL);
    for (int i = 0; i < THREAD_COUNT; i++)
        ResumeThread(hthreads[i]);
    for (int i = 0; i < THREAD_COUNT; i++)
        WaitForSingleObject(hthreads[i], 1000);
    for (int i = 0; i < THREAD_COUNT; i++)
        CloseHandle(hthreads[i]);
    WriteConsoleA(hstdout, "Must hook\n", 10, NULL, NULL); // 這個必須被Hook
    unhook();
    WriteConsoleA(hstdout, "Not hook\n", 9, NULL, NULL); // 這個必須不被Hook
}

這一部分程式碼很好理解,主函式進行基本的初始化,然後啟動5個執行緒,每個執行緒都會列印自己的執行緒id和內容。

完整程式碼見本文開頭的連結,檔名"simplehook.cpp"。


以下是上述程式碼編譯好後的一次執行結果:

30664:   Hello World 0  [hook]
16856:   Hello World 0
30664:   Hello World 1  [hook]
6648:    Hello World 0
16856:   Hello World 1
16856:   Hello World 2
16856:   Hello World 3
6648:    Hello World 1
6648:    Hello World 2
4488:    Hello World 0
4488:    Hello World 1
16856:   Hello World 4
16856:   Hello World 5
16856:   Hello World 6
16856:   Hello World 7
16856:   Hello World 8
16856:   Hello World 9
6648:    Hello World 3
6648:    Hello World 4
6648:    Hello World 5
6648:    Hello World 6
6648:    Hello World 7
6648:    Hello World 8
29936:   Hello World 0  [hook]
30664:   Hello World 2  [hook]
30664:   Hello World 3  [hook]
6648:    Hello World 9
29936:   Hello World 1  [hook]
29936:   Hello World 2  [hook]
30664:   Hello World 4  [hook]
4488:    Hello World 2
29936:   Hello World 3  [hook]
30664:   Hello World 5  [hook]
4488:    Hello World 3
4488:    Hello World 4
4488:    Hello World 5
4488:    Hello World 6
4488:    Hello World 7
4488:    Hello World 8
30664:   Hello World 6  [hook]
29936:   Hello World 4  [hook]
29936:   Hello World 5  [hook]
30664:   Hello World 7  [hook]
4488:    Hello World 9
29936:   Hello World 6  [hook]
30664:   Hello World 8  [hook]
29936:   Hello World 7  [hook]
30664:   Hello World 9  [hook]
29936:   Hello World 8  [hook]
29936:   Hello World 9  [hook]
Must hook       [hook]
Not hook

根據執行緒ID和Hook情況來看,只有30664和29936這兩個執行緒被成功Hook到了。

多執行緒Hook

由於上述簡單Hook存在較大的侷限性,所以這裡介紹一種可以在多執行緒環境下使用的Hook方法。

對於多執行緒的情況,實現起來則比較複雜,尤其是在x64的情況下。

其基本原理是提供一個跳板函式,在需要呼叫原來函式的時候,不是簡單把函式頭部位元組改回去,而是把頭部位元組的程式碼複製到一段記憶體執行,再加入一段跳轉程式碼。這樣只要透過這段記憶體就可以直接呼叫這個函式了。

由於x86和x64存在不同,具體原理分開說明。

x86

對於x86,假設我們的程式碼在00401000,被Hook的函式在7FF0100A,跳板程式碼地址在00600000。

修改前:

...
00401000  ???
...
00600000  0000
...
7FF01000  55    push ebp
7FF01001  89E5  mov ebp, esp
7FF01003  31C0  xor eax, eax
7FF01005  89D1  mov ecx, edx
7FF01007  ???
...

修改後:

...
00401000  ???
...
00600000  55           push ebp
00600001  89E5         mov ebp, esp
00600003  31C0         xor eax, eax
00600005  E9 0010907F  jmp 7FF01005
0060000A  0000
...
7FF01000  E9 FBEF6F80  jmp 00401000
7FF01005  89D1         mov ecx, edx
7FF01007  ???
...

這裡7FF01000處的程式碼已經被替換,而00600000處的程式碼則是從7FF01000處複製而來。

這樣當我們需要呼叫7FF01000這個函式的時候,則不必再改寫其頭部記憶體,而是直接呼叫00600000即可。

一個需要關注的細節是如果7FF01000處的前5位元組不能構成完整彙編指令的時候,就要多複製幾個位元組的指令,使得一條指令是完整的,但具體是幾個位元組需要提前反彙編得知。如果前5個位元組含有跳轉類程式碼,則容易造成錯誤,不適合這個方法進行Hook。

x64

在x64的情況下,情況則有所不同了,因為按照前面的方法,至少需要修改頭部的14個位元組,而14個位元組出現跳轉類程式碼的機率是很大的,所以我們要避免修改這麼多程式碼。

有一個很好的解決方法是修改頭部5個位元組的程式碼,然後像x86一樣改成jmp指令,跳轉到2G範圍內的一處空白記憶體,然後在這個空白的記憶體裡再改成14位元組的跳轉程式碼,跳轉到我們真正要執行的程式碼。這個方法經常出現在破解或修改他人程式的時候,如果修改後的程式碼大小大於其原來的大小時,就無法就地修改了,這時就可以跳轉到一處空白記憶體接著執行修改後的程式碼,執行完了再跳回去就好了。

而跳板函式的原理和x86則是基本一樣的,唯一的區別是跳轉回去的指令是14位元組。

假設我們的程式碼在0000000100001000,被Hook函式在00007FF000001000,空白的記憶體在00007FF00003A000。

修改前:

...
0000000000600000  0000
...
0000000100001000  ???
...
00007FF000001000  48895C2410  mov qword [rsp+0x10], rbx
00007FF000001005  4889742418  mov qword [rsp+0x18], rsi
00007FF00000100A  55          push rbp
00007FF00000100B  488BEC      mov rbp, rsp
00007FF00000100E  ???
...
00007FF00003A000  0000
...

修改後:

...
0000000000600000  48895C2410         mov qword [rsp+0x10], rbx
0000000000600005  68 0A100000        push 0000100A
000000000060000A  C7442404 F07F0000  mov dword [rsp+4], 00007FF0
0000000000600012  C3                 ret
0000000000600013  0000
...
0000000100001000  ???
...
00007FF000001000  E9 FB8F0300        jmp 00007FF00003A000
00007FF000001005  4889742418         mov qword [rsp+0x18], rsi
00007FF00000100A  55                 push rbp
00007FF00000100B  488BEC             mov rbp, rsp
00007FF00000100E  ???
...
00007FF00003A000  68 00100000        push 00001000
00007FF00003A005  C7442404 01000000  mov dword [rsp+4], 00000001
00007FF00003A00D  C3                 ret
00007FF00003A00E  0000
...

上面這段程式碼清晰的展示了指令如何從被Hook的函式輾轉到我們的函式地址。


那麼怎麼找到一片空白的記憶體呢?這就涉及到了Windows下可執行檔案(包括動態連結庫)的檔案結構——PE結構了。

所有的exe和dll檔案頭部都是一樣的,在他們被載入時,都是按頁(4KB一頁)將檔案的各部分載入到記憶體中,而檔案頭的結構則是按照原始的格式完整地載入到了記憶體。基於這個原理,我們就可以找到一個exe或dll的程式碼段的記憶體地址。又因為記憶體的一頁是4KB,那麼意味著在記憶體中每個模組的程式碼段最後一部分必然存在冗餘。

所以我們就可以根據模組的地址來讀取其檔案頭部,然後獲取我們需要的資訊。

幸運的是,當我們呼叫LoadLibrary或者GetModuleHandle時,若函式執行成功,其返回值就是模組在記憶體中的地址,而根據這個地址,我們就可以解析檔案頭了。

有關Windows的可執行檔案頭的具體內容,這裡做一個簡單的介紹。

第一部分是DOS頭,結構為IMAGE_DOS_HEADER,具體可以在微軟檔案找到,其欄位e_lfanew指示了PE頭的位置。

第二部分是PE頭,四個位元組,內容為"PE\0\0"。

第三部分是PE檔案頭,結構為IMAGE_FILE_HEADER

第四部分是PE可選頭,結構為IMAGE_OPTIONAL_HEADER,其大小由PE檔案頭SizeOfOptionalHeader欄位指示。

第五部分是區段(section),結構為IMAGE_SECTION_HEADER,其數量由PE檔案頭的NumberOfSections指示。

我們的目標就是獲取區段,然後找到其中的.text段,這個就是程式碼段。其區段名由Name欄位指示,其地址相對偏移由VirtualAddress欄位指示,其記憶體大小由VirtualSize欄位指示。

所以根據模組地址和程式碼段偏移和大小即可找到程式碼段的空白記憶體了。

具體程式碼如下:

static void *FindModuleTextBlankAlign(HMODULE hmodule)
{
    BYTE *p = (BYTE *)hmodule;
    p += ((IMAGE_DOS_HEADER *)p)->e_lfanew + 4; // 根據DOS頭獲取PE資訊偏移量
    p += sizeof(IMAGE_FILE_HEADER) + ((IMAGE_FILE_HEADER *)p)->SizeOfOptionalHeader; // 跳過可選頭
    WORD sections = ((IMAGE_FILE_HEADER *)p)->NumberOfSections; // 獲取區段長度
    for (int i = 0; i < sections; i++) {
        IMAGE_SECTION_HEADER *psec = (IMAGE_SECTION_HEADER *)p;
        p += sizeof(IMAGE_SECTION_HEADER);
        if (memcmp(psec->Name, ".text", 5) == 0) { // 是否.text段
            BYTE *offset = (BYTE *)hmodule + psec->VirtualAddress + psec->Misc.VirtualSize; // 計算空白區域偏移量
            offset += 16 - (INT_PTR)offset % 16; // 對齊16位元組
            long long *buf = (long long *)offset;
            while (buf[0] != 0 || buf[1] != 0) // 找到一塊全是0的區域
                buf += 16;
            return (void *)buf;
        }
    }
    return 0;
}

引數是一個模組的地址,返回值就是在這個模組找到的一片空白記憶體的地址。

例項

大部分程式碼和前面的差不多,不同的主要是Hook程式碼。

首先需要一個定義WriteConsoleA函式型別

typedef WINBOOL(WINAPI *WRITECONSOLEA) (HANDLE, CONST VOID *, DWORD, LPDWORD, LPVOID);

基本的常量:

#define HOOK_JUMP_LEN 5
#ifdef _CPU_X64
#define ENTRY_LEN 9 // 反彙編得出
#endif
#ifdef _CPU_X86
#define ENTRY_LEN 5 // 反彙編得出
#endif

還有全域性變數:

HANDLE hstdout = NULL; // 標準輸出
void *old_entry = NULL; // 原來的程式碼和跳轉的程式碼(跳板)
void *hook_func = NULL; // 被Hook函式的地址
char hook_jump[HOOK_JUMP_LEN]; // 修改函式頭部跳轉的程式碼
WRITECONSOLEA _WriteConsoleA; // 用來執行原來的程式碼

dohook()unhook()程式碼:

void dohook()
{
    HMODULE hmodule = GetModuleHandleA("kernelbase.dll");
    hook_func = (void *)GetProcAddress(hmodule, "WriteConsoleA");
    // 允許func_ptr處最前面的5位元組記憶體可讀可寫可執行
    VirtualProtect(hook_func, HOOK_JUMP_LEN, PAGE_EXECUTE_READWRITE, NULL);
    // 使用VirtualAlloc申請記憶體,使其可讀可寫可執行
    old_entry = VirtualAlloc(NULL, 32, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
#ifdef _CPU_X64
    union
    {
        void *ptr;
        struct
        {
            long lo;
            long hi;
        };
    } ptr64;
    void *blank = FindModuleTextBlankAlign(hmodule); // 找到第一處空白區域
    VirtualProtect(blank, 14, PAGE_EXECUTE_READWRITE, NULL); // 可讀寫
    hook_jump[0] = 0xE9; // 跳轉程式碼
    *(long *)&hook_jump[1] = (BYTE *)blank - (BYTE *)hook_func - 5; // 跳轉到空白區域
    ptr64.ptr = (void *)fk_WriteConsoleA;
    BYTE blank_jump[14];
    blank_jump[0] = 0x68; // push xxx
    *(long *)&blank_jump[1] = ptr64.lo; // xxx,即地址的低4位
    blank_jump[5] = 0xC7;
    blank_jump[6] = 0x44;
    blank_jump[7] = 0x24;
    blank_jump[8] = 0x04; // mov dword [rsp+4], yyy
    *(long *)&blank_jump[9] = ptr64.hi; // yyy,即地址的高4位
    blank_jump[13] = 0xC3; // ret
    // 寫入真正的跳轉程式碼到空白區域
    WriteProcessMemory(GetCurrentProcess(), blank, &blank_jump, 14, NULL);
    // 儲存原來的入口程式碼
    memcpy(old_entry, hook_func, ENTRY_LEN);
    ptr64.ptr = (BYTE *)hook_func + ENTRY_LEN; // 計算跳回去的地址
    // 設定新的跳轉程式碼
    BYTE *new_jump = (BYTE *)old_entry + ENTRY_LEN;
    new_jump[0] = 0x68;
    *(long *)(new_jump + 1) = ptr64.lo;
    new_jump[5] = 0xC7;
    new_jump[6] = 0x44;
    new_jump[7] = 0x24;
    new_jump[8] = 0x04;
    *(long *)(new_jump + 9) = ptr64.hi;
    new_jump[13] = 0xC3;
#endif
#ifdef _CPU_X86
    hook_jump[0] = 0xE9; // 跳轉程式碼
    *(long *)&hook_jump[1] = (BYTE *)fk_WriteConsoleA - (BYTE *)hook_func - 5; // 直接到hook的程式碼
    memcpy(old_entry, hook_func, ENTRY_LEN); // 儲存入口
    BYTE *new_jump = (BYTE *)old_entry + ENTRY_LEN;
    *new_jump = 0xE9; // 跳回去的程式碼
    *(long *)(new_jump + 1) = (BYTE *)hook_func + ENTRY_LEN - new_jump - 5; // 計算跳回去的指令
#endif
    _WriteConsoleA = (WRITECONSOLEA)old_entry;
    WriteProcessMemory(GetCurrentProcess(), hook_func, &hook_jump, HOOK_JUMP_LEN, NULL);
}

void unhook()
{
    WriteProcessMemory(GetCurrentProcess(), hook_func, old_entry, HOOK_JUMP_LEN, NULL);
    VirtualFree(old_entry, 0, MEM_RELEASE);
}

這部分程式碼可能看著比較多,比較亂,但是如果對著前面原理說明來看,應該不難理解。重點是VirtualAlloc函式,可以申請一段虛擬記憶體,使其有可執行的屬性,而用malloc申請的記憶體一般是不可執行的。

替換原來函式的函式:

WINBOOL WINAPI fk_WriteConsoleA(HANDLE hConsoleOutput, CONST VOID *lpBuffer, DWORD nNumberOfCharsToWrite, LPDWORD lpNumberOfCharsWritten, LPVOID lpReserved)
{
    char buf[128];
    strcpy(buf, (char *)lpBuffer);
    buf[nNumberOfCharsToWrite - 1] = '\0';
    strcat(buf, "\t[hook]\n");
    int len = nNumberOfCharsToWrite + 8;
    return _WriteConsoleA(hConsoleOutput, buf, len, NULL, NULL); // 直接簡單呼叫跳板函式即可
}

主函式和之前的略有不同,具體如下:

int main()
{
    dohook();
    hstdout = GetStdHandle(-11);
    HANDLE hthreads[THREAD_COUNT];
    for (int i = 0; i < THREAD_COUNT; i++)
        hthreads[i] = CreateThread(NULL, 0, thread_writehello, hstdout, CREATE_SUSPENDED, NULL);
    for (int i = 0; i < THREAD_COUNT; i++)
        ResumeThread(hthreads[i]);
    for (int i = 0; i < THREAD_COUNT; i++)
        WaitForSingleObject(hthreads[i], 1000);
    for (int i = 0; i < THREAD_COUNT; i++)
        CloseHandle(hthreads[i]);
    WriteConsoleA(hstdout, "Must hook\n", 10, NULL, NULL);
    unhook();
    WriteConsoleA(hstdout, "Not hook\n", 9, NULL, NULL);
}

完整程式碼在本文開頭連結,檔案是"multithreadhook.cpp"。


下面給出其中一次的執行結果:

28908:   Hello World 0  [hook]
28908:   Hello World 1  [hook]
28908:   Hello World 2  [hook]
3420:    Hello World 0  [hook]
3420:    Hello World 1  [hook]
3420:    Hello World 2  [hook]
3420:    Hello World 3  [hook]
3420:    Hello World 4  [hook]
3420:    Hello World 5  [hook]
3420:    Hello World 6  [hook]
3420:    Hello World 7  [hook]
3420:    Hello World 8  [hook]
3420:    Hello World 9  [hook]
28908:   Hello World 3  [hook]
28908:   Hello World 4  [hook]
28908:   Hello World 5  [hook]
28908:   Hello World 6  [hook]
28908:   Hello World 7  [hook]
28908:   Hello World 8  [hook]
28908:   Hello World 9  [hook]
31356:   Hello World 0  [hook]
31356:   Hello World 1  [hook]
31356:   Hello World 2  [hook]
31356:   Hello World 3  [hook]
31356:   Hello World 4  [hook]
31356:   Hello World 5  [hook]
31356:   Hello World 6  [hook]
31356:   Hello World 7  [hook]
31356:   Hello World 8  [hook]
31356:   Hello World 9  [hook]
27416:   Hello World 0  [hook]
27416:   Hello World 1  [hook]
27416:   Hello World 2  [hook]
27416:   Hello World 3  [hook]
27416:   Hello World 4  [hook]
27416:   Hello World 5  [hook]
27416:   Hello World 6  [hook]
27416:   Hello World 7  [hook]
27416:   Hello World 8  [hook]
27416:   Hello World 9  [hook]
144:     Hello World 0  [hook]
144:     Hello World 1  [hook]
144:     Hello World 2  [hook]
144:     Hello World 3  [hook]
144:     Hello World 4  [hook]
144:     Hello World 5  [hook]
144:     Hello World 6  [hook]
144:     Hello World 7  [hook]
144:     Hello World 8  [hook]
144:     Hello World 9  [hook]
Must hook       [hook]
Not hook

可以看到所有的呼叫都被Hook了。

擴充套件內容

由於本文開頭提到了本文的方法並不是最佳的,因為這個程式碼並沒有執行緒安全,而且選擇要儲存的函式頭部程式碼長度需要自己手動指定,比較麻煩,沒有實現自動Hook。

關於執行緒安全,比如你在替換被Hook函式頭部的程式碼時,某個執行緒剛好也執行到了這裡,那比如會造成執行緒執行出錯,最好的方式就是在Hook之前,暫停程式所有正在執行的執行緒,依次判斷每個執行緒的指令位置,如果剛好執行到了被Hook的函式頭部,那麼就需要專門針對其進行處理。

關於要儲存的頭部程式碼長度,則可以內建一個反彙編器,自動判斷指令的長度,然後儲存相應的程式碼到跳板函式。

如果要了解以上這些以及更多內容,你可以瞭解一下微軟的Detours開源庫和TsudaKageyu的minhook開源庫。

Detours:https://github.com/microsoft/Detours
minhook: https://github.com/TsudaKageyu/minhook

當然這兩個庫我並不是很熟悉,只是簡單看過他們的程式碼,不過基本原理應該都是差不多的。

結語

本文內容較多,篇幅有點長,能看到這裡相信你有足夠的耐心瞭解這方面的知識。

但是這些內容確實有一定的門檻,前置知識要求也相對比較高,對於初學者來說可能比較困難。如果你現在對本文的內容還是很困惑,如果你依然想要了解,那麼建議你努力學習前置知識。如果你有不懂的地方,也可以提出。

這篇文章的原理我研究了很久,又花了一整天時間才草草寫成,由於寫得倉促,一定存在不少紕漏,如果你對這方面非常熟悉,請指出錯誤,以免誤導他人。

最後,感謝你能看完全文到這裡。

相關文章