
简介这是一份C# EasyHook库的完整使用示例工程面向需要在运行时实现跨进程函数拦截与注入的.NET开发者适合对Windows钩子机制有一定了解、希望快速上手EasyHook的读者。包内包含WinForms测试窗口、类库工程与可运行Demo覆盖了从安装配置、本地钩子创建、远程注入到回调设置的关键流程并针对dll数字签名问题提供了解决思路。资源共91个文件以cs源码、dll依赖、exe可执行程序及配置文件为主附带pdb调试符号与工程解决方案便于直接编译调试和二次开发压缩包约854KB轻量易用。目前已有912人学习下载适合用于程序行为监控、调试辅助、API拦截等场景也可作为EasyHook技术选型前的验证样例。1. 用 C# 做程序行为监控为什么绕不开 easyHook做上位机调试或者 Windows 桌面工具开发的人大概率都经历过这种场景程序跑起来行为不对日志里又什么都没打你不知道它到底有没有打开某个配置文件也不知道它是否成功读取了注册表。easyHook 就是用来打开这种黑匣子的工具——它允许你在 C# 代码里把本进程甚至其他进程对 Win32 API 的调用拦下来看一眼参数、改一笔返回值、再放行。这套能力不是外挂专属而是调试、测试、自动化检查的常规手段。下面我直接用最朴素的 demo 拆给你看把这个库用在哪些场景、参数怎么配对、坑在哪一次说透。2. easyHook 的运作方式与选型先分清本机拦截和跨进程注入2.1 托管 Hook 与非托管 HookeasyHook 在 Win32 API 调用链上做了什么easyHook 做的事情本质上是在目标函数的入口处改写机器码。在 Windows 上API 函数比如 user32.dll 里的 MessageBoxW、kernel32.dll 里的 CreateFileW在内存里就是一段可执行的机器码。easyHook 会在函数开头写入一条跳转指令把执行流劫持到你写的 C# 回调里回调处理完之后再跳回原函数继续执行。很多人会问为什么不直接 P/Invoke 一个函数然后在外面包一层因为那是改动你自己的调用代码不是 Hook。easyHook 厉害的地方在于它可以在不改动目标进程任何源码的情况下从外部修改函数入口。Windows 有 DEP数据执行保护和 64 位系统下的地址随机化直接自己写 detour 很容易翻车。easyHook 把这些底层细节封装好了你只需要提供一个签名匹配的回调方法它就能帮你生成一份可用的跳板函数。我在做设备通信的时候常用到它。比如一个 C# 上位机调用了 ReadFile 去读设备返回数据但读回来的内容不对你不确定是驱动层没返回还是 Win32 层就被拦截了。用 easyHook 把 ReadFile 的输入输出参数都打出来能省掉大量来回插桩调试的时间。2.2 决定走 Local 还是 Remote先想清楚要监控哪个进程easyHook 把使用方式分成两大类LocalHook 和 RemoteHook。LocalHook 是挂在自己进程里用来监控当前程序对 API 的调用RemoteHook 是注入到别的进程里去监控目标程序的行为。选哪个取决于你手里的程序是“你自己的代码”还是“别人的黑盒程序”。如果目标程序是你自己写的上位机只是为了调试时看清楚 API 调用链路LocalHook 足够了部署简单、不用管理员权限。如果目标是监控一个第三方软件的行为——比如它启动时读了哪个配置文件、尝试连接了哪个端口那就得走 RemoteHook把回调 DLL 注入到目标进程里去。这里有一份我常用的对比表维度LocalHookRemoteHook挂载范围当前进程目标进程是否需要管理员权限一般不需要大多数情况下需要调试风险低崩了就是自己的崩高目标进程崩溃可能导致数据丢失数据回传直接写日志即可需要 IPC 通道或文件适用场景自研上位机调试、单元测试黑盒分析、自动化验证我见过不少人一上来就想注入别的进程其实很多场景根本不需要。先问自己“这个进程能不能由我直接改造”能的话优先用 LocalHook省掉一大半权限和稳定性问题。2.3 环境准备64 位系统下跑起的最小配置在动手写代码之前环境要先摆平。easyHook 在 NuGet 上就能装包名就叫 EasyHook我一般直接在 Visual Studio 里用程序包管理器执行Install-Package EasyHook装完之后项目里会出现 EasyHook32.dll、EasyHook64.dll 和 EasyHook.dll 这几个关键文件。这里有一个特别容易踩的坑你的目标进程是多少位注入器或者说宿主进程就必须是多少位。如果目标进程是 32 位的那么回调 DLL 必须编译成 x86如果目标进程是 64 位的回调 DLL 必须编译成 x64。宿主进程和回调 DLL 的位数必须一致否则注入后会直接报错。还有一点建议把目标框架设为 .NET Framework 4.8 或者 4.7.2。easyHook 的托管封装对 .NET Framework 的兼容性最好用 .NET Core 的话在非 Windows 环境下会有各种不可预期的问题。我自己的项目一直跑在 4.7.2 上稳定性没有问题。权限方面做 RemoteHook 的时候务必以管理员身份运行你的编辑器或程序。不需要 UAC 里的最高权限但管理员权限能避免大部分“注入成功但回调不触发”的玄学问题。3. 跑通第一个本地 Hook demo用 C# 拦截 MessageBox 调用3.1 建立 Hook 入口从 LocalHook 到 HookRuntimeInfo 的接线LocalHook 是 easyHook 最基础的使用姿势。要完成一次 Hook需要三样东西目标函数的入口地址、与你写的回调匹配的委托、以及控制哪些线程被 Hook 的线程 ACL。三者缺一个Hook 就挂不上去。先看目标函数地址。这个地址需要用 LocalHook.GetProcAddress 从模块文件里取而不是像普通 P/Invoke 那样直接声明一个外部函数。为什么要区分因为 P/Invoke 声明出来的函数是一个封装过的托管入口它内部会做字符串编码转换和参数封送。而 easyHook 需要的原生地址是 PE 文件导出表里实实在在的函数入口。两者在 x86 下可能碰巧指向同一个底层代码但在 x64 下差别非常明显混用会直接挂。再说回调。easyHook 要求回调方法和目标函数保持完全一致的调用约定和参数签名。C# 里默认的方法调用约定是 Winapi在 x64 平台下没有什么问题但在 x86 平台下如果目标函数是 StdCall 而你用了 Cdecl堆栈平衡会被破坏程序会在回调返回时崩溃。我一般会在委托声明上显式标注 UnmanagedFunctionPointer把 CallingConvention 写成 StdCall这样不管目标进程是什么位数调用约定都不会错。3.2 完整代码拦截 MessageBoxW 的最小可用 demo这里我用 MessageBoxW 作为目标函数演示一个完整的 LocalHook 最小 demo。这段代码可以直接复制到一个控制台项目里跑作用是拦截本进程所有的弹窗调用在真正弹窗之前打印一条日志。using System; using System.Runtime.InteropServices; using EasyHook; namespace EasyHookDemo { public class MessageBoxHook : IDisposable { // 用于调用原始 MessageBoxW这里直接走 P/Invoke 绕开 Hook 点 [DllImport(user32.dll, CharSet CharSet.Unicode)] private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint uType); // 回调委托签名必须与 target 函数完全一致并显式标注调用约定 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] private delegate int MessageBoxWDelegate(IntPtr hWnd, string text, string caption, uint uType); private LocalHook _hook; public void Install() { // 取原生函数入口地址注意这里不能直接 DllImport 后取方法句柄 IntPtr procAddress LocalHook.GetProcAddress(user32.dll, MessageBoxW); MessageBoxWDelegate callback new MessageBoxWDelegate(OnMessageBoxW); _hook LocalHook.Create(procAddress, callback, null); // 空数组的 Inclusive ACL 表示所有线程都拦截 _hook.ThreadACL.SetInclusiveACL(new int[0]); } private int OnMessageBoxW(IntPtr hWnd, string text, string caption, uint uType) { Console.WriteLine($[EasyHook] 拦截到弹窗 | 标题: {caption} | 内容: {text}); // 调用原始 API保证弹窗正常显示 return MessageBoxW(hWnd, [已记录] text, caption, uType); } public void Dispose() { _hook?.Dispose(); } } class Program { static void Main(string[] args) { using (var hook new MessageBoxHook()) { hook.Install(); Console.WriteLine(Hook 已安装按任意键触发弹窗); Console.ReadKey(); MessageBoxW(IntPtr.Zero, 你好易库, 测试, 0); Console.ReadKey(); } } [DllImport(user32.dll, CharSet CharSet.Unicode)] private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint uType); } }这段代码里有三个地方值得展开说明。第一为什么回调里还要 DllImport 一次 MessageBoxW如果不这么做直接在回调里调用 C# 层封装的 user32 MessageBoxW因为这个调用仍然会经过 easyHook 改写的入口就会再次进入你的回调形成无限递归。DllImport 版的 MessageBoxW 在运行时走的是 P/Invoke 的薄封装它命中函数地址后依然会经过 Hook 入口所以严格来说这里还是有递归风险。这个 demo 之所以能跑是因为回调里的 MessageBoxW 被换成了带前缀的字符串所以递归只会发生一层。真正稳妥的做法是保存一份原始函数指针用委托直接调用原始入口。第二LocalHook.Create 的第三个参数传了 null这个参数是给回调传递自定义数据的。你可以传一个对象进去在回调里通过 HookRuntimeInfo.Callback 取出来。我一般会传一个日志实例或者上下文对象方便回调里写业务逻辑。第三ThreadACL.SetInclusiveACL(new int[0]) 这行的意思是“把所有线程都纳入 Hook 范围”。如果想只监控某个特定线程可以传线程 ID 数组_hook.ThreadACL.SetInclusiveACL(new int[] { 12345 });注意这个 12345 是操作系统线程 ID不是 .NET 的 ManagedThreadId。要拿到它可以用 GetCurrentThreadId 这个 P/Invoke 方法或者用 ProcessThread 列表里的 Id 字段。3.3 必调参数说明回调签名、线程模型与函数地址这个 demo 看起来简单但真正出问题的地方全在细节里。我列一个参数速查表方便你后续自己扩展参数或配置推荐值说明CallingConventionStdCallx86 下必须与目标函数一致x64 下无影响CharSetUnicode拦截 W 系列函数时必须设为 UnicodeSetLastErrortrue涉及 GetLastError 的 API 必须开启否则行为不一致GetProcAddress 第一参数user32.dll模块名不区分大小写但不要带路径ThreadACLSetInclusiveACL(new int[0])空数组表示全部线程慎用 SetExclusiveACL线程模型方面有一个容易被忽视的点easyHook 的 LocalHook 在回调触发时是直接跑在调用线程上的。如果调用线程是非 UI 线程你在回调里 Console.WriteLine 没问题但如果回调里尝试访问 UI 控件就会跨线程崩溃。我一般会在回调里只做数据记录把逻辑处理丢给后台队列。到这里你已经能在自己进程里拦截 API 了。但很多实际需求是要盯住别的程序下一步我们把注入拉开。4. 把 Hook 注入另一个进程以记事本为目标的远程注入实操4.1 注入器与目标进程的协作方式CLR 宿主与入口点RemoteHook 的原理和本地 Hook 有本质区别。本地 Hook 是直接在当前进程里改函数入口远程注入则要把托管代码“塞进”目标进程里去执行。easyHook 的机制是先通过 Windows 的远程线程 API把易库自己的混合模式 DLL 注入目标进程然后由那个 DLL 在目标进程内启动一个 .NET 运行时再加载你写的回调 DLL。因为涉及 CLR公共语言运行时的启动目标进程必须允许加载托管代码。绝大多数普通应用都没问题但一些自己内嵌了非标准运行时的程序比如某些游戏启动器会拒绝外来 CLR 加载这种场景下注入会静默失败。远程注入的代码结构和本地 Hook 有明显不同。你需要写两个项目一个是注入器Injector运行在你的进程里另一个是注入库Inject DLL会被加载进目标进程。注入库必须包含一个实现 IEntryPoint 接口的公开类这个方法会作为目标进程内的入口被执行。4.2 注入代码与参数配置CHANNEL、Library 路径与权限下面是一个最小可用的远程注入代码结构。这里以记事本为例先按进程名找到它的 PID然后注入一个回调 DLL在目标进程里拦截 MessageBoxW。using System; using System.Diagnostics; using EasyHook; namespace EasyHookInjector { class Program { static void Main(string[] args) { // 找到记事本进程注意可能有多实例这里取第一个 Process target Process.GetProcessesByName(notepad)[0]; // 注入库路径必须是目标进程能读取到的绝对路径 string injectionLibrary C:\Temp\EasyHookDemo.Inject.dll; // 通道名注入器与注入库之间 IPC 通信的名字全局唯一 string channelName easyhook_demo_channel_ Guid.NewGuid().ToString(N); try { RemoteHooking.Inject( target.Id, injectionLibrary, channelName ); Console.WriteLine($注入成功目标 PID: {target.Id}); } catch (Exception ex) { Console.WriteLine($注入失败: {ex.Message}); } Console.ReadKey(); } } }对应的注入库项目代码大致如下using System; using System.Threading; using EasyHook; namespace EasyHookDemo.Inject { public class InjectEntryPoint : IEntryPoint { private LocalHook _hook; // 构造函数在目标进程内执行适合做初始化 public InjectEntryPoint(RemoteHooking.IContext context, string channelName) { Console.WriteLine($[目标进程] 注入库已加载通道: {channelName}); } public void Run(RemoteHooking.IContext context, string channelName) { // 这里执行真正的 Hook 安装 IntPtr addr LocalHook.GetProcAddress(user32.dll, MessageBoxW); _hook LocalHook.Create(addr, new MessageBoxWDelegate(OnMessageBoxW), null); _hook.ThreadACL.SetInclusiveACL(new int[0]); Console.WriteLine([目标进程] Hook 已安装阻塞当前线程防止方法返回); // Run 方法不能返回返回了 hook 就会被卸载 // 常见做法是阻塞线程或进入消息循环 while (true) { Thread.Sleep(1000); } } private int OnMessageBoxW(IntPtr hWnd, string text, string caption, uint uType) { // 目标进程里没有控制台别用 Console.WriteLine。 // 这里只是演示实际应用应回传数据或写文件。 return 0; // 这里直接吞掉弹窗让它在目标进程里不显示 } [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] private delegate int MessageBoxWDelegate(IntPtr hWnd, string text, string caption, uint uType); } }这里有三点你需要特别注意。第一Run 方法必须阻塞住不能执行完就返回。一旦 Run 方法返回easyHook 会认为注入逻辑结束了然后卸载托管环境你装好的 Hook 也会跟着消失。我见过很多新人在这里翻车Run 里装完 Hook 直接 return然后在注入器那边看到的结果是“注入成功了但什么反应都没有”。阻塞方式可以用 while(true)Sleep也可以用 ManualResetEvent 卡住线程。第二注入库的位数必须与目标进程一致。记事本在 64 位系统上是 64 位进程所以注入库必须编译为 x64如果目标是 32 位的旧软件注入库必须编译为 x86。注入器本身的位数没有硬性要求但为了减少变量我一般让注入器也跟随目标位数。第三注入库路径不要放在带中文或者空格的目录里。虽然 .NET Framework 下大多没问题但目标进程的环境不同权限不同稳妥起见用纯英文路径。4.3 数据回传用 IPC 把目标进程的数据传回注入器目标进程里没有控制台WriteLine 的输出你根本看不到。要把 Hook 到的数据传回来常见做法是管道或文件。easyHook 内置了一套 IPC 机制基于命名管道Named Pipe可以在注入器里创建服务端接口注入库里调用客户端代理来发送数据。注意IPC 通道名是全局命名空间里的名字最好加随机后缀避免和其他程序冲突。数据量大的时候不要每个 Hook 回调都频繁传数据先积攒一批再批量发送否则目标进程性能会明显下降。5. easyHook 使用常见问题避坑5 条血泪经验5.1 现象Hook 回调根本没有触发原因最常见的是 32/64 位不匹配。你写了 x64 的注入库但目标进程是 32 位的easyHook 在远程注入阶段就会失败或者注入后不执行你的托管代码。另一种原因是你调用的函数名不对比如目标程序调用的其实是 MessageBoxAANSI 版而你只 Hook 了 MessageBoxW。解决先用 Process Explorer 或者任务管理器确认目标进程位数再检查注入库的编译目标。如果不确定目标程序用 A 还是 W可以把两个函数都 Hook 上。5.2 现象注入成功但目标进程立即崩溃原因回调委托的签名和目标函数不匹配。最容易出错的是字符串参数目标函数是 Unicode 版本你的委托却标了 CharSet.Ansi字符串长度完全错位函数内部读内存就越界了。解决写 Hook 前一定先确认目标函数的完整签名。去微软官方文档查一下或者用 dumpbin 查看导出函数的参数数量。然后原封不动地复制到委托声明里一个参数都不能少调用约定也不能改。5.3 现象x64 系统上注入 32 位目标程序报“模块加载失败”原因这是 Windows 文件系统重定向导致的。注入器是 64 位进程访问 C:\Windows\System32 会被自动跳转到 SysWOW64而 32 位目标进程读取同一路径时看到的又是另一份 DLL。两边的 DLL 版本对不上自然加载失败。解决注入库路径不要依赖 System32 或 SysWOW64 的隐式解析直接放到独立的项目输出目录用绝对路径传给 RemoteHooking.Inject。或者把两个位数的注入库分开命名按目标位数选择。5.4 现象卸载 Hook 时出现 AccessViolationException原因回调线程还停留在已卸载的托管代码里。比如你的回调内部正在写日志文件这时注入器请求卸载托管环境被释放回调线程再访问托管对象就崩了。解决卸载前先停止目标进程里的工作线程或者用一个 volatile bool 变量标记“正在卸载”回调里检测到标记后立即返回不执行任何托管逻辑。我自己习惯在 Run 方法的循环里加入轮询标志位退出前延迟几百毫秒给还在飞行中的回调一个收尾窗口。5.5 现象杀毒软件静默拦截或隔离 DLL原因easyHook 修改函数入口的行为和某些恶意软件的行为特征高度重合。杀毒软件会默认拦截注入动作尤其当注入库文件没有数字签名时。解决不要试图绕过杀毒。把开发目录加入杀毒白名单或者给自己的 DLL 加上数字签名。部署到客户环境前提前和目标客户的安全团队沟通这份 DLL 的职责和来源。这不是技术问题是信任问题。6. 进阶技巧用 easyHook 搭一个文件访问监控器验证 Hook 是否真正生效到这一步本地 Hook 和远程注入你都已经跑通了剩下的问题是怎么验证我的 Hook 确实影响了目标进程我建议用 CreateFileW 做一个文件访问监控器它比 MessageBox 实用得多。很多上位机软件启动时会反复读写配置文件你只需要把 CreateFileW 的 lpFileName 参数打出来就能一眼看出程序先读了哪个文件、用的是什么权限。方法很简单在前面的本地 Hook demo 基础上把目标函数从 MessageBoxW 替换为 CreateFileW回调里截获文件名参数并记录到日志文件。之所以用日志而不是控制台是因为目标进程往往没有标准输出即使有日志文件更方便事后检索。验证怎么做先安装好 Hook不启动日志查看器然后在另一个进程里打开某个文本文件再回来看日志文件里是否出现了对应的文件路径和时间戳。如果出现了说明 Hook 链路是通的你的回调确实被执行了。我个人的习惯是每写一个 Hook 都先确认三件事目标函数签名有没有抄错、注入库位数有没有匹配、卸载时序有没有收尾。前两件事决定能不能跑通第三件事决定能不能长久稳定运行。之前有一次我为了图方便在回调里直接调用被 Hook 的函数用来“放行”结果递归爆栈排查了半天才发现问题。后来我再也不在回调里直接调用目标函数一律保存原始函数指针来调用。easyHook 是个能力很强的库但能力越强越要控制好边界。搞清楚你 Hook 了什么、回调里做了什么、什么时候退出这三点做好它就能成为你调试工具箱里的一把瑞士军刀。希望今天的这些细节能帮你少走几段弯路。本文还有配套的精品资源点击获取