简介:这是一份围绕64位与32位Inline Hook的源码学习包,面向已具备一定Windows底层编程和汇编基础、希望理解函数入口指令改写机制的开发者,可用于系统监控、调试与性能分析等场景的实验参考。压缩包共6个文件,包含2个c、2个cpp与2个头文件,分别提供x86/x64的C语言实现和C++类封装,包体约6KB,体量轻便,便于逐行阅读与对照。已有1635人学习,说明其作为Inline Hook实现参考具有一定认可度。读者可获得32位与64位下的钩子安装、卸载及原指令保存恢复思路,并通过C++类接口理解目标地址与自定义函数的管理方式;64位部分还涉及RIP相对寻址、指令重定位等细节,有助于建立从原理到代码落地的完整认知。
1. 32/64 位 InlineHook 不是改几个字节那么轻松
前段时间要把一套老系统的调用链监听起来,源码不在手上,最省事的办法就是在函数入口改几个字节——这就是 InlineHook。这份源码包提供的是一套完整实现:同时支持 32 位和 64 位进程的 Hook 安装与卸载,对外提供 C 语言函数接口和 C++ 类封装两套调用方式,附带长度反汇编、指令重定位、内存页属性切换这些底层配套,还有 C 和 C++ 两个可编译运行的示例工程。适合两类人:一类是要在运行时拦截、日志、打点、复写第三方库行为,又不想改源码的工程岗;另一类是正在啃 x86/x64 指令编码,想找一个能编译、能断点、能对着调试器逐条看跳转的实物参考。真正上手后会发现,32 位下 5 个字节就够,64 位下光是选哪种跳转编码就能卡住半天,内存地址够不着、被覆盖指令跨边界这些坑,都是亲身踩出来的。
2. 工程骨架与构建:一份在 x86 和 x64 下都能编过的目录结构
这份源码能直接复现,很大程度得益于目录划分:平台相关的代码被拆成独立文件,公共流程抽成一份,示例工程和核心库分离。比起把所有#ifdef _WIN64写在同一个 .c 文件里,这种拆法在调试时有明显优势:32 位崩溃只看hook_x86.c,64 位只看hook_x64.c,不用在几百行预处理分支之间来回跳。
2.1 目录分层:公共头、平台实现、反汇编器、示例与测试
解压后一般能看到这样的结构:
inline-hook/ ├── include/ │ ├── hook.h # C 语言对外接口,全部 extern "C" │ ├── hook.hpp # C++ 类封装,头文件即用 │ └── hook_types.h # 状态码、结构体、跨平台类型定义 ├── src/ │ ├── hook_core.c # 公共流程:校验、跳板分配、安装、卸载 │ ├── hook_x86.c # 32 位补丁生成与 rel32 偏移计算 │ ├── hook_x64.c # 64 位绝对跳转、RIP 相对寻址修正 │ ├── hook_mem.c # 内存页属性、指令缓存刷新、可执行内存分配 │ ├── hook_thread.c # 改写窗口内的线程挂起与恢复 │ └── ldisasm.c # 精简版长度反汇编器 ├── samples/ │ ├── sample_c/ # C 接口示例:hook 一个加法函数 │ └── sample_cpp/ # C++ 类示例:hook 一个成员函数 ├── tests/ # 回归用例:短函数、跨页函数、已有跳转的函数 └── CMakeLists.txthook_core.c里不放任何跟位宽相关的字节编码,它只做三件事:拿目标函数的入口地址、调长度反汇编确定要覆盖多少字节、调hook_alloc_trampoline申请跳板。真正产生机器码的动作全部下沉到hook_x86.c/hook_x64.c。这种分层带来的直接好处是:想把它移植到别的指令集,只需要重写这两个文件,上层 API 一个字节都不用动。
ldisasm.c是很多人容易忽略的一块。判断“入口处到底有几条指令、一共多少字节”不能靠数汇编行数,必须按指令编码规则逐字节解析:看前缀(0x66、0x67、0xF2、0xF3、段前缀0x2E~0x3E)、看 REX 前缀(0x40~0x4F,仅 64 位)、看 opcode 是一字节还是0x0F两字节、再看 ModRM/SIB 是否带 disp、是否带 imm。这份实现覆盖面不算全,但 x86/x64 的常见指令——push、mov、sub、test、jmp、jcc、call、lea——都能正确算长度,对 Hook 场景已经够用。
samples/下面两个工程的存在意义不只是演示,它们是最好的验证环境:先跑通 C 版本的加法函数 Hook,确认跳转链没问题,再去跑 C++ 版本的成员函数 Hook,能快速判断问题出在底层改写还是接口封装。
2.2 编译配置:几个开关会直接决定 Hook 能否稳定生效
用 CMake 生成工程时,最容易忽略的是源码文件的选择和几个优化开关。核心库里通常这么写:
# CMakeLists.txt 关键片段 cmake_minimum_required(VERSION 3.15) project(inline_hook C CXX) add_library(hook STATIC src/hook_core.c src/hook_mem.c src/hook_thread.c src/ldisasm.c ) # 按目标位宽选平台实现,而不是靠 #ifdef 全塞进一个文件 if(CMAKE_SIZEOF_VOID_P EQUAL 8) target_sources(hook PRIVATE src/hook_x64.c) target_compile_definitions(hook PUBLIC HOOK_ARCH_X64=1) else() target_sources(hook PRIVATE src/hook_x86.c) target_compile_definitions(hook PUBLIC HOOK_ARCH_X86=1) endif() # 静态链接运行库:被注入到别人进程时不用额外带 CRT set_property(TARGET hook PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded")第一处选择用CMAKE_SIZEOF_VOID_P判断,而不是自己加一个-DHOOK_64开关——指针大小是按目标平台自动算出来的,交叉编译也不会判断错。第二处是运行库:如果你的库最终要被塞进别人的进程,动态 CRT 会在加载时找不到对应版本的运行时,静态链接能省掉一整类“为什么注入后立刻退出”的问题。
还有两个编译细节值得在工程里显式处理。一是关闭“函数级链接”和全局优化对目标函数的干扰:/Gy会让每个函数独立成 COMDAT 段,/GL加链接期代码生成会把小函数直接内联掉,结果就是你在代码里写的&target_func拿到的地址根本不是你预期的那个函数体开头。二是在示例工程里对目标函数加__declspec(noinline),保证它确实是一个独立函数,能被稳定寻址。
2.3 对外接口约定:三个函数加一个枚举就能覆盖全部调用场景
C 接口刻意做得很少,一个 Hook 的生命周期就是“安装、拿原始函数指针、卸载”三步:
/* include/hook.h —— 对外只暴露这几样,全部 extern "C" */ #ifndef HOOK_H #define HOOK_H #include <stddef.h> #ifdef __cplusplus extern "C" { #endif typedef enum { HOOK_OK = 0, HOOK_ERR_NULL_ARG, /* 目标地址或代理函数为空 */ HOOK_ERR_PROLOGUE, /* 函数头字节数不足以容纳跳转 */ HOOK_ERR_ALLOC, /* 跳板内存申请失败 */ HOOK_ERR_PROTECT, /* 代码页属性修改失败 */ HOOK_ERR_EXISTS /* 该地址已被本模块挂过钩子 */ } hook_status; typedef struct hook_ctx hook_ctx; /* 不透明句柄,内部结构不对外 */ /* 安装钩子:target 为被 Hook 函数入口,detour 为你的代理函数 */ hook_status hook_attach(void* target, void* detour, hook_ctx** out); /* 取跳板地址,调用它等价于调用原函数 */ void* hook_trampoline(const hook_ctx* ctx); /* 卸载钩子,恢复原始字节 */ hook_status hook_detach(hook_ctx* ctx); const char* hook_strerror(hook_status st); #ifdef __cplusplus } #endif #endif /* HOOK_H */结构体hook_ctx只声明不定义,是刻意的:调用方拿到的是不透明指针,内部字段——目标地址、补丁原始字节备份、跳板地址、覆盖长度——全部藏在.c文件里。这样后续往里加链式 Hook 支持、加引用计数、加线程同步对象,都不影响已经编译好的调用方。
hook_status的粒度也值得注意,它把最容易出问题的四类失败单独拆开了:HOOK_ERR_PROLOGUE对应函数太短或被编译器优化得只剩一两条指令;HOOK_ERR_ALLOC对应跳板地址分配不出来;HOOK_ERR_PROTECT对应目标所在的页不可写;HOOK_ERR_EXISTS对应重复挂载。新手最常犯的错是只看返回值非零就去查代码,而实际上错误码已经清楚指出是哪一步失败。
hook_trampoline返回void*而不是强类型函数指针,是为了让调用方自己决定签名:
typedef int (*add_fn)(int, int); static add_fn g_orig_add = NULL; static int hooked_add(int a, int b) { /* 在调用原函数前后插入自己的逻辑 */ return g_orig_add(a, b) + 1000; } /* 安装完立刻取原始函数,二者必须成对出现 */ hook_ctx* ctx = NULL; if (hook_attach((void*)&target_add, (void*)&hooked_add, &ctx) == HOOK_OK) { g_orig_add = (add_fn)hook_trampoline(ctx); }参数说明上两个地方要留心:target传函数地址时必须强转void*,成员函数要先算出真实入口(后面章节会讲为什么取&Class::method在部分编译器下拿不到可执行地址);detour的签名必须与目标函数完全一致,包括调用约定——32 位下__stdcall和__cdecl混用,栈会在返回时直接失衡,表现为函数返回后栈指针错乱、进程秒崩。
3. 指令级改写:5 字节相对跳转与 14 字节绝对跳转怎么选
把控制流从 A 函数拽到 B 函数,本质是在 A 的入口写入一条跳转指令。32 位下这件事很优雅:E9加 4 字节相对偏移,一共 5 个字节,跳到 ±2GB 范围内任意地址。64 位下同样的思路直接失效——相对偏移仍然只有 32 位,而用户态地址空间宽到 48 位,你的代理函数大概率落在 2GB 之外,偏移溢出。这一章把两种位宽的补丁生成和被覆盖指令的搬迁讲透。
3.1 32 位:E9 相对跳转的偏移计算与 5 字节硬约束
x86 下的补丁就是一行jmp rel32,偏移是相对于“下一条指令”的地址算的:
/* src/hook_x86.c —— 生成 5 字节相对跳转补丁 */ #define X86_JMP_SIZE 5 int hook_x86_build_patch(void* target, void* detour, unsigned char* out_patch, size_t* out_len) { /* 相对偏移 = 目标地址 - (当前指令地址 + 指令长度) */ int64_t delta = (int64_t)((unsigned char*)detour - ((unsigned char*)target + X86_JMP_SIZE)); /* 32 位进程地址空间只有 4GB,理论上不会溢出; 但做一次显式判断,避免 64 位宿主下误用此路径 */ if (delta < INT32_MIN || delta > INT32_MAX) { return HOOK_ERR_PROLOGUE; } out_patch[0] = 0xE9; /* jmp rel32 */ *(int32_t*)(out_patch + 1) = (int32_t)delta; *out_len = X86_JMP_SIZE; return HOOK_OK; }delta用int64_t算完再收窄成int32_t,是为了在调试版本里能一眼看出溢出方向——直接写int32_t delta = ...一旦溢出就是未定义行为,编译器还会因为你“检查自己刚写的值”而删掉判断。
这段代码背后有一个硬约束:目标函数前 5 个字节必须能完整覆盖。听着简单,实际很麻烦——编译器生成的热点函数常见开头是push ebp; mov ebp, esp; sub esp, 0x20,三条指令刚好 5 字节,干净利落;但如果是优化过的叶子函数,开头可能是55 8B EC 83 EC 20之外的东西,或者只有C3(ret)一个字节。所以hook_attach在生成补丁之前必须先跑长度反汇编,把入口处的指令一条一条累加,直到总长度 ≥ 5 且不切断任何一条指令,这个累加长度才是真正的覆盖长度patch_len,而不是固定 5。
覆盖长度超过 5 时,剩下的字节用0x90(NOP)填充,保证跳转指令之后到patch_len之间不会残留半条旧指令被执行到。
3.2 64 位:FF 25 间接跳转、mov rax 组合与跳板内存布局
64 位有两条常见路线。第一条是“寄存器中转”:
48 B8 <imm64> mov rax, 目标地址 FF E0 jmp rax一共 12 字节。问题在于它动了rax。在 x64 调用约定里rax确实是易失寄存器,但函数入口处这个寄存器不一定干净——最典型的是可变参数函数,调用方会用al传入实际使用的向量寄存器个数,你在入口把rax覆盖掉,函数内部的va_start就会读到垃圾值,表现为printf类的输出错乱但不崩。这种 bug 极难查,所以我在能选的情况下不用这条路线。
第二条路线用jmp [rip+0]加一段紧跟着的 8 字节地址:
FF 25 00 00 00 00 jmp qword ptr [rip+0] <8 字节目标地址> ; 紧跟在指令之后,被 rip 直接引用总共 14 字节,不动任何寄存器,也不依赖地址距离。代价是覆盖长度比 32 位多出 9 个字节,对函数头的要求更高——这也是 64 位 Hook 失败率明显高于 32 位的原因,HOOK_ERR_PROLOGUE这个错误码在 64 位上出现的概率高一个数量级。
/* src/hook_x64.c —— 生成 14 字节绝对间接跳转补丁 */ #define X64_JMP_SIZE 14 void hook_x64_build_patch(void* detour, unsigned char* out_patch) { /* FF 25 disp32:jmp qword ptr [rip + disp32] */ out_patch[0] = 0xFF; out_patch[1] = 0x25; *(int32_t*)(out_patch + 2) = 0; /* disp32 = 0,即紧跟其后的 8 字节 */ /* 紧跟在指令后面的 8 字节绝对地址,小端存储 */ *(uint64_t*)(out_patch + 6) = (uint64_t)detour; }disp32填 0 是这条编码的关键:下一条指令的地址就是地址常量本身,[rip+0]正好指向它。这 14 个字节必须一次性写入目标函数入口,中间不能被其他线程看到半成品,后面第 4 章会专门讲这个写入窗口怎么处理。
跳板(trampoline)的布局是另一处门槛。跳板的职责是“承接被覆盖的原始指令,然后跳回原函数target + patch_len继续执行”。它由两段组成:前半段是搬迁过来的原指令副本,后半段是一条返回跳转。返回跳转优先用E9 rel32(5 字节),前提是跳板内存地址与目标函数相距在 ±2GB 内——所以申请跳板内存时通常会用VirtualAlloc带上一个靠近目标函数地址的提示值,让系统尽量就近分配。拿不到就近地址时就退化成 14 字节绝对跳转,功能上没区别,只是跳板开销大一点。
3.3 被覆盖指令的搬迁:长度反汇编之后还要做相对位移修正
把原指令按字节memcpy到跳板里,十有八九会崩。原因很简单:x86/x64 里大量指令带相对偏移,指令换了位置,原来的偏移量就指向了别处。需要修正的一共四类。
第一类是E8(call rel32)和E9(jmp rel32),指令长 5 字节,偏移字段在末尾 4 字节,修正公式是“用旧指令算出真实目标地址,再用新位置反算偏移”。第二类是0F 80~0F 8F(jcc rel32),长 6 字节,逻辑与第一类相同。第三类是70~7F(jcc rel8),只有 1 字节偏移,目标常常落在被覆盖区域之外,直接搬过去必错,常见做法是把它展开成0F 8x rel32的 6 字节形式并重新计算偏移。第四类是 RIP 相对寻址,形如mod=00, rm=101的 ModRM 后面跟 4 字节disp32,真实目标 = 原指令下一条地址 +disp32,搬迁后要用新的下一条地址重算。忽略这一类最容易被忽视,因为它在 C 代码里看不出来,只有编译器把全局变量mov成lea rax, [rip+x]的时候才会出现。
/* 搬迁过程中的单条修正:以 E8 call rel32 为例 */ static void relocate_rel32(unsigned char* dst, const unsigned char* src, size_t written, uintptr_t src_base) { /* 原指令执行时的目标 = 源地址 + 指令内偏移处的下一条地址 + 偏移 */ int32_t old_rel = *(const int32_t*)(src + 1); uintptr_t old_next = src_base + written + 5; uintptr_t abs_target = old_next + old_rel; /* 新位置下的下一条指令地址 */ uintptr_t new_next = (uintptr_t)(dst + written + 5); /* 反算新偏移并写回;跨出 ±2GB 时说明跳板分配得太远 */ *(int32_t*)(dst + written + 1) = (int32_t)((int64_t)abs_target - (int64_t)new_next); }src_base传的是“被覆盖区域在原始函数里的起始地址”,也就是target,written是当前已经搬完的字节数,两者相加才是这条指令在原始上下文里的位置。少了这个基准,算出来的abs_target会整体偏移patch_len字节,跳板跳回原函数后执行的第一条指令就是错的,现象是“Hook 装上后程序还能跑,但结果莫名其妙不对”,比直接崩溃更难定位。
修完之后,跳板尾部补一条跳回原函数的指令,把“覆盖长度之后的第一条原始指令地址”作为目标。至此一次完整的 32/64 位 Hook 链路才算闭环:入口被改写到代理函数,代理函数调用跳板,跳板执行原始指令并返回原函数继续。
4. 内存页、线程与两套接口:把补丁安全地落下去
机器码生成对了,只是完成了一半。代码段默认是不可写的,写之前要改页属性,改完要刷指令缓存;写入的这十几个字节在别的线程眼里可能是一段半成品指令;C 和 C++ 两套接口还要各自管好生命周期。这一章讲的都是“机器码没问题但程序照样崩”的那部分。
4.1 VirtualProtect 与 FlushInstructionCache 的正确顺序
代码页的默认属性通常是PAGE_EXECUTE_READ,直接memcpy上去立刻触发访问违例。标准流程是先改属性、写字节、刷缓存、再改回来:
/* src/hook_mem.c —— 把补丁字节安全写入代码段 */ hook_status hook_write_code(void* target, const unsigned char* bytes, size_t len) { DWORD old_protect = 0; if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &old_protect)) { return HOOK_ERR_PROTECT; /* 常见原因:目标页不在本进程,或已被保护 */ } memcpy(target, bytes, len); /* 关键一步:告诉 CPU 该区域的指令缓存失效 */ FlushInstructionCache(GetCurrentProcess(), target, len); /* 属性务必恢复回去,长期保持 RWX 会降低页保护强度 */ DWORD tmp = 0; VirtualProtect(target, len, old_protect, &tmp); return HOOK_OK; }VirtualProtect的第二个参数是长度,必须按“包含整个补丁”来算,而不是固定 5 或 14——补丁跨页时(target距页尾不足patch_len),朴素的写法会让VirtualProtect返回成功,但实际只覆盖了当前页,memcpy写后半段时越界崩溃。
FlushInstructionCache这一句在 x86/x64 上理论上不是必须的——处理器保证指令修改在同一核上会被观察到,跨核也有一致性协议——但这是血泪经验:在别的线程上同步安装 Hook 时,不刷缓存偶尔会出现“另一个核执行到的是旧指令”的诡异现象,概率极低但一旦出现就是随机崩溃。多加这一句的成本几乎为零。
属性恢复也有讲究。有些实现图省事,直接把目标页永久改成PAGE_EXECUTE_READWRITE就不再管了,短期跑起来没问题,但页保护实际被削弱了,而且一旦有安全检查扫描模块内存属性,很容易被标红。正确做法是保存old_protect原样恢复,卸载 Hook 时也走同一套流程。
4.2 C 接口与 C++ 类封装:谁管生命周期、谁管类型安全
两套接口的目标不同:C 接口给跨语言调用和嵌入式环境用,要求零运行时依赖、显式管理;C++ 类封装给业务代码用,要求 RAII、异常安全、少写样板。类封装不应该重新实现一遍底层,而应该只做一层薄壳:
/* include/hook.hpp —— 只做生命周期与类型转换,底层仍走 C 接口 */ #include "hook.h" class InlineHook { public: InlineHook() = default; InlineHook(void* target, void* detour) { Attach(target, detour); } ~InlineHook() { Detach(); } /* 析构自动卸载,避免忘写 detach */ InlineHook(const InlineHook&) = delete; /* 句柄不可复制 */ InlineHook& operator=(const InlineHook&) = delete; InlineHook(InlineHook&& other) noexcept : ctx_(other.ctx_) { other.ctx_ = nullptr; /* 移交所有权,避免二次卸载 */ } bool Attach(void* target, void* detour) { if (ctx_) return false; /* 重复安装直接拒绝 */ return hook_attach(target, detour, &ctx_) == HOOK_OK; } bool Detach() { if (!ctx_) return false; hook_status st = hook_detach(ctx_); ctx_ = nullptr; return st == HOOK_OK; } /* 模板参数让调用方自己指定签名,避免每处都强转 */ template <typename Fn> Fn Original() const { return reinterpret_cast<Fn>(hook_trampoline(ctx_)); } private: hook_ctx* ctx_ = nullptr; };三个设计点说明一下。移动构造函数里把源对象的ctx_置空,是为了避免两个对象析构时对同一个句柄调用两次卸载——重复卸载在原实现里会触发“恢复的字节已经是补丁字节”的错误,轻则返回错误码,重则把跳板内存重复释放。析构里调Detach而非hook_detach,是为了保证ctx_一定被清空,即使底层返回失败,对象也不会在之后被再次使用。Original<Fn>()用模板而不是返回void*,让每个调用点都能拿到正确签名,编译器也能对参数类型做检查。
成员函数 Hook 是 C++ 用户的常见诉求,这里有个坑:不同编译器对&Class::method的表示不同,某些情况下拿到的是成员函数指针的“内部编码”而不是可执行地址,甚至指向一个只做this偏移修正的 thunk。稳妥做法是让示例工程里显式把成员函数转成普通函数指针再取地址,或者对目标类加__declspec(novtable)避免虚表 thunk 干扰,具体手段在 C++ 示例工程的注释里写得很清楚。
4.3 多线程改写窗口:挂起、原子写与恢复顺序
补丁写入的这十几到几十纳秒里,如果另一个线程正好执行到target的前patch_len个字节,就会读到新旧混合的指令。E9变成E9 FF 25 ...这种组合,CPU 解码出来可能是完全无关的指令,直接跳到随机地址。这是 Hook 在单线程测试下百分百通过、一上多线程就随机崩的根本原因。
常见做法是在写入窗口内把其它线程挂起:
/* src/hook_thread.c —— 挂起除自身外的所有线程,写完后统一恢复 */ void hook_install_atomic(void* target, const unsigned char* patch, size_t len) { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); THREADENTRY32 te = { sizeof(te) }; DWORD self_tid = GetCurrentThreadId(); DWORD pid = GetCurrentProcessId(); HANDLE suspended[256] = {0}; int n = 0; if (Thread32First(snap, &te)) { do { if (te.th32OwnerProcessID != pid || te.th32ThreadID == self_tid) continue; HANDLE h = OpenThread(THREAD_SUSPEND_RESUME, FALSE, te.th32ThreadID); if (h && n < 256) { SuspendThread(h); /* 该线程停在当前指令,不会再取指 */ suspended[n++] = h; } } while (Thread32Next(snap, &te)); } CloseHandle(snap); hook_write_code(target, patch, len); /* 此时没有其它线程在取指 */ /* 必须按打开顺序的逆序恢复,减少等待时间 */ while (n-- > 0) { ResumeThread(suspended[n]); CloseHandle(suspended[n]); } }SuspendThread的前提是“该线程不在内核态长时间阻塞”,否则会等到它返回用户态,安装 Hook 的调用方可能卡住几十毫秒。所以这段逻辑只适合在初始化阶段做一次,不适合放进高频调用路径。
挂起全部线程也是重型操作,线程多时开销明显。轻量替代方案是:如果patch_len不超过 8 字节,且目标地址 8 字节对齐,可以用一条对齐的 8 字节原子写把补丁一次性落下去,靠硬件保证可见性;但 64 位 14 字节的补丁放不进一个 8 字节写入,只能拆两次,中间依然有窗口。所以工程里两条路都保留,由调用方通过编译开关选择,默认走挂起方案。
5. 排查与避坑:五个把进程干崩的真实现场
下面这几条都是调试器里一行一行追出来的,按“现象 → 原因 → 解决”整理。遇到 Hook 装上就崩或者装上不崩但结果不对,先来这里对号入座,比从汇编开始重看快得多。
5.1 现场一:装上就崩在函数第一条指令
现象是安装返回成功,程序走到被 Hook 的函数时立刻访问违例,调试器停的位置看起来像是“跳到了不存在的地址”。
原因是覆盖长度算少了或者算多了。算少了,跳转指令的后半截盖不住,原指令残留在补丁后面继续被执行;算多了,把下一条指令的开头也覆盖了,跳回时落在半条指令中间。两种情况的根因相同:长度反汇编拿到的指令边界不对,通常是对带前缀的指令处理有遗漏,比如漏了0x66操作数前缀,把它当成了独立指令,长度算成 1。
解决办法是在hook_attach里加一条硬校验:累加长度必须大于等于补丁长度,且连起来的每条指令起点都要落在反汇编器给出的边界上。再加一个调试开关,把覆盖区域的原始字节 dump 出来,用调试器反汇编看一眼,边界对不对一目了然。这个后悔药我在后面每个项目里都留着。
5.2 现场二:x64 下程序不崩但返回值全错
现象是 Hook 装上之后程序正常运行,日志也在打,但调用方拿到的返回值变成了莫名其妙的数值,尤其是带浮点参数的可变参数函数。
原因就是前面提到的mov rax, imm64方案吃掉了入口处的rax。x64 调用约定里al承载可变参数函数使用的向量寄存器个数,入口处被覆盖后,函数内部的参数遍历逻辑读到了错误计数。
解决办法是换用FF 25加 8 字节地址的 14 字节方案,不动任何通用寄存器;如果因为函数头太短必须用 12 字节方案,就在mov rax前后加push rax/pop rax,但这样补丁又变长了,实际上还是绕不开覆盖长度的问题。所以 64 位 Hook 的默认方案应该从一开始就定成绝对间接跳转。
5.3 现场三:Debug 正常、Release 崩溃或根本不生效
现象是同一份代码在 Debug 配置下 Hook 得好好的,切到 Release 后要么hook_attach返回成功但代理函数从不被调用,要么直接崩。
原因是链接器的合并优化。开启函数级链接后,内容完全相同的函数会被折叠成同一份代码(相同 COMDAT 折叠),你 hook 的那个空实现函数可能和另一个函数共享地址;更常见的是开启全局优化后,小函数被内联,&target_func取到的地址是一个只有jmp的跳桩,你改的是跳桩不是函数体。
解决办法有两层。一是对确实要 Hook 的目标函数加__declspec(noinline),并且在工程设置里关掉该编译单元的/GL;二是安装前先判断目标地址的头几个字节,如果看到E9或FF 25这种明显已经是跳转的形态,就要决定是顺着跳转跟到真实函数体,还是明确拒绝安装并返回HOOK_ERR_EXISTS——后者更安全,前者适合需要链式 Hook 的场景。
5.4 现场四:Detach 之后跑一会儿随机崩
现象是卸载 Hook 的瞬间没报错,但过几秒程序崩在原来的目标函数里,调用栈看着完全正常。
原因通常有两个。一是恢复字节之后没有FlushInstructionCache,个别核上仍在执行补丁指令,而跳板内存已经被释放,跳转目标成了野指针。二是卸载时没有做任何线程同步,正好有线程停在跳板的搬迁指令区域中间,你恢复原字节的瞬间它还在跳板里跑,等它执行到跳板尾部的“跳回原函数”指令时,原函数的入口已经被恢复成原样,逻辑上没问题——但如果跳板是先释放的,就跳飞了。
解决办法是把卸载顺序固定成:先停用入口(恢复原始字节并刷缓存),再等一小段可观测的宽限期,最后释放跳板内存。宽限期不需要精确,给一个循环等待计数器就行,目的是让还在跳板里跑的线程自然跑完。跳板内存压根不释放也是一种选择,进程退出时统一回收,用几 KB 换确定性。
5.5 现场五:跳板地址总是离目标函数太远
现象是 32 位下一切正常,64 位下hook_attach频繁返回HOOK_ERR_ALLOC,或者跳板能分配但跳回原函数的那条E9偏移溢出。
原因是VirtualAlloc不带地址提示时,系统会随便找一块空闲的 64KB 对齐区域,很可能落在离目标函数几十 GB 之外,rel32根本够不着。跳板本身用绝对跳转没问题,但跳回原函数那一段如果也用相对跳转就会溢出。
解决办法是分配时带地址提示,让系统尽量在目标函数附近找位置:
/* 在目标函数所在地址附近 2GB 内申请跳板,保证 rel32 可达 */ void* hook_alloc_near(void* target, size_t size) { uintptr_t base = (uintptr_t)target & ~(uintptr_t)0xFFFF; const uintptr_t step = 0x10000; /* 64KB 分配粒度 */ for (int i = 1; i <= 512; ++i) { /* 先向低地址找,再向高地址找,命中率更高 */ void* p = VirtualAlloc((void*)(base - i * step), size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (p) return p; p = VirtualAlloc((void*)(base + i * step), size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (p) return p; } return NULL; /* 实在找不到就退化成绝对跳转方案 */ }MEM_RESERVE | MEM_COMMIT一起传是为了避免先占位再提交的两步操作被别的分配打断。找不到就近地址时不能直接失败,而应该退化成全文绝对跳转的跳板,功能不受影响。
6. 二次开发:把这套 Hook 接进自己工程的三处改造
核心库能跑通之后,真正的工作量在于把它变成自己项目里“用得顺手、出事能查”的一块基础设施。我一般做三处改造,改动量都不大,但对长期维护影响很明显。
第一处是补一张全局 Hook 注册表。库本身按句柄管理,业务代码里句柄一多就没人知道谁挂了谁、挂在哪个地址上。加一个固定容量的表,安装时登记目标地址、代理地址、跳板地址和安装时间,卸载时摘除,再配一个dump_all打印当前所有活跃 Hook,调试时直接看输出:
/* hook_registry.c —— 轻量注册表,只做记录与查询,不干预安装流程 */ typedef struct { void* target; void* detour; void* trampoline; unsigned long tick; } hook_entry; static hook_entry g_table[128]; static size_t g_count = 0; void hook_registry_add(void* t, void* d, void* tr) { if (g_count >= 128) return; /* 满了就丢记录,不影响 Hook 本身 */ g_table[g_count++] = (hook_entry){ t, d, tr, GetTickCount() }; } const hook_entry* hook_registry_find(void* target) { for (size_t i = 0; i < g_count; ++i) if (g_table[i].target == target) return &g_table[i]; return NULL; }表容量固定成 128 是有意的:真实的埋点场景很少超过这个量级,用固定数组避免在安装路径上引入动态分配,也避免分配失败这种额外的错误分支。
第二处是把错误码翻译成人话。hook_strerror别写成return "error",每个状态码对应一句能直接指导排查的描述,比如HOOK_ERR_PROLOGUE对应“函数头字节数不足,检查是否被内联或函数过短”。新手拿到错误码后最缺的就是这句话。
第三处是给测试用例加回归。前面提到的短函数、跨页函数、已被跳转过的函数、可变参数函数,每类各写一个最小用例,装完跑一遍、卸完再跑一遍,比对结果是否与未 Hook 时一致。这套用例的价值要等到某次换了编译器版本、/GL默认打开之后才体现出来——那次我改完编译选项直接上机,Debug 全绿 Release 全红,最后对着hook_registry的表才发现有三个 Hook 装到了同一个折叠地址上。
最后补一句验证方法:任何一次要合并进主干的改动,都必须在 x86 和 x64 两个配置、Debug 和 Release 两种优化级别、加上至少一个多线程压测用例下各跑一遍。少其中一个维度,都可能把只在特定组合下出现的崩溃带到线上。
从那以后我每次改这套库,装完 Hook 的第一件事就是把覆盖区域和跳板的原始字节 dump 出来对一遍,确认边界、确认跳转目标、确认恢复后的字节和安装前的完全一致,再去做业务验证。希望帮到你。源码里 x86/x64 两份实现、C 与 C++ 两套示例和那批回归用例都在,拿过去按自己的工程结构改一改,比从零啃指令编码省事得多。
本文还有配套的精品资源,点击获取