简介:这是一份基于 C# 语言 EasyHook 库的远程函数拦截与注入入门示例工程,适合需要监控、调试或修改其他进程行为的 .NET 开发者,也可作为学习 Windows 钩子机制与远程注入技术的起点。压缩包共用 91 个文件,包含 C# 源代码、编译好的动态库与可执行程序、程序调试符号、配置文件以及完整的解决方案和项目工程文件,可以直接打开编译运行,并对照示例代码理解钩子创建、回调委托绑定、线程访问控制列表设置以及注入启动等关键步骤。示例演示了拦截系统调用时的处理逻辑,能够直观看到钩子触发前后的执行流程与返回值变化,便于反复修改试验。文件类型覆盖开发、构建与运行需求,可执行文件用于启动演示,动态库提供注入支撑,源码便于阅读修改,配置则帮助快速还原环境,整体目录结构清晰。资源还体现了程序集数字签名对注入成功率的影响,并配有相关配置说明,可避免常见踩坑问题。压缩包约 854KB,轻量易用,已有 912 人学习下载。
1. easyHook 在 C# 里到底是什么:先分清“进程内钩子”和“跨进程注入”
“C# easyHook 使用 demo”这个标题,第一眼容易让人误以为是个游戏辅助项目,但其实 easyHook 是一套把 Windows API 钩子能力封装成 .NET 可调用接口的库。你用 C# 写几行代码,就能把 user32.dll、kernel32.dll 里某个导出函数“截住”,在它执行前改写参数、篡改返回值、记录日志,或者干脆阻止它往下走。它最常见的落地场景是你自己写工具:C# 上位机做自动化回归测试、给老程序加无障碍辅助、无侵入式埋点监控,以及协议追踪和分析。适合的读者是带着具体产品需求、想在 Windows 上做进程级调试和跨进程监控的 C# 开发者,而不是刚学完语法就来问钩子怎么写的人。搞懂这个库,核心是把“进程内 LocalHook”和“跨进程 RemoteHooking”拆开理解,下面逐个讲。
2. 先跑通最小 demo:用 LocalHook 钩住 GetCurrentProcessId 并改返回值
2.1 包管理与运行环境
做一个最小闭环,用控制台程序就够了。推荐 .NET Framework 4.7.2 起步,因为它和 easyHook 的注入器配合最顺;.NET 6/8 也能跑,但要额外注意运行时兼容性,新手不建议从跨平台项目开始试。
在 Visual Studio 的“程序包管理器控制台”里执行:
Install-Package EasyHook -ProjectName HookDemo选稳定正式版,别碰带 Preview 的包。装完之后,项目输出目录里会多出EasyHookDll32.dll和EasyHookDll64.dll两个原生文件,这两个文件决定你最后能不能在某一个进程位数下跑通。项目平台目标不要直接用 AnyCPU,我一般会先在实验阶段固定成 x64,确认钩子能触发后再去考虑 32 位场景。输出目录一定要确认同时存在这两个原生 DLL,不然 LocalHook 创建时会直接抛BadImageFormatException或者加载失败异常。
2.2 最小 LocalHook 代码:挂接、回调、卸载
新建一个控制台项目,把下面这段代码完整放进去。这个 demo 选择拦截GetCurrentProcessId,因为它无参数、无副作用,是验证钩子机制的最小对象。
using System; using System.Runtime.InteropServices; using EasyHook; namespace HookDemo { internal class Program { // 导入要钩住的系统 API,调用它时会被 easyHook 截获 [DllImport("kernel32.dll")] private static extern uint GetCurrentProcessId(); // 委托签名必须和 Win32 API 的调用约定一致 [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint GetCurrentProcessIdDelegate(); // 委托要用静态字段保存,防止垃圾回收把回调回收掉 private static GetCurrentProcessIdDelegate _hookDelegate; // 钩子回调:目标进程内每次调用 GetCurrentProcessId 都会先到这里 private static uint GetCurrentProcessIdHook() { Console.WriteLine("[hook] 有人调用 GetCurrentProcessId,返回值已被改为 8888"); return 8888; } private static void Main() { // 1. 拿到 kernel32.dll 中 GetCurrentProcessId 的导出地址 IntPtr procAddr = LocalHook.GetProcAddress("kernel32.dll", "GetCurrentProcessId"); // 2. 创建本地钩子,第三个参数传 null 表示不需要回调对象上下文 _hookDelegate = new GetCurrentProcessIdDelegate(GetCurrentProcessIdHook); LocalHook hook = LocalHook.Create(procAddr, _hookDelegate, null); // 3. 主动调用一次系统 API,验证钩子是否生效 uint pid = GetCurrentProcessId(); Console.WriteLine("当前进程 PID = " + pid); Console.WriteLine("按回车卸载钩子并退出..."); Console.ReadLine(); // 4. 卸载钩子,释放原生资源 hook.Dispose(); } } }这段代码的流程是:先用LocalHook.GetProcAddress("kernel32.dll", "GetCurrentProcessId")拿到系统函数在内存里的导出地址,然后LocalHook.Create把这个地址重定向到你自己的委托上。之后进程内任何代码调用GetCurrentProcessId,都会先进GetCurrentProcessIdHook,返回 8888。Main 里通过DllImport声明的GetCurrentProcessId()自然也被拦住了,所以控制台输出的 PID 不是真实值,而是 8888。
LocalHook.Create的参数需要解释一下:第一个参数是要钩住的函数地址;第二个参数是回调委托,每次函数被调用时触发;第三个参数是回调上下文对象,easyHook 会把它和钩子实例绑定在一起,一般传null也行,但委托实例一定要用字段持有,否则 .NET 垃圾回收后回调地址就失效了,这是第一次写 easyHook 最常翻车的地方。
2.3 验证方法与线程 ACL 参数调整
跑起来之后,控制台先打印[hook] 有人调用 GetCurrentProcessId,返回值已被改为 8888,然后打印当前进程 PID = 8888。看到这个输出,说明钩子已经成功替换了函数执行路径。按回车后钩子被卸载,进程正常退出。
这里有个容易被忽略的线程控制接口:LocalHook.ThreadACL。hook 默认会对进程内所有线程的调用生效,但在 C# 上位机这种多线程环境里,你往往只想要主线程或者某个工作线程触发钩子。常见做法是用ThreadACL.SetExclusiveACL(new[] { 0 })排除安装钩子的当前线程,或者用SetInclusiveACL只放行指定线程。0是 easyHook 对调用线程的约定值,不是系统线程 ID。
如果你在 demo 里尝试钩住MessageBoxW,需要额外注意委托签名要带上CharSet.Unicode。我建议新手第一次验证还是用GetCurrentProcessId这种无参数 API,因为不用处理字符串和重入问题,最容易建立信心。
3. 跨进程注入目标进程:RemoteHooking 的注入器与入口点代码
3.1 注入链路是什么
LocalHook 只能改自己进程的 API 调用,但很多实际需求是你想把钩子装到别的进程里,比如上位机监控一个被测软件的函数调用。easyHook 的跨进程方案叫 RemoteHooking,链路分三环:注入器负责发起注入,目标进程负责接收注入,入口点类负责安装钩子并保持运行。
注入器调用RemoteHooking.Inject之后,easyHook 会在目标进程中启动 .NET 运行时,加载你指定的托管程序集,实例化实现IEntryPoint的类,然后调用这个类的Run方法。整个过程不需要目标程序里有任何预先埋好的代码,这一点对做黑盒测试和自动化监控非常有利,也是 easyHook 比单纯用 C++ 写 Hook 更省事的核心原因。
3.2 注入器端:按进程 ID 发起注入
先在同一个控制台项目里写注入器,它读取目标进程 PID,发起注入后等待:
using System; using EasyHook; internal class Injector { private static void Main(string[] args) { if (args.Length < 1) { Console.WriteLine("用法:Injector.exe <目标进程ID>"); return; } int pid = int.Parse(args[0]); string channelName = "hook_channel_" + Guid.NewGuid().ToString("N"); // 参数顺序:目标PID、注入选项、通道名、x64程序集路径、x86程序集路径、注入参数 RemoteHooking.Inject( pid, InjectionOptions.DoNotRequireStrongName, channelName, typeof(HookEntry).Assembly.Location, typeof(HookEntry).Assembly.Location, channelName); Console.WriteLine("注入完成,通道名:" + channelName); Console.ReadLine(); } }RemoteHooking.Inject的参数比 LocalHook 明显多。第二个参数InjectionOptions.DoNotRequireStrongName表示不需要强命名程序集,一般项目直接用这个选项。第四、第五个参数看起来都是同一份程序集路径,这是 easyHook 留给 64 位和 32 位的两个入口参数,你传同一个托管 DLL 路径就可以,底层会按目标进程的位数去选择。最后一个channelName是注入参数,会传给目标进程里HookEntry的构造函数。
这里是初学者最困惑的点:为什么有channelName和最后的channelName两个一样的东西?前者是 easyHook 内部 IPC 用的通道名,后者是你自定义传给入口点的参数。两者完全可以不同,但常见例子为了方便经常写同一个字符串。
3.3 目标端入口点:IEntryPoint 实现
同一个项目里需要有一个公共类实现IEntryPoint,注入器才能把它加载进目标进程:
using System; using System.Runtime.InteropServices; using System.Threading; using EasyHook; public class HookEntry : IEntryPoint { private LocalHook _hook; [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint GetCurrentProcessIdDelegate(); // 注入成功后,构造函数里安装钩子 public HookEntry(RemoteHooking.IContext context, string channelName) { IntPtr addr = LocalHook.GetProcAddress("kernel32.dll", "GetCurrentProcessId"); _hook = LocalHook.Create(addr, new GetCurrentProcessIdDelegate(GetCurrentProcessIdHook), null); } private static uint GetCurrentProcessIdHook() { // 返回一个魔数,目标进程里任何代码拿到这个值就知道钩子生效了 return 0xDEAD; } public void Run(RemoteHooking.IContext context, string channelName) { // Run 不能退出,退出后 LocalHook 会被释放,钩子自动失效 while (true) { Thread.Sleep(1000); } } }这里的关键是把钩子安装放在构造函数里,构造函数执行完后 easyHook 会调用Run,而Run必须是一个死循环或者长期驻留的循环。一旦Run返回,入口点对象被回收,LocalHook随之释放,目标进程的 API 就恢复原状了。
在实际项目里,Run里可以放 IPC 消息循环,用来接收注入器下发的指令,比如热卸载钩子、切换回调逻辑。这个 demo 里先用Thread.Sleep(1000)占住线程,能达到验证目的。
3.4 注入器与目标进程的位数匹配问题
跨进程注入的坑主要出在位数上。比如注入器是 x64,目标进程是 x86,那么 easyHook 加载的EasyHookDll64.dll根本没法和 32 位目标进程握手。现象是Inject返回成功,但入口点类从未被实例化,日志里也没有任何输出。
在实验阶段,我会用任务管理器先确认目标进程位数,再决定注入器编译成 x64 还是 x86。更保险的办法是让注入器自己判断目标进程位数:
using System; using System.Diagnostics; using System.IO; int pid = int.Parse(args[0]); using (var proc = Process.GetProcessById(pid)) { bool is64Bit = !Environment.Is64BitOperatingSystem || (proc.Modules.Count > 0 && proc.Modules[0].FileName.Contains("SysWOW64") == false); }这段判断不完美,但能帮你快速筛掉最常见的位数错误。真正严谨的判断要用IsWow64ProcessAPI 或查询进程 PEB 结构,线上工具我一般会写成独立工具函数,而不会在注入器里写这么一段略粗糙的代码。
4. C# 委托签名、线程安全与模块跳转:不踩这几个硬细节就白写
4.1 UnmanagedFunctionPointer 与导出函数签名要逐字节对上
easyHook 的钩子本质上是一个“委托替换”,你的回调必须和被钩函数有完全一致的调用约定和数据结构。很多刚开始写 C# 钩子的同学只关注参数个数,忽略CallingConvention.StdCall和CharSet.Unicode,结果回调一触发就内存访问违例,目标进程瞬间闪退。
拿钩住user32.dll!MessageBoxW举例,正确的委托声明是这样的:
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode)] private delegate int MessageBoxDelegate(IntPtr hWnd, string text, string caption, uint type); // 钩子回调:记录并吞掉消息框,返回 IDOK private static int MessageBoxHook(IntPtr hWnd, string text, string caption, uint type) { Console.WriteLine($"[hook] 已拦截 MessageBoxW,原始文本:{text}"); return 1; // IDOK,不真正弹窗 }CallingConvention.StdCall对应 Win32 里绝大多数 API 的调用约定,CharSet.Unicode对应MessageBoxW的宽字符版本。如果你钩的是MessageBoxA,就要把CharSet改成Ansi。钩错字符集之后,字符串参数会乱码,最坏情况是读内存越界直接崩溃。
我见过最隐蔽的错误是钩住MessageBoxW却声明成int返回值。Win32 API 返回int是对的,但有些人从 MSDN 复制 C++ 签名时看错成BOOL,然后回调返回true/false,虽然在机器码层面true会被当成 1,但语义全拧了。尽量逐字对照 Win32 API 文档写委托,不要凭印象。
4.2 回调线程安全与重入:锁、日志和耗时操作
钩子回调运行在调用者的线程上。也就是说,如果有三个线程同时调用GetCurrentProcessId,您的回调可能被三个线程同时执行。回调里一旦写了非线程安全的集合操作,就会出现奇怪的数据错乱。
稳妥做法是给回调加锁,并保持回调体内的操作足够轻量:
private static readonly object Gate = new object(); private static int _callCount; private static uint GetCurrentProcessIdHook() { lock (Gate) { _callCount++; Console.WriteLine($"[hook] 第 {_callCount} 次调用"); } return 8888; }锁内只做计数和日志,不做文件写入、不做数据库访问、不等待其他线程的句柄。C# 多线程场景里,最糟糕的钩子写法是回调里ManualResetEvent.WaitOne()等 UI 线程返回值,而 UI 线程又恰好触发了这个 API,直接死锁。easyHook 社区里被问最多的问题就是“为什么钩子一装上目标程序就卡死”,十有八九是回调里做了阻塞等待。
如果你需要把钩到的数据转发到别的线程处理,正确姿势是把数据塞进ConcurrentQueue<T>,然后在独立消费线程里慢慢写日志。回调里最多做一次Enqueue和一次TryDequeue边界判断,不能再多。
4.3 用反射和模块枚举时的边界处理
easyHook 的GetProcAddress("user32.dll", "MessageBoxW")只能拿到导出函数地址,函数地址本身不包含模块路径。有些同学写钩子时想校验目标进程是不是加载了某个特定 DLL,会反射遍历进程模块:
using System.Diagnostics; using System.Linq; string[] moduleNames = Process.GetCurrentProcess().Modules .Cast<ProcessModule>() .Select(m => m.ModuleName) .ToArray();这段代码在注入器里没问题,但在钩子回调里调用就要小心。Process.GetCurrentProcess().Modules每次枚举都会触发原生模块列表快照,性能很差,而且某些受保护模块会抛Win32Exception。正确做法是“安装钩子前枚举一次,把结果缓存成字段”,回调里只读取缓存,不重复枚举。
另外一个反射相关的坑是:钩子的委托实例不能用反射动态生成。有些同学想用Expression构造一个和 API 签名相同的动态委托来减少样板代码,但UnmanagedFunctionPointer要求委托类型在编译期确定,动态生成的委托没有稳定的函数指针,easyHook 拿到地址后会在调用时崩溃。老老实实给每个 API 写一个静态委托,不要在这个地方做过度抽象。
5. 避坑清单:CLR 注入失败、进程闪退、钩子不触发的排查记录
5.1 现象:RemoteHooking.Inject 返回后目标进程直接闪退
原因:
大部分是位数不匹配,或者目标进程缺少 VC++ 运行库。easyHook 的原生 DLL 依赖msvcr100.dll或更高版本的 VC++ Runtime,精简版 Windows 上特别容易缺这个。另一个原因是目标进程有Image File Execution Options调试器附加,注入器没有管理员权限时会被系统拦截。
解决:
先用 x64/x86 配对确认位数,再安装对应版本的“Visual C++ Redistributable”。如果目标进程是管理员权限启动的,注入器也必须以管理员身份运行,否则CreateRemoteThread会在权限检查处直接失败,表现就是注入后目标进程闪退。
5.2 现象:注入时报 “CLR injection failed” 或 “Access is denied”
原因:
目标进程可能是一个已经退出的僵尸 PID,也可能是一个受保护进程,比如开启了 Protected Process Light 的系统服务。easyHook 的注入机制需要向目标进程写入内存并创建远程线程,这类进程会拒绝操作。
解决:
先用任务管理器确认目标进程还活着,并且在“详细信息”里看一下是否标了“受保护”。对于普通业务软件,最常见原因其实是 PID 过期:先打开目标程序再解析 PID,不要从配置文件里拿一个早已退出的旧 PID。然后右键注入器“以管理员身份运行”,一般Access is denied就消失了。
5.3 现象:钩子回调里只写了日志都能卡死目标界面
原因:
钩子回调运行在目标进程的某个线程上,如果你在回调里调用Control.Invoke或者Dispatcher.BeginInvoke并从 UI 线程同步等待结果,而 UI 线程恰好又在调用被钩住的 API,就构成了死锁。日志本身不慢,慢的是你把日志写到了网络磁盘或者同步 IO 流上。
解决:
回调里不要做任何 UI 操作。先把日志写到内存队列,再由后台线程批量 flush 到本地文件。如果一定要刷新界面,用PostMessage或ThreadPool.QueueUserWorkItem异步通知,绝不等待回调结果。这个习惯在上位机场景里特别重要,否则你会看到目标界面每隔几秒就卡顿一次,鼠标都拖不动。
5.4 现象:钩子装了但回调从不触发
原因:
你钩的是导出函数地址,但目标程序可能通过Ordinal导入,也可能直接调用了该 DLL 内部未导出的实现函数。例如很多程序调kernel32!CreateFileW时真正走的是ntdll!NtCreateFile,你钩住 kernerl32 的导出表,某些调用路径根本不经导出表。
解决:
先用 API Monitor 或 Process Monitor 确认目标进程确实调用到了你钩的那个导出函数。如果是导入表 Ordinal 调用,easyHook 的GetProcAddress拿不到有效地址,你需要改用入口点钩子方案,或者换一个更底层、更稳定的 API 来钩。实际开发里,我一般先写一个探针进程反复调用目标 API,确认钩子能触发,再去接真实业务,避免陷入“我代码没问题为什么没反应”的玄学排查。
6. 进阶自验:探针定时器、热卸载与“先日志后拦截”的习惯
6.1 用探针定时器验证钩子是否真正生效
跨进程注入之后,你无法直接看到目标进程内部的输出,所以要在钩子代码里加一个自动探针。这个探针会定时调用被钩住的 API,并把结果和期望值对比,这样钩子是否生效就有了客观依据。
private static System.Threading.Timer _probeTimer; private static readonly object ProbeLock = new object(); private static void StartProbe() { _probeTimer = new System.Threading.Timer(_ => { lock (ProbeLock) { uint result = GetCurrentProcessId(); if (result == 0xDEAD) { Console.WriteLine("[probe] 钩子生效,返回值 0xDEAD"); } else { Console.WriteLine($"[probe] 钩子未生效,真实 PID {result}"); } } }, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); }_probeTimer必须存成字段,否则定时器对象被垃圾回收后回调不再执行。探针定时器只用于验证阶段,不要放在生产环境里一直跑,它会额外制造每秒几次的 API 调用,拉高目标进程的 CPU 占用。
6.2 热卸载钩子的正确姿势
需要让钩子停下来时,调用LocalHook.Dispose()即可,但要保证不是在钩子回调自身里调用。如果你从注入器的 IPC 通道收到命令,最好把“卸载”动作丢给一个新的ThreadPool线程执行,而不是在目标进程的任意线程上执行。
ThreadPool.QueueUserWorkItem(_ => { _hook?.Dispose(); _hook = null; });这样能避免目标线程正在执行回调时释放LocalHook造成访问违例。卸载之后,目标进程的 API 恢复原样,但如果你再次注入同一个进程,此前注册的静态委托可能会残留,必要时先重启目标进程再注入。
6.3 我一线的落地习惯
我自己做这类工具的习惯是:先让钩子回调只打日志跑满一天,确认它既不影响性能也不触发异常,再往上加参数改写和返回值篡改。别一上来就做拦截逻辑,否则出了问题你分不清是 easyHook 的锅还是业务代码的锅。这个库真正适合的是自动化测试、辅助功能和诊断工具,而不是绕过商业软件授权这类灰色需求。
如果你能从 GetCurrentProcessId 换成自己业务里的关键 API,再把回调里的 Console.WriteLine 换成结构化日志文件,这就已经是一个能交付的埋点方案了。希望帮到你。
本文还有配套的精品资源,点击获取