第37章:Windows API Hook(二):Inline Hook原理与实现(5字节跳转)
上一章我们聊了IAT Hook,那是修改导入表来劫持API调用。说实话,IAT Hook虽然稳定,但局限性也很明显——它只能钩住通过导入表调用的函数。如果目标程序用LoadLibrary + GetProcAddress动态获取函数地址,IAT Hook就抓瞎了。
这时候,Inline Hook就派上用场了。它直接在函数代码开头动手脚,不管你用哪种方式调用,统统拦截。我个人习惯把Inline Hook叫做“硬核钩子”,因为它真的在改代码。
Inline Hook的核心思想
说白了,Inline Hook的原理就一句话:把目标函数的前几个字节改成一条JMP指令,跳转到我们的Hook函数。
你想想看,CPU执行代码是一行一行来的。如果我们在函数入口处放一条JMP,CPU就会乖乖跳到我们指定的地址去执行。等我们的Hook函数处理完了,再跳回原来的函数继续执行。
嗯,这里要注意:被覆盖的原始字节必须保存下来,不然Hook函数执行完后没法恢复现场。
为什么是5字节?
这个问题我刚开始学的时候也困惑过。为什么偏偏是5字节?
答案很简单:x86架构下,一条JMP指令正好占5字节。
JMP指令的机器码格式是:
E9 xx xx xx xx
E9:JMP的操作码(1字节)xx xx xx xx:相对偏移地址(4字节)
这个偏移量怎么算?公式是:
偏移 = 目标地址 - 当前指令地址 - 5
为什么要减5?因为JMP指令执行时,EIP已经指向了下一条指令。所以偏移量要减去当前指令的长度。
核心要点:5字节跳转是最经典的Inline Hook方案。它简单、高效,覆盖了绝大多数Windows API函数的开头。
Inline Hook的完整流程
我在项目中实现过很多次Inline Hook,总结下来就是6步:
- 获取目标函数地址:用GetProcAddress拿到API的真实地址
- 保存原始字节:把函数开头的5个字节读出来存好
- 写入JMP指令:计算偏移,写入E9 + 偏移量
- 执行Hook函数:当API被调用时,先执行我们的代码
- 恢复原始字节:在Hook函数中,把保存的5字节写回去
- 调用原始API:执行真正的API函数
- 重新Hook:等原始API返回后,再把JMP写回去
我曾经踩过一个坑:多线程环境下,恢复和重新Hook之间有个时间窗口。如果另一个线程在这期间调用了API,就会直接执行原始函数,绕过我们的Hook。后来我用了InterlockedExchange之类的原子操作才解决。
代码实现
来看一个完整的实现。我以Hook MessageBoxA为例:
// 保存原始字节
BYTE g_originalBytes[5] = {0};
// Hook函数
int WINAPI HookedMessageBoxA(
HWND hWnd, LPCSTR lpText,
LPCSTR lpCaption, UINT uType)
{
// 1. 恢复原始字节
DWORD oldProtect;
VirtualProtect(g_pMessageBoxA, 5,
PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(g_pMessageBoxA, g_originalBytes, 5);
VirtualProtect(g_pMessageBoxA, 5,
oldProtect, &oldProtect);
// 2. 调用原始API
int result = MessageBoxA(hWnd,
"已被Hook拦截!", lpCaption, uType);
// 3. 重新Hook
InstallHook();
return result;
}
// 安装Hook
BOOL InstallHook()
{
// 获取目标函数地址
HMODULE hMod = GetModuleHandleA("user32.dll");
g_pMessageBoxA = (PBYTE)GetProcAddress(hMod, "MessageBoxA");
// 保存原始字节
memcpy(g_originalBytes, g_pMessageBoxA, 5);
// 计算JMP偏移
// JMP指令地址 = g_pMessageBoxA
// 目标地址 = HookedMessageBoxA
DWORD offset = (DWORD)HookedMessageBoxA -
(DWORD)g_pMessageBoxA - 5;
// 写入JMP指令
DWORD oldProtect;
VirtualProtect(g_pMessageBoxA, 5,
PAGE_EXECUTE_READWRITE, &oldProtect);
g_pMessageBoxA[0] = 0xE9; // JMP操作码
memcpy(&g_pMessageBoxA[1], &offset, 4);
VirtualProtect(g_pMessageBoxA, 5,
oldProtect, &oldProtect);
return TRUE;
}
小技巧:VirtualProtect是必须的。因为代码段默认是只读的,不改内存属性直接写会触发访问违例。
Hook链与多钩子问题
实际项目中,往往不止一个程序在Hook同一个API。这就引出了Hook链的问题。
假设A程序先Hook了MessageBoxA,B程序后Hook。B在安装Hook时,保存的是A写入的JMP指令。当B的Hook函数恢复原始字节时,恢复的是A的JMP,而不是真正的API开头。
这就乱套了。
我建议的解决方案是:用Trampoline(跳板)函数。在分配的内存中重建被覆盖的指令,然后加上JMP跳回原始函数+5的位置。这样每个Hook都有自己的跳板,互不干扰。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 直接恢复法 | 实现简单 | 多Hook冲突 |
| Trampoline法 | 支持多Hook | 实现复杂 |
| Detours库 | 微软官方方案 | 商业使用需授权 |
x64架构下的差异
如果你在x64下做Inline Hook,要注意:5字节JMP不够用了。
为什么?因为x64的地址是64位的,而JMP指令的偏移量只有32位。如果目标地址和当前地址相差超过2GB,5字节JMP就跳不过去。
解决方案有两种:
- 用14字节的JMP:mov rax, 目标地址 + jmp rax
- 用中间跳板:在附近地址放一个64位跳板
我个人更推荐第二种,因为14字节覆盖范围太大,容易破坏其他指令。
实战中的避坑指南
做Inline Hook这么多年,我总结了几条血泪教训:
警告:
- 不要Hook正在执行的函数:如果目标函数当前正在被调用,修改它的代码会导致崩溃。建议用SuspendThread暂停所有线程。
- 注意指令边界:有些函数开头不是5字节对齐的指令。比如开头是一条3字节指令加一条2字节指令,你只覆盖前5字节会破坏第二条指令。这时候要用Trampoline。
- SEH异常处理:写入内存时可能触发异常,记得用__try/__except包起来。
- 不要Hook短函数:如果函数总长度不到5字节,就没法Hook。这种情况很少见,但确实存在。
Inline Hook的应用场景
说实话,Inline Hook的用途非常广泛:
- API监控:记录程序调用了哪些API,传了什么参数
- 功能扩展:在API调用前后插入自定义逻辑
- 安全防护:拦截恶意API调用
- 游戏外挂:嗯,这个我就不多说了
我曾经在一个安全项目中,用Inline Hook拦截了NtCreateThreadEx,成功阻止了恶意代码的远程线程注入。那次经历让我深刻体会到:Hook点选对了,事半功倍。
核心流程图
下面这张图展示了Inline Hook的完整调用流程:
这张图清晰地展示了:应用程序调用API时,首先命中我们写入的JMP指令,然后跳转到Hook函数。Hook函数处理完业务逻辑后,恢复原始字节并调用真正的API,最后将结果返回给应用程序。
总结
Inline Hook是逆向工程中非常核心的技术。5字节跳转虽然简单,但背后涉及内存管理、线程安全、指令对齐等很多细节。我建议你从最简单的MessageBoxA开始练手,逐步增加复杂度。
记住一句话:Hook容易,写好难。真正的高手,是在各种边界情况下都能稳定运行的Hook。
练习建议:写一个程序,Hook CreateFileA,记录所有文件打开操作。这是很多安全软件的基础功能。
公众号:蓝海资料掘金营,微信deep3321