简介:基于EasyHook的完整函数钩子Demo程序,面向需要在Windows平台做API拦截、函数行为分析或程序运行监控的C++开发者,同时包含Hook.dll动态库和Inject.exe注入程序。资源共36个文件,压缩包仅278KB,代码主要由头文件、源文件、动态链接库、MFC资源及工程配置文件构成,用VS2010即可稳定编译。示例将下钩子机制封装成一张函数数组表,使用时只要填入模块名、原函数名和新函数入口就能快速挂接指定API;注入端采用MFC对话框,输入目标进程ID后点击按钮即可完成DLL注入,省去手动编写注入逻辑的重复工作。代码中还演示了CreateFileW、CreateFileA、ReadFile等文件API的替换写法,并配有完整的Hook工程与InjectHelper工程目录,目录内同时保留EasyHook依赖库和说明文档,便于二次扩展、对照排查与整体移植。目前已有2088人学习/下载,框架简洁且封装度高,适合想深入理解EasyHook原理、快速搭建Hook实验环境或从事Windows客户端安全分析的开发者。 写挂钩代码的Windows开发者,八成都有过一段"自己手写Inline Hook然后被各种崩溃折磨"的经历。我最初也是从直接改函数头字节码入门的,后来一碰EasyHook,才发现真正"完整稳定"的钩子框架应该长什么样:它替你处理了指令长度对齐、线程同步、远程注入这些脏活,你只要专心写回调逻辑就行。这篇文章就基于EasyHook在VS2010 C++下的实践,把从库配置、宿主注入到DLL回调、稳定性排查的完整Demo链路讲清楚,适合那些想在老工程里快速加API监控、行为拦截,又不想把时间耗在底层字节修补上的C++开发者。
1. 为什么最终选了EasyHook,而不是自己硬写Inline Hook
1.1 常见钩子方案对照
Windows下做函数钩子,主流路线其实就那么几种:系统级消息钩子(SetWindowsHookEx)、微软Detours库、手写Inline Hook,以及EasyHook这类封装好的开源框架。这几种方案我都实际用过,拿真实体验做个对照:
| 方案 | 上手难度 | 稳定性 | 适用场景 |
|---|---|---|---|
| SetWindowsHookEx | 低 | 高 | 只能钩消息级别,拿不到API调用参数 |
| Detours | 中 | 高 | 商业授权有门槛,配置稍复杂 |
| 手写Inline Hook | 高 | 低 | 练习底层原理可以,工程化很痛苦 |
| EasyHook | 低 | 高 | API级别监控、注入、回调,开箱即用 |
如果你只是想在某个进程里拦截一下MessageBox、CreateFile、RegOpenKey这类用户态API,SetWindowsHookEx做不到,因为它工作在消息循环层面,不是函数调用层面。Detours本身很成熟,但授权和配置成本让我在Demo阶段就放弃了。手写Inline Hook这个选项我最有发言权:x86下要处理E9跳转指令的长度、修改前要保存原始字节、回调时要处理重入和线程安全、卸载时要恢复函数头,任何一个环节出了差错,目标进程就给你表演一个当场崩溃。做完一轮,我最大的感受是:底层原理可以作为知识储备,但做项目还是得站在别人肩膀上。
1.2 EasyHook 的核心工作方式
EasyHook之所以敢称"完整稳定",是因为它把整个钩子生命周期都封装好了。它的基本原理分两步:第一步通过远程线程把我们的钩子DLL注入目标进程,第二步在DLL里改写目标API的函数入口,让它跳到中转函数,中转函数再调用我们导出的回调。听起来跟手写Inline Hook没什么区别,但EasyHook内部处理了几乎所有要命的细节:指令长度对齐、多线程同钩子时的同步、32位和64位代码的差异、DLL卸载时的钩子清理。
我实际用下来,最值钱的是它的卸载机制。LhUninstallHook会等待所有正在执行回调的线程退出,LhWaitForPendingRemovals确保清理完成,这对手写方案来说是极其痛苦的一环。而注入端用RhInjectLibrary一条API就能完成"创建远程线程->加载DLL->调用入口点",省掉了自己写CreateRemoteThread加LoadLibrary那套繁琐流程。所以我的结论很明确:快速实现一个稳定可用的钩子Demo,EasyHook是性价比最高的选择。
2. VS2010 漫长的环境准备:库、配置和第一个编译通过
2.1 发布包里到底该拿哪些文件
EasyHook 2.7的安装包解压后,目录里其实躺着好几类文件,新手容易拿错。整理一下我们需要关心的:
- EasyHook32.dll / EasyHook64.dll:运行时核心库,注入成功后目标进程会加载它
- EasyLoad32.dll / EasyLoad64.dll:用于注入流程的引导组件
- EasyHook32.lib / EasyHook64.lib:链接用的导入库
- EasyHook.h:唯一的头文件,包含所有公开API
- EasyHook.dll(托管版本):如果宿主程序用C#写才会用到,纯C++可以忽略
我习惯把整个包拷贝到工程目录下的ThirdParty\EasyHook,这样不污染系统路径,也方便随代码一起走版本管理。第一次调试时最常犯的错是只拷贝了DLL到目标机器,忘了把EasyLoad32.dll和EasyHook32.dll放在一起,结果注入总是静默失败,后面会专门聊这个坑。
2.2 工程配置三步走
VS2010虽然老,但配置路径跟新版本一脉相承,按顺序操作就行。假设你建好了两个项目:一个叫Host(控制台程序),一个叫HookDll(DLL工程):
- 项目属性 -> VC++目录 -> 包含目录,加入
EasyHook头文件所在目录;库目录加入lib文件所在目录。 - 项目属性 -> 链接器 -> 输入 -> 附加依赖项。32位平台加
EasyHook32.lib,64位平台加EasyHook64.lib。建议直接在代码里写#pragma comment(lib, "EasyHook32.lib"),配合条件编译来切换位数,比在IDE里改来改去省事。 - 项目属性 -> C/C++ -> 代码生成 -> 运行库。这里我推荐钩子DLL用多线程DLL(/MD),目标进程如果是用动态CRT编译的主流程序,可以减少很多莫名其妙的兼容性冲突。
一个容易被忽略的细节是目标系统版本宏。VS2010默认的_WIN32_WINNT可能是0x0600,也就是Vista,如果你的目标机器是Win7以上,建议在预处理器里显式加上_WIN32_WINNT=0x0601。这个宏会影响某些Windows API的声明,EasyHook头文件里也有少量条件编译逻辑,缺了它有时候会出现函数签名对不上的编译错误。
2.3 最容易卡住的VS2010细节
VS2010最让我头疼的问题其实是Windows SDK路径。新装完VS2010后,如果还装过其他版本的Visual Studio,平台工具集和SDK目录经常被顶掉,导致windows.h都找不到。这时打开项目属性 -> VC++目录,确认Windows SDK Version指向7.0A,路径是C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A。另一个烦人的问题是字符集:EasyHook的API大多使用宽字符,宿主程序如果不是Unicode编码,又用了wmain当入口,会得到一堆链接错误。解决办法是项目属性 -> 常规 -> 字符集,选"使用Unicode字符集",或者在代码里强制用_UNICODE和UNICODE宏。
说实话,VS2010的配置界面跟现在的Visual Studio 2022差很多,但理解了VC++目录、链接器依赖、运行库这三个层面的逻辑后,任何版本都不难迁移。环境配好了,下一步就是设计Demo的骨架。
3. Demo的整体设计:宿主注入 + DLL回调 + 日志落地
3.1 两个工程的分工与数据流向
这个Demo我设计了两个独立的模块:宿主程序Host.exe和钩子DLLHookDll.dll。宿主程序负责两件事,通过进程快照找到目标进程的PID,再调用RhInjectLibrary注入DLL。钩子DLL被加载到目标进程后,在入口点里面安装MessageBoxA的钩子,之后目标进程一旦调用MessageBoxA,就会先走进我们的回调函数。
数据流向是这样的:用户点击目标程序里的按钮,触发user32.dll的MessageBoxA,函数入口已经被改写成跳转指令,于是CPU先执行我们的MyMessageBoxA回调,回调把弹窗文本和标题追加写入日志文件,然后调用原始函数指针TrueMessageBoxA放行弹窗。整个过程对目标程序来说完全透明,弹窗正常显示,用户体验不到任何差异。
3.2 为什么宿主和DLL必须分开放
可能有人会问,为什么不把注入和钩子逻辑写在同一个模块里?这里有个底层限制:远程线程注入的目标是把DLL加载到对方进程的地址空间。宿主和DLL分开编译,DLL才能作为独立PE文件被LoadLibrary加载。如果合在一起,宿主进程本身也被钩子逻辑污染,不仅没有意义,还会让两边的生命周期耦合,错误处理混乱。
而且从工程角度看,分开后你可以把DLL做成一个通用的"探针模块",比如同时支持MessageBoxA、CreateFileW、RegOpenKeyExW的钩子集合,宿主则做成一个通用的注入工具,通过命令行参数指定目标进程和DLL路径。这样后面扩展新场景时,写一个DLL换个宿主配置就行,不需要动框架代码。
3.3 设计上的三个关键决策
动手敲代码之前,有三个设计决策要定下来,直接影响稳定性和可维护性。
第一,钩什么API。我选MessageBoxA而不是更底层的NtUserMessageBox,原因很简单:它是user32导出的普通函数,参数简单(窗口句柄、文本、标题、按钮类型),又足够典型,覆盖了"拦截-记录-透传"的完整链路。第一版Demo拿它跑通,后面改成CreateFileW或RegOpenKeyExW都是同构的操作。
第二,钩子的作用域。EasyHook用ACL(访问控制列表)管理钩子对哪些线程生效。LhSetInclusiveACL(0, 0, &g_hook)在官方文档里表示对所有线程生效。这个选择在Demo阶段最省心,但真实项目里如果只关心UI线程的行为,最好通过枚举线程ID传入精确的ACL列表,避免回调在高频工作线程里产生性能损耗。
第三,DLL的卸载策略。EasyHook的注入模式有EASYHOOK_INJECT_DEFAULT和EASYHOOK_INJECT_USE_LH_INJECT等几种,默认值已经够用。但卸载逻辑必须想清楚:宿主可以调用LhUninstallHook远程卸载,也可以由DLL内部在某个条件满足时自我清理。我的Demo里用一个全局布尔变量g_bThreadActive控制DLL入口循环,宿主退出时通过远程线程通知DLL结束,流程清晰且不容易残留钩子。
4. 完整可编译的核心代码逐段拆解
4.1 宿主端:找进程ID、注入、退出
#include <windows.h> #include <stdio.h> #include <tlhelp32.h> #define EASYHOOK_ALL_INTERFACES #include "EasyHook.h" #pragma comment(lib, "EasyHook32.lib") DWORD FindProcessId(const wchar_t* processName) { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32W pe = { sizeof(PROCESSENTRY32W) }; DWORD pid = 0; if (Process32FirstW(snap, &pe)) { do { if (_wcsicmp(pe.szExeFile, processName) == 0) { pid = pe.th32ProcessID; break; } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); return pid; } int wmain() { wprintf(L"EasyHook Host Demo\n"); DWORD pid = FindProcessId(L"TargetApp.exe"); if (pid == 0) { wprintf(L"[-] TargetApp.exe not found\n"); return 1; } wchar_t dllPath[MAX_PATH] = { 0 }; GetFullPathNameW(L".\\HookDll.dll", MAX_PATH, dllPath, NULL); HANDLE hRemoteThread = NULL; NTSTATUS nts = RhInjectLibrary( pid, // 目标进程ID 0, // 当前会话 EASYHOOK_INJECT_DEFAULT, NULL, // 原生DLL路径,EasyHook方案传NULL dllPath, // 待注入的钩子DLL NULL, // 托管DLL路径 0, &hRemoteThread); if (nts != 0) { wprintf(L"[-] RhInjectLibrary failed: 0x%08X\n", (ULONG)nts); return 1; } wprintf(L"[+] Injected into PID %u\n", pid); wprintf(L"[+] Press Enter to unload and exit...\n"); getchar(); return 0; }这里要特别解释一下EASYHOOK_ALL_INTERFACES宏。EasyHook.h默认只暴露函数声明,定义了这个宏之后,很多内部常量和内联辅助函数才会被引入。如果漏掉这个宏,EASYHOOK_INJECT_DEFAULT这样的枚举值可能未定义,编译直接报错。
RhInjectLibrary的第二个参数0表示当前会话,这是主流用法。如果你在服务程序或远程桌面会话里做注入,才需要显式传入会话ID。第四到第六个参数涉及原生路径和托管路径,纯C++场景下只用第五个参数传DLL路径,其余给NULL。
4.2 钩子DLL端:安装钩子、回调、保持存活
#include <windows.h> #include <stdio.h> #include "EasyHook.h" #pragma comment(lib, "EasyHook32.lib") #pragma data_seg(".SHARE") static volatile LONG g_bThreadActive = 1; #pragma data_seg() #pragma comment(linker, "/SECTION:.SHARE,RWS") static HOOK_TRACE_INFO g_hook = { 0 }; static BOOL g_hookInstalled = FALSE; typedef int (WINAPI *MessageBoxA_t)(HWND, LPCSTR, LPCSTR, UINT); static MessageBoxA_t TrueMessageBoxA = MessageBoxA; EXTERN_C int WINAPI MyMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) { FILE* f = NULL; fopen_s(&f, "C:\\EasyHookDemo.log", "a"); if (f) { fprintf(f, "[hook] text=%s caption=%s type=0x%X\n", lpText ? lpText : "(null)", lpCaption ? lpCaption : "(null)", uType); fclose(f); } return TrueMessageBoxA(hWnd, lpText, lpCaption, uType); } EXTERN_C void WINAPI NativeInjectionEntryPoint() { HMODULE hUser32 = GetModuleHandleW(L"user32.dll"); if (!hUser32) return; TrueMessageBoxA = (MessageBoxA_t)GetProcAddress(hUser32, "MessageBoxA"); if (!TrueMessageBoxA) return; NTSTATUS nts = LhInstallHook( TrueMessageBoxA, // 要挂钩的原始函数地址 MyMessageBoxA, // 我们的回调地址 NULL, // 回调给用户的额外参数 &g_hook); if (nts != 0) { return; } LhSetInclusiveACL(0, 0, &g_hook); g_hookInstalled = TRUE; while (g_bThreadActive) Sleep(1000); if (g_hookInstalled) { LhUninstallHook(&g_hook); LhWaitForPendingRemovals(); } }NativeInjectionEntryPoint这个名字是EasyHook的约定入口。注入完成后,EasyHook会让被注入进程在远程线程里调用这个导出函数,而不是走DllMain。这一点极其重要:不要在DllMain里做钩子安装,LoadLibrary锁、线程通知等机制很容易造成死锁,而NativeInjectionEntryPoint是在普通线程上下文里执行的,安全得多。
LhInstallHook的第一个参数是函数地址,第二个参数是跳转目标(我们的回调)。第三个参数支持给回调传自定义数据,Demo里用不到,但如果做复杂状态机,这个参数可以用来传实例指针。真正让人安心的在最后两行:LhUninstallHook和LhWaitForPendingRemovals,前者解除钩子,后者阻塞等待正在执行的回调退出。没有这两个调用,卸载DLL时很可能有人还停在回调函数里,对着一块已释放的代码继续执行,那就是经典的"用后即崩"。
4.3 代码里容易被忽略的几个生命周期问题
这段代码能跑,但有几个细节值得反复咀嚼。
第一,while (g_bThreadActive)这个循环绝对不能删。NativeInjectionEntryPoint从远程线程返回后,EasyHook会认为注入工作已完成,如果此时DLL的引用计数归零,它可能被卸载。也就是说,入口点函数必须让自己所在的线程保持存活,钩子才能持续生效。实际项目中一般会用一个事件或管道来等待卸载信号,而Demo为了简单直接轮询全局变量,这套写法配合#pragma data_seg(".SHARE")可以让多个进程共享这个变量,方便宿主远程通知卸载。
第二,回调里的日志写入用的是fopen_s和fprintf,这些是CRT函数,操作的是钩子DLL自身加载的CRT,不是目标进程的CRT。这里没有跨模块分配/释放的问题,所以安全。但如果回调里要分配内存并在别的模块释放,就会踩堆不一致的坑,后面详细说。
第三,LhSetInclusiveACL(0, 0, &g_hook)的两个0分别表示线程数数组为空、线程ID列表为空。EasyHook文档明确这个模式代表"对所有线程生效"。如果只想钩UI线程,可以传线程ID数组,这样回调不会在高频线程里被反复触发,性能更可控。
5. 实测和踩坑:跑起来后我遇到的真问题
5.1 64位目标进程注入失败
第一步实测我就踩了雷。编译一个x86版本的宿主和DLL,目标进程是x64编译的,结果RhInjectLibrary返回0xC00000BB(STATUS_NOT_SUPPORTED)。排查链路很清晰:先查目标进程是不是被管理员权限保护,排除后想到位数问题——x86 DLL不能被x64进程加载,EasyHook检测到体系结构不匹配就直接失败。
解决方式有两个。第一,给宿主和DLL各编一份x86和x64版本,按目标进程位数选择组合。第二,写一个小工具函数,用IsWow64Process2判断目标进程是原生x64还是WOW64模拟的x86,然后动态加载不同位数的库。Demo阶段推荐第一种,简单粗暴不会错。
5.2 注入返回成功但钩子没生效
有一次宿主显示注入成功,但目标程序的MessageBox行为完全不变。我用Process Explorer查看目标进程模块列表,发现HookDll.dll根本没加载进去。进一步排查日志,发现问题出在路径上:宿主程序当前工作目录跟DLL所在目录不一致,GetFullPathNameW拼接出来的路径指向了一个不存在的位置。EasyHook的注入函数报告成功,是因为远程线程创建成功了,但LoadLibraryW在目标进程里因为路径无效加载失败,而宿主函数没能感知到这个失败。
这里有个务实的建议:用绝对路径,并在注入前用GetFileAttributesW验证DLL文件真实存在。我在宿主代码里加了这样一段检查后,这个问题再也没出现过。
5.3 回调一展开程序就崩
忽略,这是老话题。我最初在回调里直接调用了MessageBoxA来展示拦截效果,结果目标进程在弹窗出现的一瞬间就崩了。原因很简单:回调地址已经被LhInstallHook设为跳转目标,如果回调内部再调用与钩子相关的API,就会形成递归挂钩;更危险的是我调用的弹窗函数本身跟我钩的函数一致,根基一乱,栈就完了。
解决思路是回调内部尽量只做无副作用的统计和日志记录,然后通过保存在TrueMessageBoxA里的原始地址透传。如果一定需要在回调里弹窗,也要启用另一个不带钩子的函数指针,保证不会二次跳转。
5.4 权限和调试环境下注入差异
Win7以上的UAC机制对远程注入影响很大。宿主进程如果不是以管理员权限运行,而目标进程具备管理员级别,RhInjectLibrary会返回STATUS_ACCESS_DENIED。这个问题的排查链路过了一遍:先确认目标进程权限,再用管理员权限运行宿主,问题消失。实战中我会建议在宿主程序里加manifest(requireAdministrator),既不烦用户弹窗,又顺便解决注入权限问题。
另一个现象是Visual Studio里按F5调试宿主程序时,程序集上下文跟直接双击运行不一样。EasyHook在调试器下创建远程线程时,某些杀软或调试拦截工具会跳出来干扰,导致注入结果不稳定。遇到这种请先把宿主程序用管理员身份直接运行,排除调试器干扰因素。
5.5 卸载时的崩溃现场
卸载阶段的崩溃最让人头大。现象是宿主退出后,目标进程过几秒也跟着挂了。排查发现我的DLL入口函数写完就直接返回了,没有调用LhUninstallHook和LhWaitForPendingRemovals。钩子还挂在函数上,DLL却被卸载,目标进程下一次调用MessageBox时就跳到了一块已释放的内存地址。
正确的卸载顺序应该是:先在DLL内停止入口循环,然后LhUninstallHook移除钩子,再LhWaitForPendingRemovals等所有回调线程退出,最后才能让模块真正卸载。如果是宿主主动触发卸载,可以远程调一个ShutdownHook导出函数走这套流程,比从外部强杀远程线程靠谱得多。
6. 把Demo变成长期工具的扩展建议
6.1 从MessageBox到钩任何API的通用化
MessageBox只是验证链路,实际项目里更常需要监控文件操作、注册表访问、网络连接。把这些目标API的地址换一下就可以,但要注意参数解析的差异。比如CreateFileW的参数是路径、访问模式、共享模式、安全描述符等一大串,回调里要按ARGUMENT_PRESENT判断空指针,还要处理内联字符串的解引用。我一般会把"函数地址-函数原型-日志解析"做成一张配置表,新增一个API只需加一行,不重复写安装和卸载逻辑。
另外一个常用技巧是同时钩一族函数。比如监控文件操作时,把CreateFileW、ReadFile、WriteFile、SetFilePointer全装上,用自增序号维护调用关系,这样能还原一次完整的文件操作序列,对逆向分析和行为审计很有价值。
6.2 日志写入的性能陷阱
我在回调里直接fopen_s/fprintf/fclose,这样每个弹窗都做一次磁盘I/O,Demo没问题,但如果目标是高频API,磁盘写入会成为主要性能瓶颈,还会阻塞目标进程。改进方案是回调只做内存计数器累加,或者通过共享内存/管道把日志发给独立进程去写。EasyHook官方还提供从目标进程传数据到宿主进程的机制,但我更推荐自建环形缓冲区:回调里把日志写到预分配的内存区,宿主定时拉取,既快又安全。
6.3 更多易踩的隐蔽坑
钩子DLL和目标进程如果使用不同版本的CRT,且回调里new出来的内存交给目标模块delete,就会触发堆损坏。我吃过这个亏之后,定了一条规矩:回调代码不跨模块传递动态内存,需要传数据就用固定大小的字节数组或者文件。
还有一点是关于杀软的。有些安全软件会检测远程线程注入行为,导致EasyHook的注入在部分机器上失败。这不是EasyHook本身的问题,但发布工具时需要把这种情况写进常见问题文档,提示用户添加白名单或改用驱动级方案。
EasyHook在我做Windows API监控和软件行为分析的项目里,省下的开发时间以周为单位计算。这套Demo跑通之后,你在VS2010上就有了一个可复用的底座:宿主负责注入,DLL负责钩取和回调,日志负责验证,接下来无论监控注册表还是追踪文件操作,都只是换函数名和参数解析的问题。唯一要记住的,永远是把钩子的拆卸流程放第一位,那里藏着最多的崩溃。
本文还有配套的精品资源,点击获取