资讯动态

EasyHook函数钩子实战:从原理到C++ Demo完整解析

发布时间:2026/10/26 18:35:54 来源:尧图企业网站定制
简介这份资源面向需要在 Windows 平台做函数拦截的 C 开发者尤其适合刚接触 API Hook、希望拿到一套可直接编译运行范例的中级学习者。它基于 EasyHook 封装了一套稳定的下钩机制把 Hook.dll 动态库与 Inject.exe 注入程序拆成两个独立模块前者通过填写函数映射表即可完成对目标 API 的挂钩后者用 MFC 界面输入进程 ID 就能把 DLL 注入指定进程省去自行处理远程注入与符号解析的麻烦。压缩包共 36 个文件以 h 头文件、cpp 源文件为主另含 dll、lib、def、vcxproj 与 sln 等工程与依赖文件整体约 278KB用 VS2010 可直接打开解决方案编译。目前已有 2095 人学习下载。代码风格规范示例中对 CreateFileW、ReadFile 等函数的挂钩与转发逻辑清晰便于读者理解钩子注册、原函数调用与卸载流程也可作为二次开发的基础模板。1. 从一次“改不动”的第三方库说起EasyHook 函数钩子 Demo 到底能干什么接手一个老项目时最让人头疼的不是业务逻辑而是某个第三方 DLL 里写死的行为——日志格式不对、返回值被截断、某个内部函数偷偷做了你不想要的转换而源码不在你手上。改不了源码又不想整个模块重写这时候函数钩子Hook就是那根救命稻草。EasyHook 是一套在 Windows 上做 API 与函数拦截的开源框架核心价值在于它把“往目标进程里注入代码、改写函数入口、再把控制权交回原函数”这一整套脏活封装成了相对可用的接口并且同时支持托管.NET与非托管C两种调用方式。这份 VS2010 C 版本的完整 Demo就是围绕 EasyHook 的非托管接口搭出来的一套可编译、可运行、可对照修改的样例工程覆盖本地钩子、远程注入钩子、以及钩子与原始函数的串联调用。它适合两类人一类是需要在 C 里做 API 监控、行为改写、兼容层适配的从业者另一类是刚接触 Hook 机制、想找一个能跑起来的最小闭环来理解“注入—改写—回调—还原”全流程的新手。下面按“先立住原理、再动手复现、最后讲坑”的顺序拆。2. 钩子的底层账本EasyHook 凭什么能拦住一个函数2.1 函数钩子的三种实现路径与 EasyHook 的取舍要理解 EasyHook 的 Demo 为什么这么写先得知道 Hook 这件事在 Windows 上有几条路。最常见的是IAT Hook导入地址表钩子PE 文件加载时导入函数地址会被填进 IAT改这张表就能让调用跳到自己的函数。它简单但只对“通过导入表调用”的路径有效遇到直接GetProcAddress拿地址再调、或者模块内部自调用就失效。第二种是Inline Hook内联钩子直接改目标函数开头几个字节写入一条跳转指令把执行流劫持到自己的处理函数处理完再执行被覆盖的原始字节并跳回。它覆盖面广但涉及指令长度判断、多线程竞争、内存保护属性修改坑最多。第三种是SSDT/内核层钩子需要驱动门槛和风险都高一般业务场景不碰。EasyHook 走的是 Inline Hook 为主、配合远程注入的路线。它的非托管 C 接口把“分配可执行内存、写跳转桩trampoline、保存原始字节、改页面保护”这些步骤包在LhInstallHook一类调用后面开发者主要关心的是“钩哪个函数、钩住后干什么、怎么调原函数”。这份 Demo 的价值就在于把这条链路完整摆出来一个本地进程内钩子的例子一个把钩子注入到另一个进程的例子以及钩子回调里如何安全地调用原始函数。选型上如果你的目标是监控本进程内某个 API 的调用参数本地钩子足够如果要观察另一个进程的行为就得走远程注入Demo 里两条路都有对应工程正好对照着看差异。需要提前说清楚边界Inline Hook 对目标函数的机器码有假设遇到被编译器做过热补丁、函数体极短不足一条跳转指令长度、或者开了控制流保护CFG的模块成功率会下降。这不是 EasyHook 独有的问题是所有用户态 Inline Hook 的共同约束。Demo 能跑通不代表换一个目标 DLL 就一定能钩上这一点后面避坑章节会展开。2.2 注入、跳转桩与回调一次钩子调用的完整生命周期把一次钩子调用拆开看大致是四步。第一步是注入远程钩子场景下EasyHook 需要把一段引导代码和一个 DLL 路径写进目标进程通过CreateRemoteThread之类的手段让目标进程加载你的钩子模块。第二步是安装在目标进程内定位目标函数地址保存函数开头若干字节的原始指令写入跳转指令指向你的回调。第三步是回调执行目标函数被调用时执行流进入你的处理函数你可以在里面读参数、改参数、决定是否调用原始函数。第四步是还原卸载钩子时把原始字节写回释放跳转桩内存。这里有个容易被忽略的细节跳转桩trampoline。因为目标函数开头被你的跳转指令覆盖了想调用“原始函数”就不能直接跳回函数入口否则又会进你的回调形成死循环。EasyHook 的做法是把被覆盖的原始指令复制到一块新分配的可执行内存里在这块内存末尾再跳回目标函数被覆盖部分之后的地址这块内存就是跳转桩。你的回调里通过 EasyHook 提供的原始函数委托去调它等价于调用了“没被钩住的原始逻辑”。Demo 里回调函数中调用原始函数的那几行背后就是这套机制在支撑。理解了这个生命周期再看 Demo 的工程结构就不迷糊了注入端负责准备参数和触发注入被注入的钩子 DLL 负责在DllMain或导出函数里完成LhInstallHook回调函数负责业务逻辑。三者职责分离改的时候知道该动哪一块。2.3 Demo 工程的组成与编译前必须确认的环境项这份 VS2010 C 版本 Demo 通常包含注入器Injector、钩子 DLLHook DLL、以及可能的目标测试程序三部分。编译前有几项环境必须对齐否则大概率卡在链接或运行阶段检查项要求不满足时的典型表现平台工具集VS2010v10032/64 位需与目标进程一致注入成功但钩子不生效或直接注入失败运行库注入端与钩子 DLL 的 CRT 链接方式尽量一致跨模块释放内存时崩溃目标进程位数注入器位数必须匹配目标进程LhInject返回参数错误权限注入器需有调试权限管理员运行更稳打开进程句柄失败错误码 5EasyHook 库对应位数的EasyHook32.lib/EasyHook64.lib与 DLL链接报未解析外部符号32 位和 64 位不能混这是新手翻车率最高的一条。注入器是 32 位、目标进程是 64 位OpenProcess可能成功但后续注入会失败反过来也一样。Demo 一般会提供两套配置编译时确认活动解决方案平台选对了再点生成。3. 把 Demo 跑起来从编译到钩子生效的完整操作链3.1 编译工程与对齐位数、运行库拿到工程后不要急着 F5。先在 VS2010 里确认解决方案配置管理器中的平台把注入器、钩子 DLL、测试目标三者统一成同一平台。然后检查每个工程的“C/C → 代码生成 → 运行库”注入端和钩子 DLL 建议都用多线程调试 DLL/MDd或多线程 DLL/MD保持一致。混合使用 /MT 和 /MD 时跨 DLL 传递std::string、new/delete这类操作会在模块边界上出问题表现为释放时崩溃或堆损坏。# 以命令行方式确认生成产物位数Windows SDK 自带 dumpbin dumpbin /headers Injector.exe | findstr machine dumpbin /headers HookDll.dll | findstr machine # 32 位输出 x8664 位输出 x64两者必须一致上面两条命令用来核对产物位数machine一行会告诉你这个二进制是 x86 还是 x64。如果注入器和钩子 DLL 位数不一致注入阶段就会失败不用等到回调。生成顺序上先编钩子 DLL再编注入器因为注入器运行时需要按路径加载钩子 DLL路径通常写在注入器的配置或命令行参数里确认这个路径指向你实际生成的 DLL别指向旧版本残留。3.2 本地钩子最小可运行的回调与原始函数调用本地钩子是最适合入门的形态不涉及跨进程注入就在自己进程里钩一个已知函数验证回调能不能进、原始函数能不能调。Demo 里本地钩子的骨架大致如下函数名与参数按实际目标调整#include EasyHook.h // 原始函数指针类型必须与目标函数签名严格一致 typedef int (WINAPI *MessageBoxW_t)(HWND, LPCWSTR, LPCWSTR, UINT); MessageBoxW_t OriginalMessageBoxW NULL; // 钩子回调签名与目标函数一致内部通过原始指针调原函数 int WINAPI Hook_MessageBoxW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) { // 在这里可以读参数、改参数、记录日志 OutputDebugStringW(L[Hook] MessageBoxW 被调用\r\n); // 调用原始函数避免死循环OriginalMessageBoxW 由 EasyHook 填充 return OriginalMessageBoxW(hWnd, L已被钩子改写, lpCaption, uType); } int main() { // 1. 安装钩子目标函数地址、回调地址、原始函数指针的地址 NTSTATUS status LhInstallHook( GetProcAddress(GetModuleHandleW(Luser32.dll), MessageBoxW), Hook_MessageBoxW, NULL, OriginalMessageBoxW); if (FAILED(status)) { // 安装失败打印错误常见原因是地址无效或已被占用 return -1; } // 2. 只钩当前进程避免影响系统其他进程 LhSetExclusiveACL(NULL, 0, NULL); // 3. 触发一次调用验证回调是否进入 MessageBoxW(NULL, L原始文本, L测试, MB_OK); // 4. 卸载钩子还原原始字节 LhUninstallHook(NULL); return 0; }这段代码的逻辑顺序是“装钩子 → 设 ACL → 触发 → 卸钩子”。LhInstallHook的第一个参数是目标函数地址用GetProcAddress拿第二个是回调地址第三个参数在 Demo 里常传NULL表示不使用 EasyHook 的线程死锁规避机制需要时传一个TRACED_HOOK_HANDLE第四个是接收原始函数指针的地址EasyHook 会把跳转桩地址写进去。LhSetExclusiveACL用来限定钩子只对当前进程生效参数传NULL, 0表示“排除列表为空”即只作用于本进程这是本地钩子的标准写法漏了它可能影响同机其他进程。回调里必须通过OriginalMessageBoxW调原函数直接再调MessageBoxW会重新进入回调栈会一路涨上去直到崩。参数上最容易错的是函数签名。MessageBoxW的调用约定是WINAPI即__stdcall回调也必须标WINAPI否则 32 位下栈平衡被破坏表现为回调返回后程序莫名崩溃。64 位下调用约定统一问题不明显所以很多人 64 位调通、换 32 位就炸根子在这里。3.3 远程注入钩子注入器与钩子 DLL 的配合远程注入比本地钩子多一层钩子代码不在自己进程里而在目标进程里。Demo 的注入器通常做三件事——打开目标进程、准备注入参数、调用 EasyHook 的注入接口。钩子 DLL 则在自己的加载入口里完成LhInstallHook。一个典型的注入调用如下#include EasyHook.h int wmain(int argc, wchar_t** argv) { if (argc 3) return -1; DWORD targetPid wcstoul(argv[1], NULL, 10); // 目标进程 PID const wchar_t* hookDllPath argv[2]; // 钩子 DLL 绝对路径 // 注入参数这里用字符串把配置传给钩子 DLL长度需按字节算 const wchar_t* payload Ldemo-config; ULONG payloadSize (wcslen(payload) 1) * sizeof(wchar_t); // 调用 EasyHook 注入接口内部完成远程线程与模块加载 NTSTATUS status RhInjectLibrary( targetPid, 0, // 唤醒事件0 表示不需要 EASYHOOK_INJECT_DEFAULT, // 注入标志 NULL, 0, // 32 位注入参数按需 (PVOID)hookDllPath, (ULONG)((wcslen(hookDllPath) 1) * sizeof(wchar_t)), (PVOID)payload, payloadSize); if (FAILED(status)) { // 注入失败常见原因位数不匹配、权限不足、DLL 路径不可达 return -2; } return 0; }RhInjectLibrary的参数里第一个是目标 PID第三个是注入标志EASYHOOK_INJECT_DEFAULT表示默认行为后面成对出现的是“用户数据”和“用户数据大小”分别对应 32 位和 64 位注入场景。Demo 里常把钩子 DLL 路径和一段配置字符串一起传进去钩子 DLL 在初始化时通过LhGetUserData一类接口取回。这里有个硬性要求传进去的路径必须是绝对路径且目标进程有权限访问该路径。相对路径在注入端进程的工作目录下能解析到了目标进程里工作目录不同加载就会失败错误往往只表现为注入返回失败不告诉你具体原因。钩子 DLL 一侧初始化通常放在导出函数或DllMain的DLL_PROCESS_ATTACH分支里但要注意DllMain里不宜做复杂操作加载其他 DLL、等待线程稳妥做法是导出一个初始化函数由注入端在注入后通过远程线程调用或者利用 EasyHook 的注入完成回调。Demo 若把LhInstallHook直接写在DllMain里能跑通但属于“能工作但不推荐”的写法自己改的时候建议挪到导出函数里。3.4 验证钩子是否真的生效三个可观测信号钩子装上了不等于生效了得有办法确认。第一个信号是回调里的日志在回调里写OutputDebugString或写文件用调试器附加或 DebugView 观察能看到输出说明回调确实被触发。第二个信号是行为变化像上面MessageBoxW的例子弹窗文本被改写肉眼可见。第三个信号是返回值与错误码LhInstallHook返回STATUS_SUCCESS0只代表安装调用没报错不代表目标函数后续一定走你的回调还要结合前两个信号。远程注入场景下注入返回成功但回调不触发优先查位数、查目标函数是否真的被调用、查 ACL 是否把目标进程排除了。4. 避坑与排查钩子不生效、崩溃、注入失败的常见账4.1 现象注入返回成功但回调一次都不进原因通常有三类。一是目标函数根本没被调用你钩了一个冷门 API程序运行路径压根没走到自然没回调先确认目标函数在测试场景下确实会被执行。二是位数不匹配注入器 32 位、目标 64 位或反之RhInjectLibrary可能返回成功但钩子模块加载后无法正确改写目标地址表现为静默失效用dumpbin /headers核对两边位数。三是ACL 配置把目标进程排除了LhSetExclusiveACL的语义是“只对这些进程生效”或“排除这些进程”参数传错方向会导致钩子对目标进程不生效本地钩子传NULL, 0是只作用本进程远程场景要按 Demo 里的写法确认包含/排除逻辑。解决顺序先确认函数被调用再核位数最后查 ACL。4.2 现象回调里调用原始函数后程序崩溃或栈溢出这是最典型的死循环。原因是你没有通过 EasyHook 给的原始函数指针去调而是直接又调了目标函数名执行流重新进入回调无限递归直到栈耗尽。解决回调内一律使用LhInstallHook第四个参数回填的原始函数指针且该指针必须在安装成功后才可用安装失败时它是空的回调里要先判空。另一个变体是签名不一致导致栈不平衡32 位下回调漏了WINAPI返回时ESP对不上表现为“调完原始函数回来就崩”核对调用约定即可。4.3 现象卸载钩子后目标函数行为异常原因是卸载时原始字节没有正确还原或者跳转桩内存被提前释放。LhUninstallHook之后应确认没有其他线程还在执行被钩函数的中间状态稳妥做法是先LhUninstallAllHooks再释放资源且卸载与安装成对出现。如果程序在钩子仍激活时退出目标进程可能残留被改写的指令下次运行同一进程时行为诡异。解决把卸载放在明确的清理路径里异常退出场景下接受“进程结束即还原”的事实不要指望跨进程残留能被自动修复。4.4 现象编译通过运行时报找不到 EasyHook DLL 或入口点错误原因是运行时 DLL 搜索路径没包含 EasyHook 的EasyHook32.dll/EasyHook64.dll或者链接的 lib 与 DLL 位数/版本不一致。解决把对应位数的 EasyHook DLL 放到注入器和钩子 DLL 同目录或加入PATH确认链接的 lib 与 DLL 来自同一份发布包。入口点错误多半是 32/64 位混用重新核对平台配置。4.5 现象目标进程有反注入或控制流保护钩子装不上部分模块启用了控制流保护CFG或对自身代码段做了校验Inline Hook 改写入口会被拦截或触发异常。表现是LhInstallHook返回失败或安装后目标进程直接崩溃。这类场景没有通用解法只能换钩子点钩调用方而非被调方、改用 IAT Hook如果调用走导入表、或放弃对该模块的钩取。Demo 跑通的目标通常是普通测试程序换到受保护模块上失败属于预期内不要误以为是 Demo 写错了。5. 进阶把 Demo 改成可复用的钩子骨架与验证习惯Demo 跑通只是起点真正要用起来得把它改造成“换目标函数只改配置”的骨架。我的做法是把钩子安装、回调分发、原始函数表三块拆开安装层维护一张“目标函数名 → 回调 → 原始指针”的表回调层按函数签名分派业务逻辑单独放。这样新增一个钩子点只加一条表项和一个回调不用动注入流程。下面是一个简化的表驱动骨架示意struct HookEntry { const char* moduleName; // 目标模块如 user32.dll const char* funcName; // 目标函数名 PVOID hookProc; // 回调地址 PVOID* original; // 接收原始函数指针 }; // 集中登记新增钩子只改这张表 HookEntry g_hooks[] { { user32.dll, MessageBoxW, (PVOID)Hook_MessageBoxW, (PVOID*)OriginalMessageBoxW }, // 继续追加其他目标 }; bool InstallAll() { for (auto e : g_hooks) { HMODULE mod GetModuleHandleA(e.moduleName); if (!mod) continue; // 模块未加载则跳过 PVOID target GetProcAddress(mod, e.funcName); if (!target) continue; // 函数不存在则跳过 if (FAILED(LhInstallHook(target, e.hookProc, NULL, e.original))) return false; // 任一失败即中止便于定位 } LhSetExclusiveACL(NULL, 0, NULL); // 统一设 ACL return true; }这张表的好处是排错时能快速定位“是哪个钩子点失败”而不是一堆散落的LhInstallHook调用。参数上注意original是二级指针EasyHook 往里写跳转桩地址回调里解引用使用。InstallAll里任一失败即返回是为了避免“部分钩子生效、部分没生效”的中间状态那种状态最难排查。验证习惯上我一般会强制走三步先在本地钩子工程里验证回调签名和原始函数调用没问题再换到远程注入验证位数和路径最后才接到真实目标上。每步都用OutputDebugString留痕确认回调进入次数与预期一致。这套流程看着笨但能挡掉大部分“钩子装了却没反应”的玄学问题。从那以后我每次接新的钩子目标都强制先跑一遍本地最小闭环再上远程省下的排错时间远比多写的几行验证代码值。希望这份拆解帮到你把 Demo 跑通只是第一步把它改成自己的骨架才算真正落地。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑