资讯动态

64位 InlineHook 稳定关键:指令搬运与重定位避坑

发布时间:2026/10/24 6:55:20 来源:尧图企业网站定制
简介一份面向Windows底层开发与安全研究学习者的Inline Hook示例合集聚焦32位与64位环境下用C语言及C类封装实现函数入口劫持适合具备C/C基础、想理解代码注入与调试监控机制的开发者对系统监控、调试分析与性能观测等应用场景也有参考价值。压缩包共6个文件约6KB以2个.c、2个.cpp和2个.h组成C源码分别覆盖x86与x64实现C源码给出类封装版本头文件提供接口声明便于对照阅读、编译与验证。已有1635人学习。内容围绕目标函数入口指令替换、原指令保存与恢复、EIP/RIP相关处理以及64位重定位等关键点展开C类部分将安装与卸载逻辑收敛为可复用接口相比零散代码片段合集把32位与64位、C与C两条路线放在同一包内便于横向比对入口点定位、指令长度判断与跳转构造差异并进一步理解Hook稳定性、兼容性与安全边界。1. 结论先放这64 位 InlineHook 翻车多半死在“搬指令”上先说一个不太符合直觉的结论64 位 InlineHook 的稳定性九成不取决于跳转指令写得漂不漂亮而取决于入口那几字节的指令有没有被整条完整搬走。我之前给一个 64 位进程做接口拦截单线程回归全绿一上并发压测就随机崩崩溃栈每次都指向被 hook 函数的第一条指令反汇编一看——入口那条 4 字节的指令被我硬生生截掉了 1 个字节后面整段全乱。C语言 写 64位Hook 的人第一次几乎都会在这里吃亏跳转指令本身是死的jmp就那几种但“到底要覆盖多长”“覆盖完的指令搬到哪里”“搬过去之后相对地址还算不算数”这三件事全靠自己判断。这篇不聊概念聊的是 32 位和 64 位 InlineHook 的真实差异在哪、C 接口版和 C Hook类 两种封装各自适合谁、以及哪些坑是必须提前绕开的。已经知道 hook 是什么、但在 64 位下被坑过一轮的人看这篇最划算。2. 32 位与 64 位 InlineHook 的差异5 字节 jmp 为什么在 64 位下不够用2.1 32 位那套写法为什么好写32 位下做 InlineHook主流就一条路在目标函数入口写E9 xx xx xx xx也就是jmp rel32一共 5 个字节。这条指令后面跟的是 4 字节有符号相对偏移取值范围 ±2GB。32 位用户态进程的地址空间总共才 4GB从任意一个函数地址出发±2GB 基本能覆盖到同进程内任何一处我们自己分配的可执行内存所以“跳过去”这件事在 32 位下几乎不需要动脑子。正因为如此32 位实现里很多细节被“惯”坏了。比如很多人直接硬编码“覆盖 5 个字节”写完跳转就把后面剩下的半条指令丢在那不管再比如跳板里直接原样拷贝被覆盖的字节因为E8/E9这类相对跳转在 32 位下重算偏移也就是加减一个地址差。这些写法在 32 位下能跑是因为运气好——大部分函数的入口前几条指令刚好凑够 5 字节而且距离近重定位简单。另外 32 位还有几种补充手法push imm32; ret9 字节等价于绝对跳转、hotpatch 风格的mov edi, edi两字节占位加 5 字节跳转。这些在 64 位下要么失效要么需要改写。所以如果你手上有一份跑了很多年的 32 位 hook 代码想直接编译成 64 位版本基本等于重写。2.2 64 位为什么不能照抄rel32 只有 ±2GB64 位进程的地址空间是 47 位部分平台 48 位有效寻址用户态模块分散在极广的范围内加上地址随机化两个模块之间隔开几个 TB 是常态。这时候E9 rel32的 ±2GB 就完全不够看了如果我们的替代函数和被 hook 的目标函数距离超过 2GB这条跳转写下去就是跳到一片不可访问的内存进程当场结束。所以 64 位下必须换成绝对跳转。常见的有三种做法各有取舍方案字节数覆盖范围对入口的破坏适用场景E9 rel325±2GB最小32 位进程或 64 位下 detour 恰好落在目标 ±2GB 内FF 25 00000000 8 字节地址14全地址空间最大64 位常规做法逻辑最直白48 B8 8 字节 FF E012全地址空间较大64 位想省 2 字节但会占用一个寄存器E9 rel32跳到中继 stub5目标侧全地址空间最小入口指令太短或想尽量少改原函数FF 25 00000000这条值得单独说它的编码是jmp qword ptr [rip0]rip0指向的就是紧随其后的 8 字节所以整体语义等价于“跳到紧跟在我后面的那个 64 位地址”。好处是不占用任何通用寄存器坏处是要吃掉 14 个字节对函数入口的破坏最大。我个人更偏好第四种在目标函数附近±2GB 内申请一小块可执行内存当“中继”目标入口只写 5 字节E9跳到中继中继里再放FF 25绝对跳转。这样目标函数入口只被破坏了 5 个字节需要搬运和重定位的指令最少出问题的面也最小。代价是多一次跳转、多管理一块内存但对稳定性来说这笔买卖很值。2.3 长度反汇编不是可选项是地基不管你选哪种跳转方案都要回答同一个问题我到底要覆盖目标函数入口的多少字节这个数字不能拍脑袋。x86/x64 是变长指令集一条指令短则 1 字节push rbp是55长则 15 字节。举个真实的例子。某个 64 位函数入口是55 push rbp 48 89 E5 mov rbp, rsp 48 83 EC 28 sub rsp, 0x28三条指令合计 8 字节。如果你按“够 5 字节就行”去算写 5 字节E9的时候sub rsp, 0x28这条 4 字节指令只被覆盖了 2 字节剩下 2 字节还在原地。CPU 执行到那里会把EC 28当成另一条指令的编码栈指针直接错位返回地址全乱。崩溃点通常离 hook 点很远排查起来非常痛苦。所以长度反汇编引擎是这套东西的地基没有它后面的重定位、跳板拼装全是空中楼阁。工程上这块有两条路一是自己写表驱动的长度解码器覆盖常见的一字节、二字节、三字节操作码表加上 ModRM/SIB/位移/立即数的规则二是引入成熟的解码库。自己写的优势是完全可控、体积小劣势是前缀组合、VEX 编码这类边角情况容易漏。我一般会先写一份覆盖主流的解码器然后在大量真实函数上跑一遍“解码总长是否等于下一条指令起始地址”的自检把漏掉的情况补上。提示解码器一定要能返回“解码失败”。遇到看不懂的字节就老老实实放弃这次 hook而不是猜一个长度继续写——猜错的下场就是随机崩而且崩在你完全想不到的地方。3. 指令搬运与重定位稳不稳就看这一步3.1 搬多少按整条指令累加够数就停定覆盖长度的规则很简单从目标入口开始一条一条指令累加长度直到总长大于等于跳转指令需要的字节数5 或 14并且必须停在完整指令的边界上。下面这段 C语言 代码是这套逻辑的最小实现/* 计算需要覆盖的字节数从 target 起逐条累加指令长度 * 直到长度 min_len 且停在完整指令边界上。 * 返回 0 表示解码失败调用方必须放弃本次 hook。 */ static size_t calc_patch_len(const void *target, size_t min_len) { size_t total 0; const unsigned char *p (const unsigned char *)target; while (total min_len) { size_t one x86_insn_len(p total); /* 长度解码失败返回 0 */ if (one 0 || one 15) /* 非法长度直接判定失败 */ return 0; total one; } return total; /* 一定是某条指令的结束位置 */ }这里的参数含义需要说清楚target是目标函数入口地址min_len是你选的跳转方案需要的字节数E9传 5FF 25传 14。返回值是“从入口开始、覆盖了完整指令的最小字节数”它可能远大于min_len——比如入口前两条指令是 1 字节加 8 字节min_len传 5返回值就是 9。这个“可能远大于”的特性很关键。如果你的跳板缓冲区按min_len分配就会溢出。缓冲区大小要按最坏情况给我一般给到 32 字节以上。还有一个容易忽略的点total算出来之后如果超过 14 或者 16就说明目标入口有很长的指令覆盖它意味着要搬运大量内容、重定位风险陡增。这时候我更倾向于改用“中继 stub”方案把入口覆盖压到 5 字节把风险降下来。3.2 相对寻址指令的修正清单指令搬走之后凡是“依赖自己所在位置”的指令语义就变了必须重算。这是 64 位 InlineHook 里第二容易翻车的地方而且症状很隐蔽——hook 生效了程序也不崩但逻辑悄悄错了。需要处理的类型我整理成这张表指令/编码含义修正方式E8 cdcall rel32重算新偏移 原目标 − 新指令地址 − 5E9 cdjmp rel32同上但通常直接判定为“不可搬运”改用别的方式0F 80~0F 8FJcc rel32同E8按 6 字节指令长度重算70~7F、EBJcc rel8/jmp rel8跨度只有 ±127搬远后必然越界改成目标反转的短跳 E9或整体判失败ModRMmod00, r/m101RIP 相对寻址64 位disp32改为相对新位置的偏移E3 cb/E2 cb/E1 cb/E0 cbjrcxz/loop系列跨度为 8 位相对几乎无法安全重定位直接判失败F2/F3前缀 上述带前缀的同一类指令按真实指令长度处理别漏掉前缀字节最阴的是 RIP 相对寻址。64 位下mov rax, [rip0x1234]这种写法在访问全局变量时极其常见编译器默认就这么生成。这条指令搬到跳板里之后rip变了disp32如果不改读到的就是完全错误的内存。它不会立刻崩溃而是读到脏数据然后在几百行之后以一个莫名其妙的值触发异常。我的做法是搬运过程中逐条解码只要发现是上表里的类型就进入对应的重定位分支如果遇到表里最后两行那种“几乎没法安全搬”的就直接放弃这次 hook返回失败。能 hook 的函数多得是没必要在一个搬不动的入口上硬刚。3.3 入口太短、入口本身是跳板这两种情况要单独处理有两种目标的入口很特殊按常规流程走会出问题。第一种是入口指令太短就遇到ret或者无条件跳转。比如某些极简的转发函数编译出来就三四个字节。这时候无论你选 5 字节还是 14 字节跳转覆盖范围都会超出函数体本身把后面那个函数的开头也吃掉了。检测方法很简单在累加长度的过程中如果遇到C3ret、C2ret imm16、CCint3、E9/EB无条件跳转且此时总长还没到min_len就说明这个入口不适合直接改得换中继方案或者干脆放弃。第二种更常见目标的入口本身已经是一条跳转。调试版程序里的增量链接跳板jmp [xxx]6 字节、导入表转发、以及被其他 hook 库处理过的函数入口第一条就是跳转。这种情况下你改的是跳板而不是真实函数体一旦那个跳板被复用或者重定位你的 hook 就失效了还可能把别人搞崩。处理方法我一般是这样先顺着入口的跳转往下追追到真正的函数体再动手追的过程中记一个层数超过 3 层就放弃说明这里有循环或者异常结构。追的过程要判断跳转类型——E9 rel32、FF 25绝对间接、48 B8 ... FF E0这几种都要认。4. 跳板与封装C 接口版和 C Hook类 怎么选4.1 可执行内存跳板放哪、谁来管、什么时候释放跳板必须放在可执行内存里。这块内存从哪来直接决定了你要不要处理重定位里的距离问题。如果走“目标入口写 14 字节绝对跳转”的路线跳板可以随便放系统堆、独立映射都行因为FF 25覆盖全地址空间。但如果走“入口写 5 字节E9跳中继”的路线中继就必须落在目标 ±2GB 内否则E9的偏移算不出来。64 位下找一个特定范围内的空闲可执行页需要一点技巧我一般是先在目标地址附近按页粒度做一次探测从目标地址向上向下各扫一段找到第一个可用的空洞再映射。内存属性也要管好。跳板写完之后应该从“可读可写”改成“可读可执行”而不是一直挂着写权限——一直挂着写权限等于给自己留了一个随时可能被写坏的口子。写入目标函数入口那几字节的时候同理先改成可写写完立刻改回去中间窗口越短越好。还有指令缓存刷新。x86 上因为缓存一致性做得比较强很多人不刷也能跑但在多核、跨核调度、以及某些虚拟化环境下不刷就可能出现“某个核心还在执行旧指令”的情况。我一般写完后都刷一次成本极低省下的是玄学级别的偶发崩溃。/* 平台适配层这两组接口在实现文件里按平台各写一份 * os_make_writable / os_make_executable - 修改内存保护属性 * os_flush_icache - 刷新指令缓存 * 写完代码段之后必须调用 flush否则多核下可能出现旧指令残留 */ int os_make_writable(void *addr, size_t len); int os_make_executable(void *addr, size_t len); void os_flush_icache(void *addr, size_t len);把这三个操作单独抽成适配层好处是上层 hook 逻辑完全不用关心平台差异移植的时候只改这一个文件。我强烈建议一开始就这么分层别等到代码写到三千行再重构。4.2 跳板拼装的顺序先搬指令再写跳回跳板的内容由两部分拼成前面是重定位后的原指令后面是一条跳回“目标函数 覆盖长度”的绝对跳转。顺序不能反反了就是死循环。/* 跳板布局 * [0, patch_len) 重定位后的原始指令 * [patch_len, patch_len 14) 跳回 target patch_len 的绝对跳转 * 返回 0 成功-1 失败失败时必须整体回滚不能留下半成品 */ static int build_trampoline(hook_ctx_t *ctx) { unsigned char *p (unsigned char *)ctx-trampoline; /* 1. 逐条搬运并重定位任何一条失败就整体放弃 */ if (relocate_block(ctx-target, ctx-patch_len, ctx-trampoline) ! 0) return -1; /* 2. 追加绝对跳转FF 25 00000000 8 字节目标地址 */ p ctx-patch_len; p[0] 0xFF; p[1] 0x25; *(uint32_t *)(p 2) 0; /* rip 0 */ *(uint64_t *)(p 6) (uint64_t)ctx-target ctx-patch_len; os_flush_icache(ctx-trampoline, ctx-patch_len 14); return 0; }几个参数的含义ctx-trampoline必须至少patch_len 14字节且已经映射成可执行内存ctx-patch_len来自前面calc_patch_len的结果必须落在指令边界上ctx-target ctx-patch_len就是“被我们覆盖掉的那部分指令之后的第一条原始指令”跳回这里才能接着执行原函数剩下的逻辑。32 位版本这里要改一处跳回指令用E9 rel32就够只需要 5 字节偏移按dest - (trampoline patch_len) - 5算。写代码时用一个宏或者条件编译把这段隔开别指望一套字节序列通吃两种位宽。4.3 C 接口版和 C Hook类 的六个维度对比这份资源同时提供了 C语言 接口和 C Hook类 两种封装这不是凑数是因为两类使用场景差别很大。我按自己实际用下来的感受列一下维度C 接口版C 类版调用开销直接函数调用无额外层次类方法多一层间接但通常可被内联抵消多实例管理需要自己维护句柄数组一个对象管一个 hook天然隔离生命周期手动 init / uninit漏了就是泄漏构造装、析构卸RAII 保证不遗漏与其他语言互通导出 C 符号即可被多数语言直接调用需要额外包一层 C 接口异常与错误传播靠返回码调用方必须逐个检查可在构造时抛但析构里不能抛适合场景嵌入式、内核态风格代码、跨语言调用应用层、需要批量管理多个 hook 的工程我自己的用法是底层实现只写一份 C 接口C 类版做成薄薄一层包装不重复实现任何逻辑。这样两种封装的 bug 面是同一份测试成本也减半。反过来如果 C 接口版和 C 类版各写一套实现早晚会出现“C 版修了这个 bugC 版没修”的分裂这个教训我吃过不止一次。另外类版在析构函数里做卸载时有个必须注意的点析构函数不应该抛异常。如果卸载过程中恢复原字节失败正确做法是记录错误码、把对象标记成“未成功卸载”而不是在析构里抛。否则在栈展开过程中抛异常程序直接终止连日志都留不下来。5. 避坑与排查五类最常见的翻车现场5.1 现象hook 装完第一次调用就崩崩溃地址就在目标函数入口几条指令内原因覆盖长度算错把某条指令切断了一半。最常见的触发场景是目标入口前几条指令总长刚过min_len一点点比如 8 字节而跳转只写了 5 字节剩下 3 字节是半条指令。解决把calc_patch_len的返回值打印出来和目标入口的前 32 字节反汇编逐条对照看返回值是不是正好落在某条指令的末尾。如果对不上说明长度解码器有问题。另一个快速自检把patch_len之后的第一个字节喂给解码器如果解不出来基本可以确定切断了。5.2 现象单线程跑几万次都正常一上多线程压测就偶发崩溃概率很低原因写入目标函数入口的那几字节不是原子的。14 字节的绝对跳转要分好几次写如果此时另一个线程正好执行到入口它会读到“一半新一半旧”的字节序列通常是非法指令。解决两个思路。一是把入口覆盖压到 5 字节以内并在写入前用对齐的 8 字节原子写一次性替换需要入口地址对齐通常要靠中继 stub 方案配合二是挂起其他线程再写写完再恢复。我一般优先用中继 stub 5 字节方案实在避不开长跳转时才挂线程因为挂线程本身有代价也容易和别的同步逻辑打架。5.3 现象调试器里挂着跑一切正常脱开调试器直接崩原因调试器会改变内存布局和地址随机化的结果。调试器下目标和 detour 距离可能刚好在 ±2GB 内E9能用脱开调试器布局变了距离超过 2GB跳转飞出去。另一个常见原因是调试器把某些页的写保护放宽了脱离后写入失败但返回值没检查。解决永远不要在调试器里验证“距离够不够”这件事。代码里对E9的偏移做一次显式范围检查超出 ±2GB 就自动降级到中继方案。内存写入的返回值必须逐个检查写失败要回滚并上报不能当没发生。5.4 现象hook 生效了程序也不崩但业务逻辑偶发算错日志里出现完全不合理的数据原因重定位漏了 RIP 相对寻址。被搬运的指令里有mov rax, [ripdisp32]这类访问全局变量的写法搬到跳板后rip变了disp32没改读到的是一片无关内存。它不崩只是读到脏值。解决在重定位函数里加一条硬性规则——凡是解出 ModRM 且mod0、rm5的统一按 RIP 相对处理没有任何例外。验证方法把被 hook 函数在搬运前后的行为做对比测试用一个全局变量做哨兵看读取值是否一致。5.5 现象卸载之后原函数不能用了或者第二次 hook 同一个函数失败原因卸载时恢复的字节不对。要么备份的时候备份的是“已经被改过”的内容重复 hook 时最容易发生要么恢复的长度和写入的长度不一致。还有一种情况是跳板内存被释放了但目标函数还没恢复中间窗口里有人调用了它。解决备份必须在写入之前做且备份长度等于patch_len。卸载顺序固定为先恢复目标函数入口字节并刷指令缓存确认恢复成功后再释放跳板内存。二次 hook 同一个函数时先检查这个地址是否已经在 hook 表里命中就直接返回已有句柄而不是再改一遍。6. 验收怎么证明这份 hook 在 32 位和 64 位下都站得住写完代码只是开始能不能用要看验收。我给自己定的验收流程分四步每一步都有明确的通过标准任何一步不过就不算完成。第一步是长度自检。把待 hook 的目标函数入口前 64 字节喂给解码器逐条打印出“起始偏移 / 指令长度 / 指令字节”人工抽查前 5 条是否合理再看累加出来的patch_len是否落在边界上。这一步能拦住大部分低级错误。第二步是重定位自检。写一个和被 hook 函数签名完全一致的“原函数影子”通过跳板调用它把返回值与被 hook 之前记录的真实返回值逐字节比对。测试数据要覆盖到那几个容易出问题的路径访问全局变量的、有相对调用的、有分支的。这一步过不了说明重定位有漏。第三步是并发自检。开满 CPU 核数的线程每个线程循环调用目标函数一百万次同时主线程在循环中途反复装、卸、再装。跑够十分钟不崩、不出现返回值异常才算通过。这个测试最容易暴露写入非原子和指令缓存的问题。第四步是 32/64 双位宽验证。同一份业务代码分别编译成 32 位和 64 位跑同样的前三步。特别要确认 32 位下跳回指令用的是E9 rel32、64 位下用的是FF 25绝对跳转别因为条件编译写错导致 32 位版本也去写 14 字节。验收项通过标准不通过的典型原因长度自检patch_len落在指令边界解码器漏前缀或 VEX 编码重定位自检影子调用返回值与原函数一致漏 RIP 相对修正并发自检十分钟无崩溃、无异常返回入口写入非原子、未刷指令缓存双位宽验证32/64 位均通过前三项条件编译分支写反回过头看我在这套东西上栽得最狠的一次不是技术难点而是偷懒某个版本的calc_patch_len我只写了“够 5 字节就返回”想着目标函数入口基本都是标准的push rbp; mov rbp, rsp8 字节够用。结果遇到一个入口是push rbpmov rbp,rspsub rsp,0x28的函数返回 5把sub切了一半线上偶发崩溃查了整整两天。从那以后我每次改完长度计算都强制走一遍“解码总长必须等于下一条指令起始地址”的自检不管改动多小。这个习惯比任何调试技巧都值钱。资源里 C 接口版和 C 类版两套封装已经把这套流程固化成可复用的结构拿去对照着自己实现一遍比从头啃要快得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑