C# EasyHook 实战:API 级进程监控与托管 Hook 注入指南
简介C# EasyHook 入门 Demo 是一份面向 C# 开发者的远程函数拦截与注入学习资源适合需要掌握 Windows Hook 机制、进行程序调试或行为监控的读者。资源包含完整示例工程演示从创建本地钩子、注入目标进程到设置回调与安装钩子的全流程并给出了拦截 ExitWindowsEx 等 API 的代码片段同时强调 DLL 数字签名对跨进程注入成功率的影响。压缩包共 91 个文件约 854KB以 cs 源码、dll 库文件、exe 可执行程序及 pdb 调试符号为主另有 config 配置、sln 解决方案和 snk 签名文件等便于直接编译运行和二次修改。已有 913 人学习下载。通过学习这份 Demo读者可以快速理解 EasyHook 的 API 组织方式并得到一个可运行的拦截示例为后续开发调试工具、性能分析插件或安全研究打下基础。1. C# EasyHook 使用 Demo三小时拿下 API 级进程监控有次接手一个老项目的维护任务需要监控某个闭源组件的文件写入行为翻遍文档也没找到日志开关。最后是靠 EasyHook 在托管代码里直接 Hook 了 CreateFileW 才把问题定位到具体目录。那次经历让我确信C# 开发者做 Windows 下进程内拦截EasyHook 是上手成本最低的方案。它不是玩具而是能用在生产环境的库支持 x86 和 x64 注入并且在目标进程里跑的是托管回调写起来比纯 C 的 Detours 舒服太多。这篇就从一个可跑的 Demo 出发把环境配置、注入流程、回调参数设计、以及我在实际项目中踩过的坑一次讲清楚。适合已经会用 C# 写 WinForms 或服务程序、但对 Hook 机制还比较陌生的朋友。2. 为什么选 EasyHook 而不是其他拦截方案原理与选型依据2.1 Hook 的本质在 API 入口做手脚Windows 应用程序调用系统 API 时走的路径是“应用程序代码 - DLL 导出函数 - 内核”。以文件操作为例用户态调用 kernel32.dll 里的 CreateFileW再转到 ntdll.dll 的 NtCreateFile最后进入内核。Hook 要做的就是在这条路径上插入一个中间层让调用先经过我们的代码再由我们决定是放行、修改参数还是直接返回假结果。EasyHook 做的是“用户态 inline hook”它会在目标函数的入口处改写前几个字节插入一条跳转指令让 CPU 跳到我们自己生成的托管回调桩上。这个过程在官方文档里叫“hook injection”关键是需要在目标进程内完成字节码修补所以必须有注入环节。传统做法是给目标进程注入一个原生 DLL在 DLLMain 里完成字节修补。EasyHook 把这个过程包装成了托管接口我们只需要写一个继承自IEntryPoint的类在Run()方法里配置 LocalHook 就能完成拦截。这看起来像魔法但底层的线程劫持、指令重定位都是库做好的我们不需要碰汇编。2.2 和其他方案的对比为什么它是 C# 场景的最佳选择如果只用 C# 官方类库根本碰不到 API 级拦截最多用FileSystemWatcher做事件监听但那种方式拿不到是被谁调用的、参数是什么。如果转向 CDetours 是微软的经典方案但它要求原生代码环境C# 团队要维护两套代码成本太高。对比见表方案技术栈回调语言注入机制适用场景EasyHookC# 少量原生C#托管自动支持远程注入快速原型、生产工具链DetoursCC手动需要自己写注入器性能极敏感的系统级 HookSetWindowsHookEx 系列C/C事件回调依赖消息机制键盘鼠标、消息监控FileSystemWatcherC#事件无文件级监听拿不到调用参数我的经验是如果目标是监控某个进程的行为、做自动化测试的 API 拦截、或者给老系统补日志EasyHook 是最稳的选择。它的托管回调意味着我们可以在回调里调用任意 C# 代码包括读写数据库、发 HTTP 请求这在纯 C Hook 场景下要费很大功夫。2.3 核心类型和命名空间先分清谁是谁EasyHook 有几个核心类型写 Demo 前必须搞清楚它们的职责分工。第一个是LocalHook它负责在“当前进程”内安装 Hook。使用场景是我们自己写了一个程序想 Hook 自己进程里调用的 API。这个模式下不需要跨进程注入最简单适合验证 API 签名和回调逻辑。第二个是RemoteHook/Inject()系列。它的职责是把目标 DLL 注入到其他进程然后在该进程内安装 Hook。实际项目中 90% 的代码都跑在这个模式下因为我们要监控的通常不是自己写的进程。第三个是HookRuntimeInfo。它提供当前 Hook 上下文的信息比如线程 ID、调用栈在调试回调时非常有用。第四个是IEntryPoint接口。所有注入到目标进程的托管代码类必须实现这个接口其中Run()方法是入口参数是一个RemoteHooking.IContext对象以及我们在注入时传入的InChannel参数。还要注意x86和x64的区分。EasyHook 库本身有区分注入器进程的位数必须和目标进程一致否则注入会失败。我一开始就吃过这个亏用 x64 的测试程序去注入 x86 的记事本结果抛BadImageFormatException后面会专门讲这个问题。3. 搭建 EasyHook 开发环境从 NuGet 到第一个本地 Hook3.1 项目配置引用哪些包平台怎么选打开 Visual Studio 创建一个 .NET Framework 控制台应用注意 EasyHook 目前对 .NET Core / .NET 5 的支持不太完整生产环境我建议用 .NET Framework 4.7.2 或 4.8。如果非要用 .NET 6也能跑但需要手工处理原生依赖不推荐新手碰。在包管理器控制台执行以下命令PM Install-Package EasyHook -Version 2.7.7098安装完成后项目的packages目录下会出现EasyHook.dll、EasyHook32.dll和EasyHook64.dll。三个DLL各有分工EasyHook.dll是托管主程序集我们在代码里直接引用它EasyHook32.dll是 32 位原生辅助库注入 32 位进程时被加载EasyHook64.dll是 64 位原生辅助库注入 64 位进程时被加载。关键配置是“平台目标”。如果目标进程是 32 位测试程序要设为x86如果是 64 位要设为x64。这在“项目属性 - 生成 - 平台目标”里设置。不要用 AnyCPU因为 EasyHook 的本地注入器位数需要和测试程序一致AnyCPU 在 x64 系统上默认按 x64 跑会导致无法注入 32 位进程。3.2 实现第一个本地 Hook拦截 MessageBoxW 并修改标题先从最简单的本地注入开始目的是验证 EasyHook 在当前进程内能正常工作。下面的代码拦截user32.dll导出的MessageBoxW函数在消息框弹出前偷偷改掉它的标题。using System; using System.Runtime.InteropServices; using EasyHook; namespace EasyHookLocalDemo { public class MessageBoxHook : IDisposable { // 原始函数签名必须完全匹配EasyHook 靠签名生成委托来调用原函数 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode, SetLastError true)] private delegate int MessageBoxWDelegate(IntPtr hWnd, string lpText, string lpCaption, uint uType); private LocalHook _hook; public void Install() { // 以全局函数形式注册回调避免委托被GC回收 _hook LocalHook.Create( LocalHook.GetProcAddress(user32.dll, MessageBoxW), new MessageBoxWDelegate(MessageBoxWHookProc), null); // 不设置的话默认只拦截当前线程这里改为拦截所有线程 _hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); } private int MessageBoxWHookProc(IntPtr hWnd, string lpText, string lpCaption, uint uType) { // 篡改弹窗内容lpCaption 是从原始调用传进来的标题 lpCaption lpCaption [已拦截]; // 调用原始 MessageBoxW注意触发当前Hook会导致递归调用见避坑章节 IntPtr user32 LoadLibrary(user32.dll); IntPtr originalAddr GetProcAddress(user32, MessageBoxW); var original (MessageBoxWDelegate)Marshal.GetDelegateForFunctionPointer(originalAddr, typeof(MessageBoxWDelegate)); return original(hWnd, lpText, lpCaption, uType); } [DllImport(kernel32.dll, CharSet CharSet.Unicode)] private static extern IntPtr LoadLibrary(string lpFileName); [DllImport(kernel32.dll, CharSet CharSet.Ansi)] private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); public void Dispose() { _hook?.Dispose(); } } }这段代码是 EasyHook 本地 Hook 的完整骨架。LocalHook.Create的三个参数分别是要拦截的函数地址通过GetProcAddress拿到、自定义的托管委托实例、以及可选的回调上下文参数。线程 ACL 部分SetExclusiveACL(new int[] { 0 })表示排除线程 ID 为 0 的线程也就是对所有线程生效如果注释掉这一行默认只拦截当前线程多线程程序会漏掉大部分调用。特别注意回调内部去获取原始函数地址的方式。由于 LocalHook 已经把函数入口改写直接调用原地址会再次进入自己的回调最终栈溢出。所以要通过LoadLibraryGetProcAddress再拿一次地址然后用Marshal.GetDelegateForFunctionPointer重新封装成委托这样绕过了被 Hook 的入口。这种方式有个副作用——每次调用都动态获取地址性能一般如果做高频 API 拦截建议把原始委托缓存在 static 字段里。3.3 验证效果与常见失败在Main方法里实例化MessageBoxHook调用Install()然后调用系统自带的MessageBox看看效果static void Main(string[] args) { using (var hook new MessageBoxHook()) { hook.Install(); MessageBox.Show(这是原始内容, 原始标题); Console.WriteLine(按任意键退出...); Console.ReadKey(); } }第一次运行如果直接抛异常八成是权限问题。EasyHook 在安装 Hook 时需要写内存保护属性这在普通权限下就能完成但如果启用了强制签名检查或杀毒软件拦截会出现AccessViolationException。解决方法是给项目添加“应用程序清单”并以管理员权限运行调试。Visual Studio 里右键项目 - 添加 - 新建项 - 应用程序清单文件然后修改requestedExecutionLevel为requireAdministrator。如果 MessageBox 弹出来了但标题没有变化先检查回调委托的签名是否正确。EasyHook 使用托管委托签名来生成代理调用一旦参数类型与实际导出函数不符行为是未定义的可能静默忽略也可能内存损坏。4. 远程注入实战监控另一个进程的文件写入操作4.1 注入器与注入后模块两个程序的分工本地 Hook 只能监控自己的进程真正有价值的是远程注入。EasyHook 的实现方式比较特殊它把一个托管 DLL我们写的类库注入到目标进程然后在目标进程内启动一个新线程来执行Run()方法。所以我们的解决方案至少要拆成三个项目项目类型职责HookInjectorExe控制台/WinForms负责选择目标进程并调用 RemoteHooking.InjectHookLibrary类库.NET Framework实现 IEntryPoint 和各个 Hook 回调共享接口类库可选定义注入器与 Hook 库共享的数据结构目标进程会加载 HookLibrary 的托管程序集因此该程序集不能引用任何注入器独有的配置。这是很多新手容易忽视的点——本来只想监控一个函数但 HookLibrary 不小心引用了某个业务 DLL结果注入时整个依赖链都被加载内存暴涨。4.2 编写可注入的 Hook 库入口点、管道与回调下面是一个完整的文件写入监控示例。假设要监控notepad.exe的CreateFileW调用记录所有以.txt结尾的文件路径并把日志通过命名管道发回注入器。先写共享管道类放在单独文件里[Serializable] public class FileWriteInfo { public string FileName { get; set; } public uint Access { get; set; } public uint ShareMode { get; set; } public DateTime Timestamp { get; set; } }FileWriteInfo是注入器和 Hook 库之间的数据传输对象必须用[Serializable]标记。EasyHook 的远程通道默认使用序列化传输如果类不标记序列化属性运行时会直接报SerializationException。接下来是 Hook 库主体public class FileMonitorEntryPoint : IEntryPoint { private RemoteHooking.IContext _context; private readonly ChannelClientFileWriteInfo _channel; private LocalHook _createFileHook; // 构造函数会收到上下文、通道名称和注入参数 public FileMonitorEntryPoint(RemoteHooking.IContext context, string channelName) { _context context; // 连接注入器创建的命名管道注意ChannelName必须和注入器传入的一致 _channel RemoteHooking.IpcConnectClientFileWriteInfo(channelName); } public void Run(RemoteHooking.IContext context, string channelName) { // 在这里安装Hook不能放入构造函数构造函数时机太早 IntPtr targetAddr LocalHook.GetProcAddress(kernel32.dll, CreateFileW); _createFileHook LocalHook.Create( targetAddr, new CreateFileWDelegate(CreateFileWHookProc), this); // 只监控目标进程所有线程这里不是排除0是新的写法 _createFileHook.ThreadACL.SetInclusiveACL(new int[] { 0 }); // 阻塞等待保持注入线程存活 RemoteHooking.WaitForProcessExit(); _channel.Dispose(); } [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode, SetLastError true)] private delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private IntPtr CreateFileWHookProc( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { if (lpFileName.EndsWith(.txt, StringComparison.OrdinalIgnoreCase)) { _channel.Write(new FileWriteInfo { FileName lpFileName, Access dwDesiredAccess, ShareMode dwShareMode, Timestamp DateTime.Now }); } // 调用原始API绝不能省略返回值 return _originalCreateFileW( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } // 在Run里预先缓存原始委托避免每次回调都GetProcAddress private CreateFileWDelegate _originalCreateFileW; }Run()方法里先把原始函数地址解析出来并缓存到委托然后在回调里通过这个缓存委托调用原始 API。这比每次回调都动态解析要快一个数量级在高频调用场景下效果明显。之所以不能在构造函数里装 Hook是因为构造函数执行时注入线程还没完全准备好此时修改线程 ACL 和安装钩子容易导致线程同步异常。ThreadACL.SetInclusiveACL(new int[] { 0 })是“包括所有线程”的语义。和上一节本地 Hook 里SetExclusiveACL(new int[] {0})正好相反它表示“排除线程 0”也就是所有线程都放行。两种写法都常见语义要记牢用反的话回调永远不会被触发。4.3 注入器代码找到进程、传入通道、处理日志注入器负责把上面这个类库注入进记事本进程。核心调用是RemoteHooking.Inject的静态重载static void Main(string[] args) { // 获取记事本进程如果没有就启动一个 Process[] procs Process.GetProcessesByName(notepad); if (procs.Length 0) { Process.Start(notepad.exe); System.Threading.Thread.Sleep(2000); procs Process.GetProcessesByName(notepad); } int targetPid procs[0].Id; // 命名管道的名称随意起但要唯一 string channelName FileMonitor_ Guid.NewGuid().ToString(N); // 创建服务端通道 RemoteHooking.IpcCreateServerFileWriteInfo(ref channelName); // 注入参数按顺序传给HookLibrary构造函数 RemoteHooking.Inject( targetPid, InjectionOptions.DoNotRequireStrongName, typeof(FileMonitorEntryPoint).Assembly.Location, typeof(FileMonitorEntryPoint).FullName, new object[] { channelName }); Console.WriteLine(已注入进程 {0}按任意键退出监控..., targetPid); Console.ReadKey(); }IpcCreateServer中的泛型参数是消息类型不是回调类型。EasyHook 会把管道消息反序列化成该类型再传给注入器端的消息处理器。管道创建后Server 端会触发DataReceived事件但上面代码里没有挂事件需要补充var server RemoteHooking.IpcCreateServerFileWriteInfo(ref channelName); server.DataReceived new RemoteHooking.IpcServerFileWriteInfo.DataReceivedHandler((info) { Console.WriteLine([文件写入] {0} | 访问方式 0x{1:X} | 共享 0x{2:X}, info.FileName, info.Access, info.ShareMode); });注入参数的顺序极其重要。RemoteHooking.Inject的第 4 个参数是目标类型全名第 5 个参数是object[]数组它会按顺序传给目标类型的构造函数。我在上面的示例里构造函数参数是(RemoteHooking.IContext context, string channelName)所以数组里只需要一个channelNameEasyHook 会隐式传入IContext作为第一个参数。如果构造函数声明了三个参数数组里就要传两个元素。4.4 测试全流程并排查注入失败编译整个解决方案注意 HookLibrary 的程序集名不能包含空格或中文否则注入时加载程序集可能失败。运行注入器前先手动打开一个记事本再运行注入器控制台应该输出“已注入进程”。然后在记事本里按 CtrlS 保存任意内容为.txt文件注入器控制台就能看到文件路径输出。注入失败的常见现象是抛InjectionFailedException事件查看器里能看到 .NET Runtime 错误。我先列出三条高频原因一是托管程序集依赖缺失。HookLibrary 引用了某个不属于 .NET Framework 基础类库的 DLL目标进程加载时会失败。把那行引用注释掉或者把相关逻辑转移到别的模块。二是 x64/x86 位数不匹配。32 位注入器去注入 64 位进程必失败。排查方法是看任务管理器里目标进程是否带“32位”标记点击进程标签栏右键启用即可看到。三是杀毒软件拦截创建远程线程。这个最玄学代码没问题但就是注入失败。临时关掉实时保护测试如果关闭后正常给开发目录加白名单。出现这种问题的时候我会先在事件视图器里看有没有来自EasyHook的错误日志它经常把底层失败原因写得很详细。5. 避坑手册EasyHook 实战踩坑记录与排查方法5.1 回调里调用 MessageBox 导致界面冻结现象在 Hook 回调里写了一个调试用的 MessageBox拖动目标窗口时窗口直接卡死几秒后提示“未响应”。原因目标进程的主线程正在处理消息循环回调会把主线程阻塞在 MessageBox 的模态循环里而模态循环需要依赖该窗口的线程状态。更要命的是消息框也调用了 User32 的内部函数形成二次重入。解决回调里严禁任何阻塞 UI 的操作。想打日志用 File.AppendAllText 或者写到内存队列再异步批量落盘。必须弹窗的话用独立线程围绕断点信息弹出或者干脆在注入器端弹因为注入器是独立进程不会干扰目标进程。5.2 注册的回调委托被 GC 回收导致程序崩溃现象运行一段时间后Hook 突然失效随后目标进程抛ExecutionEngineException并退出。原因EasyHook 把委托传入LocalHook.Create后内部只保存了函数指针不保存对托管的引用。如果你的委托是局部变量函数返回后 GC 会回收它函数指针变成野指针。解决在 Hook 类里用静态字段保存所有委托的引用在类生命周期内绝不释放。上面的MessageBoxHook示例中_messageBoxHook放在类字段里就是为了防止回收如果你把它声明成局部变量十有八九要踩这个坑。5.3 递归调用导致无限循环CPU 飙到 100%现象Hook 安装后一调用目标 API 就死循环调试器显示栈空间被海量重复的 Hook 调用挤爆。原因回调里直接调用了GetProcAddress返回的原函数地址但因为该函数入口已被修改等于再次进入自己的回调。解决在Run()或者Install()时一次性解析原函数地址并缓存为委托后续回调全部调用这个缓存委托。代码格式参考上一章的_originalCreateFileW字段把它定义成private static以确保全局只解析一次。5.4 x64 和 x86 混淆导致注入器直接崩溃现象编译选择“任意 CPU”在 64 位系统上运行时RemoteHooking.Inject抛异常信息不明有时直接进程退出。原因AnyCPU 在 64 位系统上默认以 64 位运行但EasyHook的 Inject 内部要选择对应位数的原生 DLL。如果你在代码里同时引用了 32 位和 64 位版本的 DLLEasyHook 库无法自动判断该用哪一个。此外HookLibrary 是类库如果编译成了 AnyCPU注入到 32 位进程时它会尝试按 64 位加载直接抛 BadImageFormatException。解决整个解决方案的相关项目统一成同一个平台目标。常用做法是全部编译为 x64专门处理 32 位目标进程时单独开一个 x86 配置分组。判断目标进程位数用Environment.Is64BitProcess别猜。5.5 注入成功但回调从未触发现象注入器无异常进程列表能看到注入线程但日志文件一直没有内容。原因多半是 ACL 配置不对。默认情况下 LocalHook 只拦截“安装时的当前线程”那一根线程如果没调 ACL其他线程的调用都会直接穿透。另一个原因是你把ThreadACL.SetInclusiveACL和SetExclusiveACL用反了在错误的方向上过滤线程。解决先改用SetInclusiveACL(new int[]{0})测试0 表示“当前进程内所有线程”。如果依然没响应在回调入口加一行File.AppendAllText写字节码级别的日志看调用是否真的到达了回调主体。如果到了说明过滤问题如果没到说明注入的线程和实际发起 API 调用的线程是不同线程且 ACL 把它拦下了。6. 进阶实践把 EasyHook 做成多进程通用监控工具的经验把 Demo 扩展成通用小工具的场景下我习惯把注入器和 Hook 库的通信升级成双管道一条走监控数据一条走命令控制。原因是默认的单管道模型适合单向数据流但生产工具往往需要动态修改过滤规则比如运行时决定“只记录文件名以 tmp 结尾的写入”。这部分我用一个ControlChannel来传递指令注入器端收到指令后调用LocalHook的ThreadACL做动态调整或者直接重新设置回调内部的过滤条件。另一个我比较依赖的技巧是利用HookRuntimeInfo.Callback(IntPtr)和HookRuntimeInfo.ThreadId获取当前回调所在的线程和返回值地址。这在分析多线程调用场景时特别有价值——你可以把线程 ID 写进监控日志事后还原出到底是哪个业务线程在调用危险的 API。实现成本很低只需要在回调第一行记录这两个值。var threadInfo new ThreadInfo { ThreadId HookRuntimeInfo.ThreadId, CallbackAddress HookRuntimeInfo.Callback(IntPtr.Zero), Timestamp DateTime.Now }; _channel.Write(threadInfo);关于性能EasyHook 托管回调的开销在每秒几十万次调用的场景下完全可控。我实测过对CreateFileW的连续拦截每回调约 5 微秒主要开销在委托边界和参数封送如果对延迟特别敏感可以关闭SetLastError自动捕获。这份 Demo 工程最值得借鉴的地方在于它把注入、管道、事件分离三层写清楚了。新手往往想在一个类里把注入和监控混在一起结果耦合严重。从一开始强制分红黑两层后面加功能会轻松很多。我现在每次新做监控项目都会先从这份骨架开始把FileMonitorEntryPoint的泛型消息类型换成自己的业务对象然后直接套用管道和事件处理逻辑。这套模式已经在我手上跑过至少六个类似的监控需求每次都是有惊无险地落地。希望这份拆解能帮你少踩几个坑把 EasyHook 真正变成顺手可用的工具。本文还有配套的精品资源点击获取