ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

EasyHook 实战:VS2010 C++ 实现 API Hook 注入 Demo 与稳定避坑指南

EasyHook 实战:VS2010 C++ 实现 API Hook 注入 Demo 与稳定避坑指南 简介EasyHook 函数钩子完整稳定 Demo 程序面向使用 VS2010 进行 C 开发的 Windows 程序员提供了一套可直接复用的 Hook 注入与拦截方案。资源包含 Hook.dll 动态库与 Inject.exe 注入器动态库将下钩子逻辑封装为数组配置形式后续只需按表格填写模块名、函数名与新函数即可完成挂钩大幅降低二次开发成本。包内共 36 个文件以 h/cpp 源码、DLL 动态库、rc 资源脚本及 vcxproj 工程文件为主压缩包仅 278KB结构紧凑且便于学习。Inject.exe 基于 MFC 实现界面输入进程 ID 即可完成注入代码片段展示了 RhInjectLibrary 注入入口与 CreateFileW 等函数的替换写法也演示了符号挂钩的完整调用链。已有 2091 人学习该资源代码风格良好VS2010 下可稳定编译通过适合需要快速掌握 EasyHook 使用方法的初中级 C 开发者参考实践。1. 为什么老程序还要用 EasyHook一个稳定钩子 Demo 的使命函数钩子这个技术一句话说就是“在目标函数入口处插入一段自己的逻辑”。在排查旧系统时经常遇到一个场景某个关闭按钮点了没反应怀疑消息弹窗被什么模块拦截了。猜是没用的直接 Hook MessageBoxW看参数和调用来源一遍就能定位。自己动手做也不是不行但 SetWindowsHookEx 只能挂消息纯 C 远程注入要从 CreateRemoteThread、LoadLibrary 一路写到修改函数入口指令每一步都是坑。EasyHook 把这条链路封装成库提供注入器与钩子 DLL 的完整调用链一个 Demo 就能在 VS2010 上跑通。这篇笔记专注 VS2010 C 版本怎么搭工程、怎么配置库、参数怎么设、坑在哪让有同样需求的人照着能复现改一改也能挪到自己的老项目里用。2. EasyHook 的注入与 Hook 流程先弄清楚它在底层做了什么2.1 内联 Hook 比 IAT Hook 稳在哪里底层拦截 API 有两类常见做法我先说为什么 EasyHook 选的是内联 HookInline Hook。IAT Hook 的做法是修改模块导入表把目标函数的导入项指向自己的函数。它的毛病在于很多 API 是运行后才动态获取地址的或者由程序通过 GetProcAddress 直接拿函数指针此时导入表根本拦不住。只要程序绕开了静态导入IAT Hook 就失效了表现就是“注入成功但没效果”。内联 Hook 的做法则是在目标函数入口处改写机器码把开头的几条指令替换成一条跳转指令让 CPU 执行到这里时直接跳到我们的 Hook 函数。这种方式不依赖导入表只要目标函数真正被执行就一定会经过入口。EasyHook 的核心机制就是这种先读取目标函数头部的若干字节转移到一块可执行内存里然后把头部改写为绝对跳转。那几字节被搬走后不是直接完事还要拼上一条跳回原函数后续代码的指令这一整块搬移代码就是跳板trampoline。跳板做不好程序第一次调用目标 API 就崩而 EasyHook 在指令长度解析和地址重定位上做了不少处理。这也是它的工程看起来比普通 Dll 注入模板大很多的原因稳定都是靠这些细节堆出来的。2.2 RemoteHooking.Inject 到回调一次注入的完整生命周期先理解全流程后面调代码时就不容易发懵。常见做法是注入器进程启动拿到目标进程的 PID目标进程可以是你自己 CreateProcess 启动的也可以是已运行的进程。调用 RemoteHooking.InjectEasyHook 在目标进程里创建一个远程线程并加载你指定的 Hook DLL。远程线程进入 DLL 后EasyHook 的加载器会调用 DLL 里导出的 NativeInjectionEntryPoint 函数。在这个入口函数里调用 LhInstallHook 把目标 API比如 MessageBoxW的入口替换为跳转指令同时记录原函数跳板地址。目标进程的业务代码调用这个 API 时先跳进你的 Hook 回调函数回调函数执行完自己的逻辑后再通过跳板调用原函数。卸载时调用 LhUninstallHook把指令还原成原始字节释放跳板内存。这个流程里最容易被忽略的是第 4 步的 ACL 设置。EasyHook 允许你把钩子限定在某几个进程里生效或者反过来排除某几个进程。如果 ACL 没设对即使 DLL 已经被注入回调也可能不会被触发。我平时在 Demo 里一律用 LhSetInclusiveACL 把当前进程加进去这样只对注入的那个进程生效不会波及系统里其它进程。2.3 位数匹配为什么 32/64 不齐会导致整晚白干EasyHook 的库分成 x86 和 x64 两份注入器的位数、Hook DLL 的位数、目标进程的位数必须满足对应关系。最简单也最不容易出错的组合是把注入器和 Hook DLL 全部编成 32 位目标进程也跑 32 位。如果目标进程是 64 位则注入器和 Hook DLL 都要换成 64 位版本。最怕的是不同版本混着用比如注入器编译成 x64DLL 编译成 Win32运行时 Inject 大概率返回错误而且错误码并不直观新手容易在权限和路径里浪费一整晚。这里有一个值得记住的选择策略在 VS2010 里先把所有相关工程都统一成 Win32 平台编译一遍跑通流程后再单独开一份 x64 配置。这样你拆问题的时候至少知道位数不是变量之一。Demo 阶段追求稳定不要一上来就做“一个注入器同时支持 32/64”的万能版本。3. 在 VS2010 里搭出最小可运行 Hook Demo从编译库到拦下 MessageBoxW3.1 先编译一份 EasyHook 2.x 的静态库你可能会想直接拿别人编译好的二进制来链接但版本不一致时很容易出现“库在那边能用到我这不行”的问题。更可靠的常见做法是拉一份 EasyHook 2.x 的源码在 VS2010 里自己编一遍。源码包里有解决方案文件用 VS2010 打开后顺手确认平台工具集是 v100再分别生成 Win32 和 x64 的库。编译完成后你至少会得到四个关键产物EasyHook32.dll、EasyHook64.dll、EasyHook32.lib、EasyHook64.lib。这里要注意VS2010 用的运行时库是较老的 ms vcr100如果源码包里某些工程配置成高版本工具集VS2010 会报“工具集版本不受支持”。常见处理方式是把解决方案里的项目全部选中右键属性把平台工具集改成 v100重新生成。这个步骤不难但必须做因为 Demo 后续能不能稳定运行取决于库和调用方的运行时环境是否一致。3.2 创建两个工程注入器与注入 DLL一个完整的 EasyHook Demo 需要两个工程。注入器是一个控制台程序负责发送注入命令被注入的 DLL 则负责安装钩子和实现回调逻辑。在 VS2010 里分别创建Injector.exeWin32 控制台应用链接 EasyHook32.lib。HookDemo.dllWin32 DLL链接 EasyHook32.lib。两个工程的“平台”都要选 Win32字符集选 UnicodeRelease 配置下运行时库选多线程 DLL/MD。头文件目录里加上 EasyHook 源码的 src 目录这样#include easyhook.h才能找到。编译完成后把 Injector.exe、HookDemo.dll、EasyHook32.dll 三个文件放在同一个目录里后面运行时会用。3.3 Demo 核心代码从命令行注入到拦截 MessageBoxW先写注入器。这个程序只做一件事接收一个进程 PID然后调用 RemoteHooking.Inject 把 HookDemo.dll 注入进去。// Injector.cpp - 用法: Injector 目标进程PID #include windows.h #include stdio.h #include easyhook.h int wmain(int argc, wchar_t* argv[]) { if (argc 2) { wprintf(Lusage: Injector PID\n); return 1; } DWORD targetPid (DWORD)_wtoi(argv[1]); wchar_t injectDll[] LC:\\Tools\\HookDemo.dll; NTSTATUS status RemoteHooking::Inject( targetPid, // 目标进程 PID injectDll, // 要注入的 DLL 路径 injectDll, // Host 路径这里与注入 DLL 相同 nullptr, // 透传缓冲区没有就传空 0); // 透传缓冲区大小 if (status 0) { wprintf(L[] Hook 安装成功\n); } else { wprintf(L[-] Inject 失败STATUS0x%08lx\n, status); return -1; } return 0; }这里RemoteHooking::Inject的前两个参数是目标进程 PID 和 DLL 路径第三个参数是 Host 路径。在单独注入场景里Host 路径和 DLL 路径相同即可。透传缓冲区用于在注入器和目标进程之间传递自定义数据Demo 用不到就传空。注入器必须以管理员身份运行因为跨进程创建远程线程需要 SeDebugPrivilege 权限普通权限下 Inject 很容易返回错误。再写 Hook DLL。它需要导出一个名为 NativeInjectionEntryPoint 的函数EasyHook 注入时会在目标进程里调用这个入口。// HookDemo.dll - 被注入到目标进程的钩子模块 #include windows.h #include stdio.h #include easyhook.h typedef int (WINAPI *MESSAGEBOXW)(HWND, LPCWSTR, LPCWSTR, UINT); static MESSAGEBOXW OriginalFunc nullptr; extern C __declspec(dllexport) int WINAPI HookMessageBoxW( HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) { // 记录参数后调用原函数尽量不影响调用方 wchar_t line[512] {0}; wsprintfW(line, LMessageBoxW(text%s, caption%s)\n, lpText ? lpText : L, lpCaption ? lpCaption : L); OutputDebugStringW(line); return OriginalFunc(hWnd, lpText, lpCaption, uType); } extern C __declspec(dllexport) void __stdcall NativeInjectionEntryPoint( REMOTE_ENTRY_INFO* remoteInfo) { HMODULE hUser32 GetModuleHandleW(Luser32.dll); if (!hUser32) { hUser32 LoadLibraryW(Luser32.dll); } if (!hUser32) { return; } HOOK_TRACE_INFO hookInfo {0}; NTSTATUS status LhInstallHook( (PVOID)GetProcAddress(hUser32, MessageBoxW), HookMessageBoxW, nullptr, hookInfo); if (status ! 0) { return; } // 获取原函数跳板地址后续通过它调用原逻辑避免递归 TRACED_HOOK_INFO traced {0}; LhGetHookInfo(hookInfo, traced); OriginalFunc (MESSAGEBOXW)traced.Original; // 只钩当前进程避免影响系统里其它进程 ULONG currentPid GetCurrentProcessId(); LhSetInclusiveACL(currentPid, 1, hookInfo); wchar_t log[128] {0}; wsprintfW(log, L[HookDemo] hooked MessageBoxW for PID%lu\n, currentPid); OutputDebugStringW(log); }这段代码里有几个必须说清楚的点NativeInjectionEntryPoint必须用 extern C 导出否则 C 名字粉碎会让 EasyHook 找不到入口函数。LhInstallHook的第三个参数是一个可选的上下文回调Demo 不需要就传 nullptr真正对事件敏感的场景可以传一个回调接收计数信息。TRACED_HOOK_INFO.Original是跳板地址。不要直接保存 GetProcAddress 返回的 MessageBoxW 地址作为 OriginalFunc因为那个地址的开头已经被改了直接调用会再次踩进钩子形成死循环。LhSetInclusiveACL的参数是进程 PID 数组和数组长度。这里传当前进程的 PID表示只有当前进程会触发钩子。编译完成后测试流程是先用 VS2010 编一个会弹 MessageBox 的小测试程序32 位启动后记下 PID再用管理员命令行运行 Injector.exe把这个 PID 传进去最后回到测试程序里点击弹窗看到钩子日志输出。如果日志是输出到 OutputDebugString可以配合本地调试器或输出工具查看。3.4 VS2010 工程配置清单配置项推荐值平台工具集v100目标平台Win3264 位目标时选 x64附加包含目录EasyHook 源码 src 目录附加依赖项EasyHook32.lib / EasyHook64.lib运行时库多线程 DLL/MD字符集使用 Unicode 字符集预处理定义WIN32;NDEBUG;_CONSOLE;NOMINMAX如果你的目标环境是 64 位那么上表里的 Win32 全部换成 x64库也换成 EasyHook64.lib。最忌讳的是注入器用 x64 编译DLL 却编译成 Win32这种情况下 LhInstallHook 本身在 DLL 里可能正常但 EasyHook32.dll 根本无法被 64 位进程加载注入过程会在加载阶段就失败。4. 把 Demo 补成“完整稳定”参数取舍、原函数跳板与卸载时机4.1 回调函数别贪多稳定优先的取舍Hook 回调函数运行在目标进程的业务线程里它是被同步调用的。这意味着回调里做任何耗时操作都会直接拖慢目标进程。最常见的设计问题是有人把日志写入文件的代码直接塞进回调测试时调用频率一高目标程序明显卡顿甚至假死。正确取舍是回调只做最轻量的参数记录把重活交给别的线程。另一个取舍是参数指针的使用。像 MessageBoxW 的 lpText、lpCaption 都是指针回调拿到后必须立即拷贝不能存指针到全局变量里。因为原函数返回后调用方随时可能释放这块内存你留一个悬空指针到后面再用就是野指针。Demo 里我直接用 wsprintfW 拷贝到局部数组所有数据就地处理完再走。4.2 原函数跳板与递归陷阱这个问题排得上回调崩溃原因前几名。EasyHook 安装钩子时会把目标函数入口处的几字节指令搬到一块独立内存里那块的入口地址就是 TRACED_HOOK_INFO.Original。很多新手以为 Original 就是“原始函数地址”其实是“原始指令的搬运工”。如果你在回调里拿到保存的 GetProcAddress 结果去调用CPU 会再次执行那几条被改写的指令结果又是一次跳入钩子形成递归。正确的调用方式只能通过跳板地址OriginalFunc (MESSAGEBOXW)traced.Original; return OriginalFunc(hWnd, lpText, lpCaption, uType);除此以外还要防一种隐蔽的递归回调内部自己又调用了 MessageBoxW。比如你想在日志里弹个窗结果这一声 MessageBoxW 又被打回钩子入口无限递归。常见做法是加一个线程局部重入标志进入回调时标记调用原函数前检查__declspec(thread) bool insideHook false; extern C __declspec(dllexport) int WINAPI HookMessageBoxW(...) { if (insideHook) { return OriginalFunc(hWnd, lpText, lpCaption, uType); } insideHook true; // 记录日志 insideHook false; return OriginalFunc(hWnd, lpText, lpCaption, uType); }这个保护虽然简陋但在 VS2010 的 C 代码里足够演示。真正常年跑在线的钩子模块会把这个标志位放进纯净的 TLS 槽里避免和其它模块共享状态。4.3 注入参数与权限什么时候一定要管理员RemoteHooking.Inject 的参数并不是随便填就能用。我常用的是五个参数的重载含义如下参数作用InTargetPid远程进程的 PID决定钩子落到哪个进程InLibraryPath要注入的 Hook DLL 路径用绝对路径InHostPath宿主模块路径通常与 InLibraryPath 一致InPassThruBuffer透传给 DLL 入口的缓冲区可传一个自定义结构体指针InPassThruSize缓冲区字节数传指针时必须同时给大小权限问题集中在 InTargetPid 上。要往别人的进程里创建远程线程注入器必须有 SeDebugPrivilege。这个特权默认情况下属于管理员但管理员状态的控制台也不是默认就打开它。常见做法是用管理员身份运行注入程序如果你希望程序自动提升权限可以在编译时嵌入 manifest指定 requireAdministrator。另外一种常见失败是目标进程是系统服务或其它高权限进程此时光有管理员还不够一般建议直接换用系统权限的调试服务来注入或者让目标进程以相同身份启动。Demo 阶段不要在这种场景上硬扛。4.4 解钩时机别等进程退出才做很多人写完 Demo 就忘了解钩。但这种钩子一旦装上如果目标进程继续运行它的函数入口就一直处于被改写状态。进程退出时系统清理资源问题不大但如果你想把钩子模块卸载掉就有一个顺序问题。正确的卸载路径是先 LhUninstallHook 还原函数入口指令再 FreeLibrary 释放模块。如果反过来一旦 DLL 被卸载跳板代码也跟着消失但函数入口还挂着跳转指令目标进程下一次调用该 API 就会跳到一块已释放的内存里直接访问违例。还有一个坑是卸载发生在某个线程正在执行回调的过程中这时跳板内存可能已经被释放回到回调里的那一段代码自然就崩了。常见做法是把卸载动作放到专用控制线程里通知目标线程暂停对目标 API 的调用后再执行解钩和释放。不要在 DllMain 的 DLL_PROCESS_DETACH 里调用 LhUninstallHook。DllMain 执行期间系统持有加载器锁此时再等待其它线程完成回调非常容易死锁。5. EasyHook 常见问题与排查跑挂 Demo 的 5 个坑5.1 注入失败错误码像天书现象Inject 返回非零值目标进程里没有出现 HookDemo.dll。错误码可能是 0xC0000135、0x80070005 这类一眼看不出具体意思。原因大多是四类问题。DLL 路径写错或没放在同一目录运行时库不匹配Hook DLL 依赖的 msvcr100.dll 不在目标机器上注入器没有管理员权限位数不一致。解决把 EasyHook32.dll、HookDemo.dll、Injector.exe 放在同一个目录运行时用绝对路径注入确认注入器是以管理员权限运行的用调试器附加到目标进程查看模块列表里有没有 HookDemo.dll。如果模块存在但 Inject 仍失败优先怀疑 EasyHook 库版本与运行时库不匹配换用与 VS2010 同期的 2.x 版本重新编译库。5.2 注入成功但回调没有触发现象模块已经出现在目标进程里日志显示 NativeInjectionEntryPoint 进来了但目标程序调用 MessageBoxW 时完全没有走到我们的钩子里。原因ACL 没配置好是最常见的。如果用了 LhSetExclusiveACL 且传空列表等于“除了列表里的进程之外都生效”目标进程被排除了。另一个原因是只 Hook 了 MessageBoxW目标程序调用的却是 MessageBoxA或者通过 MessageBoxEx 一类的变体走了别的入口。解决确认使用 LhSetInclusiveACL并把当前进程 PID 放进去。然后同时处理 MessageBoxA 和 MessageBoxW 两个入口分别安装钩子。还可以用拦截工具确认目标进程实际调到的是哪个导出函数。5.3 回调里一调用原 API 就死循环现象目标进程 CPU 飙高程序卡死一开调试器看到栈里全是 HookMessageBoxW 的重复帧。原因回调里用保存的原函数地址直接调用而不是用 TRACED_HOOK_INFO.Original或者回调内部又调用了被钩住的 API。解决必须通过 LhGetHookInfo 拿到的跳板地址去调原函数。如果业务上无法保证不二次调用就在回调入口加线程局部重入标志命中时直接走原函数。5.4 卸载后目标进程不稳定偶发崩溃现象调用 LhUninstallHook 后目标进程没有马上崩但下几次调用同一 API 时随机崩溃。原因卸载顺序反了先把 DLL 卸载再还原指令或者卸载时还有线程停在回调里跳板被释放后正在执行的线程下一步就是访问已释放内存。解决先卸载钩子回到原始指令状态再释放 DLL。释放前要确认所有钩子回调都已经退出。如果目标进程里有多线程并发调用宁可先暂停目标业务线程再执行卸载也不要冒着并发风险硬卸。5.5 杀毒软件把注入行为当恶意操作现象编译出的 DLL 刚复制过去就被隔离或者 Injector.exe 一运行就被提示跨进程注入。原因EasyHook 的核心注入方式仍然是远程线程创建加 DLL 加载这一套动作在杀软眼里和恶意程序投放模块高度相似。解决Demo 阶段把工程目录、运行目录加入杀软白名单或者给 DLL 做数字签名。真正要用于生产环境时不要做全局钩子尽可能用 Include ACL 把影响范围限定到指定进程降低被关联误判的概率。6. 让 Demo 真正可用把回调里的文件日志改成异步队列前面的 Demo 用 OutputDebugString 输出避免了同步 IO这已经是正确的方向。但如果你要拦截的并不是 MessageBoxW 这种低频消息而是 WriteFile、NtReadFile 一类高频调用回调里任何形式的直接输出都会成为瓶颈。我常用的一个打磨技巧是把回调改写成“只入队不处理”。思路是这样在 Hook DLL 里准备一个固定大小的环形缓冲区回调函数里把关键参数拷贝到数组里加一个锁放入队列然后立刻返回另一个后台线程从队列里取数据统一写日志。这个后台线程的生命周期由 DLL 管理钩子卸载时先停止输出线程再卸载钩子。struct LogItem { wchar_t text[256]; }; static LogItem g_logBuffer[8192]; static volatile LONG g_logHead 0; static volatile LONG g_logTail 0; extern C __declspec(dllexport) int WINAPI HookMessageBoxW( HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) { LogItem item {0}; wcsncpy_s(item.text, lpText ? lpText : L, 255); LONG slot InterlockedIncrement(g_logTail); g_logBuffer[(slot - 1) % 8192] item; // 不要在这里刷新日志由后台线程统一做 return OriginalFunc(hWnd, lpText, lpCaption, uType); }这个简化版本用 InterlockedIncrement 分配槽位相当于一个无锁的单生产者队列。写入方只有在槽位被覆盖前完成拷贝就不会阻塞目标线程。实际工程里要考虑消费速度追不上生产者的情况但只要你的消费线程只做文件追加一秒钟处理几千条消息绰绰有余。我之前在一个模拟项目 X 里图省事直接在回调里 fopen 然后 fprintf测试时调用频率到每秒两百次目标程序很快就假死了。改成队列后才想起这个道理钩子回调是别人进程里的执行现场任何等待和阻塞都是风险。你现在照着 Demo 搭哪怕暂时没有高频需求也建议一开始就把输出通道设计成异步队列后面切真实场景时能省很多事。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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