简介:EasyHook 是 Windows 平台下基于动态注入的钩子库,压缩包提供了一套完整可编译的 VS2010 C++ Demo,包含 Hook.dll 动态库与 Inject.exe 注入程序。Hook.dll 将下钩子流程封装为函数表数组,填写模块名、函数名与新函数指针即可批量挂钩,适合中高级 C++ 开发者用于 API 监控、调试辅助或逆向分析。Inject.exe 基于 MFC,输入进程 ID 即可完成 DLL 注入。包内共 36 个文件,以 h/cpp 源码为主,附带 rc 资源、vcxproj/sln 工程配置、dll/lib 依赖及 EasyHook 库,便于在 VS2010 中直接编译运行,整体约 278KB,已有 2000 余人学习。Demo 代码风格清晰,带 TRACE 输出,并演示 CreateFileW/ReadFile 双版本 API 挂钩,可作后续钩子项目基础框架。
1. EasyHook 是什么:一个让 Hook 从黑匣子变成透明管道的完整 Demo
某公司一台老服务器上的 VC++ 程序已经十年没动过,业务方想统计它到底在什么时候调用了多少次 MessageBox,既不能改源码,也不能重编译。某开发者在网上翻遍资料后,最终落到 EasyHook 这套 C++ Demo 上。跑通之后才发现:Hook 并没有想象中那么玄学,注入、安装、回调、清理四个环节的代码全部完整可编译,剩下的就是重复劳动。
这套 Demo 的核心价值,是把四个环节用一个 VS2010 直接打开就能编译的工程完整呈现。宿主程序负责注入,HookDll 负责拦截目标 API,回调数据通过进程间通信回传到宿主端,最后还能干净卸载。适合两类人:一类是给老项目加 API 监控能力、又不想自己写汇编跳板的人;一类是想把 EasyHook 钩子机制从原理到实战完整过一遍,再照着做二次开发的 C++ 工程师。
2. 技术选型与工程结构:为什么用 EasyHook 而不是 Detours
2.1 选型对比:Detours、EasyHook 与手写 Inline Hook
在 Windows 上做函数钩子,摆在面前的路有三条。手写 Inline Hook 是一条“看似可控、实则全是坑”的路:你要手工修改目标函数头部的字节,把它改成一条跳转指令,同时在别的内存区域保存被覆盖的原始指令,还要处理多线程同时进入被 Hook 区域的竞争问题。一个字节算错,目标进程直接崩溃,而且这种崩溃极难复现,调试器一挂上就“一切正常”。
Detours 比手写高明不少,它把指令修补和 trampoline 生成都封装好了。但真实接入老工程时你会发现两个麻烦:一是它的发布版对老编译环境的支持不算友好,二是授权成本需要评估。对一个内部工具来说,为一个统计需求引入商业授权,流程上就过不去。EasyHook 的优势在于它把最复杂的部分——注入器、x86/x64 双通道、回调分发、线程 ACL 过滤——全部封装成了 DLL 和一组 API,你的代码只需要关心“钩子装上之后干什么”。
| 方案 | 注入支持 | x64 支持 | 回调复杂度 | 老工程适配 |
|---|---|---|---|---|
| 手写 Inline Hook | 自己实现 | 自己实现 | 高 | 中 |
| Detours | 需配合 SDK | 需配合 SDK | 中 | 中 |
| EasyHook | 自带注入器 | 内置 | 低 | 高 |
选 EasyHook 还有一个很实际的理由:它能让你在 VS2010 里用同一份 C++ 代码分别编译出 x86 和 x64 两个版本的 DLL,位数差异被库本身屏蔽掉了。对一台跑着老系统的服务器来说,能少编译一次就少一次风险。
2.2 工程结构:宿主 EXE 与注入 DLL 的分工
打开这个 Demo 的 sln,你会看到解决方案里有两个主要工程,外加一个共享头文件目录,整体结构是这样的:
EasyHook-Demo/ ├── EasyHook-Demo.sln ├── Host/ # 宿主程序,负责注入与日志展示 │ ├── Host.cpp │ ├── Host.vcxproj │ └── HostDlg.cpp # 简单的注入控制界面 ├── HookDll/ # 被注入目标进程的拦截库 │ ├── HookDll.cpp │ ├── HookDll.def │ └── HookDll.vcxproj ├── shared/ │ └── HookProtocol.h # 宿主与注入 DLL 共用的数据结构 └── bin/ ├── EasyHook32.dll ├── EasyHook64.dll └── run_demo.batHost 工程是整个 Demo 的“前台”。它负责枚举当前系统的进程列表,让你选一个目标进程,然后调用 EasyHook 的注入 API,把 HookDll.dll 送进目标进程。HookDll 工程则是真正干活的“后台”,它被加载到目标进程后,会在自己的初始化线程里完成LhInstallHook安装操作,并注册回调函数。shared 目录下的 HookProtocol.h 放的是两头都要用的结构体定义,比如回调日志的数据格式,保证编译 Host 和 HookDll 时用的是同一份协议。
bin 目录容易被忽略,但它其实是跑通 Demo 的关键。EasyHook 运行时包含两个 DLL,一个 32 位一个 64 位,注入目标进程时需要按目标进程的位数选择对应版本。如果你只拷贝了一个 EasyHook32.dll 到 bin 里,而目标是 64 位进程,注入就会静默失败,这是这个 Demo 第一个值得记住的坑。
2.3 一次完整的 Hook 生命周期
在动手读代码之前,先用一张“路线图”把整个流程串起来。一次成功的 Hook 生命周期分五个阶段:
第一阶段,宿主程序拿到目标进程 PID,调用RhInjectLibrary,参数里带上 HookDll 的路径、宿主自身的路径和一个透传缓冲区。第二阶段,目标进程加载 HookDll,系统触发DLL_PROCESS_ATTACH,DLL 里启动一个独立初始化线程,不在 DllMain 里做重活。第三阶段,初始化线程拿到目标 API 的函数地址,调用LhInstallHook挂上回调,再调用LhSetInclusiveACL设置线程白名单。第四阶段,目标进程里有人调用被 Hook 的 API,流程就会先进入你写的回调函数,回调数据通过命名管道或共享内存送回宿主。第五阶段,宿主或 DLL 触发卸载流程,依次调用LhUninstallHook、LhWaitForPendingRemoval,最后释放 DLL。
后面所有代码拆解,本质上都是对这张路线图的具体化。你把每一段代码和这五个阶段对应上,整个工程就不再是黑匣子了。
3. 编译与首次运行:在 VS2010 环境里把 Demo 完整跑起来
3.1 环境配置:平台工具集、字符集与运行库
在 VS2010 里打开 sln 之后,别急着点生成,先检查三个配置项,否则你会在编译阶段浪费大量时间。
第一项是平台工具集。确认工程使用的是 v100 平台工具集,而不是高版本 Visual Studio 默认的 v143。如果工程被高版本 IDE 自动升级过,编译时会报 MSB8025 之类的工具集错误,解决办法是在工程属性的“常规 → 平台工具集”里改回 v100。第二项是字符集。EasyHook 的 API 参数大多以LPCWSTR为主,建议把字符集设置成“使用 Unicode 字符集”,避免代码里的宽字符串和窄字符串混用导致一堆 C2664 编译错误。第三项是运行库。HookDll 和 Host 都建议使用“多线程调试 DLL (/MDd)”或“多线程 DLL (/MD)”模式,不要让注入 DLL 静态链接一份独立的 CRT,否则把它注入目标进程后可能和对方的 CRT 冲突。
这三项配置里最容易翻车的是运行库。你平时写 EXE 时用 /MT 和 /MD 都能跑,但注入 DLL 是跑到别人进程里去的,DLL 里如果自带一份静态 CRT,全局状态就和宿主进程不一致,轻则日志写不进去,重则加载直接失败。这个项目场景下,我一般会统一改成 /MD,省心。
3.2 编译顺序与运行步骤
编译顺序上,先编译 HookDll 再编译 Host。这个顺序不是强制的,但先编出 DLL 会让你心里有底:Host 注入时依赖的 HookDll 路径已经存在,避免运行时才发现 DLL 缺失。x86 和 x64 各编译一次,会得到四个主要产物:Host_x86.exe、Host_x64.exe、HookDll_x86.dll、HookDll_x64.dll。
编译完成后,把产物和 EasyHook 运行库统一放到 bin 目录。可以写一个批处理搞定:
@echo off set BIN=bin copy /Y Host\Release_x86\Host.exe %BIN%\Host_x86.exe copy /Y Host\Release_x64\Host.exe %BIN%\Host_x64.exe copy /Y HookDll\Release_x86\HookDll.dll %BIN%\HookDll_x86.dll copy /Y HookDll\Release_x64\HookDll.dll %BIN%\HookDll_x64.dll copy /Y %BIN%\EasyHook32.dll %BIN%\EasyHook32.dll copy /Y %BIN%\EasyHook64.dll %BIN%\EasyHook64.dll echo done批处理里有个细节要注意:copy命令不会自动创建目标目录,所以 bin 目录要提前建好。另外EasyHook32.dll和EasyHook64.dll必须和 EXE 在同一目录,因为 EasyHook 注入器运行时是按模块路径加载这两个库的。
运行步骤也不复杂。用管理员权限打开命令行,启动 bin 目录下的 Host 程序;再启动一个“会调用 MessageBox 的测试程序”,比如一个用来做验证的小工具;在 Host 界面里输入目标进程的 PID,点击注入按钮;最后在目标程序里触发一次 MessageBox 调用,观察 Host 端的日志窗口。
3.3 验证钩子是否生效:三种验证方式
注入之后怎么确认钩子真的生效了?有三种常用的验证手段。
第一种是看日志。回调函数里写入日志文件,目标进程每触发一次被 Hook 的 API,文件里就会追加一行,包含调用线程 ID 和参数内容。如果日志行数和触发次数对得上,说明链路是通的。第二种是看宿主端界面。如果 Demo 用了命名管道做 IPC,回调会把计数消息发给宿主,宿主界面上会有一个不断增长的数字,这是最直观的验证。第三种是看行为变化。Hook 回调里如果直接篡改了 API 的返回值,比如让 MessageBox 直接返回 IDOK 而不弹窗,目标程序的行为会立刻改变,这是“钩子真的拦到函数”的最有力证据。
第三种方式也是最容易让新人产生误解的地方。Hook 回调里修改行为,不代表你的 Hook 写得有问题,它恰恰是在证明拦截成功。验证阶段建议先用这种方式确认链路,再用日志方式做数据统计。
4. 核心模块拆解:从 InstallHook 到 UninstallHook 的完整链路
4.1 注入入口:RhInjectLibrary 的参数与调用时机
Host 端最核心的一段代码,是对RhInjectLibrary的调用。几乎所有注入逻辑都由这个函数封装好了:
// Host.cpp: 注入目标进程 ULONG targetPid = 4172; // 从进程列表里选出来的目标 PID BYTE passThru[256] = { 0 }; // 透传缓冲区,随 DLL 一起进目标进程 NTSTATUS status = RhInjectLibrary( targetPid, // 目标进程 ID L"bin\\HookDll_x86.dll", // 32 位注入 DLL 路径 L"bin\\HookDll_x64.dll", // 64 位注入 DLL 路径 L"bin\\Host_x86.exe", // 宿主程序自身路径 L"EasyHook Host", // 宿主窗口标题,用于 IPC 标识 passThru, // 透传缓冲指针 sizeof(passThru) // 透传缓冲字节数 ); if (!NT_SUCCESS(status)) { // 把 status 用 FormatMessage 转成可读错误文本 // 常见的失败原因:权限不足、位数不匹配、DLL 依赖缺失 }参数逐一说一下。targetPid决定 HookDll 被注入到哪个进程,这个值通常通过 Toolhelp 快照 API 遍历进程列表拿到,而不是手写。两个 DLL 路径是 EasyHook 特有的设计,注入器会先判断目标进程的位数,再决定加载哪个版本的 HookDll,如果你只编译了 x86 版本,那注入到 64 位进程时就会失败。InHostProcessPath指向宿主程序本身,EasyHook 需要用这个路径找到宿主来建立 IPC。最后一个透传缓冲区很关键,它是 HookDll 加载后拿到的第一份数据,可以放配置开关、宿主 PID、回调标志之类的东西,相当于宿主和 DLL 之间的“握手包”。
调用时机上,常见做法是 Host 启动后先弹出进程列表,用户选中目标后立刻注入。我一般会在注入前强制做一次位数检查:把目标进程的位数和 HookDll 两个版本的产物存在性都验证一遍,再调用RhInjectLibrary,这样能省掉大量“注入失败但不知道错在哪”的排查时间。
4.2 DLL 内安装钩子:LhInstallHook 与线程 ACL
HookDll 被加载进目标进程后,DllMain会被调用。这里有个 EasyHook 开发中必须遵守的规矩:不要在DllMain里直接安装钩子。DllMain持有系统加载锁,在里面做复杂调用极易造成死锁,正确的姿势是启动一个独立的初始化线程,让线程去完成安装:
// HookDll.cpp DWORD WINAPI InstallThread(LPVOID context) { HMODULE hUser32 = LoadLibraryW(L"user32.dll"); LPVOID pMessageBoxW = (LPVOID)GetProcAddress(hUser32, "MessageBoxW"); if (!pMessageBoxW) { return 1; } TRACED_HOOK_HANDLE hHook = { 0 }; NTSTATUS status = LhInstallHook( pMessageBoxW, // 要拦截的目标函数地址 (LPVOID)&HookMessageBoxW, // 自己的回调函数地址 NULL, // 回调句柄,Demo 里用不到就传 NULL &hHook // 安装后拿到的钩子句柄 ); if (!NT_SUCCESS(status)) { return 2; } DWORD tid = GetCurrentThreadId(); status = LhSetInclusiveACL(1, (HANDLE*)&tid, &hHook); if (!NT_SUCCESS(status)) { return 3; } return 0; } BOOL WINAPI DllMain(HINSTANCE hDll, DWORD reason, LPVOID reserved) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hDll); HANDLE hThread = CreateThread(NULL, 0, InstallThread, NULL, 0, NULL); CloseHandle(hThread); } return TRUE; }LhInstallHook是 EasyHook 最核心的安装接口。第一个参数是目标函数的真实入口地址,通过GetProcAddress拿到;第二个参数是你的回调函数地址;最后一个参数是输出型参数,安装成功后里面就是钩子句柄,之后卸载要靠它。安装成功后还必须调用LhSetInclusiveACL来设置线程白名单,这个 ACL 的意思是:只有列表里的线程才会被拦截,其他线程直接放行。这一步极其重要,如果不设 ACL 或者设成空列表,目标进程里所有线程都可能在任意时刻触碰被 Hook 的函数,回调并发问题会让你调试到怀疑人生。
回调函数本身也很有讲究。由于被 Hook 的是 MessageBoxW,回调必须使用和它兼容的签名,调用约定也要保持 WINAPI(也就是__stdcall):
int WINAPI HookMessageBoxW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) { // 记录一次调用:哪个线程,弹窗标题是什么 WriteLog("MessageBoxW intercepted, caption=%s", lpCaption); // 直接返回 IDOK,目标进程会认为用户点了确定 // 注意:这里不能再调用原 MessageBoxW,入口已被改写,调用会递归回来 return IDOK; }这里有一个新手必踩的坑:回调里直接调用::MessageBoxW,以为能弹出一个正常的框,结果立刻栈溢出。因为 user32.dll 里的 MessageBoxW 入口已经被 EasyHook 改写成跳转到你的回调了,你再调它,等于又进了回调,形成无限递归。Demo 的处理方式是回调里不调用原函数,只记录日志后篡改返回值,这样既能验证拦截成功,又规避了 trampoline 的复杂性。
4.3 数据回传与安全卸载:IPC 通道与 LhUninstallHook
回调函数在目标进程里执行,记录的数据怎么传回宿主进程?常见做法是命名管道。HookDll 通过透传缓冲区拿到宿主 PID 和管道名,然后用CreateFile连接宿主端已经创建好的管道;宿主端用CreateNamedPipe监听,收到一条就写一条日志:
// Host.cpp 侧:创建命名管道监听 HANDLE hPipe = CreateNamedPipeW( L"\\\\.\\pipe\\EasyHookDemo", PIPE_ACCESS_INBOUND, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, 1, 0, 0, 30000, NULL ); while (ConnectNamedPipe(hPipe, NULL)) { char buf[512]; DWORD read = 0; if (ReadFile(hPipe, buf, sizeof(buf), &read, NULL)) { AppendLog(buf, read); } DisconnectNamedPipe(hPipe); }这段代码是宿主端的监听循环。PIPE_TYPE_MESSAGE表示按消息模式传输,每次ReadFile读到的就是完整的一条日志,不会出现半包错位。ConnectNamedPipe会阻塞等待 DLL 侧连接,DLL 侧每发一条数据就连接一次、写一次、断开一次,这样宿主端就能稳定地逐条接收。
卸载流程同样有顺序讲究。很多人卸载钩子时只调用LhUninstallHook就立刻FreeLibrary,结果目标进程在下一次调用原 API 时直接崩溃:
// 卸载钩子:顺序不能乱 LhUninstallHook(&hHook); // 第一步:摘除钩子 LhWaitForPendingRemoval(); // 第二步:等待正在执行的回调全部退出 CloseHandle(hPipe); // 第三步:关闭 IPC 通道 FreeLibrary(hDll); // 第四步:最后卸载 DLLLhUninstallHook只是把函数入口的跳转指令改回来,但此刻可能还有别的线程正停留在回调函数里。如果紧接着卸载 DLL,那些线程返回时就会跳到一块已卸载的内存地址上,崩溃就成了必然。LhWaitForPendingRemoval干的事就是等回调队列排空,所以它必须在FreeLibrary之前调用。我把这个顺序写进代码注释里,因为每次调试崩溃时第一反应基本都会怀疑到它头上。
5. 常见问题与避坑:跑通 Demo 后才会遇到的四个高频故障
5.1 注入超时:DLL 加载阶段直接卡死
现象:RhInjectLibrary返回超时类错误,目标进程没有被注入,或者 Host 端长时间无响应。
原因:注入 DLL 在目标进程内加载时卡住了。最常见的两个诱因是 DLL 的依赖项缺失和 CRT 运行库不匹配。如果 HookDll 隐式链接了一个目标进程里不存在的 DLL,系统加载会一直等下去;如果 HookDll 用 /MT 编译而目标进程用 /MD,也可能在 CRT 初始化阶段出问题。
解决:先用 dumpbin 的/dependents参数检查 HookDll 依赖了哪些 DLL,确认里面没有第三方运行库;再把 HookDll 的工程配置改成 /MD 重新编译。如果问题依旧,换一个目标进程试试,排除目标进程自身持有加载锁的可能。
5.2 LNK2019:外部符号链接失败
现象:编译 Host 或 HookDll 时,报unresolved external symbol,指向LhInstallHook、RhInjectLibrary这类 EasyHook API。
原因:EasyHook 的头文件里声明了这些函数,但链接阶段没有把对应的 .lib 文件加进输入列表。还有一个隐蔽场景:附加库目录指向了错误的平台目录,比如用 x86 工程去链接 x64 的 lib,符号就对不上。
解决:打开工程属性,在“链接器 → 输入 → 附加依赖项”里加上 EasyHook.lib;确认“附加库目录”指向和当前编译平台一致的那份 lib。这里我习惯在切换平台配置后重新检查一次,因为 VS2010 的库目录经常会带着旧平台的残留路径。
5.3 钩子装不上:64 位目标进程静默失败
现象:注入函数返回成功,但目标进程调用被 Hook 的 API 时,日志里没有任何记录。
原因:这个故障九成是位数不匹配。Host 是 32 位、目标是 64 位,注入器会尝试加载 HookDll_x64.dll,但你没有编译 x64 版本,或者编译出来了却放在错误路径下。EasyHook 在这种情况下不会立刻报错,而是在目标进程里静默失败。另一个高频原因是目标进程以管理员身份运行,而 Host 没有管理员权限,注入被系统拒绝。
解决:打开进程管理器确认目标进程位数;检查 bin 目录下 HookDll_x86.dll 和 HookDll_x64.dll 是否都存在;最后用管理员权限重新启动 Host 和测试程序。我把这个检查项叫做“位数匹配三步确认”,每次跑 Demo 前花十秒钟扫一眼,能省掉大量消耗在玄学调试上的时间。
5.4 卸载后崩溃:回调还没结束就释放了 DLL
现象:钩子卸载完毕,宿主正常退出,但目标进程在几秒后突然崩溃,崩溃栈里指向 HookDll 的内存区域。
原因:LhUninstallHook摘除了钩子,但没有等待回调队列排空。目标进程中可能有某个线程正在执行回调函数,或者正准备跳进回调,而你的代码已经执行了FreeLibrary,那块内存被回收后变成悬空指针,线程一碰就炸。
解决:严格遵循“先LhUninstallHook,再LhWaitForPendingRemoval,最后FreeLibrary”的顺序。后两个调用之间不要插入任何耗时操作。这个教训我在一次给老系统做热更新时栽过跟头,从那以后卸载代码永远是三行固定注释摆在那里,一行都不敢动。
6. 二次开发:把 Demo 改造成一个可配置的通用 Hook 工具
6.1 从硬编码到配置:把 PID 和 Hook 目标抽出来
Demo 里目标进程 PID 是写死的,MessageBoxW 也是写死的。要把它变成能反复使用的工具,第一件事就是做一个配置项结构体:
struct HookConfig { DWORD targetPid; // 要注入的目标进程 LPCWSTR moduleName; // 被拦截函数所在模块,如 L"user32.dll" LPCWSTR functionName; // 被拦截函数名,如 L"MessageBoxW" int callbackMode; // 0=只记录,1=改返回值,2=调用原函数 };然后从配置文件里读这个结构体,Host 启动时加载配置,注入时把配置通过透传缓冲区传给 HookDll。这样以后要换一个 Hook 目标,只需要改配置文件,不需要重新编译任何东西。
6.2 调用原函数:从“篡改行为”到“保留行为”
Demo 里回调直接返回 IDOK,这是为了安全验证。真实场景里你往往需要既拦截又保留原行为。这时候就不能直接调::MessageBoxW了,正确做法是在安装钩子前保存原函数地址,并通过 EasyHook 提供的 trampoline 机制拿到可安全调用的原始实现。这块机制值得单独花时间读 EasyHook 的文档和源码,搞清楚它怎么复制被覆盖的头部指令,你就真正理解了 Inline Hook 的底层原理。改造完成后要验证两件事:一是回调触发前后目标进程行为一致,二是连续触发上千次不崩溃、不泄漏。
6.3 改造后的验证习惯
每次改完配置和回调逻辑,我都强制走一遍固定流程:确认 Host、HookDll、目标进程三者位数一致,用管理员权限启动,小规模触发目标 API,查看日志落盘和回调计数,最后做一次卸载测试,观察目标进程是否还活着。这套流程看起来笨,但在 Hook 这种“一旦崩溃很难复现”的开发场景下,它是最可靠的后悔药。那次被卸载顺序坑过之后,我每次拿到一个 Hook 工程都会先做一遍“位数匹配三步确认”和“卸载四步顺序检查”,确认无误再谈效果。这个习惯帮我躲过了后面好几次大坑,希望帮到你。
本文还有配套的精品资源,点击获取