简介:这份资源是面向C#开发者、调试与逆向分析爱好者的EasyHook实战示例包,聚焦运行时远程函数拦截与DLL注入这一进阶主题。内容围绕钩子创建流程展开,涵盖本地方法定义、远程方法注入、回调委托获取以及DLL数字签名等关键环节,并以拦截ExitWindowsEx为例演示完整调用链,帮助读者理解跨进程Hook的实现思路与安全注意事项。压缩包共91个文件,约854KB,包含17个cs源码、15个dll依赖库、6个exe可执行程序、3个csproj工程文件及sln解决方案,另有pdb调试符号、config配置、resx资源与snk签名文件等,工程结构完整,可直接编译运行对照学习。目前已有912人学习下载,适合具备一定C#基础、希望掌握EasyHook注入与拦截技术的开发者参考实践。
1. 从一次线上事故说起:为什么我要在 C# 里用 EasyHook 做 API 拦截
去年帮朋友排查一个 C# 上位机的问题:程序调用第三方 DLL 读写设备,偶尔返回错误码,但日志里什么都看不到,因为那个 DLL 内部把异常吞了。当时试过反编译、试过加日志代理,都不太顺手,最后用 EasyHook 在目标进程里挂了一个钩子,把CreateFile和DeviceIoControl的调用参数和返回值全打出来,半小时定位到是路径拼接少了一个反斜杠。这件事之后,我把 EasyHook 当成了排查 C# 上位机疑难杂症的常规武器。
EasyHook 是一个开源的 .NET 钩子库,核心能力是在运行时拦截 Win32 API 或托管方法的调用,支持 32/64 位进程,能在目标进程内注入并执行你自己的处理逻辑。它解决的是「我改不了源码,但我想知道/改变它到底干了什么」这类问题。适合做 C# 上位机、工控采集、协议分析、老系统行为观测的工程师,也适合想学 API Hook 原理但不想一上来就啃 Detours 的人。这篇笔记按「先跑通最小 demo → 再拆解参数 → 再讲注入和 IPC → 最后说避坑」的顺序写,每一步都有可复制的代码。
2. EasyHook 最小可跑 demo:从零到拦截第一个 API
2.1 环境准备与项目结构
我一般用 Visual Studio 2022 建一个解决方案,里面放两个项目:一个是被注入的目标程序(Target),一个是注入器加钩子逻辑(Injector)。EasyHook 的 NuGet 包直接搜EasyHook安装即可,注意要跟目标进程的位数对齐——目标进程是 x64,注入器和钩子 DLL 也必须编译成 x64,这是新手翻车最多的地方。
项目结构建议这样:
EasyHookDemo/ ├── TargetApp/ # 被注入的进程,控制台程序 │ └── Program.cs ├── InjectorApp/ # 注入器,控制台程序 │ ├── Program.cs │ └── HookManager.cs # 钩子逻辑Target 程序写简单点,循环调用MessageBox或者CreateFile都行,方便观察。我习惯用CreateFile,因为参数多、返回值直观,适合演示。
// TargetApp/Program.cs using System; using System.IO; using System.Threading; class Program { static void Main() { Console.WriteLine("Target 进程已启动,PID: " + Environment.ProcessId); while (true) { // 反复调用 CreateFile,方便钩子捕获 var handle = File.OpenRead(@"C:\Windows\win.ini"); Console.WriteLine("CreateFile 返回句柄: " + handle.SafeFileHandle.DangerousGetHandle()); handle.Dispose(); Thread.Sleep(2000); } } }这段代码的作用是制造一个稳定的CreateFile调用源。File.OpenRead内部会走CreateFileW,EasyHook 挂上CreateFileW之后就能看到每次调用的文件名和返回句柄。参数上没什么可调的,重点是让目标进程持续运行,别一启动就退出,否则钩子还没挂上进程就没了。
2.2 用 EasyHook 挂 CreateFileW 的完整代码
注入器这边要做三件事:枚举目标进程、创建 IPC 通道、注入钩子 DLL。EasyHook 提供了RemoteHooking.Inject方法,把这三步封装得比较顺手。下面是我常用的写法,直接放在 InjectorApp 的Program.cs里。
// InjectorApp/Program.cs using EasyHook; using System; using System.Diagnostics; using System.Linq; using System.Runtime.Remoting; class Program { static void Main(string[] args) { // 1. 找到目标进程,这里按名字找,实际项目里建议按 PID var target = Process.GetProcessesByName("TargetApp").FirstOrDefault(); if (target == null) { Console.WriteLine("未找到 TargetApp,请先启动目标进程"); return; } // 2. 准备 IPC 通道名,钩子 DLL 里要用同一个名字 string channelName = null; RemoteHooking.IpcCreateServer<HookInterface>( ref channelName, WellKnownObjectMode.SingleCall); // 3. 注入钩子 DLL,注意路径要写绝对路径 RemoteHooking.Inject( target.Id, InjectionOptions.Default, typeof(HookInterface).Assembly.Location, // 32 位钩子 DLL typeof(HookInterface).Assembly.Location, // 64 位钩子 DLL channelName); Console.WriteLine("注入完成,按任意键退出"); Console.ReadKey(); } }这段代码的关键在RemoteHooking.Inject的四个参数:目标 PID、注入选项、32 位钩子 DLL 路径、64 位钩子 DLL 路径。实际项目里如果只跑 64 位,两个路径可以传同一个;但如果目标进程可能是 32 位,就必须准备两份编译产物,否则注入会直接失败,错误信息还不一定明显。IpcCreateServer那行是给钩子 DLL 回传数据用的通道,后面讲 IPC 时会展开。
2.3 钩子 DLL 里怎么写拦截逻辑
钩子 DLL 是真正干活的地方。EasyHook 要求入口类实现IEntryPoint,在Run方法里安装钩子。下面这段代码拦截CreateFileW,把文件名和返回值打到控制台,同时通过 IPC 回传给注入器。
// HookManager.cs,编译进钩子 DLL using EasyHook; using System; using System.Runtime.InteropServices; public class HookInterface : MarshalByRefObject { // 供注入器调用的回调,钩子里触发 public void OnCreateFile(string fileName, IntPtr handle) { Console.WriteLine($"[Hook] CreateFileW: {fileName} -> {handle}"); } } public class MainEntryPoint : IEntryPoint { private HookInterface _interface; private LocalHook _createFileHook; // 原始函数委托 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern IntPtr CreateFileW( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); public MainEntryPoint(RemoteHooking.IContext context, string channelName) { // 从 IPC 通道拿到注入器那边的接口实例 _interface = RemoteHooking.IpcConnectClient<HookInterface>(channelName); } public void Run(RemoteHooking.IContext context, string channelName) { // 安装钩子,注意委托要保持引用,否则会被 GC 回收导致崩溃 _createFileHook = LocalHook.Create( LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"), new CreateFileWDelegate(CreateFileWHook), this); _createFileHook.ThreadACL.SetExclusiveACL(new[] { 0 }); // 通知注入器钩子已就绪 _interface.OnCreateFile("Hook installed", IntPtr.Zero); // 保持线程存活,否则钩子会随线程退出而失效 while (true) System.Threading.Thread.Sleep(500); } IntPtr CreateFileWHook( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 调用原始 API,别在这里写死返回值,否则目标进程行为会异常 IntPtr result = CreateFileW( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); // 回传给注入器 _interface.OnCreateFile(lpFileName, result); return result; } }这段代码有几个必须注意的点。第一,CreateFileWDelegate的签名必须和原始 API 完全一致,包括调用约定CallingConvention.StdCall和字符集CharSet.Unicode,差一个参数类型就会导致栈不平衡,目标进程直接崩。第二,LocalHook.Create返回的_createFileHook必须用字段持有,不能是局部变量,否则 GC 一回收钩子就失效。第三,SetExclusiveACL(new[] { 0 })表示只钩住当前线程,如果想让所有线程都生效,传new int[0]或者不调用这行。第四,钩子函数里一定要调用原始 API,不能直接返回伪造值,除非你明确知道自己在做什么。
3. 参数怎么设:EasyHook 注入选项与钩子 ACL 的取舍
3.1 InjectionOptions 的三个取值与适用场景
RemoteHooking.Inject的第二个参数是InjectionOptions,枚举值有三个:Default、NoService、BreakOnException。很多人直接抄Default就不管了,但实际项目里选错会导致注入失败或者调试困难。
| 选项 | 含义 | 适用场景 |
|---|---|---|
| Default | 完整注入流程,包含服务创建和 IPC | 大多数常规场景 |
| NoService | 不创建 EasyHook 服务,直接注入 | 目标进程权限受限、服务创建失败时 |
| BreakOnException | 钩子内异常时触发断点 | 调试钩子逻辑,生产环境别用 |
我一般先用Default跑通,如果遇到「注入成功但钩子没生效」或者「目标进程直接退出」,再换NoService试试。BreakOnException只在本地调试钩子函数时用,因为它会让目标进程在异常时挂起,线上环境用这个等于给自己埋雷。
3.2 ThreadACL 的粒度控制:全钩还是只钩当前线程
ThreadACL.SetExclusiveACL和SetInclusiveACL是 EasyHook 里控制钩子生效范围的核心 API。SetExclusiveACL传线程 ID 数组,表示「除了这些线程,其他都钩」;SetInclusiveACL反过来,表示「只钩这些线程」。传new[] { 0 }是个特殊值,代表当前线程。
实际项目里怎么选?如果目标进程是多线程的,而你只想观察某个特定线程的行为,用SetInclusiveACL把那个线程 ID 传进去,避免其他线程的调用把日志刷爆。如果想让所有线程都生效,用SetExclusiveACL(new int[0]),空数组表示排除列表为空,即全部钩住。我踩过的坑是:在钩子 DLL 的Run方法里调用SetExclusiveACL(new[] { 0 }),结果只有Run所在线程被钩住,目标进程的业务线程完全没反应,排查了半天才发现是 ACL 设错了。
3.3 钩子函数的参数传递与返回值处理
钩子函数的签名必须和原始 API 严格一致,这一点在 2.3 节已经强调过。但还有一个容易忽略的点:SetLastError。如果原始 API 会设置GetLastError,你的委托上必须加SetLastError = true,并且在钩子函数里调用完原始 API 后不要做任何可能覆盖错误码的操作,否则目标进程拿到的错误码就是错的。
返回值处理上,我一般遵循「透传」原则:钩子函数调用原始 API,拿到返回值,做自己的记录或修改,然后原样返回。只有在做 mock 测试或者故障注入时,才会伪造返回值,这时候要确保伪造的值符合目标进程的预期,否则会引发更诡异的崩溃。
4. 注入与 IPC:钩子 DLL 怎么把数据传回注入器
4.1 IPC 通道的建立与连接
EasyHook 的 IPC 基于 .NET Remoting,注入器用IpcCreateServer创建服务端,钩子 DLL 用IpcConnectClient连接。通道名由IpcCreateServer的ref参数生成,必须原样传给RemoteHooking.Inject,否则钩子 DLL 连不上。
// 注入器侧 string channelName = null; RemoteHooking.IpcCreateServer<HookInterface>( ref channelName, WellKnownObjectMode.SingleCall); // 钩子 DLL 侧 _interface = RemoteHooking.IpcConnectClient<HookInterface>(channelName);WellKnownObjectMode.SingleCall表示每次调用创建新实例,适合无状态回传;如果需要在钩子 DLL 里保持状态,用Singleton。我一般用SingleCall,因为钩子逻辑本身不依赖服务端状态,回传数据而已。
4.2 回传数据的序列化与性能注意
HookInterface里定义的方法参数会被序列化传输,所以参数类型必须是可序列化的。基本类型、字符串、IntPtr都没问题,自定义类需要加[Serializable]。如果回传频率很高,比如每秒几千次 API 调用,IPC 会成为瓶颈,这时候建议在钩子 DLL 里做聚合,比如攒够 100 条再一次性回传,或者直接写本地文件,注入器去读文件。
我做过一个采集项目,目标进程每秒调用RegQueryValueEx上万次,一开始每条都回传,注入器 CPU 直接跑满。后来改成钩子 DLL 内部用ConcurrentQueue缓存,每 500 毫秒批量回传一次,CPU 降到 5% 以下。这个经验说明:IPC 不是越多越好,批量是必须的。
4.3 钩子卸载与进程退出的清理
钩子 DLL 里的Run方法通常用一个while(true)保持线程存活,但目标进程退出时这个线程会被强制终止,钩子也就没了。如果需要在进程退出前做清理,可以在Run里监听一个退出信号,或者用LocalHook.Dispose显式卸载。实际项目里我很少主动卸载,因为目标进程退出时操作系统会回收所有资源,主动卸载反而容易在卸载过程中触发竞态导致崩溃。
5. EasyHook 避坑记录:5 个让我加班到凌晨的坑
5.1 现象:注入成功但钩子不生效,目标进程行为无变化
原因:最常见的是位数不匹配。注入器是 x64,目标进程是 x86,RemoteHooking.Inject会返回成功但实际什么都没做。其次是ThreadACL设错,只钩了当前线程,业务线程没被钩住。
解决:先确认注入器和目标进程的位数一致,用任务管理器看目标进程有没有*32标记。然后检查SetExclusiveACL的参数,如果想让所有线程生效,传new int[0]。
5.2 现象:目标进程在钩子安装后几秒内崩溃
原因:钩子委托被 GC 回收。LocalHook.Create返回的对象如果只被局部变量引用,方法执行完就可能被回收,钩子失效后目标进程调用原始 API 时跳到已释放的内存,直接崩。
解决:把LocalHook对象用静态字段或实例字段持有,确保在整个钩子生命周期内都有强引用。
5.3 现象:钩子函数里调用原始 API 返回错误码 126(找不到模块)
原因:LocalHook.GetProcAddress拿到的函数地址在钩子 DLL 的上下文中可能无效,尤其是目标进程加载的 DLL 版本和钩子 DLL 预期的不一致时。
解决:用DllImport直接声明原始 API,让 CLR 去解析地址,而不是手动GetProcAddress。如果必须手动获取,确保在目标进程的上下文中调用。
5.4 现象:IPC 回传数据丢失或注入器收不到
原因:通道名不一致,或者WellKnownObjectMode选错。另外,如果钩子 DLL 在Run方法里抛异常,IPC 连接会断开,后续回传全部失败。
解决:在钩子 DLL 的Run方法里加 try-catch,把异常写到本地日志,避免静默失败。通道名用ref参数生成后打印出来,两边核对。
5.5 现象:64 位目标进程注入 32 位钩子 DLL 报错「无法加载 DLL」
原因:EasyHook 的注入器会根据目标进程位数选择对应的钩子 DLL,如果只提供了一个位数的 DLL,另一个路径传了空或者错误路径,注入就会失败。
解决:在项目里建两个编译配置,分别输出 x86 和 x64 的钩子 DLL,注入时两个路径都传对应的文件。如果只跑一种位数,另一个路径传同一个文件也行,但必须保证文件存在。
6. 进阶技巧:用 EasyHook 做 API 调用链追踪与性能采样
跑通最小 demo 之后,我一般会把 EasyHook 用在两个进阶场景:调用链追踪和性能采样。调用链追踪的思路是在钩子 DLL 里维护一个线程本地栈,每次进入钩子函数时压栈,退出时弹栈,这样就能还原出 API 的调用层级。性能采样更简单,在钩子函数进入和退出时各取一次Stopwatch.GetTimestamp,差值就是这次调用的耗时,攒够一批回传后做聚合分析。
下面是一个性能采样的核心片段,挂在CreateFileW上统计调用耗时:
// 钩子 DLL 内,性能采样逻辑 private static readonly ConcurrentQueue<long> _latencyQueue = new(); IntPtr CreateFileWHook( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { long start = Stopwatch.GetTimestamp(); IntPtr result = CreateFileW( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); long elapsed = Stopwatch.GetTimestamp() - start; // 转成微秒后入队,避免回传频率过高 _latencyQueue.Enqueue(elapsed * 1_000_000 / Stopwatch.Frequency); // 每 100 条回传一次 if (_latencyQueue.Count >= 100) { var batch = new List<long>(); while (_latencyQueue.TryDequeue(out long v)) batch.Add(v); _interface.OnLatencyBatch(batch); } return result; }这段代码的关键在批量回传:_latencyQueue攒够 100 条才通过 IPC 发一次,避免高频调用把注入器拖垮。Stopwatch.GetTimestamp的精度取决于硬件,一般够用;如果要纳秒级,得用QueryPerformanceCounter。回传的List<long>在注入器侧做排序和分位数计算,就能得到 P50、P95、P99 这些指标。
验证钩子是否真的生效,我习惯用「双盲法」:先在目标进程里手动调用一次已知 API,看钩子有没有捕获到;再在钩子函数里加一个计数器,运行一段时间后对比目标进程的实际调用次数和钩子捕获次数,两者一致才说明钩子覆盖完整。如果捕获次数偏少,大概率是ThreadACL没设对,或者某些调用走了不同的 API 入口(比如CreateFileA和CreateFileW是两个不同的函数)。
最后说个我自己的习惯:每次用 EasyHook 之前,先在虚拟机里跑一遍,确认目标进程不会因为钩子崩溃再上生产。钩子这东西,写对了是手术刀,写错了就是炸弹。希望帮到你。
本文还有配套的精品资源,点击获取