Windows下x86和x64平台的Inline Hook介绍

发布时间 2023-03-22 19:12:04作者: Peacoor-Zomboss

前言

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

当然这两个库我并不是很熟悉,只是简单看过他们的代码,不过基本原理应该都是差不多的。

结语

本文内容较多,篇幅有点长,能看到这里相信你有足够的耐心了解这方面的知识。

但是这些内容确实有一定的门槛,前置知识要求也相对比较高,对于初学者来说可能比较困难。如果你现在对本文的内容还是很困惑,如果你依然想要了解,那么建议你努力学习前置知识。如果你有不懂的地方,也可以提出。

这篇文章的原理我研究了很久,又花了一整天时间才草草写成,由于写得仓促,一定存在不少纰漏,如果你对这方面非常熟悉,请指出错误,以免误导他人。

最后,感谢你能看完全文到这里。