简介面向需要监控或修改Windows进程行为的.NET开发者这套基于C# EasyHook的完整使用Demo覆盖了远程函数拦截与注入的实战流程。压缩包约854KB共91个文件包含17个C#源文件、15个DLL运行库、6个EXE示例程序及配套PDB调试符号、配置文件、资源文件与解决方案工程结构清晰打开即用。内容围绕EasyHook的NuGet安装配置、本地钩子创建、远程注入、回调委托绑定、线程ACL设置、DLL签名防拦截等关键环节展开涵盖LocalHook.Create、RemoteHooking、LocalHook.Install等核心API用法并给出了可运行的拦截示例演示如何钩住user32.dll中的ExitWindowsEx方法在触发时输出自定义日志同时讲解签名与权限处理以避免注入失败。已有912人学习下载适合具备C#基础、希望在实际项目中运用Hook技术做调试、性能分析或行为监控的开发者。1. EasyHook 为什么是 C# 拦截 API 的首选一个 Demo 换来的认知Windows 上想拦截某个系统 API传统做法是写 C/C 的 DLL 注入再挂一个全局钩子。对于 C# 开发者来说这条路光是把回调函数地址稳定地传给原生层就能耗掉一整晚。EasyHook 的价值在于它把 inline hook、线程过滤、注入和远程调用都封装成了托管 API你只需要写一个带正确签名的委托就能在 C# 里直接拿到被拦截调用的参数和返回值。这个 Demo 是我花了两个晚上跑通的期间踩了运行时服务缺失、位数不匹配、回调死锁三个坑后面都会展开。如果你正打算用 C# 做 API 监控、函数拦截、输入法级别的键盘过滤或者想给老旧程序加行为日志这份资源值得先跑一遍再改。2. EasyHook 的运行机制LocalHook、线程 ACL 与回调分发2.1 LocalHook 与全局注入到底差在哪EasyHook 提供了两套完全不同的使用路径。LocalHook 是在当前进程内安装钩子不需要额外注入适合做“自己程序里的 API 监控”比如检测某个第三方库是否偷偷访问了文件、或者拦截自己进程对 MessageBox 的调用。全局注入则要借助 RemoteHooking.CreateAndInject 把钩子 DLL 塞进目标进程适合做跨进程的监控工具。从原理上讲EasyHook 的底层是 inline hook它在目标函数的入口地址处改写前几条指令插入一个跳转指令使得该函数一旦被调用就先进入 EasyHook 的 thunk再由 thunk 调度到你在 C# 里注册的托管回调。这个机制决定了三件非常重要的事情第一被 hook 的函数地址是“真实地址”而不是模块名加函数名的字符串匹配所以你必须通过 LocalHook.GetProcAddress 拿到入口。第二回调是在目标函数所属线程上执行的不是新开线程所以回调里不能做无界等待否则会卡住调用方。第三inline hook 对函数调用的入口状态极其敏感委托签名有一丁点不对轻则拿不到参数重则栈不平衡直接崩进程。2.2 安装、回调、卸载的三段式生命周期一个标准 EasyHook 钩子实例生命周期可以拆成三个阶段。安装阶段用 LocalHook.Create 注册回调这个方法需要三个参数目标函数入口地址、回调委托实例、以及一个用于远程通信的对象引用在本地进程场景里第三个参数传 this 即可。紧接着要设置 ThreadACL即线程访问控制列表用于决定哪些线程的调用会被拦截。这个 ACL 有两种模式SetInclusiveACL 表示“只拦截列表里的线程”SetExclusiveACL 表示“除列表里的线程外全部拦截”。阶段二就是等待目标 API 被调用。回调执行的线程和调用线程一致所以你在回调里拿到的 Thread.CurrentThread.ManagedThreadId和调用方的线程 ID 是对应的。阶段三是卸载调用 LocalHook.Dispose 释放 hook 实例再调用 LocalHook.Release 清理 EasyHook 内部的运行时资源。如果只 Dispose 不 Release多次安装卸载后可能累计内部句柄这是很多“用久了才出问题”的根源。LocalHook hook LocalHook.Create( LocalHook.GetProcAddress(kernel32.dll, CreateFileW), new CreateFileWDelegate(CreateFileWCallback), this); hook.ThreadACL.SetExclusiveACL(new[] { 0 }); // 0 代表当前安装线程这段代码的意图是钩子只拦截“除了当前安装线程之外”的全部线程调用。为什么我要排除当前线程因为我的主线程负责安装、卸载和日志控制如果它自己在回调里去调用 CreateFileW很容易触发重入。实际项目中我会把安装操作放到初始化线程里然后让业务线程去触发 API这样日志不会因 hook 自身行为而自证清白。2.3 回调参数与封送规则EasyHook 的回调本质是委托CLR 会通过 UnmanagedFunctionPointer 特性把托管委托转成非托管函数指针然后 EasyHook 生成的 thunk 根据非托管调用约定调过来。所以委托的参数类型必须和 Win32 API 的 C 签名严格对应特别要注意三类错误一是指针类型滥用。C# 里你能看到的 string、IntPtr、bool到了原生层对应关系并不直观。Windows API 里的 LPCWSTR 在 x64 下是 64 位指针在委托里应该用 string 加 CharSet.Unicode 让封送层处理而 HANDLE 则必须用 IntPtr否则在 64 位进程里会被截断句柄。二是 bool 与 BOOL 的区别。Win32 的 BOOL 是 32 位整型不是 C# 的 bool若用 bool 接收 BOOL 返回值封送层只读一个字节高位数据错乱后续调用可能莫名失败。三是调用约定。绝大多数 Win32 API 是 StdCall但部分 CRT 函数是 CdeclEasyHook 不会自动帮你判断写错后栈里的参数无法解析回调即使执行也会读出垃圾值。[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int MessageBoxWDelegate( IntPtr hWnd, string lpText, string lpCaption, uint uType);上面这个是 MessageBoxW 的合法签名。uType 用 uint 而不用 int是为了后续做按钮类型位运算时避免符号扩展。这个习惯我保留了很长时间直到有一次用 int 判断 MB_YESNO 出了边界问题才彻底改掉。3. 搭建第一个 Demo环境配置与四步拦截 MessageBox3.1 环境配置与运行库部署很多人在 NuGet 里装上 EasyHook 包、写了一堆钩子代码结果一运行就报“EasyHook is not correctly installed”然后开始怀疑包版本问题。实际原因是 EasyHook 运行时不光需要托管 DLL还需要两个原生服务程序EasyHook32Svc.exe 和 EasyHook64Svc.exe它们负责系统全局钩子的底层安装和卸载。NuGet 包会把它们放进包目录但默认不会自动复制到输出目录。第一步在项目里安装 EasyHook NuGet 包目标框架建议 .NET Framework 4.7.2 或 .NET 6 的 Windows 专用版本。第二步打开项目文件把 EasyHook 包底下 run-time 目录里的原生文件设置成始终复制。我用的是手动处理在项目输出目录建一个 lib 子目录放四个文件EasyHook32.dll、EasyHook64.dll、EasyHook32Svc.exe、EasyHook64Svc.exe。ItemGroup None Includelib\EasyHook32.dll CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None None Includelib\EasyHook64.dll CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None /ItemGroup这里有个容易忽略的参数坑Svc 程序必须和宿主程序在同一目录不能只放在子目录里否则 EasyHook 内部用相对路径找服务进程时会失败。我一般会把四个文件全部放在输出根目录虽然乱一点但排查问题时省心。另外项目平台目标建议直接选 x64 或 x86不要用 AnyCPU。AnyCPU 程序在 64 位系统上会以 x64 进程运行如果引用的某个原生依赖是 32 位运行时会直接崩掉而且崩的位置很难从托管异常里看出来。3.2 编写钩子类注册、回调与卸载接下来进入核心编码。把 MessageBoxW 作为目标因为它的签名简单、参数直观适合验证整个链路是否通畅。MessageBoxW 是 user32.dll 的导出函数返回 int四个参数分别是窗口句柄、内容字符串、标题字符串和按钮类型。我们拦截它的目的很简单吞掉这个调用不让真正的消息框弹出只把参数打印到控制台。这个行为对验证 hook 生效非常直观。回调里需要注意的第一件事是绝对不能直接调用 MessageBox 原函数。因为原函数入口已经被改写再调用会重新进入钩子造成无限递归。对于这个 Demo我直接返回一个固定值 0 来模拟用户点击了“确定”按钮。using System; using System.Runtime.InteropServices; using EasyHook; namespace EasyHookDemo { public class MessageBoxHook { [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int MessageBoxWDelegate( IntPtr hWnd, string lpText, string lpCaption, uint uType); private LocalHook _hook; public void Install() { _hook LocalHook.Create( LocalHook.GetProcAddress(user32.dll, MessageBoxW), new MessageBoxWDelegate(MessageBoxHookCallback), this); // 只拦截安装线程的调用避免其他线程被意外拦截导致 UI 卡住 _hook.ThreadACL.SetInclusiveACL(new[] { 0 }); } private int MessageBoxHookCallback( IntPtr hWnd, string lpText, string lpCaption, uint uType) { Console.WriteLine($[拦截] 标题: {lpCaption}); Console.WriteLine($[拦截] 内容: {lpText}); Console.WriteLine($[拦截] 按钮类型: {uType}); return 0; } public void Uninstall() { _hook?.Dispose(); LocalHook.Release(); } } }这段代码里有三个细节值得展开。SetInclusiveACL(new[] { 0 }) 里的 0 表示“当前安装线程”。如果目标 API 是在工作线程里调用的ACL 列表里没那个线程钩子就不触发这是有意为之用来降低 Demo 的干扰面。LocalHook.Create 的第三个参数传 this在纯本地场景没有实际通信需求但框架要求这个参数不能为 null传 this 可以让内部远程上下文保持正常。回调返回 0 是模拟 MessageBox 的 IDOK如果这里不返回而是继续调用原函数就需要保存原函数的调用方式第三章末尾我再讲替代方案。3.3 主程序触发与验证主程序需要做三件事安装钩子、触发 MessageBox、卸载钩子。为了验证“拦截生效”而不是“程序出错”我在触发之前加了控制台提示触发之后再看控制台输出。class Program { static void Main(string[] args) { var msgHook new MessageBoxHook(); msgHook.Install(); Console.WriteLine(钩子已安装按任意键触发 MessageBox...); Console.ReadKey(); MessageBox(IntPtr.Zero, 这是一条被监控的消息, Demo, 0); Console.WriteLine(触发完成按任意键卸载钩子...); Console.ReadKey(); msgHook.Uninstall(); Console.WriteLine(钩子已卸载.); Console.ReadKey(); } [DllImport(user32.dll, CharSet CharSet.Unicode)] private static extern int MessageBox( IntPtr hWnd, string lpText, string lpCaption, uint uType); }运行后你会看到控制台先打印“钩子已安装”按下任意键后真正的 MessageBox 窗口不会弹出来控制台打印出拦截到的标题和内容。这一步能跑通说明 EasyHook 的安装、thunk 跳转、委托调用的完整链路没问题。如果窗口还是弹出来了优先检查 ThreadACL——注意我是在 Install 之后设置的 ACL顺序不能反因为 LocalHook.Create 内部会启用默认拦截策略Create 之后立即设置 ACL 才能确保没有线程在此之前被误拦。这个 Demo 中“吞掉调用”的方式虽然简单但已经能体现钩子最核心的能力在调用真正发生之前拿到参数改变进程行为。下一章我会把这个能力扩展成更实用的文件访问监控。4. 把 Demo 改造成实用工具拦截 CreateFileW 并记录访问日志4.1 为什么选 CreateFileW 作为进阶目标MessageBox 拦截只是验证 hook 机制实际工作中更常见的需求是记录程序访问了哪些文件。CreateFileW 是 Windows 文件访问的入口函数几乎所有文件打开操作都会经过它除非程序用了直接内存映射的底层接口。拦截这个函数有三个挑战参数多、透传难、句柄容易泄漏。相比 MessageBox 只有四个参数CreateFileW 有七个参数其中还有两个是指针和句柄类型回调签名稍微写错就会导致参数错位。在动手之前必须先确认签名。CreateFileW 的原型来自 kernel32.dll第一个参数 lpFileName 是文件路径第二个参数 dwDesiredAccess 是访问模式第三个 dwShareMode 是共享模式最后一个 hTemplateFile 是模板句柄。委托签名必须和原生声明一致访问模式和共享模式用 uint句柄用 IntPtr不能简化。[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);这里的核心设计是返回值类型 IntPtr。CreateFileW 的返回值在成功时是一个有效的文件句柄失败时是 INVALID_HANDLE_VALUE也就是 IntPtr(-1)。如果把返回值写成 int在 64 位进程里句柄会被截断成 32 位后续 ReadFile 传到内核层就会拿到一个错误的句柄值属于非常隐蔽的运行时错误。这个坑我踩过一次现象是文件访问监控程序本身运行正常但被监控的目标程序偶尔报句柄无效排查了很久才发现是回调委托的返回值类型写错了。4.2 回调实现记录路径并精准放行文件访问监控不能像 MessageBox Demo 那样直接吞掉调用否则目标程序会失去文件访问能力。这里的策略是把路径记录进日志然后把真实调用交给系统去执行。但这里有一个重入问题如果在回调里直接调用 CreateFileW因为钩子已经在函数入口处做了改写这次调用会被同一线程的钩子再次拦截形成无限递归。EasyHook 没有公开的“调用原函数”API常见的做法有两个一是通过 ThreadACL 临时移除当前线程的拦截二是调用一个功能等价、但入口地址不同的替代函数。对于 CreateFileW我会用 CreateFileA 作为透传替身。CreateFileA 是 ANSI 版本的同一函数逻辑完全一致不同点是文件路径按当前代码页编码。因为我的日志场景主要处理 ASCII 路径用 CreateFileA 不会出问题如果目标程序大量使用中文路径这个方案会有编码损耗那就需要提前把字符串转成 ANSI 字节再传入。这是我在生产环境里实际用过的权衡方案适合 Demo 阶段理解问题本质。public class FileHook { [DllImport(kernel32.dll, CharSet CharSet.Ansi, SetLastError true)] private static extern IntPtr CreateFileA( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private LocalHook _hook; private readonly object _lock new object(); public void Install() { _hook LocalHook.Create( LocalHook.GetProcAddress(kernel32.dll, CreateFileW), new CreateFileWDelegate(CreateFileWCallback), this); // 全进程拦截排除当前安装线程 _hook.ThreadACL.SetExclusiveACL(new[] { 0 }); } private IntPtr CreateFileWCallback(...) { lock (_lock) { Console.WriteLine($[文件访问] {lpFileName}); } // 透传调用 A 版本函数避免重入 return CreateFileA(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } }回调里的 lock 很重要。EasyHook 的回调是在调用方的线程上执行的也就是说多个业务线程同时调用 CreateFileW 时回调会在多个线程并发执行。如果 Console.WriteLine 不保护控制台输出会交叉后续改成写文件日志时还会出现文件句柄竞争。用 lock 锁住全局输出是最简单的做法但它只适合日志量低的场景如果目标程序高频访问文件瓶颈不在 hook 而在锁那就要改为每线程缓存、异步批量刷盘。4.3 日志模块把输出从控制台挪到文件控制台输出适合验证不适合长期运行。生产环境下钩子程序本身往往没有交互窗口日志需要落到文件。我在改造时增加了一个简单的日志类用 Queue 收集日志行再开一个后台定时线程定期刷盘。这里特别注意不要在回调里直接用 File.AppendAllText那个方法会频繁打开关闭文件句柄而钩子回调本身是在业务线程上执行的长时间阻塞会导致调用方卡顿。private readonly ConcurrentQueuestring _logQueue new ConcurrentQueuestring(); private void EnqueueLog(string message) { _logQueue.Enqueue(${DateTime.Now:HH:mm:ss.fff} {message}); } private void FlushLogs() { using (var writer new StreamWriter(file_access.log, append: true)) { while (_logQueue.TryDequeue(out string line)) { writer.WriteLine(line); } } }FlushLogs 方法放在一个 Timer 里每隔 5 秒调用一次。这样回调里只做入队不做 IO统计了 10 万次文件访问之后目标程序基本感受不到性能损耗。我测试过把 FlushLogs 的间隔调到 1 秒日志实时性好了但磁盘 IO 波动明显最终商品化版本里我在配置里加了间隔参数默认 3 秒。这个文件访问钩子跑通后你会发现 EasyHook 的调试逻辑其实很有规律先确认 API 签名再确认 ACL 范围最后确认透传方式。三件事做对剩下的就是业务逻辑。下一章集中讲我在这个 Demo 过程中真实遇到过的五个坑每个都是先给现象再给原因。5. 避坑手册五位不匹配、运行库缺失与回调死锁的排查记录5.1 运行时报“EasyHook is not correctly installed”现象安装完之后一调用 LocalHook.Create 就抛出异常提示 EasyHook 没有正确安装异常堆栈里还能看到对 EasyHook32Svc.exe 的引用。原因EasyHook 的运行时服务进程没有放到宿主程序的输出目录。NuGet 包通常不会自动部署原生服务程序项目输出目录里只有托管 DLL。解决手动把四个原生文件复制到输出根目录。我在一个同事的机器上排查时发现他复制了文件但放进了子目录EasyHook 用完整路径找不到这个问题当时浪费了一个小时。提示部署时不仅要有 EasyHook32.dll、EasyHook64.dll还要有对应位数的 Svc.exe。很多发行版只拷贝了 DLLSvc 缺失时钩子安装功能会静默降级只在特定操作时才报错。5.2 64 位系统上 x86 进程钩子不生效现象目标程序是 32 位宿主程序编译成 AnyCPU钩子安装时没有报错但回调一次都不执行。原因宿主程序以 64 位进程运行EasyHook 内部加载的是 EasyHook64.dll而目标 API 所在进程是 32 位两者位宽不匹配。EasyHook 的 LocalHook 只能在当前进程内使用如果你的宿主程序和目标代码不在同一个进程空间这个钩子本身就没有意义。解决把宿主程序显式编译为 x86或者为目标进程使用全局注入那一套。我这里被坑过的地方是误以为“AnyCPU 能兼容所有情况”实际上 AnyCPU 只解决 CLR 层问题原生 inline hook 完全依赖进程位宽。5.3 回调里调用被 hook 的 API 导致死循环现象控制台日志疯狂刷屏或者目标进程进入假死状态CPU 占用达到单核 100%。原因回调里直接调用被 hook 的函数比如 CreateFileWCallBack 里又调了 CreateFileW因为入口处已经被做了 inline 跳转所有调用都会再次进入回调。解决透传调用同一函数族的其他版本或者用 ThreadACL 将当前线程排除在拦截范围之外。我自己的习惯是拦截 CreateFileW 就透传给 CreateFileA拦截 FindFirstFileW 就透传给 FindFirstFileA保证透传调用永远不被自身的钩子捕获。5.4 委托签名错误导致进程崩溃现象钩子安装成功但目标 API 一旦被调用进程直接退出没有托管异常Windows 事件日志里只显示“应用程序错误”。原因回调委托的签名与原生函数入口不匹配thunk 跳转后从栈里解析参数拿不到正确数据导致后续指令异常。最常见的错误是把 bool 和 BOOL 混用、把 IntPtr 写成 int、或者把 StdCall 错写为 Cdecl。解决在编写委托前打开 Microsoft Docs 核对原生签名逐个参数核对类型对于所有指针和句柄一律使用 IntPtr返回值类型也按原生声明严格对应。这个属于归纳过很多次的问题我每次给新成员代码审查时都会优先检查这三项。5.5 注入进程权限不足导致安装失败现象目标进程能打开但注入后目标进程崩溃或者钩子实例无法访问 ContinueInjection 上下文。原因目标进程是管理员权限启动而宿主程序是普通权限EasyHook 的 Svc 进程无法向高权限进程写入钩子数据。解决宿主程序以管理员身份运行必要时在进程启动阶段开启 SeDebugPrivilege但要注意 Windows 对系统级保护进程依然会拒绝访问这种场景 EasyHook 也无能为力。我一般在部署文档里明确写监控目标必须和宿主程序同权限否则一切 hook 相关 API 都可能失败。6. 进阶技巧注入外部进程并验证钩子生效本地进程内的 LocalHook 适合工具型项目但更常见的需求是“监控别的程序访问了什么文件”。这套机制的核心是继承 IEntryPoint 的注入类配合 RemoteHooking.CreateAndInject 启动目标进程。一个最小可用方案是这样的宿主程序负责拉起目标进程注入类在目标进程内部完成 LocalHook 安装然后通过日志文件或命名管道把信息传回。public class FileMonitorEntry : IEntryPoint { private LocalHook _hook; public FileMonitorEntry(RemoteHooking.IContext context, string channelName) { // 构造阶段不要安装钩子等待 Run 执行 } public void Run(RemoteHooking.IContext context, string channelName) { _hook LocalHook.Create( LocalHook.GetProcAddress(kernel32.dll, CreateFileW), new CreateFileWDelegate(CreateFileWCallback), this); _hook.ThreadACL.SetExclusiveACL(new[] { 0 }); // 注意这里不能阻塞要让注入返回控制权 RemoteHooking.WaitForProcessExit(); } }宿主程序启动目标进程的代码是 RemoteHooking.CreateAndInject传入目标 exe 路径、进程 ID、注入库路径和 IPC 参数。实操中我发现最容易出问题的是注入库路径必须写绝对路径而且 EasyHook 的 Svc 会读取注入 DLL 所在目录找运行库如果目录里没有配套的原生文件注入进程会启动失败。验证钩子是否生效的具体技巧是在 Run 方法里先写一行启动日志再在回调里写访问日志两者都出现在日志文件中才算链路完整。常见情况是启动日志写了、回调日志没写那就说明 ACL 或函数签名有问题只管动这两个地方。我一般还会让目标进程主动调用一次 CreateFileW今天的验证循环是启动注入 → 观察启动日志 → 目标进程调 CreateFileW → 观察访问日志 → 检查返回值是否被正确处理。从那以后我每次搭 EasyHook 项目都会强制走一遍三查流程查运行库文件是否齐全、查委托签名是否逐字段匹配、查 ACL 范围是否符合预期。这套流程帮我避免了大半的“玄学”问题也希望帮到你。本文还有配套的精品资源点击获取