简介:C# EasyHook 使用示例 Demo,面向需要在运行时对目标进程进行远程函数拦截与注入的 .NET 开发者,可应用于软件监控、调试、性能分析、安全研究等场景。压缩包共 91 个文件,约 854KB,包含 17 个 cs 源文件、15 个 dll 库、6 个 exe 工程、6 个 config 配置、7 个 pdb 调试符号,以及解决方案、资源文件和签名文件,覆盖主程序、测试窗口与类库项目,源码、库、可执行程序三层结构清晰,便于对照理解 LocalHook 创建、RemoteHooking 注入、委托回调设置等完整流程。内容同时梳理了通过 NuGet 安装 EasyHook、Windows 钩子机制、目标进程 ACL 权限控制,以及 dll 数字签名对注入成败的影响。附带的拦截 ExitWindowsEx 防止退出的示例代码,配合可运行的 WinForms 测试工程,可直接复现调试和二次开发;调试符号与签名文件也有助于在 Visual Studio 中定位钩子回调,排查注入失败、权限拦截等常见问题。已有 912 人学习/下载,适合初次接触 EasyHook、希望快速完成 API Hook 原型验证的 C# 开发者。
1. 一个“注入”别人的程序、偷听接口调用的 C# 技能,EasyHook 到底能干嘛?
做上位机、做桌面工具的人,迟早会遇到这类需求:我想知道某个第三方程序点了按钮之后内部调了什么函数;我想在别人进程里挂一个自己的回调,把键盘输入录下来做自动化;我想拦截某个 API,把参数改掉再放回去。C# 里最常被提起的库就是 EasyHook,它让 .NET 开发者不用写一坨 C++ 注入代码,就能在别的进程里跑自己的 C# 方法。很多人第一次搜 EasyHook 是想抄一个 demo 跑通,但这东西真正用起来,卡点反而不在代码,而在权限、位数、回调死锁这些“看不见的地方”。本文就用一个最小可用 demo 把 EasyHook 从安装、注入、回调到卸载完整走一遍,把那些会让你翻车的参数和坑摊开讲。适合已经在写 C#、想给自己的工具加“钩子”能力的开发者,不适合完全没碰过委托和事件的人。
2. EasyHook 的原理与选型:为什么是它,而不是 SetWindowsHookEx 或自写注入器
2.1 EasyHook 的本质:托管代码里的 API 钩子,不是消息钩子
很多人把 EasyHook 和 WinForms 里的SetWindowsHookEx混淆。SetWindowsHookEx钩的是 Windows 消息,比如键盘消息WM_KEYDOWN、鼠标消息WM_MOUSEMOVE,它只能在消息进入目标窗口消息队列时插一脚,拿不到进程内部函数的调用参数,也拦不了底层 API。EasyHook 做的是“API 钩子”:它把你指定的函数入口处的前几个字节改成一条跳转指令,跳到你的回调函数,你的回调执行完,再跳回原函数继续跑。这事在 C/C++ 世界里叫 inline hook,很多人自己写过,但 EasyHook 的价值在于:你用 C# 写回调,库负责把托管回调“注入”到目标进程里,并处理好 CLR 在对方进程的初始化、线程同步、异常传播。
这里要理解一个关键机制:注入不是把代码“发”过去,而是让目标进程加载你的非托管 DLL,再由这个 DLL 启动 .NET 运行时,把你的托管程序集拉进来。EasyHook 内置了RemoteInjector和CreateAndInject这类方法,干的就是“远程线程注入 + CLR 启动器”的活儿。所以你的 C# 回调最终跑在目标进程的地址空间和线程上下文里,而不是你的主程序进程里。这带来一个直接影响:回调里不能随便访问你主程序里的静态变量,因为那是两个进程;需要跨进程通信才能把数据传回自己的程序。
2.2 选型理由:和自写注入器、SetWindowsHookEx 的对比
如果你要的是“截获某个进程的 CreateFile 调用参数”,SetWindowsHookEx做不到,因为它根本不碰 API 调用。自己写注入器呢?你需要写一个 C++ 的 DLL,实现 DLLMain,用 WriteProcessMemory 和 CreateRemoteThread,还得处理 x64 和 x86 的调用约定、重定位、加载顺序,这些坑够你折腾两周。EasyHook 把这些封装成LocalHook和HookManager两类接口:
LocalHook类适合“在自己的进程里钩自己”或“在已经注入的进程里钩 API”,它通过LocalHook.Create(IntPtr, Delegate, Object)在运行时修改函数入口。HookManager类则提供更上层的全局钩子封装,比如HookManager.SetKeyboardHook可以一键挂起全局键盘事件,HookManager.SetHook可以注入到指定进程并安装钩子。你写 demo 时先用HookManager快速验证流程,再深入了解LocalHook,这个学习路径最顺。
对比一句话:SetWindowsHookEx 是“窗户边的哨兵”,只看消息;自写注入是“潜入对方大楼”,但你要自己配钥匙;EasyHook 是“给你一把通用钥匙,还帮你把楼里的灯打开”。如果你的目标只是键盘鼠标全局监听,用 EasyHook 的HookManager确实比 SetWindowsHookEx 省事,但也要付出代价:它启动慢、进程里会多一个 .NET 运行时、被杀软盯上的概率高。
2.3 适用边界:它不是万能钩子
EasyHook 能钩大多数用户态 API,但有几类场景要绕开。第一,不能钩内核态函数,比如 NtCreateFile 的底层实现你得用驱动;第二,不能钩短生命周期进程,比如某些程序启动后 1 秒内就开始调用目标 API,你的注入还没完成它已经跑完了;第三,不能钩系统关键进程,比如 winlogon.exe 这类受保护进程,注入 Direct 会失败。别指望 EasyHook 做游戏外挂或系统级安全监控,它的主战场是:工具类软件的功能增强、测试时的 API 参数录制、自动化测试中的输入模拟。
提示:EasyHook 的运行机制决定了目标进程的位数必须和回调程序集的位数一致。x64 进程只能注入 x64 的 DLL,x86 进程只能注入 x86 的 DLL。这个看似简单的限制,是新手踩坑重灾区。
3. 跑通最小 demo:注入记事本、拦截 CreateFileW 并修改参数
3.1 环境准备:项目类型与目标进程规划
先说环境。EasyHook 是 .NET 库,最稳的搭配是 .NET Framework 4.6.1 及以上 + Visual Studio 2019/2022。很多人用 .NET Core / .NET 5+ 的类库项目跑 EasyHook,会在运行时遇到“未能加载 EasyHook32/64.dll”这种报错,因为 EasyHook 的非托管 DLL 加载逻辑默认面向 .NET Framework 的 AppDomain 模型。所以我的建议:demo 阶段老老实实建一个 .NET Framework 的 WinForms 或控制台项目,等流程跑通了再考虑迁移到现代 .NET。运行平台选 x64 还是 x86,看你的目标进程——这里我们用记事本,Windows 64 位系统默认装的是 x64 的记事本,所以主项目的平台目标也选 x64。
用 NuGet 装EasyHook包,装完后你会在输出目录看到EasyHook32.dll和EasyHook64.dll两个非托管文件,以及EasyHook.dll托管程序集。这三个文件缺一不可,部署时要把它们放在同一目录。安装命令是:
Install-Package EasyHook装完检查一下:项目输出目录里是否有 EasyHook32.dll、EasyHook64.dll。如果只有 EasyHook.dll,说明 NuGet 包没触发构建后复制,可以在项目文件里手动加一段<Content>引用,或者使用EasyHook自带的Injector时调用Config.Register来预加载。最常见的情况是:x64 目标程序集引用了 x86 的非托管 DLL,导致加载失败,所以先确认你的 VS 平台目标是不是 x64。
3.2 全局钩子版 demo:三行代码挂起全局键盘监听
如果你只是想验证 EasyHook 在你的机器上能不能用,最快的 demo 不是注入,而是全局键盘钩子。它不需要目标进程的概念,直接在HookManager上挂事件:
using EasyHook; public class KeyboardDemo { public static void Main() { HookManager.KeyboardEvent += OnKeyboardEvent; Console.WriteLine("键盘钩子已挂载,按 Esc 退出"); while (Console.ReadKey().Key != ConsoleKey.Escape) { } HookManager.KeyboardEvent -= OnKeyboardEvent; // 卸载钩子 } private static void OnKeyboardEvent(object sender, KeyEventArgs e) { Console.WriteLine($"按下: {e.KeyCode}, 状态: {e.KeyState}"); } }这个 demo 的输入输出很直观:启动后任意按一个键,控制台会打印一个KeyEventArgs,包含按键码和键状态。它的背后是 EasyHook 在系统层面为你的进程安装了一个低级键盘钩子,所有进程的键盘消息都会流经你的回调。跑通这个,你就知道 EasyHook 的托管/非托管边界在哪了:回调代码是托管的,但事件源是非托管的,一旦回调里抛异常,进程可能直接崩掉。
3.3 注入版 demo:拦截记事本的 CreateFileW 调用
全局键盘钩子只是热身,真正体现 EasyHook 能力的是注入特定进程、钩住指定 API。下面这个 demo 会启动一个记事本,等它跑起来后把钩子注入进去,拦截CreateFileW。CreateFileW是 Windows 上几乎所有文件操作都会经过的 API,记事本打开“打开对话框”时,它会在某些路径上调用这个函数。我们可以在回调里打印出被调用的文件路径参数。
using System; using System.IO; using System.Runtime.InteropServices; using EasyHook; public class FileMonitor : IEntryPoint { // 声明与原函数相同的委托签名:CreateFileW 的五个关键参数 private delegate IntPtr CreateFileDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private CreateFileDelegate _originalCreateFile; private LocalHook _hook; public FileMonitor(RemoteHooking.IContext context, string channelName) { // 构造函数里不做事,注入后 RemoteHooking 会调用 Run } public void Run(RemoteHooking.IContext context, string channelName) { // 钩住当前进程内的 CreateFileW _originalCreateFile = LocalHook.GetProcDelegate<CreateFileDelegate>("kernel32.dll", "CreateFileW"); _hook = LocalHook.Create( LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"), new CreateFileDelegate(CreateFileHook), this); _hook.ThreadACL.SetInclusiveACL(new int[] { 0 }); // 只钩主线程,避免死锁 Console.WriteLine("钩子已注入,按任意键卸载"); Console.ReadKey(); _hook.Dispose(); } private IntPtr CreateFileHook( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 在回调里打印参数,然后调用原始函数 Console.WriteLine($"[CreateFileW] 路径: {lpFileName}, 打开方式: {dwCreationDisposition}"); return _originalCreateFile(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } }这个类不是在你自己的进程里跑的,它是被 EasyHook 注入到记事本进程后的“入口点”。所以它必须实现IEntryPoint接口,并且构造函数、Run方法的签名要和RemoteHooking的约定一致。主程序通过RemoteHooking.Inject把这个程序集注入到记事本进程:
// 在主程序里: int targetPid = FindPidByName("notepad"); RemoteHooking.Inject( targetPid, typeof(FileMonitor).Assembly.Location, // 当前程序集路径 typeof(FileMonitor).Assembly.Location, // 非托管 DLL 的路径(实际会用内置的 EasyHook32/64) "任意字符串参数", // 传给 FileMonitor 构造函数的 channelName out channelName);注意Inject的第四个参数是 string 类型的,随意给一个标识字符串即可。FileMonitor构造函数里的context和channelName参数不是摆设,channelName就是你在注入时传的那串字符,用来在注入完成后建立 IPC 通道。如果在Run方法里用RemoteHooking.WakeUpProcess()唤醒目标进程,注入就会生效;如果忘了调用WakeUpProcess,目标进程会一直停在挂起状态,这算是个经典坑。
3.4 参数说明:ThreadACL 为什么要设置成只含主线程
demo 里_hook.ThreadACL.SetInclusiveACL(new int[] { 0 })这行是关键。ThreadACL是 EasyHook 的线程过滤器,SetInclusiveACL表示只允许指定线程 ID 列表里的线程触发钩子。这里传0是 EasyHook 的约定,表示“调用Create方法的当前线程”。为什么注释里写“避免死锁”?因为 Windows 的文件 API 内部有时会做二次调用,如果你钩住 CreateFileW 后,在回调里又调用了 File API——比如打印路径时用了Console.WriteLine,而它内部FlushFileBuffers又会触发 CreateFileW 的再次调用——就会陷入递归。加锁或者只钩主线程,至少把并发问题隔离了。
实际项目中你常常不能只钩一个线程,特别是多线程程序。这时一个变通做法是排除自己注入的线程:SetExclusiveACL(new int[] { 当前线程ID }),意思是除了这个线程,别的线程都钩。千万别把ThreadACL想当然地省略,默认行为是“所有线程都钩”,这很容易在回调里引发重入崩溃。我的经验是:SetInclusiveACL(new int[] { 0 })适合验证性 demo,正式场景尽量用SetExclusiveACL排除掉你用来通信的线程。
3.5 回调里的线程与状态管理
Run方法里创建的LocalHook一旦生效,回调可能在目标进程的任何一个线程上被触发。而你的FileMonitor实例是在目标进程里被 CLR 创建的,该实例的字段如_originalCreateFile在多个线程之间共享。如果多个线程同时调 CreateFileW,你的回调也会被并发执行。demo 里之所以不会出问题,是因为只钩了主线程。正式使用时,回调里要么加锁,要么用ConcurrentQueue把参数丢到队列里,等另一个线程去消费。不要阻塞,不要 Sleep,不要在回调里弹窗,这些都是会造成目标进程卡死的坏习惯,后面避坑章节会逐条展开。
4. 避坑指南:EasyHook 最常见的 5 个翻车现场与排查方法
4.1 注入报错“Access is denied”或“OpenProcess 失败”
现象:RemoteHooking.Inject抛异常,提示无法打开目标进程句柄,或者“拒绝访问”。
原因有三类。第一,权限不足:目标进程是管理员权限,你的程序是普通权限,Windows 的进程访问控制直接拒绝你打开句柄。第二,目标进程是受保护的系统进程,比如csrss.exe、winlogon.exe,这些进程默认拒绝外部注入。第三,你的主进程位数与目标进程位数不匹配,虽然这不直接导致 Access denied,但 EasyHook 在注入前会检查目标位数,位数不一致会报别的错误。
解决:用Process.GetProcessesByName("explorer")之类选一个普通用户进程来做验证;必须注入管理员权限的进程时,右键以管理员身份运行你的工具,或者给项目加上 app.manifest 请求requireAdministrator。UAC 开启时,管理员权限也是分层的,EasyHook 文档里明确说它必须运行在“同一完整性级别”或更高级别下。
4.2 x64/x86 位数不匹配导致的“加载失败”
现象:注入时 EasyHook 报错 “This is an x86 (or x64) process, but the target is x86 (or x64)” 类似的信息,或者更隐蔽的,注入成功但回调从未被触发。
原因:EasyHook 的非托管 DLL 分 32 位和 64 位两个版本,必须和目标进程匹配。比如目标记事本是 64 位的,你的项目平台目标是 x86,注入器把 x86 的 EasyHook32.dll 注入 x64 进程,Windows 加载器直接拒绝。
解决:在 VS 项目属性里把“平台目标”设为 x64(或 AnyCPU 但勾选“首选 32 位”为 false),并且确认目标程序集FileMonitor的声明中引用的非托管 DLL 路径正确。如果你用的是 NuGet 的 EasyHook 包,注入传参时有一个重载允许指定非托管 DLL 路径,调试时可以先打印Environment.Is64BitProcess来确认你的进程位数。对付位数问题最快的方式:准备两个编译版本,x86 注入 32 位进程,x64 注入 64 位进程。
4.3 回调死锁:目标进程直接卡死、界面假死
现象:注入成功,目标进程还能打开窗口,但一操作到被钩的那个 API,整个进程就像凝固了一样,任务管理器里 CPU 占用 0%,过几分钟后恢复或一直卡死。
原因:死锁。最常见的死锁源是回调里调用了Console.WriteLine,而目标进程是 GUI 进程——Shell 或 WinForms 的同步上下文里有创建窗口的代码,控制台输出会尝试附加到一个父控制台上,如果父控制台是你的注入器进程,而注入器正在等待注入完成,就互相等待了。还有一种更隐蔽的:回调里调用了Environment.GetEnvironmentVariable或任何会触发 CLR 加载其他程序集的操作,而目标进程的加载器锁正好被持有。
解决:回调函数里尽量只做参数拷贝、入队、原子自增这类无阻塞操作。把数据塞进ConcurrentQueue,然后通过 EasyHook 的 IPC 通道发回主程序,或者干脆写到一个日志文件里。如果你就是想在回调里打印一些东西,用OutputDebugString或Debug.WriteLine,它们不会创建控制台也不会触发复杂的运行时初始化。
4.4 卸载钩子时崩溃,或卸载后目标进程行为异常
现象:调用_hook.Dispose()后目标进程直接崩溃,或者卸载后原函数表现异常,比如返回值不对、参数被篡改。
原因:EasyHook 卸载钩子的方式是先把函数入口恢复为原始字节码,如果你在Run方法里已经调用过原始函数,而且回调中有局部缓存(比如把_originalCreateFile缓存成了委托),卸载时如果还有另一个线程正停在回调里,这个回调后续要 return 到原函数地址,但那个地址已经被改写了,线程一回去就执行了错误指令,直接 access violation。
解决:在调用Dispose()前确保没有其他线程正在执行回调。简单粗暴的办法是注入的入口进程里加一个开关,回调第一行判断_isHooking,卸载时先置false,睡 200 毫秒,再Dispose。这不能消除所有并发,但能把概率降到很低。另外,RemoteHooking的 IPC 通道建议在卸载后关闭,否则目标进程退出时可能等待 channel 而挂住。
4.5 被杀软拦截:EasyHook 的远程线程注入模式太扎眼
现象:装了 360 或 Windows Defender 的机器上,注入直接失败,或者注入成功后目标进程被隔离,有的杀软会弹窗询问是否允许。
原因:EasyHook 的高频注入接口RemoteHooking.Inject本质上还是CreateRemoteThread + LoadLibrary的套路,这是杀软重点盯防的行为。而且注入的 DLL 里带 .NET 运行时标志,有些杀软会认为是恶意脚本。
解决:无解,至少没有完全无解的方案。你只能在部署时把杀软加白名单,或者改用SetWindowsHookEx这类不注入进程的、相对温和的钩子方案。真要硬刚这个限制,就得自己做驱动回调,那不是 EasyHook 的范畴。所以做商业项目前,一定要在目标机器环境里先测试杀软兼容性,越早发现越好。这块没有捷径,只能靠提前试水。
5. 进阶与验证:从 demo 到可靠钩子,我的验证方法和调试习惯
先说我验证 demo 是否真正生效的“土办法”:不用任何调试器,就在回调里把参数写到一个文本文件里,然后在目标进程里手动触发一个已知的文件操作。比如钩CreateFileW,就去记事本里按 Ctrl+O 打开文件对话框,翻几个目录。如果日志文件里出现了你看到的那些目录路径,说明钩子不仅在跑,而且拿到的参数是对的。之前我遇到过一次回调一直不触发的情况,排查了一小时才发现是位数不对注入了个寂寞。这时候先看日志有没有文件生成,能立刻缩小范围。
从 demo 走向正式使用,有一个我踩过两次的教训:回调里的任何异常都不能让主程序知道。EasyHook 的回调像 Windows 消息钩子一样,一旦回调抛了异常,目标进程可能直接终止。所以我的习惯是在回调外部包一层 try/catch,把异常信息入队而不是抛出去。有一次我在回调里用了File.AppendAllText记录日志——这函数本身就可能触发目标 API 的重入,结果就是一边记日志一边死锁。后来的做法是自定义一个环形缓冲区,回调里只做内存写入,另一个线程专门负责刷盘。回调里能省则省,这是血泪经验换来的原则。
关于全局键盘钩子HookManager和 API 钩子LocalHook,我的选择标准是这样的:只需要监听键盘鼠标消息,优先HookManager,它内部用了钩子链机制,安全性高,卸载干净。需要拿到 API 参数、修改参数,那必须LocalHook。但你要知道,LocalHook修改的是进程私有地址空间的指令字节,同一个 API 在不同进程里是各改各的,所以你有多少目标进程,就要做多少次注入。我做过一个监控多个进程文件操作的小工具,注入三个进程后不得不建了一个统一的 IPC 消息总线,这才发现 EasyHook 的IPCServer通道是可以一对多的——主程序做 server,每个注入的入口做 client。如果你有类似需求,直接看RemoteHooking.CreateIPCServer和RemoteHooking.Connect这对 API,比自己做 socket 省心。
性能方面的一个参考标准:回调里只做整数运算和指针拷贝,每个调用的额外开销大约是几百纳秒,对于正常的文件 API 调用可以忽略。但如果钩的是高频短函数,比如GetTickCount,你的进程吞吐量会肉眼可见地下降。这时候就得用ThreadACL缩小监控范围,或者用采样逻辑,每 N 次调用才记录一次。这些优化不在于 EasyHook 本身,而在于你对调用频率的估算对不对。有个笨办法:先不钩,用 Performance Counter 统计目标进程的 API 调用频率,再决定要不要剪裁回调逻辑。
最后分享一个自己的教训,也是每次做钩子相关的功能都会提醒自己的:先跑通注入,再写监控逻辑。我年轻时喜欢一口气把文件监控、参数过滤、界面显示全写完,然后开始调试,结果根本分不清是钩子没起效还是事件被过滤器吞了。现在我的固定流程是:第一步只打印参数到日志;第二步确认参数无误后,再加过滤;第三步才做参数修改功能——因为修改原函数参数这种事,一旦错了,目标进程就是一片混乱,没有“后悔药”吃。
希望帮到你。
本文还有配套的精品资源,点击获取