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

来源:https://www.cnblogs.com/PeaZomboss/archive/2023/02/17/17131778.html
-Advertisement-
Play Games

01.什麼是實時操作系統(RTOS)? 實時操作系統 (Real-Time Operating System,RTOS) 是一種為實時應用程式提供服務的操作系統,該類操作系統能快速響應並處理數據,處理時間要求以0.1秒的精度進行增量,處理結果能夠在規定的時間之內控制生產過程或對處理系統做出快速響應, ...


前言

我在之前研究文明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

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

結語

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

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

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

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 一.併發和並行 多任務:一定時間段內,充分利用cpu資源,同時去執行多個任務 併發: 快速交替的 去執行多任務 並行: 真正同時的 去執行多任務 ,就是同時進行 二.多進程 1.多進程入門 知識點: 進程含義: 運行中的程式 進程特點: cpu資源分配的 最小單位 多進程模塊: multiproce ...
  • 一、ElasticSearch概述 官網:https://www.elastic.co/cn/downloads/elasticsearch Elaticsearch,簡稱為es,es是一個開源的高擴展的分散式全文檢索引擎,它可以近乎實時的存儲、檢索數據;本身擴展性很好,可以擴展到上百台伺服器,處理 ...
  • 教程簡介 Instagram營銷初學者教程 - 從簡單和簡單的步驟學習Instagram營銷從基本到高級概念,包括概述,業務戰略,安裝和註冊,發佈和參與,活動審查,微調內容,營銷工具和應用程式,集成其他平臺,分析工具。 教程目錄 Instagram營銷教程 Instagram營銷 - 概述 Inst ...
  • Spring配置 1. 別名 alias 設置別名 , 為bean設置別名 , 可以設置多個別名 <!--設置別名:在獲取Bean的時候可以使用別名獲取--> <alias name="userT" alias="userNew"/> 2. Bean的配置 <!--bean就是java對象,由Spr ...
  • 已經支持OpenAI官方的全部api,有bug歡迎朋友們指出,互相學習。 源碼地址:https://github.com/Grt1228/chatgpt-java 不對之處歡迎指正。 註意:由於這個介面: https://platform.openai.com/docs/api-reference/ ...
  • public static class LocalSetupHelper { #region 欄位 /// <summary> /// json文本 /// </summary> private static string json; /// <summary> /// 指定保存路徑 /// </s ...
  • 今天我們購買的每臺電腦都有一個多核心的 CPU,允許它並行執行多個指令。操作系統通過將進程調度到不同的內核來發揮這個結構的優點。然而,還可以通過非同步 I/O 操作和並行處理來幫助我們提高單個應用程式的性能。在.NET Core中,任務 (tasks) 是併發編程的主要抽象表述,但還有其他支撐類可以使 ...
  • 概述 抽象工廠模式為創建一組對象提供了一種解決方案。與工廠方法模式相比,抽象工廠模式中的具體工廠不只是創建一種產品,它負責創建一組產品。抽象工廠模式定義如下: 抽象工廠模式(Abstract Factory Pattern):提供一個創建一系列相關或相互依賴對象的介面,而無須指定它們具體的類。抽象工 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...