简介:面向Windows平台C++开发者的EasyHook函数钩子Demo程序,是一套可直接复用的完整工程样例,旨在解决API钩子开发中稳定性差、门槛高的问题。压缩包共36个文件,包含Hook动态库与InjectHelper注入程序两个工程,以头文件、cpp源码、dll动态库及VS2010工程配置为主,并附带lib、def等构建支撑文件,整体仅278KB,目录结构清晰,已有2088人学习下载。资源核心是Hook.dll封装了一套基于函数地址表的下钩子机制,后续增加钩子只需在数组中添加模块名、API名称与新函数指针即可,省去重复编写注入逻辑的麻烦;Inject.exe则采用MFC界面,输入目标进程ID即可完成注入,操作直观且带状态反馈。配套源码覆盖CreateFileW、CreateFileA、ReadFile等典型文件API挂钩示例,保留EasyHook官方库文件与头文件,可在VS2010下直接编译运行,适合逆向分析、安全监控、软件调试等场景,或用于快速搭建稳定的函数钩子框架。 做Windows客户端开发,绕不开“函数钩子”这件事——不是那种键盘鼠标的全局钩子,而是API级Hook:在目标进程内部,把CreateFileW、RegOpenKeyEx、send这类关键函数的执行路径截下来,先看一眼参数、记录日志,再放行给原始函数。我最早是在维护一个老监控软件时,需要把进程内所有文件操作盘清楚,市面上能用的库试了一圈,最后是EasyHook把我的工作量砍掉了大半。这库最大的优点是用户态搞定,不需要驱动签名,32/64位都有,配合VS2010 C++做一个稳定的钩子Demo,属于老工程维护者、测试工具开发者、安全分析人员必备的技能。今天这篇就把我整理的那套“能直接抄”的完整方案铺开讲,目标是一个在VS2010下能编译、能运行、能稳定Hook住Notepad里CreateFileW的端到端Demo。
1. 先搞清楚EasyHook解决什么问题,再来谈Demo
先说背景。日常项目里想监控某个进程的API调用,常见思路大概有三条路。第一条是写内核驱动,挂SSDT回调或者用Minifilter,这条路最全能,但驱动签名、兼容性、蓝屏风险都是成本,小工具根本犯不上。第二条是IAT Hook,修改目标进程导入地址表,把CreateFileW的地址替换成自己的函数,这种方式对静态导入有效,但对方程序如果用GetProcAddress动态获取地址,或者调的是kernelbase里的转发实现,就很容易漏掉。第三条就是EasyHook采用的Inline Hook,它直接修改函数入口处的机器码,插入一条跳转指令,把控制流导到我们的回调函数里,再通过我保存下来的原始指令跳回原函数继续执行。
1.1 API Hook的三种实现思路梳理
这三种思路在工程里的表现差异很大。IAT Hook实现简单,改内存页属性、写入新地址就行,但它管不了动态解析,而且很多加固过的程序会做IAT校验。Inline Hook灵活,但从零写很痛苦:要处理指令长度对齐、线程同步、多核环境下修改指令的原子性,稍有不慎目标进程直接崩。EasyHook的价值在于把这些脏活都封装好了,它内部会缓存原指令、处理并发进入、还提供了一套线程ACL机制,可以指定哪些线程被Hook、哪些线程放行。这种封装让我不需要去读一堆Intel手册,也能写出稳定的Hook代码。
1.2 为什么还在用VS2010配合EasyHook
可能有人问,都什么年代了还在VS2010?说实话,不是想用,是老工程锁死了。很多工控、医疗、政企软件至今跑在Win7甚至XP上,工具链就是VS2010 SP1,你去动编译器版本,牵一发动全身。EasyHook 2.x本身就是那个时代的产物,对老编译器兼容性极好,C++ DLL用VS2010编译完全没压力。另外VS2010生成的C Runtime是VC100,在一堆旧组件、OCX、第三方库之间混用,稳定性经过十几年验证,这就是它现在还有价值的原因。
1.3 这个Demo的最终效果
这套Demo做出来是什么样子?管理器启动后,输入一个进程ID,比如记事本的PID,它会把C++编写的Hook DLL注入进去。之后目标进程里所有CreateFileW调用都会被记录,参数中的文件路径通过命名管道实时回传到管理器,控制台一行行打出来。你在记事本里按Ctrl+O打开文件对话框、切目录、点保存,管理器里就会看到一串文件路径。这个场景小,但牵扯到的注入、跨进程通信、钩子安装/卸载、线程安全,都是真实项目里必须处理的核心问题,扩展成监控注册表、网络API都顺理成章。
2. Demo的整体架构设计
做Hook程序最忌讳上来就写代码,先把进程模型画清楚。一套完整的EasyHook Demo体系里必须有三个角色:管理器、桥接DLL、Hook DLL。它们各干各的,协作关系要理清楚。
2.1 三个角色的职责划分
| 角色 | 实现语言 | 职责 |
|---|---|---|
| 管理器(EXE) | C# | 创建命名管道、注入目标进程、接收并打印日志 |
| 桥接DLL | C# | 被EasyHook注入目标进程内,负责加载C++ Hook DLL并调用安装函数 |
| Hook DLL | C++ | 真正执行LhInstallHook,监控API,收集参数,写管道 |
为什么要多一个桥接层?因为EasyHook的跨进程注入机制依赖.NET,它需要一个托管DLL在目标进程内创建远端对象,再利用这个对象去调用原生DLL。直接把C++ DLL丢进注入API里是不行的,至少官方支持路径里不是这么玩的。理解这一点,后面看代码就不懵了。
2.2 跨进程通信为什么选命名管道
管理器要和Hook DLL交换数据,我先排除了共享内存和邮槽。共享内存要自己处理互斥、信号量,多一个客户端就是多一堆同步代码;邮槽是单向广播,回传数据不方便。命名管道最合适:双向、按字节流传输、自带阻塞/异步语义,客户端断线重连也容易控制。管理器先创建管道服务端,再注入目标进程,Hook DLL在InstallHook之后连接管道,这个顺序不能反,否则DLL连不上服务端。实际项目里如果管道还没就绪,可以让Hook DLL重试几次,尤其被注入进程启动很快时容易撞上这种时序问题。
2.3 线程模型与数据流
数据流向是:目标进程内任意线程调用CreateFileW,进入我们安装的回调函数;回调函数里加锁写命名管道;管理器管道服务端另起一个线程持续读取,打日志。要注意的是钩子回调运行在目标进程的调用线程上下文里,不是你DLL自己创建的线程,这意味着回调必须非常轻量,不能阻塞、不能做耗时操作。写管道本身是同步IO,缓冲区满时会阻塞,所以Demo里用临界区保护写入,真实项目建议改成生产者-消费者模型,回调里只入队,后台线程统一发送,不然目标进程会因为日志拖慢到肉眼可见的卡顿。
3. 环境配置:VS2010与EasyHook的工程搭建
看一遍架构,下面到动手环节。先说环境:VS2010专业版或旗舰版都行,建议打上SP1。Win10上装VS2010如果遇到智能提示、debugger问题,通常是组件兼容或补丁缺失,像KB983509这类更新包主要是修标准库和预编译相关的坑,顺手装掉能省很多事。
3.1 必须准备的EasyHook文件
EasyHook 2.7的二进制包里,最关键的是这几个:EasyHook32.dll和EasyHook64.dll(运行时Hook库)、EasyHook32.lib和EasyHook64.lib(C++链接用的导入库)、EasyHook.dll(.NET程序集,供C#项目引用)、EasyHook.h(C++头文件)。32位和64位版本别混,后面会详细说。下载后建议建一个CommonLibs目录统一放库文件,管理器项目和C++工程都从这里引用,路径清晰不少。
3.2 C++ DLL项目的编译设置
C++ Hook DLL在VS2010里新建一个Win32项目,选DLL、空项目,然后按经验调这几点:字符集选Unicode,默认的多字节字符集在老工程里也能跑,但路径处理推荐Unicode,宽字符在日志回传里省一次编码转换;运行库选多线程(/MT),这样DLL不依赖VC100运行时的动态版本,放到目标机器更省心;目标平台按需选x86或x64,后面注入器会根据目标进程位数加载对应版本。还有一个隐藏要求:定义HOOK_TRACE_INFO变量时必须16字节对齐,代码里直接写__declspec(align(16)) HOOK_TRACE_INFO g_hookXXX = {0};,这是EasyHook底层原子操作的要求,不对齐偶尔会出诡异崩溃。
3.3 C#管理器项目的.NET版本
管理器是C#控制台工程,VS2010默认目标框架是.NET Framework 4.0,但EasyHook官方要求2.0/3.5。在项目属性里把Target Framework改成.NET Framework 2.0,否则运行时加载EasyHook.dll会报强名称或版本兼容错误。平台目标也要注意,如果只做演示,64位系统上建议管理器编译成Any CPU,由EasyHook在运行时自动匹配目标进程位数,但要注意桥接DLL和Hook DLL的路径传递,别把32位的DLL传给64位进程。
3.4 库文件引用和路径配置
C++项目里要做的引用很直接:头文件目录指向EasyHook.h所在目录,附加依赖库填EasyHook32.lib或EasyHook64.lib,工程属性里的“链接器-常规-附加库目录”指到对应目录,这步漏了我见过各种LNK2019错误。C#项目更简单,直接在解决方案里添加引用,把EasyHook.dll拖进去就行。编译输出目录建议统一到同一个文件夹,这样运行时所有DLL都在一块,不需要再手动复制。
4. 核心代码解析:完整可运行的Demo
环境搭好,来拆代码。我会按调用顺序讲关键片段,不是贴全部源码,但保证核心逻辑完整。
4.1 管理器:起管道、注入、读日志
管理器第一步是创建命名管道服务端,用异步等待客户端连接。这里用NamedPipeServerStream,管道名建议带上进程ID或GUID防止冲突,多个实例跑起来会互相连错。
static void Main(string[] args) { int pid = int.Parse(args[0]); var server = new NamedPipeServerStream( "EasyHookDemoPipe_" + Guid.NewGuid().ToString("N"), PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); string pipeName = server.GetHashCode().ToString(); server.BeginWaitForConnection(OnPipeConnected, server); Console.WriteLine("[Host] Waiting for hook DLL connection..."); string bridge = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FileMonBridge.dll"); string hookDll = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FileMonitorHook.dll"); RemoteHooking.Inject( pid, InjectionOptions.DoNotRequireStrongName, bridge, pipeName, hookDll); Console.WriteLine("[Host] Injected into PID " + pid); Console.ReadLine(); }注意RemoteHooking.Inject的第三个参数是桥接DLL的路径,后面几个参数是传给桥接DLL的透传参数,管道名和Hook DLL路径就在这里面传过去。顺序别写反:一定是先起管道服务端,再Inject,否则Hook DLL首次尝试连接管道就会失败。
OnPipeConnected回调里,用一个循环读管道,直到客户端断开:
static void OnPipeConnected(IAsyncResult ar) { var server = (NamedPipeServerStream)ar.AsyncState; server.EndWaitForConnection(ar); using (var reader = new StreamReader(server, Encoding.Unicode)) { string line; while ((line = reader.ReadLine()) != null) Console.WriteLine("[Hook] " + line); } }4.2 桥接DLL:在目标进程内启动C++钩子
桥接DLL是一个C#类库,里面定义一个继承自IEntryPoint的类。EasyHook把桥接DLL注入目标进程后,会创建这个类并调用它的Run方法,所以这里是我们加载C++ Hook DLL并把控制权交给原生代码的位置。
public class FileMonBridge : IEntryPoint { private readonly string channelName; private readonly string hookDllPath; public FileMonBridge(RemoteHooking.IContext context, string channelName, string hookDllPath) { this.channelName = channelName; this.hookDllPath = hookDllPath; } public void Run(RemoteHooking.IContext context, string channelName, string hookDllPath) { IntPtr hDll = LoadLibrary(hookDllPath); IntPtr installProc = GetProcAddress(hDll, "InstallHook"); var install = (InstallHookDelegate)Marshal.GetDelegateForFunctionPointer( installProc, typeof(InstallHookDelegate)); install(channelName); RemoteHooking.WaitForProcessExit(); } }Delegate的定义要匹配C++ DLL的导出函数签名,例如[UnmanagedFunctionPointer(CallingConvention.StdCall)] delegate void InstallHookDelegate(string pipeName);。这一步很多人忽略:C++导出的函数如果是__stdcall,C#这边就必须显式指定StdCall,否则调用时栈不平衡,目标进程直接崩。
4.3 Hook DLL:安装钩子和线程ACL
C++这边,InstallHook函数负责两件事:初始化管道连接信息,安装CreateFileW钩子。DllMain里不该做重操作,所以我的做法是InstallHook由桥接DLL从外部调用,这时Loader Lock已经释放,可以安全做初始化。
extern "C" __declspec(dllexport) void __stdcall InstallHook(LPCWSTR pipeName) { g_pipeName = pipeName; HMODULE hKernel = GetModuleHandleW(L"kernel32.dll"); FARPROC pFunc = GetProcAddress(hKernel, "CreateFileW"); NTSTATUS status = LhInstallHook( pFunc, HookCreateFileW, NULL, &g_hookCreateFileW); if (status != 0) return; ULONG_PTR acl[1] = { 0 }; LhSetInclusiveACL(acl, 1, &g_hookCreateFileW); }这里最关键的一行是LhSetInclusiveACL(acl, 1, &g_hookCreateFileW)。ACL列表里放0代表“当前进程的所有线程”,这样无论目标进程在哪个线程里调CreateFileW,都会进入我们的回调。如果想只钩某个特定线程,就把线程ID填进去;想排除某些线程,用LhSetExclusiveACL。这个设计比传统的SetWindowsHookEx那种全局钩子干净得多,粒度可控。
4.4 钩子回调:参数记录与管道发送
回调函数签名必须和原函数一模一样,这一点错了编译能过,但运行时栈错乱,目标进程秒崩。回调里做的事尽量精简:拼一个日志字符串,加锁写管道,再调用原始函数。
typedef HANDLE(WINAPI* CreateFileW_t)( LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE); HANDLE WINAPI HookCreateFileW( LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile) { // 懒连接:第一次调用时连接管理器管道 if (g_pipeHandle == INVALID_HANDLE_VALUE) ConnectToPipe(); if (g_pipeHandle != INVALID_HANDLE_VALUE) { WCHAR msg[1024]; int len = wsprintfW(msg, L"CreateFileW: %s", lpFileName); EnterCriticalSection(&g_pipeLock); DWORD written = 0; WriteFile(g_pipeHandle, msg, (len + 1) * sizeof(WCHAR), &written, NULL); LeaveCriticalSection(&g_pipeLock); } return OriginalCreateFileW(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); }懒连接设计是故意的:DLL刚注入时管道服务端不一定就绪,与其在线程里反复重试,不如等第一次真实API调用时再连,连接失败也不影响原函数执行。管道连接函数内部对CreateFileW有依赖,所以一定要放到钩子回调之外,否则自己钩自己,形成递归死循环。
4.5 卸载与进程退出清理
进程退出时,钩子DLL会被系统卸载,但EasyHook要求显式卸载钩子,避免补丁残留:
extern "C" __declspec(dllexport) void __stdcall UninstallHook() { LhUninstallHook(&g_hookCreateFileW); LhWaitForPendingRemovals(); if (g_pipeHandle != INVALID_HANDLE_VALUE) CloseHandle(g_pipeHandle); DeleteCriticalSection(&g_pipeLock); }LhWaitForPendingRemovals的作用是等待所有线程离开钩子回调,防止卸载过程中还有线程正在执行我们的代码,这个细节在长时间运行的服务进程里特别重要。
5. 实战测试与踩坑排查
代码捋顺了,现在跑起来看效果。下面是我实际测试时的完整操作序列和踩过的坑。
5.1 完整测试步骤
- 打开一个记事本,记下PID(任务管理器能看)。
- 运行管理器程序,命令行传PID:FileMonHost.exe 12345。
- 管理器窗口先显示等待管道连接,然后notepad被注入,控制台开始输出CreateFileW路径。
- 在记事本里按Ctrl+O打开文件对话框,切几个目录,管理器会刷新出大量路径。
- 关闭记事本,管理器读到管道断开,程序干净退出。
这中间如果什么都没输出,重点检查进程位数是否匹配。64位Windows默认记事本是64位进程,如果你只编译了32位版本的Hook DLL和桥接DLL,注入大概率失败。正确做法是32/64版本都编译出来,管理器根据目标进程位数动态选择路径。
5.2 高频问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 注入时抛异常“BadImageFormat” | 桥接DLL/Hook DLL位数与目标进程不匹配 | 确认目标进程位数,提供对应版本的DLL |
| 注入成功但无日志 | 管道名不一致或未接上 | 检查ConnectToPipe里复用的管道名;管理器先启动服务端 |
| 目标进程崩溃 | 回调函数参数或调用约定错误 | 核对函数指针签名;确认是__stdcall且参数个数一致 |
| 某些线程不触发钩子 | ACL设置不当 | 检查LhSetInclusiveACL参数,列表传0覆盖所有线程 |
| 编译报错找不EasyHook API | C++工程没链接导入库或没加头文件路径 | 检查附加依赖项和附加包含目录 |
| Hook后操作很卡 | 回调里干了太多事 | 回调只做入队或最小操作,发送交给独立线程 |
| 卸载时服务进程崩溃 | 未等待挂起移除 | 强烈建议调用LhWaitForPendingRemovals |
5.3 稳定性层面的几条经验
回调函数里尽量不要调用会再次进入被Hook API的函数,比如在日志逻辑里调用fopen或者低级IO,一旦路径相同就死循环了。我自己调试时用OutputDebugString向调试器输出,不经过文件IO,最安全。另外钩子回调要保存并恢复调用者的LastError,虽然Demo无所谓,但监控别人的进程时,因为多了一次WriteFile导致LastError变化,会让目标程序判断出错,这是隐藏很深的Bug。保存方式很简单,进入回调时GetLastError,调用原函数成功后把原函数产生的错误码记下来,日志写完再SetLastError回去,保证外部调用方感知不到Hook存在。
另一个常见坑是进程内同时存在多个Hook,特别是不小心对同一个API装了两次。EasyHook的HOOK_TRACE_INFO是链表结构,重复安装会叠加,导致回调进入两次或者混乱。建议在InstallHook里用一个静态标志位做幂等,确保同一个DLL在同一个进程里只安装一次。
6. 从Demo走向真实项目的扩展建议
这套Demo最大的价值不只是演示,把它改造成生产级工具,我个人经验是往这几个方向补。
6.1 多API挂接与过滤规则
CreateFileW只是敲门砖。真实监控往往要同时挂数十个API:RegOpenKeyEx、RegSetValueEx、InternetOpenA、socket、send、recv等等。每个API一套回调,日志格式可以统一成一个结构体,用API枚举标识类型,回传后统一解析。这时候回调里再拼接字符串就太浪费了,直接传原始参数,管理器端做格式化。过滤规则也可以提前到回调里执行,比如只记录包含指定路径前缀的调用,减少管道写压力。
6.2 通信方案从命名管道升级
命名管道在Demo里够用,但日志量大时,WriteFile的锁竞争和目标进程的IO延迟会暴露出来。更稳的方案是回调用环形缓冲区写入原始数据,后台线程批量发送;或者直接用共享内存加两个事件,一个通知有数据,一个通知消费完毕,吞吐量高一个量级。还有一条路是直接用EasyHook自带的IPC机制,C#端注册远程对象,C++端通过EasyHook API调用,但这个方案对C++和.NET的版本耦合较深,老工程里我反而更倾向自己维护共享内存。
6.3 多进程与32位/64位统一管理
真实场景往往不是盯一个进程,而是批量注入一批同名进程。注入前先枚举进程,按位数分发对应DLL版本;IO线程也要独立,一个进程一个管道客户端,管理器端用管道名规则自动路由。多进程注入还有一个隐藏问题:同一份Hook DLL被加载进多个进程,全局变量互不影响,但静态标志位要用进程内锁或原子操作保护,避免竞态导致部分进程安装失败。
还有个最后想给的建议,别只在Debug下测试。Release版本跑一遍,不开调试器跑一遍,开启优化后某些不安全的指针用法会暴露得特别明显。钩子这种东西,平时没毛病,一上线就崩,十有八九是发布配置下的时序或优化问题。我实际验证过,这套代码思路在VS2010 + Win10 + 64位记事本这个组合下稳定运行,希望也能帮你少走几条弯路。
本文还有配套的精品资源,点击获取