ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:一份面向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; ret(9 字节,等价于绝对跳转)、hotpatch 风格的mov edi, edi两字节占位加 5 字节跳转。这些在 64 位下要么失效,要么需要改写。所以如果你手上有一份跑了很多年的 32 位 hook 代码,想直接编译成 64 位版本,基本等于重写。

2.2 64 位为什么不能照抄:rel32 只有 ±2GB

64 位进程的地址空间是 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 [rip+0],rip+0指向的就是紧随其后的 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传 5,FF 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重算:新偏移 = 原目标 − 新指令地址 − 5
E9 cdjmp rel32同上,但通常直接判定为“不可搬运”,改用别的方式
0F 80~0F 8FJcc rel32同E8,按 6 字节指令长度重算
70~7F、EBJcc rel8/jmp rel8跨度只有 ±127,搬远后必然越界,改成目标反转的短跳 +E9,或整体判失败
ModRMmod=00, r/m=101RIP 相对寻址(64 位)disp32改为相对新位置的偏移
E3 cb/E2 cb/E1 cb/E0 cbjrcxz/loop系列跨度为 8 位相对,几乎无法安全重定位,直接判失败
F2/F3前缀 + 上述带前缀的同一类指令按真实指令长度处理,别漏掉前缀字节

最阴的是 RIP 相对寻址。64 位下mov rax, [rip+0x1234]这种写法在访问全局变量时极其常见,编译器默认就这么生成。这条指令搬到跳板里之后,rip变了,disp32如果不改,读到的就是完全错误的内存。它不会立刻崩溃,而是读到脏数据,然后在几百行之后以一个莫名其妙的值触发异常。

我的做法是:搬运过程中逐条解码,只要发现是上表里的类型,就进入对应的重定位分支;如果遇到表里最后两行那种“几乎没法安全搬”的,就直接放弃这次 hook,返回失败。能 hook 的函数多得是,没必要在一个搬不动的入口上硬刚。

3.3 入口太短、入口本身是跳板,这两种情况要单独处理

有两种目标的入口很特殊,按常规流程走会出问题。

第一种是入口指令太短就遇到ret或者无条件跳转。比如某些极简的转发函数,编译出来就三四个字节。这时候无论你选 5 字节还是 14 字节跳转,覆盖范围都会超出函数体本身,把后面那个函数的开头也吃掉了。检测方法很简单:在累加长度的过程中,如果遇到C3(ret)、C2(ret imm16)、CC(int3)、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 版修了这个 bug,C++ 版没修”的分裂,这个教训我吃过不止一次。

另外类版在析构函数里做卸载时有个必须注意的点:析构函数不应该抛异常。如果卸载过程中恢复原字节失败,正确做法是记录错误码、把对象标记成“未成功卸载”,而不是在析构里抛。否则在栈展开过程中抛异常,程序直接终止,连日志都留不下来。

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, [rip+disp32]这类访问全局变量的写法,搬到跳板后rip变了,disp32没改,读到的是一片无关内存。它不崩,只是读到脏值。

解决:在重定位函数里加一条硬性规则——凡是解出 ModRM 且mod==0、rm==5的,统一按 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, rsp,8 字节,够用。结果遇到一个入口是push rbp+mov rbp,rsp+sub rsp,0x28的函数,返回 5,把sub切了一半,线上偶发崩溃查了整整两天。从那以后我每次改完长度计算,都强制走一遍“解码总长必须等于下一条指令起始地址”的自检,不管改动多小。这个习惯比任何调试技巧都值钱。资源里 C 接口版和 C++ 类版两套封装已经把这套流程固化成可复用的结构,拿去对照着自己实现一遍,比从头啃要快得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进