资讯详情

C# API拦截实战:EasyHook从入门到避坑

📅 2026/10/30 10:19:52 | 华诺云谱 👁 阅读
C# API拦截实战:EasyHook从入门到避坑
简介这份资源是面向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从零到拦截第一个 API2.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内部会走CreateFileWEasyHook 挂上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.IpcCreateServerHookInterface( 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.IpcConnectClientHookInterface(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.IpcCreateServerHookInterface( ref channelName, WellKnownObjectMode.SingleCall); // 钩子 DLL 侧 _interface RemoteHooking.IpcConnectClientHookInterface(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目标进程是 x86RemoteHooking.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 ConcurrentQueuelong _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 Listlong(); while (_latencyQueue.TryDequeue(out long v)) batch.Add(v); _interface.OnLatencyBatch(batch); } return result; }这段代码的关键在批量回传_latencyQueue攒够 100 条才通过 IPC 发一次避免高频调用把注入器拖垮。Stopwatch.GetTimestamp的精度取决于硬件一般够用如果要纳秒级得用QueryPerformanceCounter。回传的Listlong在注入器侧做排序和分位数计算就能得到 P50、P95、P99 这些指标。验证钩子是否真的生效我习惯用「双盲法」先在目标进程里手动调用一次已知 API看钩子有没有捕获到再在钩子函数里加一个计数器运行一段时间后对比目标进程的实际调用次数和钩子捕获次数两者一致才说明钩子覆盖完整。如果捕获次数偏少大概率是ThreadACL没设对或者某些调用走了不同的 API 入口比如CreateFileA和CreateFileW是两个不同的函数。最后说个我自己的习惯每次用 EasyHook 之前先在虚拟机里跑一遍确认目标进程不会因为钩子崩溃再上生产。钩子这东西写对了是手术刀写错了就是炸弹。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑