2026/10/12 4:38:41

C# easyHook实战:本地API钩子拦截与放行完全指南

C# easyHook实战:本地API钩子拦截与放行完全指南 简介面向C#开发者的EasyHook远程钩子注入示例包演示了如何在运行时拦截目标进程函数、注入自定义逻辑适用于程序调试、行为监控及逆向分析场景。压缩包共91个文件854KB含17个cs源码、15个dll库、6个exe可执行程序、7个pdb调试符号及配套配置文件工程包含完整解决方案与调试符号方便二次开发与断点排错代码结构清晰易懂可打开即用。示例覆盖从NuGet安装、命名空间引入到LocalHook.Create、Install启动钩子及回调委托绑定的完整流程并特别展示了未签名dll触发Windows安全拦截时的处理思路。包内提供可直接运行的演示程序便于观察钩子生效过程通过阅读源码可掌握ExitWindowsEx等API的拦截写法与返回处理技巧。已有912人学习下载适合刚接触EasyHook、希望获得可运行范例的C#开发者参考。1. C# easyHook使用demo本地 API 钩子到底能帮你省掉什么写客户端自动化测试时最烦的不是点击坐标而是弹窗把流程卡住。以前我用 SetWindowsHookEx 做键盘钩子句柄、消息泵、回调线程全要自己管稍不留神主线程就崩换成 C# easyHook 使用 demo 那套做法后栈上是托管委托直接挂在 user32.dll、kernel32.dll 的导出函数上拦截、改参、放行全写在 C# 里可以断点、可以单测。它最适合作 UI 自动化、行为日志采集、无障碍辅助这类偏底层的需求新手跟得上熟手也能靠线程 ACL 控粒度。下文先讲本地钩子的运行模型再给最小可复现 demo、CreateFileW 拦截示例最后集中说权限、位数、递归这些最容易翻车的点。2. easyHook 的选型与运行模型为什么托管钩子比纯 P/Invoke 钩子更省心2.1 三种开箱即用的钩子方案easyHook 在托管生态里的位置想在 C# 里做 API 级钩子绕不开三种姿势。第一种是用 SetWindowsHookEx它本质上是窗口消息钩子适合键盘、鼠标、剪贴板这类消息场景但低级钩子要求回调函数常驻在一个带消息循环的线程里你在 C# 里就必须自己拼一个消息泵出来回调里收到的是消息码而不是干净的函数参数处理起来非常绕。第二种是用 C/C 的 Detours 一类库它的做法是把函数入口的前几个字节改写为一条 jmp 到自定义函数的指令能力强但记性要求高纯 C# 团队接手这份代码往往是灾难。第三种就是 easyHook 这种托管封装方案你在 C# 里声明一个和导出函数签名一致的委托传给 LocalHook.Create剩下的跳板、原始字节保存、按线程放行全部由库完成。三种方案放在一起对比能直接看出差异方案适合场景跨进程注入上手成本维护难点SetWindowsHookEx窗口消息类钩子需要额外 DLL 注入中消息泵、线程生命周期Detours 类C/C 深度定制支持但很复杂高架构、位数、指令长度easyHook托管 API 钩子内置注入宿主支持低签名匹配、权限、位数我当时选 easyHook 的理由很简单团队全是 C# 背景不想为一条钩子专门维护 C 工程而且 easyHook 的回调就是一个普通委托错误可以直接抛到托管层而不是一个无声无息的访问违例。它的维护节奏偏老但 API 非常稳定社区里能找到的坑基本都是同一个套路这反而让后来者省心。2.2 本地钩子与全局注入demo 到底该选哪一种easyHook 里最常用的入口是 LocalHook作用范围是你自己的进程。它在进程内把某个原生导出函数的入口替换成跳板当该进程的线程调用这个函数时先走进你注册的托管回调。这个范围对绝大多数调试、探针、自动化测试场景已经够用。全局注入则是另一条路线easyHook 提供一个宿主机制把一个托管程序注入到目标进程里让回调逻辑在目标进程内执行常用于键盘鼠标全局监听、跨进程 UI 自动化。我一般会建议客户先跑通本地再考虑全局。原因是本地钩子不涉及提权、不涉及宿主程序位数匹配出错时可以单步调试全局注入需要管理员权限而且宿主程序的 x86/x64 位数必须和目标进程一致否则注入那一层就够你折腾半天。有一个很典型的例子某次我要统计一个老客户端软件在启动阶段打开哪些配置文件目标进程是 32 位如果直接用 64 位宿主去注入注入器会直接失败。这类边界问题留在本地方案里根本不存在。所以选型顺序很明确先确定你要观察的目标函数是不是自己进程会调的如果是用 LocalHook如果需要盯着别的进程才考虑全局注入并且先确认目标位数和权限条件。2.3 一次 Hook 的生命周期Create、Install 和 ThreadACL一次标准 Hook 流程就是三个动作。先用 LocalHook.GetProcAddress(user32.dll, MessageBoxW) 拿到目标模块里导出函数的真实入口地址再把这个地址、一个与目标函数签名一致的委托、一个可选的用户数据参数传给 LocalHook.Create得到一个 LocalHook 实例最后调用 Install 让钩子生效。卸载时调用 Uninstall。这里最关键的概念是 ThreadACL。easyHook 不像 C 钩子那样对所有线程一刀切它允许你按线程 ID 控制钩子的生效范围。SetInclusiveACL 表示只钩列表中指定的线程SetExclusiveACL 表示除了列表中的线程之外全部钩。这个机制除了控制范围还承担着“放行”的职责回调里想调用真正的原函数需要先把当前线程临时豁免否则会再次跳进跳板形成递归。后面 3.3 和 4.1 的代码都会用到这个点。另外一个必须记住的事实回调是在被钩线程的栈上执行的不是有一个独立线程跑你的回调。所以回调里绝对不要做阻塞式等待、不要弹模式对话框、不要 Sleep否则被钩线程会被你亲手卡死。3. 跑通最小 demoNuGet 引入、拦截 MessageBoxW 与放行原函数3.1 用 dotnet add package 把 EasyHook 放进控制台项目我习惯先用控制台项目验证跑通之后再往业务工程里迁移。项目要求很简单.NET 6/8 或 .NET Framework 4.7.2 均可但必须把目标平台锁定成 x64原因是 easyHook 的原生库分 x86/x64 两份AnyCPU 下运行时可能加载到错误的那一份。用命令行创建项目并安装包步骤如下dotnet new console -n EasyHookDemo cd EasyHookDemo dotnet add package EasyHook dotnet build如果你用的是带 NuGet 图形界面的 IDE直接搜索 EasyHook 安装也一样。装完后打开 csproj确保里面有下面这一行平台设置PlatformTargetx64/PlatformTarget对 .NET 6 项目建议同时指定 RuntimeIdentifier 为 win-x64避免发布和 Debug 运行时原生库加载路径不一致。这个细节属于典型的不报错但运行时不生效后面第 5 章会展开讲。包安装成功后项目里应能引用 EasyHook 命名空间。3.2 吞掉 MessageBoxW 的最小 C# 代码选 MessageBoxW 做第一个 demo 是很划算的因为 user32.dll 一定在进程里MessageBoxW 的签名简单到只有四个参数而且它弹窗有可见效果钩子有没有生效一眼就能看出来。下面这段代码的目标是程序里的 MessageBoxW 调用被拦截弹窗不出现后台打印一条日志并直接向调用方返回 1也就是 IDOK让调用方以为用户点了确定。using System; using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices; using EasyHook; namespace EasyHookDemo { public static class MessageBoxHookDemo { // 声明与 MessageBoxW 完全一致的托管委托 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] private delegate int MessageBoxWDelegate( IntPtr hWnd, string text, string caption, uint type); private static LocalHook _hook; private static IntPtr _originalMessageBoxW; public static void Install() { // 1. 取得 user32.dll 里 MessageBoxW 的真实入口 _originalMessageBoxW LocalHook.GetProcAddress(user32.dll, MessageBoxW); // 2. 构建钩子入口地址、托管回调、用户数据 _hook LocalHook.Create( _originalMessageBoxW, new MessageBoxWDelegate(OnMessageBoxW), null); // 3. 把当前进程现有线程全部纳入钩子范围 var threadIds Process.GetCurrentProcess().Threads .CastProcessThread() .Select(t t.Id) .ToArray(); _hook.ThreadACL.SetInclusiveACL(threadIds); // 4. 安装钩子从这一行之后进程内 MessageBoxW 都会被接管 _hook.Install(); } // 回调签名必须和被钩函数一致 private static int OnMessageBoxW( IntPtr hWnd, string text, string caption, uint type) { Console.WriteLine($[hook] 拦截到弹窗: {caption} - {text}); // 直接吞掉弹窗返回 1(IDOK)调用方会认为用户点了“确定” return 1; } public static void Uninstall() { _hook?.Uninstall(); } } }这段代码的关键在ThreadACL.SetInclusiveACL。我把当前进程里所有现存线程的 ID 收集成一个数组传进去意思是只有这些线程会被钩住。demo 里主线程就是弹窗线程所以主线程必须在这个集合里。如果漏掉回调就不会被触发。ProcessThread.Id返回的是操作系统线程 ID不是 C# 的托管线程 ID别混用。_originalMessageBoxW这个字段先保留原始地址后面放行原函数时会用到。在示例的“吞掉弹窗”版本里我们不需要调用原函数这个字段纯粹为下一节做准备。调用Install之后随便在 Main 里 P/Invoke 一次 MessageBoxW 或者调用MessageBox.Show窗口都不会出现控制台会打印拦截日志。这个效果足够证明钩子已生效。3.3 放行原函数用线程豁免避免递归吞掉弹窗只是钩子的第一种用法更多时候你要做的是改参数再放行。比如给弹窗标题加一个“demo”前缀让用户看到钩子真实改写了行为。放行的难点在于直接调用_originalMessageBoxW并不是真的绕过钩子因为那个地址指向的指令已经被 easyHook 跳板改写再次调用会重新进入你的回调形成递归直到栈溢出。easyHook 给出的解法就是线程豁免。思路是回调里先调用SetExclusiveACL把“当前线程”临时剔除出钩子范围然后去调原始函数调用完毕后再用SetInclusiveACL恢复原来的集合。放在 MessageBoxW 这个例子里代码长这样private static int OnMessageBoxW( IntPtr hWnd, string text, string caption, uint type) { // 记录当前线程 ID所有控线程 ID 都用同一个集合保存 var myThreadId GetCurrentThreadId(); // 1. 临时豁免当前线程后面调用原函数不会被再次拦截 _hook.ThreadACL.SetExclusiveACL(new[] { myThreadId }); try { // 2. 从已保存的原始地址封送委托调用真正的 MessageBoxW var original Marshal.GetDelegateForFunctionPointerMessageBoxWDelegate( _originalMessageBoxW); // 3. 参数已经被改写过标题加前缀正文加前缀 int result original( hWnd, $[hook]{text}, $[demo]{caption}, type); return result; } finally { // 4. 无论是否异常都要恢复原来的钩子范围 var threadIds Process.GetCurrentProcess().Threads .CastProcessThread() .Select(t t.Id) .ToArray(); _hook.ThreadACL.SetInclusiveACL(threadIds); } } // 通过 P/Invoke 拿操作系统线程 ID [DllImport(kernel32.dll)] private static extern int GetCurrentThreadId();这段逻辑里最容易忽略的是 try/finally 结构。如果省略放行过程中一旦被钩函数抛异常当前线程就一直停留在“豁免”状态之后的调用全部绕过钩子看起来就是钩子突然失效。这个习惯必须保持ACL 改动是临时状态异常也要恢复。另外注意SetExclusiveACL传入的是当前线程 ID不是托管线程的ManagedThreadId。这就算完整跑通了“拦截、改参、放行”三个动作。一次 hook 的核心能力全在这里后面拦截其他 API 只是换签名和业务处理。4. 拦截 kernel32.CreateFileW参数映射、日志统计与验证方法4.1 用 LocalHook 钩住 CreateFileW 并记录文件访问看完 MessageBoxW 的示例拦截文件 API 就是复制套路。这里我选择 CreateFileW 作为第二个 demo因为它参数多、类型杂最能体现非托管参数映射的规则。目标是程序里所有通过 CreateFileW 打开的文件路径都被记录到日志同时统计调用次数文件本身照常打开。回调里放行需要线程豁免正好把 3.3 学到的方法复用一遍。using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using EasyHook; namespace EasyHookDemo { public static class CreateFileHookDemo { // CreateFileW 的参数比 MessageBoxW 多签名必须准确 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] private delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private static LocalHook _hook; private static IntPtr _originalCreateFileW; private static int _callCount; private static readonly object LogLock new object(); public static void Install() { // 有些系统 API 的实现在 win32u 或 ntdll但 CreateFileW 入口在 kernel32 _originalCreateFileW LocalHook.GetProcAddress( kernel32.dll, CreateFileW); _hook LocalHook.Create( _originalCreateFileW, new CreateFileWDelegate(OnCreateFileW), null); var threadIds Process.GetCurrentProcess().Threads .CastProcessThread() .Select(t t.Id) .ToArray(); _hook.ThreadACL.SetInclusiveACL(threadIds); _hook.Install(); } private static IntPtr OnCreateFileW( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { Interlocked.Increment(ref _callCount); // 日志写入自己进程内不要在这里做耗时操作 lock (LogLock) { File.AppendAllText( createfile_hook.log, ${DateTime.Now:HH:mm:ss.fff} access{dwDesiredAccess:X8} file{lpFileName}\r\n); } // 放行先豁免当前线程再调原函数 _hook.ThreadACL.SetExclusiveACL(new[] { GetCurrentThreadId() }); try { var original Marshal.GetDelegateForFunctionPointerCreateFileWDelegate( _originalCreateFileW); return original( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } finally { var threadIds Process.GetCurrentProcess().Threads .CastProcessThread() .Select(t t.Id) .ToArray(); _hook.ThreadACL.SetInclusiveACL(threadIds); } } [DllImport(kernel32.dll)] private static extern int GetCurrentThreadId(); } }这段代码里有两个容易被忽略的设计。第一个是用了文件日志和Interlocked计数器而不是Console.WriteLine因为 CreateFileW 在程序启动阶段会被 .NET 运行时内部高频调用直接写控制台会造成大量 IO 竞争影响被钩线程的执行节奏。第二个是放行时没有改动任何参数只是原样转发最大程度保证行为一致。有一点必须提醒.NET 的File.WriteAllText、FileStream底层大概率走的是 NtCreateFile 链路而不是直接走到 kernel32.CreateFileW所以钩子可能不触发。你需要用 P/Invoke 直接调 CreateFileW 来验证而不是以为钩子没生效。下面这个验证方法会专门处理这件事。4.2 非托管参数映射表从 DWORD 到 IntPtrCreateFileW 的七个参数恰好覆盖了钩子声明里最常用的非托管类型。写回调之前先把几个高频类型映射搞清楚不然回调签名错一个字母运行时就是访问违例。非托管类型C# 类型大小说明备注LPCWSTRstring按指针传必须配合 CharSet.UnicodeDWORDuint4 字节固定不要写成 int高位可能被解释成负数HANDLEIntPtrx64 下 8 字节返回值统一用 IntPtrBOOLint4 字节不是 C# boolbool 在非托管布局里只有 1 字节LPVOIDIntPtr进程位数相关指针类型一律用 IntPtrSIZE_TUIntPtrx64 下 8 字节计数和长度类参数LPARAM/WPARAMIntPtr/UIntPtrx64 下 8 字节消息回调里常见签名核对顺序也是固定的先看参数个数再看每个参数的非托管类型最后确认调用约定。x64 下 Windows 只有一种调用约定这层风险主要在 x86但如果你的进程跑在 x86回调就必须和被钩函数一致地声明成 StdCall。GetProcAddress 返回 0 时先查模块名是否拼错再查这个函数是不是真的由该模块导出。4.3 钩子是否生效从日志和计数器看结果验证要能自证不要靠“感觉”。我给这个 demo 配的验证方法是Install 之后先用 P/Invoke 主动调一次 CreateFileW 写一个测试文件然后检查 createfile_hook.log 有没有对应的一行再查看_callCount是否增加。P/Invoke 触发代码是这样[DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern IntPtr CreateFileW( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); IntPtr handle CreateFileW( C:\temp\hook_test.txt, 0x40000000, // GENERIC_WRITE 0x1, // FILE_SHARE_READ IntPtr.Zero, 2, // CREATE_ALWAYS 0x80, // FILE_ATTRIBUTE_NORMAL IntPtr.Zero);如果日志里出现了这一条说明钩子链路完整。如果没出现先检查进程位数是否 x64再检查回调签名是否完全一致最后确认Install确实被调用了。这里有个很容易误导人的现象用File.WriteAllText触发反而看不到日志于是以为钩子坏了实际是因为底层走的是 NtCreateFile根本没经过 kernel32.CreateFileW。这个差别能在排错上省掉几个小时属于绕不开的认知。5. easyHook 踩坑记录权限、位数、递归与加载失败5.1 装钩子后程序崩溃回调签名与真实调用约定不一致现象Install 之后程序运行到目标函数时直接访问违例或者事件查看器里记录一条 0xc0000005 崩溃更隐蔽的情况是回调被触发但参数全是乱码特别是字符串参数显示成一小串乱字符。原因回调委托的签名和被钩函数的真实签名不一致。最容易出错的是三处DWORD 错写成 intBOOL 错写成 C# bool字符串没声明 CharSet.Unicode导致宽字符被当成 ANSI 解析。调用约定同样关键x86 下 MessageBoxW 是 StdCall如果你声明成 Cdecl栈平衡会全部错乱。解决回调签名严格对照 MSDN 的函数声明逐参数核对一个类型都不要偷懒。写完之后先用普通 DllImport 把目标函数调一次如果 DllImport 能正确工作再把同样一套签名搬进委托里。这一步能从源头上筛掉一半的崩溃。5.2 全局注入失败或无声无息宿主位数与管理员权限现象使用全局注入把宿主程序注入另一个进程返回了成功但目标进程里没有任何回调日志或者直接抛异常异常信息指向权限不足。还有一种更隐蔽的表现注入成功但目标进程监控的窗口消息一个都收不到。原因全局注入的宿主程序位数必须与目标进程一致。目标是 32 位进程宿主却是 x64注入链路根本走不通反过来也一样。另一个坑是权限注入跨进程钩子需要管理员权限普通权限下 easyHook 的注入器会静默失败。解决先确认目标进程位数把宿主程序单独编译成匹配的位数然后整个控制台程序用管理员身份启动。验证注入是否到位可以在宿主程序启动时写一个标志文件目标进程没出现标志文件就不要继续查业务逻辑。5.3 回调日志疯长到卡死放行原函数没有豁免当前线程现象钩子装上后只要目标函数被调一次日志就瞬间刷出成千上万行程序很快无响应最后抛出 StackOverflowException。原因入口函数已经被替换成跳板你在回调里再调用“原始函数地址”时实际又走进了跳板跳板再次触发你的回调无限递归。这个递归不是因为签名错而是因为跳板不会自动识别“这是回调自己在调用”。解决放行前用SetExclusiveACL临时豁免当前线程放行后再恢复。恢复动作必须放在 finally 里否则被钩函数抛出异常后线程会一直处于豁免状态钩子从此静默失效。ACL 切换本身有开销所以高频函数的回调里不要频繁改 ACL能批量收集的就批量收集。5.4 x64 进程钩不到 x86 模块位数决定可见性现象进程是 64 位模块列表里能看到某个 32 位 DLL 的路径但用 GetProcAddress 去取它的导出函数地址返回 0钩子直接报错。原因进程只能加载与自身位数匹配的 DLL。64 位进程加载不了 32 位 DLL列表里的路径可能是其他 64 位依赖里的同名模块跨位数函数地址本身就不在同一个地址空间直接取指针没有任何意义。解决把钩子进程编译成与目标模块一致的位数。如果你监控的目标本身是 32 位进程就老老实实做全局注入用 32 位宿主不要指望在 64 位进程里隔着地址空间钩 32 位入口。这属于体系结构边界不是代码能绕过去的。5.5 hook 对象被 GC 回收钩子跑一会自己失效现象钩子刚运行的前几分钟工作正常之后某个时间点开始目标函数再也没进过回调程序也没有任何异常。原因LocalHook实例没有保存在静态字段里被 GC 回收掉了。easyHook 的托管包装对象一旦被回收对应的原生钩子资源会被释放但进程本身看起来一切正常所以表现成“莫名失效”。解决把 LocalHook 实例放在静态字段或长生命周期对象里持有显式调用Uninstall而不是依赖析构。我一般把 hook 实例和回调逻辑封装成一个类字段保存_hook再提供一个 Disposable 风格的释放方法保证卸载路径唯一。这个习惯能省掉好几次“鬼打墙式”排错。6. 进阶把钩子封装成可复用的探针并坚持三个验证习惯到了这一步你已经能跑通 demo也踩过权限和位数的坑。再往前一步就是把钩子从一次性脚本变成可复用的探针。我的做法是定义一个统一的探针类把 Install、Uninstall、回调入口都收在一个类里外部只依赖一个静态方法public sealed class HookProbe : IDisposable { private readonly LocalHook _hook; private readonly IntPtr _original; private bool _disposed; public HookProbe(string module, string function, Delegate callback) { _original LocalHook.GetProcAddress(module, function); _hook LocalHook.Create(_original, callback, null); var threadIds Process.GetCurrentProcess().Threads .CastProcessThread() .Select(t t.Id) .ToArray(); _hook.ThreadACL.SetInclusiveACL(threadIds); _hook.Install(); } public IntPtr Original _original; public void Dispose() { if (_disposed) return; _hook.Uninstall(); _disposed true; } }使用时传模块名、函数名和一个签名正确的委托实例化即生效。配合环境变量控制启用开关比如只在ENABLE_HOOK1时实例化探针就能让同一份代码既能跑回归测试又能做行为探查。三个验证习惯我会一直坚持一回调里只做计数和轻量日志绝不放 UI 刷新和网络请求二每次新增被钩函数先写一个能确定触发该函数的 P/Invoke 测试调用保证验证路径不是猜测三修改 ACL 豁免逻辑时强制使用 try/finally把恢复动作当成必做项而不是可选操作。某次我把豁免放在 try 外面结果异常路径把整个钩子静默废掉足足排查了一个下午才找到是线程集合没恢复。这类问题没有后悔药只能靠习惯兜底。希望帮到你照着这套思路去写 easyHook 钩子至少能少走一半弯路。本文还有配套的精品资源点击获取