ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

64位inline hook原理与实战:从E9相对跳转到x64绝对跳转

64位inline hook原理与实战:从E9相对跳转到x64绝对跳转 64位hook函数这个东西说简单也简单说坑也真坑。32位时代一个5字节E9跳转就能搞定的事到了64位全变了样——地址空间大到离谱指令长度和栈对齐规则也跟着重构网上大部分教程还停留在“照着抄能用”的水平遇到多线程、被hook函数正在执行、跨DLL跳转这些场景一崩一个准。这篇文章我会从原理讲起把64位下inline hook为什么麻烦、手工实现的关键细节、以及实际工程里的选型经验一次说清楚适合对Win32程序设计有一定基础、想真正搞懂hook机制而不是只会抄代码的人。1. 64位下hook函数为什么比32位麻烦这么多1.1 地址空间变大了E9跳转跳不动32位时代做inline hook最经典的套路是在目标函数头部写一条E9 xx xx xx xx也就是jmp相对偏移。这条指令本身只占5个字节跳转范围是当前指令地址加减2GB。32位进程的整个地址空间也就4GB用户态可访问范围通常不超过2GB所以E9指令在绝大多数情况下都够用。这也是为什么32位hook工具遍地开花实现门槛低。64位进程就不一样了。进程的虚拟地址空间是16EB理论值实际可用也就128TB左右系统DLL、应用模块、堆、栈分布在完全不同的地址区域。一个hook DLL的代码地址和目标函数地址之间的距离完全可能超过2GB。E9指令的rel32偏移一旦溢出结果就是跳到一个乱七八糟的地址直接访问违例。更麻烦的是64位下很多编译器生成的指令本身就是变长的函数头部5个字节可能落在一条指令中间你要是一刀切下去覆盖5个字节后面反汇编解析出来的指令流就全乱了。所以64位inline hook的核心思路变了不再依赖相对跳转而是用“寄存器绝对地址跳转”先往一个通用寄存器里装入目标的绝对地址再jmp这个寄存器。最常用的是FF 25这种间接跳转或者48 B8 立即数 FF E0的组合也就是mov rax, imm64; jmp rax。这套组合一共12个字节两倍多于一跳覆盖的函数头区域更宽后续处理就更麻烦。表32位与64位inline hook的关键差异对比项32位64位跳转方式E9 rel32相对跳转mov rax, imm64 jmp rax绝对跳转覆盖长度5字节12字节相对距离限制±2GB通常够用经常超限必须绝对地址原指令恢复难度较低必须处理指令边界复杂栈对齐要求4字节对齐16字节对齐 shadow space1.2 指令边界问题覆盖12字节不只是“多写几个字节”很多人第一次写64位hook会觉得无非就是把覆盖范围从5字节改成12字节前面改成跳转、后面跳板里保存原字节完事。但真正调试过的人都知道最恶心的恰恰是这12字节怎么截。被hook的C/C函数入口通常有固定的prolog序列比如mov [rsp8], rbx; push rbp; push rsi; ...。这些指令大多从3到6字节不等。你为了凑够12个字节很可能把一条指令拦腰截断。跳板trampoline里重放原指令的时候从截断位置开始解释就会得到错误结果然后整个函数行为就乱了。所以动手之前必须先反汇编目标函数确认前12字节落在完整的指令边界上。如果正好截断要么把覆盖长度往上加凑到下一个边界要么换一个偏移量做patch。这也引出一个工程常识hook函数不是“写代码”的问题而是“先读懂目标函数的二进制布局”的问题。我自己的习惯是先用dumpbin或者x64dbg把目标函数头部的反汇编截出来然后对着指令表逐条数长度确定patch边界再写代码。1.3 x64调用约定带来的隐藏约束除了地址空间和指令边界64位Windows下还有一个特别容易翻车的地方栈对齐和shadow space。x64调用约定要求call指令执行前栈指针rsp必须按16字节对齐。hook函数作为被call的对象进入时rsp满足对齐条件但如果你在hook函数里又去call原函数而没有保持同样的对齐关系几次下来栈就歪了程序会毫无征兆地崩溃。更隐蔽的是shadow space。x64约定调用者必须在栈上为被调用函数的四个参数预留32字节空间即使参数少于四个。很多手工hook代码在跳板里原样重放完原指令后直接jmp回目标函数剩余部分这本身没问题但如果跳板里除了重放指令还额外push/pop了寄存器就必须保证原函数后续代码拿到的rsp状态跟它预期的一致。所有hook库在这方面都吃过亏我见过不少“看着没问题一调用就崩溃”的案例最后查出来是栈被弄歪了。2. 动手前必须确认的几个前提条件2.1 确认目标进程确实是64位这个听起来像废话但真的会被忽略。很多人拿一个32位的DLL去hook64位进程里的函数加载都加载不进去更别说patch了。判断一个人家进程是不是64位除了看任务管理器更稳妥的方式是用IsWow64Process2注意这个API是Windows 10 1709之后才有的老系统要用IsWow64Process配合GetNativeSystemInfo来判断。BOOL IsTargetProcess64() { BOOL isWow64 FALSE; if (!IsWow64Process(GetCurrentProcess(), isWow64)) return FALSE; SYSTEM_INFO sysInfo {0}; GetNativeSystemInfo(sysInfo); // 如果当前进程是32位但系统是x64且目标进程不是Wow64则目标是64位 return (sysInfo.wProcessorArchitecture PROCESS_ARCHITECTURE_AMD64 !isWow64); }如果是在自己写的程序里hook自己这个问题不存在。但如果做的是“hook别人进程里的函数”这种工具型DLL第一步永远是把目标进程架构确认清楚搞反了后面全白搭。2.2 用反汇编确认目标函数头部布局前面说过指令边界的问题这步不能省。我常用的方法是先在调试器里对目标函数下断点命中后看反汇编窗口。如果没有调试环境也可以用dumpbin配合导出表或者直接用Capstone引擎写个小工具把目标函数头32字节的反汇编打出来。关键要看前12字节是不是正好覆盖若干条完整指令。举个例子一个典型函数开头可能是mov [rsp8], rbx ; 4字节 push rbp ; 1字节 push rsi ; 1字节 sub rsp, 40h ; 4字节 lea rbp, [rsp20h] ; 4字节连起来前几条共10字节加第四条就14字节。这时候前12字节就会截断sub rsp, 40h这条指令。遇到这种情况要么把覆盖长度调整成14字节要么换一个指令边界靠前的位置做patch。实际工程里确实见过有人为了对齐把覆盖区域扩大到18甚至更多字节这也是允许的但跳板需要重放的指令就更多。2.3 编译选项和安全警告要提前处理64位编译环境下CRT对安全函数的检查比32位更严格这是很多人刚开始接触时最容易踩的坑也是网上搜“c 64位 fopen报安全错误”这类问题的大头原因。Vista/Server 2008之后编译器默认把fopen、strcpy这类函数映射到带_s后缀的安全版本在64位工程里如果你不处理编译直接报错C4996或者链接报错。简单的处理方式是在项目属性里加_CRT_SECURE_NO_WARNINGS宏或者每个文件顶部#define _CRT_SECURE_NO_WARNINGS #include cstdio但我个人的实际建议是既然做64位hook这种底层开发安全函数报警提示你改掉旧API也未必是坏事。hook代码里经常要处理缓冲区、路径拼接用安全版本能省掉不少隐患。只有确认是无害的使用场景时才去禁用警告不要无脑屏蔽。另外x64下Visual Studio不再支持内联汇编所有的钩子跳板逻辑要么写成汇编模块.asm要么用纯机器码字节数组来构造这对很多习惯了__asm的32位老手来说是第一道门槛。提前心里有数别等到写代码才反应过来。2.4 开发环境里的常见排查项我见过不少人在Visual Studio Code里写C项目结果所有函数变量都无法跳转热词里有不少类似的检索。这通常不是代码问题而是IntelliSense没有正确配置。64位环境下尤其常见因为要正确解析Windows SDK和编译器路径。这里给一个快速排查路径打开C_Cpp.default.compilerPath设置填写完整路径到cl.exe并设置compilerArgs包含/std:c17之类的标准参数。还有一个容易忽略的选项是C_Cpp.intelliSenseMode要设置成windows-msvc-x64而不是默认的gcc-x64。改完重启VS Code的C插件索引重建完后跳转就正常了。3. 手工实现64位inline hook12字节方案与跳板构造3.1 整体流程设计手工方案的核心流程可以拆成四步确定目标函数入口地址反汇编确认前12字节的指令边界。分配一块可读可写可执行的内存作为跳板把原函数前12字节拷贝进去再在末尾追加一条跳转指令跳回目标函数地址 12继续执行原逻辑。修改目标函数头部写入mov rax, imm64; jmp raximm64填的是自定义hook函数的地址这样目标函数一被调用实际执行的是你的hook函数。在hook函数里完成你想做的事然后通过跳板调用原函数逻辑完事后还能再收尾。这套思路跟Detours、MiniHook这些库的基本原理完全一致只是它们把细节封装好了我们手工实现能看得更清楚。3.2 关键代码实现下面是一套简化但完整可用的实现重点在于展示12字节patch和跳板构造的核心逻辑#include windows.h #include cstdio // 保存原函数头部字节 BYTE g_originalBytes[12]; BYTE* g_trampoline nullptr; // 分配跳板空间 BOOL SetupTrampoline(void* target, int patchSize) { // 跳板大小 原头部指令 12字节的跳回指令 g_trampoline (BYTE*)VirtualAlloc(nullptr, patchSize 12, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!g_trampoline) return FALSE; // 1. 拷贝原函数头部patchSize字节 memcpy(g_trampoline, target, patchSize); // 2. 在末尾写入 mov r11, targetpatchSize; jmp r11 // mov r11, imm64 - 49 BB 8字节地址 (10字节) // jmp r11 - 41 FF E3 (3字节) BYTE* p g_trampoline patchSize; p[0] 0x49; p[1] 0xBB; *(UINT64*)(p 2) (UINT64)target patchSize; p[10] 0x41; p[11] 0xFF; p[12] 0xE3; return TRUE; } // 修改目标函数头部为 mov rax, hookFunc; jmp rax BOOL InstallHook(void* target, void* hookFunc, int patchSize) { // 先把内存改为可写 DWORD oldProtect; if (!VirtualProtect(target, patchSize, PAGE_EXECUTE_READWRITE, oldProtect)) return FALSE; BYTE patch[12]; // mov rax, imm64 - 48 B8 8字节地址 (10字节) patch[0] 0x48; patch[1] 0xB8; *(UINT64*)(patch 2) (UINT64)hookFunc; // jmp rax - FF E0 (2字节) patch[10] 0xFF; patch[11] 0xE0; memcpy(target, patch, 12); VirtualProtect(target, patchSize, oldProtect, oldProtect); return TRUE; }这段代码里有两个细节要解释清楚。第一个是为什么跳板回跳用r11而不是rax。因为跳板里重放的原始指令很可能已经修改过rax的值如果在末尾继续用jmp rax跳转目标就是错的。r11在x64调用约定里属于volatile寄存器被调函数可以直接使用而不需要保存用它来中转相对安全。但严格来说如果原函数头部的12字节里正好有一条mov r11, xxx我们的回跳也会被破坏。这就是前面强调“必须反汇编确认指令布局”的原因极端情况下要分析重放指令列表选一个没有被破坏的寄存器来做回跳。第二个是VirtualProtect的使用。64位进程里代码段通常是只读的直接写入会触发访问违例。先改成PAGE_EXECUTE_READWRITE写完再恢复原保护属性这是inline hook的标配操作。生产环境里我不建议用READWRITE这种大敞口的权限写完后一定要恢复成PAGE_EXECUTE_READ这种最小权限能显著降低被安全软件扫描或攻击者利用的风险。3.3 真正调用原函数通过跳板hook函数里要调用原函数不能直接调用函数入口因为入口已经被我们改成跳转了。正确姿势是保存跳板地址通过跳板调用// 假设我们要hook的目标函数是 int TargetFunc(int a, int b) // 这里用函数指针指向跳板 typedef int (*OriginalFunc)(int, int); OriginalFunc g_originalFunc (OriginalFunc)g_trampoline; int MyHookFunc(int a, int b) { printf([hook] 调用参数: a%d, b%d\n, a, b); int result g_originalFunc(a, b); printf([hook] 返回值: %d\n, result); return result; }这里有个容易踩的坑跳板地址指向的内存不一定是标准的函数入口跳板里先是原指令副本、再来一条jmp回原函数剩余代码。只要g_trampoline里的字节流整体上等价于“执行完原头部代码再跳回去”用函数指针方式调用是完全合法的。4. 处理“函数正在执行时被patch”的竞态问题4.1 朴素方案的致命缺陷最直观的hook安装方案是先VirtualProtect然后memcpy写入跳转字节。这在单线程或者确定没有并发调用的场景下没问题。但在多线程程序里你正在改写目标函数头部那几十个字节的同时另一个线程可能正好执行到这条指令的中间位置。它读到的可能是“半条指令”——第一个字节改成了0x48后面还是旧内容CPU直接解析成非法指令进程瞬间崩溃。有人会想用SuspendThread把目标线程都暂停改完再恢复。问题是用户态很难可靠枚举进程里所有线程而且目标线程可能正在内核态调用SuspendThread根本挂不住。微软自己的Detours库在这个问题上用了很巧妙的“两阶段提交”策略。4.2 两阶段提交先用int3占位思路是这样的既然同时修改12个字节会引入半个patch窗口那就先制造一个“让所有线程都停住”的屏障。第一阶段先把patch字节里第6到第12字节也就是mov rax立即数的高位部分全部写成一个字节的int30xCC。目标线程如果在这之后进入目标函数它会执行我们改好的跳转指令中的前五字节然后撞上0xCC触发断点异常。这个异常被我们注册的SEH处理器接住处理器让线程进入一个等待状态。第二阶段等所有在目标函数的线程都被“卡”在int3上此时目标函数区域内没有正在执行其他位置的线程了。我们快速把剩余字节第一字节到第五字节以及立即数的低位部分补齐把整个跳转指令变成一条完整的mov rax, imm64; jmp rax。然后通知等待的SEH处理器继续线程恢复执行时看到的是完整指令。这个机制的巧妙之处在于用int3作为“停车标志”把线程同步问题转化为异常处理问题。Detours、MiniHook内部都有类似的逻辑。手工实现这套东西比较繁琐要处理异常栈、线程挂起、超时等一堆边角情况很少有人真的从头写。所以我的建议是理解这个原理但在多线程场景下不要自己造轮子直接用成熟库。4.3 演示代码层面的简化处理如果你只是跑一个Demo验证hook效果可以接受简化处理在安装hook前先确保目标函数此刻不会被执行。一个常见的办法是安装hook前先进入临界区hook完成后再退出并假设在这个短暂窗口内目标函数不会被调用。CRITICAL_SECTION g_hookCs; void InstallHookSafe(void* target, void* hook, int patchSize) { EnterCriticalSection(g_hookCs); InstallHook(target, hook, patchSize); LeaveCriticalSection(g_hookCs); }注意这只能挡住通过同一临界区的调用者挡不住直接调这个函数的外部代码。所以这个方案仅限自用程序或者Demo验证生产环境多线程进程必须用库的实现。在演示场景里为了复现方便我通常还会把原函数包装成构造函数式初始化调用确保hook过程在进入业务逻辑前完成。5. 一个能跑的演示hook MessageBoxW并输出日志5.1 演示目标不引入注入直接在一个控制台程序里hook自己调用的MessageBoxW。目标有两个观察弹窗的标题和内容记录日志然后调用原函数让弹窗正常显示。选择MessageBoxW是因为它是user32导出函数入口地址好查参数结构清晰。先反汇编MessageBoxW头部确认我们可以安全覆盖前12字节。不同Windows版本上user32的代码略有差异但通常入口附近的指令长度组合是适合12字节patch的。如果不确定用x64dbg检查一下或者直接选择hook一个自己写的导出函数可控性更高。这里为了方便我hook的是user32的MessageBoxW。5.2 完整实现代码#include windows.h #include cstdio // 保存原函数头部和跳板 static BYTE s_originalBytes[12]; static BYTE* s_trampoline nullptr; static WNDPROC s_originalFunc nullptr; // 跳板构造逻辑与上一节相同 static BOOL SetupTrampoline(void* target, int patchSize) { s_trampoline (BYTE*)VirtualAlloc(nullptr, patchSize 12, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!s_trampoline) return FALSE; memcpy(s_trampoline, target, patchSize); BYTE* p s_trampoline patchSize; p[0] 0x49; p[1] 0xBB; *(UINT64*)(p 2) (UINT64)target patchSize; p[10] 0x41; p[11] 0xFF; p[12] 0xE3; return TRUE; } static BOOL InstallHook(void* target, void* hookFunc, int patchSize) { DWORD oldProtect; VirtualProtect(target, patchSize, PAGE_EXECUTE_READWRITE, oldProtect); BYTE patch[12] {0}; patch[0] 0x48; patch[1] 0xB8; *(UINT64*)(patch 2) (UINT64)hookFunc; patch[10] 0xFF; patch[11] 0xE0; memcpy(target, patch, 12); VirtualProtect(target, patchSize, oldProtect, oldProtect); return TRUE; } static BOOL UninstallHook(void* target, int patchSize) { DWORD oldProtect; VirtualProtect(target, patchSize, PAGE_EXECUTE_READWRITE, oldProtect); memcpy(target, s_originalBytes, 12); VirtualProtect(target, patchSize, oldProtect, oldProtect); return TRUE; } // 自定义hook函数 static int WINAPI MyMessageBoxW( HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) { wprintf(L[hook] 拦截到弹窗: 标题[%s] 内容[%s]\n, lpCaption ? lpCaption : L(null), lpText ? lpText : L(null))); // 通过跳板调用原函数 typedef int (WINAPI* OrigMessageBoxW)(HWND, LPCWSTR, LPCWSTR, UINT); OrigMessageBoxW orig (OrigMessageBoxW)s_trampoline; return orig(hWnd, lpText, lpCaption, uType); } int main() { HMODULE hUser32 GetModuleHandleW(Luser32.dll); void* target (void*)GetProcAddress(hUser32, MessageBoxW); if (!target) return 1; // 保存原头部字节用于卸载 memcpy(s_originalBytes, target, 12); SetupTrampoline(target, 12); InstallHook(target, (void*)MyMessageBoxW, 12); // 触发一次弹窗 MessageBoxW(nullptr, L这是原始内容, L原始标题, MB_OK); // 卸载hook再弹一次验证恢复 UninstallHook(target, 12); MessageBoxW(nullptr, L这是卸载后的内容, L卸载后标题, MB_OK); return 0; }运行之后控制台会先输出拦截日志再弹出正常弹窗卸载后第二次弹窗不再有拦截日志。这个Demo虽然简单但完整覆盖了“安装-触发-卸载”全流程理解它可以应付大部分常规hook需求。5.3 验证hook生效的小技巧很多人第一次跑通后不知道怎么确认hook真的生效以为日志没打印就是失败。我的经验是先在hook函数里加一个OutputDebugString或者写文件确保输出通道正常工作然后在hook函数里故意不调用原函数只返回一个固定值。如果程序行为跟着变了说明hook肯定生效。把逻辑拆成“先证明hook生效再证明原函数调用正常”排查问题会快很多。6. 工程化经验与踩坑记录6.1 该不该自己手写hook框架上文的示例代码能跑但我不建议在正式项目里直接拿来用。64位inline hook的完整工程化涉及指令解码、指令重定位、多线程同步、异常处理、x64栈回溯等一堆复杂问题。业界成熟的方案是Detours微软官方库功能强大支持x64和ARM64商业项目需要授权个人研究直接用。MiniHook开源基于Detours思路重写支持x64代码量小是我个人比较推荐的。它内部实现了指令长度反汇编和跳板生成不需要你手动确认边界。Mhook另一个开源库也支持x64思路和MiniHook类似。选型上我的建议是产品项目无脑用Detours或MiniHook自己手写框架只出现在“需要深度定制hook行为”或者“学习研究”这两个场景。手写方案的维护成本很高尤其是Windows更新后系统DLL的函数头部布局一变你硬编码的字节偏移可能就失效了。6.2 我在实际项目里踩过的一组坑先说64位工程里最让人头疼的CRT安全警告。热词里不是有“c 64位 fopen报安全错误”嘛这个我遇到过太多次了。最坑的一次是整个工程在32位编译正常切到x64平台后fopen、localtime直接报C4996错误一堆同事在群里问。原因就是x64平台默认开启了SDL检查编译器强制你使用安全版本函数。处理方式前面说过要么定义_CRT_SECURE_NO_WARNINGS要么改写成fopen_s。我后来统一改成了安全版本其实成本没有想象中高。再说一个非常隐蔽的坑x64下VirtualProtect的调用本身没有问题但如果你对一个跨页的函数头部做写保护修改比如目标函数地址恰好跨过一页边界你得同时修改两个页面的保护属性否则第二页访问违例。很多hook库的崩溃都出在这里手工实现时一定要检查目标地址附近的内存页边界。还有安全软件的问题。64位hook要往代码段写数据这在杀毒软件眼里是非常敏感的行为。我遇到过在测试机上一切正常部署到客户环境后被安全软件拦截的情况。解决思路有两种一是hook代码所在模块不要有可疑的签名问题尽量用正规签名二是尽量用PAGE_EXECUTE_READ而不是PAGE_EXECUTE_READWRITE降低被扫描的嫌疑。6.3 指令重定位和L2缓存那点事64位下跳板里重放的指令如果包含rip相对寻址比如lea rax, [rip0x1234]你原样搬到跳板后这段指令的相对偏移就变了结果是从跳板里的新位置计算而不是从原函数位置计算。这就是指令重定位问题。成熟的hook库会反汇编指令、识别出rip相对指令、修正偏移量再放到跳板里。手工方案如果遇到这种情况最稳妥的办法是References里的VMTHook或者ImportAddressHook尽量避开inline hook需要指令重定位的场合。还有个小坑是关于CPU指令缓存的。现代处理器有指令缓存你虽然改了内存里的字节但CPU可能还在执行缓存里的旧指令。通常来说VirtualProtect切换内存保护属性的一次系统性开销量级足够让流水线刷新外加我们紧接着会调用目标函数大都会触发正确的取指。但极端密集调用场景下可以考虑显式调用FlushInstructionCache图个安心成本也不高。6.4 合法使用的边界要清楚hook函数本身是个调试和扩展手段合法场景非常多API调用追踪、竞品功能逆向分析、兼容性修复、测试桩注入等。但必须明确一点用hook技术绕过授权验证、篡改数据、实现外挂逻辑都是越界行为轻则被封号重则吃官司。我在处理这类需求时有个习惯凡是hook来hook去的方案先问一句“这个函数是不是我该碰的”。如果是自己的程序、自己的代码随便hook如果是第三方程序且涉及授权、计费、账号体系立刻打住。安全边界问题不是技术问题是底线问题。整个行业对hook技术的态度越来越严格做事之前想清楚省得后面麻烦。最后再说一个个人体会64位hook函数看着复杂核心其实就是三个点——绝对地址跳转、指令边界处理、多线程同步。把这三件事吃透不管是用现成库还是自己写心里都有底。如果你正在开发类似的项目建议先从MiniHook的源码入手读一遍它怎么处理指令重定位和线程安全的比从零开始写强太多了。
返回列表