前言
我在之前研究文明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。推荐是直接调用Wri