FEATURED · 精选文章

EasyHook实战:C++ API钩子注入与回调完整指南

发布时间 / 2026/9/9 20:45:22
来源 / 创域科博编辑部
栏目 / 资讯中心
EasyHook实战:C++ API钩子注入与回调完整指南 简介基于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 Hook1.1 常见钩子方案对照Windows下做函数钩子主流路线其实就那么几种系统级消息钩子SetWindowsHookEx、微软Detours库、手写Inline Hook以及EasyHook这类封装好的开源框架。这几种方案我都实际用过拿真实体验做个对照方案上手难度稳定性适用场景SetWindowsHookEx低高只能钩消息级别拿不到API调用参数Detours中高商业授权有门槛配置稍复杂手写Inline Hook高低练习底层原理可以工程化很痛苦EasyHook低高API级别监控、注入、回调开箱即用如果你只是想在某个进程里拦截一下MessageBox、CreateFile、RegOpenKey这类用户态APISetWindowsHookEx做不到因为它工作在消息循环层面不是函数调用层面。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那套繁琐流程。所以我的结论很明确快速实现一个稳定可用的钩子DemoEasyHook是性价比最高的选择。2. VS2010 漫长的环境准备库、配置和第一个编译通过2.1 发布包里到底该拿哪些文件EasyHook 2.7的安装包解压后目录里其实躺着好几类文件新手容易拿错。整理一下我们需要关心的EasyHook32.dll / EasyHook64.dll运行时核心库注入成功后目标进程会加载它EasyLoad32.dll / EasyLoad64.dll用于注入流程的引导组件EasyHook32.lib / EasyHook64.lib链接用的导入库EasyHook.h唯一的头文件包含所有公开APIEasyHook.dll托管版本如果宿主程序用C#写才会用到纯C可以忽略我习惯把整个包拷贝到工程目录下的ThirdParty\EasyHook这样不污染系统路径也方便随代码一起走版本管理。第一次调试时最常犯的错是只拷贝了DLL到目标机器忘了把EasyLoad32.dll和EasyHook32.dll放在一起结果注入总是静默失败后面会专门聊这个坑。2.2 工程配置三步走VS2010虽然老但配置路径跟新版本一脉相承按顺序操作就行。假设你建好了两个项目一个叫Host控制台程序一个叫HookDllDLL工程项目属性 - VC目录 - 包含目录加入EasyHook头文件所在目录库目录加入lib文件所在目录。项目属性 - 链接器 - 输入 - 附加依赖项。32位平台加EasyHook32.lib64位平台加EasyHook64.lib。建议直接在代码里写#pragma comment(lib, EasyHook32.lib)配合条件编译来切换位数比在IDE里改来改去省事。项目属性 - C/C - 代码生成 - 运行库。这里我推荐钩子DLL用多线程DLL/MD目标进程如果是用动态CRT编译的主流程序可以减少很多莫名其妙的兼容性冲突。一个容易被忽略的细节是目标系统版本宏。VS2010默认的_WIN32_WINNT可能是0x0600也就是Vista如果你的目标机器是Win7以上建议在预处理器里显式加上_WIN32_WINNT0x0601。这个宏会影响某些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(LEasyHook Host Demo\n); DWORD pid FindProcessId(LTargetApp.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 type0x%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(Luser32.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返回0xC00000BBSTATUS_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。这个问题的排查链路过了一遍先确认目标进程权限再用管理员权限运行宿主问题消失。实战中我会建议在宿主程序里加manifestrequireAdministrator既不烦用户弹窗又顺便解决注入权限问题。另一个现象是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/ODemo没问题但如果目标是高频API磁盘写入会成为主要性能瓶颈还会阻塞目标进程。改进方案是回调只做内存计数器累加或者通过共享内存/管道把日志发给独立进程去写。EasyHook官方还提供从目标进程传数据到宿主进程的机制但我更推荐自建环形缓冲区回调里把日志写到预分配的内存区宿主定时拉取既快又安全。6.3 更多易踩的隐蔽坑钩子DLL和目标进程如果使用不同版本的CRT且回调里new出来的内存交给目标模块delete就会触发堆损坏。我吃过这个亏之后定了一条规矩回调代码不跨模块传递动态内存需要传数据就用固定大小的字节数组或者文件。还有一点是关于杀软的。有些安全软件会检测远程线程注入行为导致EasyHook的注入在部分机器上失败。这不是EasyHook本身的问题但发布工具时需要把这种情况写进常见问题文档提示用户添加白名单或改用驱动级方案。EasyHook在我做Windows API监控和软件行为分析的项目里省下的开发时间以周为单位计算。这套Demo跑通之后你在VS2010上就有了一个可复用的底座宿主负责注入DLL负责钩取和回调日志负责验证接下来无论监控注册表还是追踪文件操作都只是换函数名和参数解析的问题。唯一要记住的永远是把钩子的拆卸流程放第一位那里藏着最多的崩溃。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻