简介面向系统编程与安全研究场景的Inline Hook实现包同时覆盖32位与64位环境适合具备C/C和基础汇编能力的开发者。Inline Hook通过替换目标函数入口指令跳转到自定义逻辑无需重写原函数即可注入行为资源针对x86下的EIP修改和x64下的RIP相对寻址、重定位等差异分别提供了示例。压缩包共6个文件含C语言版.c与C类封装版.cpp/.h每个版本均有两套架构实现包体仅6KB结构紧凑便于直接阅读、编译和移植。已有1633人学习下载。使用者能获得可直接运行的Hook演示代码、C类中初始化/安装/卸载钩子的完整接口设计以及处理原指令保存与恢复、跳转地址计算等关键细节的参考思路可用于监控、调试、性能优化或功能扩展等实际项目。1. 完美支持6432位InlineHook同一套 C/C 接口背后的两套跳转工程在 Windows 平台做 64 位 InlineHook最常被问的一句是「为什么照着 32 位的 E9 跳转写放在 x64 下就崩」。标准答案很简单64 位下函数地址之间可能超过 2GB5 字节的jmp rel32不够用换成mov rax, imm64; jmp rax之后真正的坑又藏在被搬走的指令里。这套实现要讲的是同时覆盖 64/32 位、C 语言接口和 C 类封装都齐全的 InlineHook 方案从跳板结构、RIP 相对寻址修复到成员函数钩取的 this 处理最后落到 5 个高频崩溃场景的排查。适合做安全检测、调试辅助、性能插桩和跨进程接口增强的开发者读完你能判断哪些函数能 hook、哪些必须放弃以及排错该从哪里下手。2. InlineHook 底层原理为什么 64 位不能直接抄 32 位的 5 字节跳转2.1 函数入口补丁的指令布局E9 相对跳转和 rax 间接跳转InlineHook 说到底是三件事把目标函数入口处的几条指令替换成一个无条件跳转让执行流先进自定义的 detourdetour 干完自己的事之后要能继续执行原函数剩余的指令为了让原函数剩余部分可访问我们要先把被覆盖的指令原样搬到一块独立内存里并在末尾补一个「跳回原函数剩余位置」的跳转。这块独立内存就叫跳板trampoline它才是保证原函数逻辑完整的核心。很多第一次写 hook 的人只盯着入口跳转根本不管跳板结果 detour 一调用原函数就飞。32 位下最标准的入口跳转是E9 rel32一条固定 5 字节的相对跳转指令。32 位进程的用户态地址空间本身就小于 2GB从目标函数到我们自己的 detour 函数绝大多数时候相对偏移不会溢出所以 5 字节就够用。严格说32 位模块布局下目标函数和 detour 之间超过 2GB 的情况极少但不是零所以完整实现需要先算(int32_t)(dest - src - 5)并检查是否有符号溢出溢出就走绝对跳转分支。这个判断在 64 位版本里是常态在 32 位版本里是兜底。64 位就不一样了模块可能被加载到 0x1_4000_0000detour 在 0x7ff6_xxxx中间距离轻松超过 2GBE9 rel32的 32 位有符号位移覆盖不了。硬写的话其实也不会立刻报错而是运行时跳到一个错误地址经常让排查的人一头雾水。所以 64 位下通用方案是 12 字节的绝对跳转48 B8 imm64mov rax, imm64加FF E0jmp rax共 12 字节。写入后的效果是任何线程走到目标函数入口都先装载 rax 并跳到 detour。因为执行流已经离开目标函数detour 内部怎么折腾 rax 都不会影响原函数语义——这跟「保存现场」的 call hook 不一样InlineHook 本来就不承诺保存寄存器这点在使用前要清楚。2.2 64 位下 RIP 相对寻址跳板搬迁最隐蔽的偏移陷阱如果说跳转指令长度是明坑RIP 相对寻址就是暗坑也是 64 位 InlineHook 翻车率最高的地方。被我们覆盖并搬进跳板的那些指令很多并不「干净」比如lea rax, [rip0x1a2b]、mov rax, [rip0x10]这种。它们的操作数不是绝对地址而是「当前 RIP 一个 32 位位移」位移是编译器在链接期按原入口位置算好的。指令被搬到跳板之后RIP 变了位移没变计算出来的地址自然就指到不相干的内存。举个具体例子原指令在地址 A长度 len指令里的 disp32 是 d那么它想访问的绝对地址是 A len d。搬到跳板地址 B 之后为了让访问目标不变新的位移 d 必须满足 B len d A len d也就是 d d (A - B)。这个修正逻辑并不复杂但前提是你得知道哪条指令是 RIP 相对寻址而判断依据在指令编码的 ModRM 字节里当 mod 不是 11、RM 字段为 101、且没有 SIB 字节时这个 disp32 就是相对 RIP 的。手工解析容易漏我一般直接用现成的指令长度解码引擎来处理。提示跳板离原函数越远RIP 相对修正后的数值就越大越容易在反汇编视图里出现一个夸张的偏移。调试时看到「跳板指令操作数莫名巨大」第一反应就应该是 RIP 相对寻址没修。2.3 跳板指令修复x86 变长指令解码在 hook 里的关键作用还有一个基础问题x86/x64 指令是变长的1 到 15 字节不等。很多粗暴实现直接 memcpy 12 个字节到跳板一旦第 12 字节切在一条指令中间跳板第一步就走到非法指令。所以安装 hook 的第一件事是从目标入口开始逐条解码把指令长度一条条累加直到累计长度不小于跳转指令长度32 位 5 字节、64 位 12 字节而且必须保证最后一条是完整解出来的。这个「逐条解码」动作是整个方案的骨架。我习惯封装一个decode_insn_len函数输入目标地址和一个硬编码最大长度 15输出指令长度。它的实现可以直接用你手上的反汇编引擎也可以只解析 prefix / opcode / ModRM / SIB / disp / imm 的长度表完成后做一轮随机测试。解码出错后续所有修复都是空中楼阁这也是我早期把「完美支持 6432 位」挂在嘴边时被现实狠狠教育过的点。理论的正确顺序是先解码出完整指令集再修 RIP 相对寻址然后拷贝进跳板最后才写入口跳转。3. 用 C 语言实现 32/64 双架构 InlineHook核心代码与参数调优3.1 数据结构设计目标、跳板、原指令快照、恢复计数器先贴一个我常用的 C 接口头文件尽量精简但字段都有存在理由typedef struct _HOOK_CTX { unsigned char* target; // 目标函数入口地址 unsigned char* detour; // detour 函数入口地址 unsigned char* trampoline; // 跳板内存块基址 unsigned char backup[16]; // 被覆盖的原始指令快照 int backup_len; // 快照有效长度x6412x865 int jump_len; // 入口跳转指令长度x6412x865 int installed; // 是否已安装防止重复 install } HOOK_CTX;backup固定 16 字节是因为 x64 绝对跳转最长 12 字节加上解码误差余量16 足够存一份完整快照。trampoline不用数组而是指针是为了能在运行时通过VirtualAlloc选择靠近目标函数的内存同时把可执行权限收在最后一步。installed看起来小事实际上卸载、重装的幂等全靠它。32 位和 64 位在数据布局上没有差异差异只出现在安装函数内部的两个分支。初始化函数很简单memset(ctx, 0, sizeof(HOOK_CTX))即可不需要在头文件里放构造函数。这个 C 接口的设计目标是让 C 层封装时能直接持有HOOK_CTX并且能安全地拷贝对象所以全部字段都是值类型动态内存只有trampoline由安装函数统一管理。3.2 安装钩子指令解析、跳板构造、原子写入与权限恢复安装函数是整个 hook 的核心。我按步骤拆开写每步都有参数和错误返回int hook_install(HOOK_CTX* ctx, void* target, void* detour) { if (!ctx || !target || !detour) return -1; memset(ctx, 0, sizeof(*ctx)); ctx-target (unsigned char*)target; ctx-detour (unsigned char*)detour; // 第一步按需取指令直到覆盖足够长 int pos 0; while (pos HOOK_JUMP_LEN_X64) { // x64 需要 12 字节 int len decode_insn_len(ctx-target pos, 15); if (len 0) return -2; // 解码失败目标入口不可信 ctx-backup[pos] ctx-target[pos]; pos len; } ctx-backup_len pos; ctx-jump_len HOOK_JUMP_LEN_X64; // 第二步分配跳板并把原始指令复制进去 ctx-trampoline (unsigned char*)VirtualAlloc( NULL, 64, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!ctx-trampoline) return -3; memcpy(ctx-trampoline, ctx-target, ctx-backup_len); // 第三步修正跳板内的 RIP 相对寻址 fix_rip_relatives(ctx-trampoline, ctx-target, ctx-backup_len); // 第四步在跳板末尾写入跳回原函数剩余位置的代码 unsigned char* retaddr ctx-target ctx-backup_len; encode_abs_jump(ctx-trampoline ctx-backup_len, retaddr); // 第五步把入口指令替换成跳向 detour 的指令 if (!make_page_writable(ctx-target, ctx-backup_len)) return -4; write_abs_jump(ctx-target, ctx-detour); // 写入 12 字节 flush_code_cache(ctx-target, ctx-backup_len); // 刷新指令缓存 ctx-installed 1; return 0; }逻辑说明与参数说明HOOK_JUMP_LEN_X64在 64 位编译下是 1232 位编译下是 5。用两套宏的话这个函数可以做到同一个源码编译出 32/64 两个版本这也是「6432 位都有」的工程基础。decode_insn_len返回当前指令的字节长度最大 15判断条件len 0很重要目标函数入口可能落在一段坏代码上直接返回可辨识的错误码比后面调试崩溃好一万倍。fix_rip_relatives遍历跳板里的每一条指令识别 RIP 相对寻址并修正 disp32修正公式是new_disp 绝对目标地址 - (新地址 指令长度)绝对目标地址在解码时顺手算出来就行。encode_abs_jump在跳板末尾追加一条 12 字节的绝对跳转让原函数剩余部分可以无缝继续执行。make_page_writable内部调用VirtualProtect把目标页从PAGE_EXECUTE改成PAGE_EXECUTE_READWRITE写完后flush_code_cache调用FlushInstructionCache。这两个细节缺一个都会出现「写失败」或「写成功但执行的是旧指令」的诡异现象。跳转指令的编码我单独抽了个函数32 位和 64 位各一份static void write_jump(void* target, void* dest) { unsigned char* p (unsigned char*)target; #if defined(_M_X64) || defined(__x86_64__) p[0] 0x48; p[1] 0xB8; // mov rax, imm64 *(uint64_t*)(p 2) (uint64_t)dest; p[10] 0xFF; p[11] 0xE0; // jmp rax #else p[0] 0xE9; // jmp rel32 *(int32_t*)(p 1) (int32_t)((uint64_t)dest - (uint64_t)target - 5); #endif }这段代码要提醒一点32 位的(uint64_t)dest - (uint64_t)target - 5必须先算 64 位差值再截断成 int32直接拿两个 32 位指针相减再转 int32 也没问题但显式 64 位计算能避开符号溢出的习惯性怀疑。x64 分支里第 2 到第 9 字节是目标地址第 10、11 字节是jmp rax写完 12 字节后VirtualProtect恢复原权限。读完这段你应该明白为什么「完美支持」的前提是指令解码引擎可靠而不是跳转写得多花哨。3.3 卸载钩子原指令恢复的幂等原则卸载是安装的逆过程但有两个反直觉的细节。第一恢复原指令时必须按backup快照整体写回而不是把入口那几个字节的「现场」交给想象第二跳板不能立刻释放因为可能还有别的线程正停在原函数剩余部分执行。保守做法是先写回快照、刷指令缓存再释放跳板最后置installed 0int hook_uninstall(HOOK_CTX* ctx) { if (!ctx || !ctx-installed) return 0; // 幂等卸载重复调不用报错 make_page_writable(ctx-target, ctx-backup_len); memcpy(ctx-target, ctx-backup, ctx-backup_len); flush_code_cache(ctx-target, ctx-backup_len); if (ctx-trampoline) { VirtualFree(ctx-trampoline, 0, MEM_RELEASE); ctx-trampoline NULL; } ctx-installed 0; return 0; }参数说明make_page_writable只对目标函数所在页做一次VirtualProtect写完立即恢复VirtualFree第三个参数填MEM_RELEASE时第二个参数必须是 0这个在 Windows 上写错会返回错误而跳板不释放属于多年血泪经验之一。卸载后如果马上再次安装直接复用同一个HOOK_CTX因为hook_install开头会memset所以也不会带上旧快照。整体来看C 接口保持纯函数式、显式传ctx是为了让外层封装可以获得最大的生命周期控制权。4. C 类封装与成员函数钩取从 C 接口到「都有」的正确姿势4.1 把 C 接口包成 RAII 类构造安装、析构卸载C 接口已经很干净但 C 项目里直接操作HOOK_CTX仍然容易忘卸载。常见做法是包一个 RAII 类构造时安装析构时卸载配合栈对象让生命周期自己管理class InlineHook { public: InlineHook() default; InlineHook(void* target, void* detour) { install(target, detour); } ~InlineHook() { uninstall(); } bool install(void* target, void* detour) { return hook_install(ctx_, target, detour) 0; } void uninstall() { hook_uninstall(ctx_); } template typename T T origin() const { return reinterpret_castT(ctx_.trampoline); } InlineHook(const InlineHook) delete; InlineHook operator(const InlineHook) delete; private: HOOK_CTX ctx_{}; };要点说明这个类禁用拷贝是为了避免两份对象持有同一个trampoline指针析构时重复释放。originT()返回跳板地址类型由调用方指定这样 detour 里调用原逻辑时不需要维护全局变量。还有一点析构里做uninstall时如果目标模块已经被卸载hook_uninstall里的VirtualProtect会碰已释放的内存这是框架层面要防守的问题方案是安装前记录模块加载状态、或者让 hook 生命周期短于模块生命周期。写产品代码时这个类的构造参数我建议只接受(target, detour)禁止默认构造后裸奔这样每个对象从出生起就处于明确状态。4.2 成员函数取地址为什么翻车thiscall 与真实入口的偏差很多人第一次用 InlineHook 钩成员函数会自然写出hook-install(SomeClass::method, my_detour)。这条代码在 C 里大概率编译不出来即使强转编译过了运行也几乎必崩。原因有两个层面一是成员函数指针不是普通函数指针它内部可能带一个 this 调整偏移虚函数还可能是一层间接二是成员函数被常见编译器编译后的调用约定通常是 thiscall在 32 位下 this 通过栈传递64 位下 this 固定放到 rcx而 C 接口的 detour 位置完全没有 this 的语义。所以我的原则是C 封装层永远不直接接受成员函数地址。你要钩一个类的方法先写一个静态成员函数或者全局函数当 detour这个函数的第一个参数显式接 this 指针后面的参数与目标方法保持一致。这样在 64 位 Windows 上rcx 里的 this 恰好落在第一个参数位32 位下 this 也在栈顶两边都不用额外处理。这个「第一个参数永远留给 this」的约定整套方案用下来最不容易出错团队成员读了代码也一眼能懂。4.3 静态壳加实例上下文在 detour 里还原 this 的通用方案实际项目里detour 往往需要访问对象的私有状态。常见做法是给目标类写一个静态壳函数壳里把 this 解出来再调用对象的真正处理逻辑同时把原方法调用替换为走跳板class Counter { public: int add(int delta); static int add_thunk(Counter* self, int delta); private: int value_ 0; }; static InlineHook g_add_hook; int Counter::add_thunk(Counter* self, int delta) { // 走跳板调用原实现避免再次进入 hook using OrigFn int (Counter::*)(int); auto orig g_add_hook.originOrigFn(); return (self-*orig)(delta); }这段代码的说明add_thunk的签名与add在 ABI 层面完全一致只是把 this 显式放第一位。originOrigFn()把跳板还原回成员函数指针类型调用语法(self-*orig)(delta)会和调用原方法一样完成 this 调整不会递归。这个模式早期也有人直接在 thunk 里写成self-add(delta)那就递归了必须用跳板。C 的封装价值就在这里C 接口解决的是指令层面的搬移C 类解决的是类型和调用约定的组织两者配合才称得上「C 语言、C 类 都有」。5. InlineHook 避坑指南64 位下 5 个高频崩溃场景与排查路径5.1 症状跳板里执行原指令瞬间崩溃现象hook 安装成功后第一次调用目标函数就触发访问冲突调用栈最后几帧停在跳板地址反汇编窗口里跳板指令看起来一切正常。原因跳板中被搬移的指令含 RIP 相对寻址fix_rip_relatives没有执行或者执行了但修错了方向。常见于lea、mov reg, [ripdisp]这类访存指令。解决检查跳板指令的 modrm 编码确认 RIP 相对指令的 disp32 被正确替换。调试时最快的验证方式是「对比法」把目标函数入口复制到跳板位置在调试器里直接执行崩溃点与 fix 前后差异一目了然。修完公式之后给这个模块加一组随机指令样本做单元测试我现在每次改编码器都这么跑一次能揪出一批边界问题。5.2 症状安装成功但调用直接崩目标入口不足 12 字节现象hook_install返回 0调用目标函数时立即执行非法指令或跳飞反汇编发现跳板里出现「半个指令」。原因目标函数入口总长度不足 12 字节或者虽然长度够但第 12 字节刚好把一条指令截断了。常见于短小的转发函数、函数末尾直接jmp另一处代码的 tail-call 型函数。解决安装前必须用指令长度解码器逐条累加不允许「先拷贝 12 字节再说」。遇到长度不足的入口直接返回错误码或者改用 hotpatch 方案在函数入口前面的 nop 区写入跳转入口本身只保留 5 字节的jmp rel32短跳。硬 hook 短函数是 64 位 InlineHook 最经典的翻车现场没有之一。5.3 症状多线程下时好时坏功能偶发失效现象单线程跑 1000 次全对开 8 个线程后偶发崩溃或旧逻辑继续执行但崩溃点不固定。原因12 字节的入口跳转是分多次写进内存的另一个线程可能正好在指令写到一半时进入目标函数抓到半条指令。memcpy不是原子的。解决把「构造完整 12 字节指令」和「写入目标」分成两步用锁或者启用写入期间的线程暂停不太现实更实际的方案是选择流量可控的时机安装模块加载早期、业务尚未并发时或者使用 x64 下常见的 8 字节原子写 剩余 4 字节补齐并接受一个极小的窗口对安全产品而言更稳妥的是入口改用 5 字节短跳并把目标模块限制在 ±2GB 范围内短跳天然原子性更好。写完后立刻FlushInstructionCache否则指令缓存可能让旧代码继续执行。这块属于典型的「单测过了、压测翻车」排查时第一件事是确认崩溃时其他线程正在跑什么。5.4 症状detour 里调用「原函数」变成无限递归现象程序卡死或栈溢出调用栈里 detour 反复出现层数呈指数增长。原因detour 内部直接调用了target()或者self-method()而这两个入口都已经被 patch调用结果等于再进一次 detour。这个坑在 C 封装里尤其明显因为大家习惯直接调成员函数。解决detour 内一律通过origin()拿跳板再调用。可以在类里给 detour 专设一个using别名避免误写。如果递归已经发生先暂停所有线程再用调试器的「restart」重打一遍现场经验是检测到函数调用栈深度增长就立刻中断省得等爆栈。这个坑的诡异之处在于它不一定崩而是表现为「卡死」容易被误判成死锁。5.5 症状目标函数入口被别人 hook 过卸载后一塌糊涂现象自己的 hook 安装成功但过一会儿程序异常或者卸载后目标函数直接变成野代码反汇编看入口还残留着上一家的跳转指令。原因目标函数可能在集成环境中已经被别的组件 hook 过。备份时拿到的「原始指令」其实只是上一家写下的跳转自己的跳板里搬着一堆跳转指令卸载时再把跳转恢复回去等于把原函数入口彻底破坏。解决安装前先解码入口判断「是否属于正常函数起始」——如果第一条指令就是一个jmp或call且目标不在本函数范围就拒绝 hook或者在前一家的跳板上继续链式 hook。多数单机工具不需要后者直接报错最安全。给这个检查单独留一个错误码方便外面提示用户。这条是集成项目里最容易触发的暗坑两个框架装到同一个进程时入口被反复改写是常态。6. 用 demo 验证 hook 生效三种证明方式与最后一道检查写一个最小 demo目标函数选一个不会被内联且签名简单的add__declspec(noinline) int64_t add(int64_t a, int64_t b) { return a b; } int64_t add_detour(int64_t a, int64_t b) { printf(hooked: %lld %lld\n, a, b); return a * b; } void demo() { InlineHook hook; hook.install(add, add_detour); printf(result %lld\n, add(3, 4)); // 输出 hooked 行result 12 }验证分三步。第一步看返回值正常add返回 7hook 后返回 12说明跳转路径生效。第二步在调试器里看内存目标函数入口的前十几个字节应该能看到48 B8加上目标地址再加FF E0这能证明字节替换没问题。第三步是恢复测试调用hook.uninstall()后再次执行add(3, 4)结果回到 7说明备份与恢复闭环成立。最后一道检查是「边界体检」在交付前把要 hook 的函数过一遍指令长度、入口类型、是否在排除名单里。我早年做 64 位版本时只追求「装上不崩」结果在 RIP 相对寻址上整体翻车靠调试器追了两天才意识到跳板里的指令被搬走之后语义已经变了。从那以后我把「先解码、再修复、后写入」当作固定工序任何一步缺失都直接算实现事故。这套方案硬 hook 常规函数足够稳但短函数、尾调用函数和已经被别的组件占用的入口该放弃就放弃这比硬撑着「完美支持」四个字踏实得多。希望帮到你。本文还有配套的精品资源点击获取