ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# EasyHook 实战:本地与远程 API Hook 最小 Demo 及避坑指南

C# EasyHook 实战:本地与远程 API Hook 最小 Demo 及避坑指南 简介这是一份面向C#开发者的EasyHook远程函数拦截与注入实战示例包适合需要监控、调试或修改其他进程行为的中高级开发者参考。资源围绕EasyHook库的本地钩子创建、远程注入、回调委托设置以及DLL数字签名等关键环节展开并附带拦截ExitWindowsEx方法的完整示例代码帮助读者理解钩子从定义、安装到生效的完整链路。压缩包共91个文件约854KB以cs源码、dll动态库、exe可执行程序、pdb调试符号、config配置、resx资源及sln解决方案等为主涵盖多个演示工程与类库模块目录结构便于对照学习。目前已有912人学习下载。通过该示例读者可掌握EasyHook的安装配置、钩子注入流程与签名注意事项为跨进程拦截与调试场景提供可复用的代码骨架和排错思路。1. 从一次线上事故说起为什么我要在 C# 里用 EasyHook 做 API Hook去年维护一个 C# 上位机项目客户现场反馈某台设备连续运行 8 小时后日志里开始出现句柄泄漏但代码里所有FileStream、Socket都规规矩矩写了using。翻遍业务代码找不到问题最后只能上 Hook——把CreateFileW、CloseHandle这对 Win32 API 拦下来记录每次调用的调用栈和句柄值跑一晚上就定位到是某个第三方串口组件内部偷偷开了句柄没释放。当时用的就是 EasyHook一个在 .NET 生态里做用户态 API Hook 相当成熟的开源库。这篇要讲的就是C# EasyHook 使用 demo从零搭一个能跑起来的最小 Hook 工程把目标进程里的MessageBoxW拦下来改成弹自己的内容再讲清楚本地钩子和远程钩子的区别、参数怎么传、为什么你照着网上 demo 抄会翻车。适合两类人一是做 C# 上位机、需要监控第三方组件行为的工程师二是想学 API Hook 但被 C 那套 detour 劝退的 .NET 开发者。读完你能自己写出一个可复现的 Hook demo并知道哪些场景不该用 EasyHook。2. EasyHook 的定位与选型它到底解决了什么问题2.1 用户态 API Hook 的三种常见做法对比在 Windows 上做 API Hook绕不开三条路微软官方的 DetoursC、开源的 MinHookC、以及 EasyHook.NET 友好。很多人一上来就问「哪个最强」其实这个问题问错了应该问「我的目标进程是什么、我的注入代码用什么语言写」。方案语言托管进程支持注入方式典型场景DetoursC/C需自己处理 CLRDLL 注入商业级、系统级 HookMinHookC/C需自己处理 CLRDLL 注入轻量、x86/x64 通用EasyHookC#/C原生支持托管注入 原生注入.NET 上位机、快速验证EasyHook 的核心价值在于它把「注入目标进程」和「在目标进程里执行你的托管代码」这两件麻烦事封装好了。你写一个继承自EasyHook.IEntryPoint的类编译成 DLL然后通过RemoteHooking.Inject把它塞进目标进程剩下的地址改写、trampoline 跳转、参数封送库都替你处理了。这就是为什么做 C# 上位机的同行更愿意选它——不用为了一个 Hook 去啃 C 的 detour 汇编。但要注意一个边界EasyHook 的托管注入依赖 .NET Framework 运行时。如果目标进程是纯原生 C 程序且没装对应版本的 .NET Framework托管注入会失败这时只能退回到它的原生 APILhInject系列。这是选型时第一个要确认的点。2.2 本地钩子与远程钩子的区别以及你该选哪个EasyHook 把 Hook 分成两类概念必须先分清否则 demo 都跑不对。本地钩子Local Hook在你自己进程内 Hook 某个 API。比如你的 C# 程序想监控自己调用的MessageBoxW用LocalHook.Create就行不需要注入不需要额外 DLL代码全在一个工程里。这是最容易跑通的 demo 形态。远程钩子Remote Hook把 Hook 代码注入到另一个进程拦截那个进程的 API 调用。这需要写一个IEntryPoint实现类编译成独立 DLL再用RemoteHooking.Inject注入。远程钩子才是 EasyHook 真正区别于普通反射的地方也是坑最多的部分。选哪个取决于你的目标如果只是验证 Hook 原理、或者监控自己程序的 API 调用本地钩子足够如果要监控第三方上位机、串口组件、DCS 客户端的行为必须用远程钩子。下面两章分别给一个能直接跑的最小 demo。3. 本地钩子最小 demo拦截 MessageBoxW 并改写内容3.1 工程准备与 NuGet 依赖新建一个 .NET Framework 4.7.2 的控制台工程注意EasyHook 对 .NET Core/.NET 5 的支持不完整做 Hook 场景建议老老实实用 .NET Framework。通过 NuGet 安装Install-Package EasyHook安装后确认引用里出现EasyHook.dll和EasyHook32.dll/EasyHook64.dll。后者是原生辅助库必须和你的进程位数匹配——你的程序是 x64就得保证EasyHook64.dll在输出目录里否则运行时报「无法加载 EasyHook 原生库」。这是新手第一个翻车点。3.2 用 LocalHook.Create 挂上目标 API本地钩子的核心是三步定义委托签名、写 Hook 处理函数、调用LocalHook.Create。下面这段代码拦截MessageBoxW把弹窗内容改成我们自己的文本。using System; using System.Runtime.InteropServices; using EasyHook; class Program { // 1. 按目标 API 的原型定义委托 // MessageBoxW 签名: int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType) [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] delegate int MessageBoxWDelegate(IntPtr hWnd, string lpText, string lpCaption, uint uType); // 2. 保存原始函数地址Hook 处理函数里要回调它 static MessageBoxWDelegate _originalMessageBoxW; // 3. Hook 处理函数签名必须和委托完全一致 static int HookMessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType) { Console.WriteLine($[Hook] 原始内容: {lpText}); // 改写内容后再调用原始 API return _originalMessageBoxW(hWnd, 内容已被 EasyHook 改写, lpCaption, uType); } [DllImport(user32.dll, CharSet CharSet.Unicode, SetLastError true)] static extern int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType); static void Main() { // 获取目标 API 在内存中的地址 IntPtr targetAddr LocalHook.GetProcAddress(user32.dll, MessageBoxW); // 创建本地钩子 var hook LocalHook.Create( targetAddr, new MessageBoxWDelegate(HookMessageBoxW), null); // 激活钩子只对当前线程生效传 null 表示对所有线程 hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); // 保存原始函数供 Hook 处理函数回调 _originalMessageBoxW (MessageBoxWDelegate)Marshal.GetDelegateForFunctionPointer( targetAddr, typeof(MessageBoxWDelegate)); Console.WriteLine(Hook 已激活按回车弹出测试对话框...); Console.ReadLine(); // 触发一次调用验证 Hook 是否生效 MessageBoxW(IntPtr.Zero, 这是原始文本, 测试, 0); Console.ReadLine(); hook.Dispose(); } }逻辑说明LocalHook.GetProcAddress拿到user32.dll里MessageBoxW的实际内存地址LocalHook.Create在这个地址上写入跳转指令把执行流引到我们的HookMessageBoxWThreadACL.SetExclusiveACL(new int[] { 0 })表示只对线程 ID 为 0 的线程生效——这里传 0 是个常见写法实际含义是「排除列表为空」即对所有线程生效具体语义见下一节的参数说明。参数说明LocalHook.Create的第三个参数是 Hook 回调上下文对象本地钩子传null即可SetExclusiveACL传的是「不 Hook 的线程 ID 数组」传new int[] { 0 }是官方 demo 的惯用写法表示没有线程被排除。如果你只想 Hook 特定线程把该线程 ID 填进去。3.3 运行验证与结果解读按 F5 运行控制台会先打印「Hook 已激活」回车后弹出的对话框标题是「测试」但内容变成了「内容已被 EasyHook 改写」同时控制台打印出原始文本「这是原始文本」。这说明 Hook 生效了调用方传进去的字符串被我们截获并替换。如果弹窗内容没变按顺序排查三件事一是EasyHook64.dll是否在输出目录二是SetExclusiveACL是否漏调没激活的钩子不会生效三是_originalMessageBoxW是否在Create之后才赋值——顺序反了会拿到被改写后的地址导致无限递归直接栈溢出。这个递归坑是本地钩子最经典的翻车方式现象是程序一调用目标 API 就崩溃没有异常信息。4. 远程钩子 demo注入目标进程拦截文件操作4.1 编写 IEntryPoint 实现类远程钩子需要两个工程一个「注入器」控制台程序负责发起注入一个「Hook DLL」类库包含IEntryPoint实现。先写 Hook DLLusing System; using System.Runtime.InteropServices; using EasyHook; public class FileHook : IEntryPoint { [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); static CreateFileWDelegate _originalCreateFileW; // 构造函数必须匹配 IEntryPoint 约定参数由注入方通过 channel 传入 public FileHook(RemoteHooking.IContext context, string channelName) { // 这里可以初始化但不要做耗时操作 } public void Run(RemoteHooking.IContext context, string channelName) { // Run 在目标进程内执行在这里挂 Hook IntPtr addr LocalHook.GetProcAddress(kernel32.dll, CreateFileW); var hook LocalHook.Create(addr, new CreateFileWDelegate(HookCreateFileW), this); hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); _originalCreateFileW (CreateFileWDelegate)Marshal.GetDelegateForFunctionPointer( addr, typeof(CreateFileWDelegate)); // 通知注入方Hook 已就绪 RemoteHooking.WakeUpProcess(); // 保持线程存活否则目标进程可能卸载我们的 DLL System.Threading.Thread.Sleep(Timeout.Infinite); } static IntPtr HookCreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 只记录不阻断避免影响目标进程正常逻辑 if (lpFileName ! null lpFileName.EndsWith(.log, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($[FileHook] 打开日志文件: {lpFileName}); } return _originalCreateFileW(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } }逻辑说明Run方法是 EasyHook 在目标进程内调用的入口所有 Hook 挂载逻辑写在这里。RemoteHooking.WakeUpProcess()是必须的它通知注入方「我已经准备好了」注入方才能继续往下走。最后的Thread.Sleep(Timeout.Infinite)是为了让 Hook 线程常驻否则线程一退出目标进程可能把我们的 DLL 卸载掉Hook 就失效了。参数说明CreateFileW的dwDesiredAccess、dwCreationDisposition这些是 Win32 常量Hook 处理函数里原样透传即可不要随意修改否则目标进程的文件操作会异常。lpFileName是我们要监控的关键参数注意判空——某些调用会传null。4.2 注入器端RemoteHooking.Inject 的调用姿势注入器工程同样引用 EasyHook核心代码using System; using EasyHook; class Injector { static void Main(string[] args) { if (args.Length 1) { Console.WriteLine(用法: Injector.exe 目标进程PID); return; } int targetPid int.Parse(args[0]); string channelName null; // 创建 IPC 通道用于注入方和 Hook DLL 通信 RemoteHooking.IpcCreateServerFileHook(ref channelName, System.Runtime.Remoting.WellKnownObjectMode.Singleton); // 执行注入参数依次是 目标PID、Hook DLL路径、DLL类型全名、通道名 RemoteHooking.Inject( targetPid, FileHook.dll, // 32 位 DLL 路径 FileHook.dll, // 64 位 DLL 路径 channelName); Console.WriteLine($已注入进程 {targetPid}按回车退出...); Console.ReadLine(); } }逻辑说明IpcCreateServer建立了一个进程间通信通道Hook DLL 里可以通过这个通道回传数据给注入器。RemoteHooking.Inject的第二个和第三个参数分别是 32 位和 64 位 DLL 路径——如果你的注入器和目标进程位数一致两个填同一个路径即可如果目标进程位数不确定就得准备两份编译产物。参数说明targetPid必须是目标进程的真实 PID可以用任务管理器或Process.GetProcessesByName获取。注入前要确认目标进程的位数和你的 Hook DLL 匹配32 位注入器往 64 位进程注入会直接抛ArgumentException。4.3 验证注入是否成功先启动一个会写.log文件的目标程序比如随便一个记事本另存为 log 也行但更典型的是启动一个持续写日志的上位机。记下它的 PID运行注入器传入 PID。如果注入成功注入器会打印「已注入进程」目标进程里每次打开.log文件注入器的控制台就会打印文件名。验证失败的排查顺序目标进程是否以管理员权限运行权限不对等会导致注入失败Hook DLL 是否和注入器在同一目录目标进程是否已经加载了不同版本的 .NET Framework。这三点覆盖了 90% 的远程注入失败。5. EasyHook 避坑清单五个我真实踩过的坑5.1 坑一Hook 处理函数里回调原始 API 导致无限递归现象程序一调用目标 API 就崩溃没有托管异常直接进程退出。原因在LocalHook.Create之前就调用了Marshal.GetDelegateForFunctionPointer获取原始地址此时地址还没被改写拿到的是原始地址没错但如果顺序反了在Create之后才获取拿到的可能是被 Hook 后的地址回调时又进入 Hook 处理函数无限递归。解决严格保证「先 Create再获取原始委托」的顺序或者用 EasyHook 提供的hook.HookRuntimeInfo里的原始地址。我一般会在代码里加一行注释锁死这个顺序。5.2 坑二ThreadACL 没设置Hook 静默失效现象代码编译通过运行不报错但目标 API 行为完全没变。原因LocalHook.Create只是创建了钩子对象必须调用ThreadACL.SetExclusiveACL或SetInclusiveACL才会真正激活。很多人抄 demo 时漏了这行。解决创建钩子后立刻设置 ACL。SetExclusiveACL(new int[] { 0 })表示对所有线程生效SetInclusiveACL则相反只对列表里的线程生效。搞不清就用 Exclusive 传 0。5.3 坑三目标进程位数与 Hook DLL 不匹配现象RemoteHooking.Inject抛ArgumentException提示「无法注入位数不匹配」。原因32 位注入器无法往 64 位进程注入托管 DLL反之亦然。EasyHook 的托管注入对位数非常敏感。解决注入器和 Hook DLL 都编译成AnyCPU并勾选「首选 32 位」不一定管用最稳的做法是明确编译 x64 版本注入前用Process.GetCurrentProcess确认目标进程位数。如果必须跨位数只能用 EasyHook 的原生注入接口复杂度上一个台阶。5.4 坑四Hook 处理函数里做耗时操作拖垮目标进程现象注入成功后目标进程明显变卡甚至无响应。原因Hook 处理函数是在目标进程的调用线程里同步执行的。你在里面写文件、发网络请求、加锁都会直接阻塞目标进程的业务线程。解决Hook 处理函数里只做最轻量的记录把数据丢进无锁队列由独立线程异步消费。我一般用ConcurrentQueue加一个后台线程绝不在 Hook 里直接Console.WriteLine或写文件。5.5 坑五.NET Framework 版本不匹配导致注入后目标进程崩溃现象注入瞬间目标进程闪退事件查看器里有 CLR 相关的错误。原因Hook DLL 编译时用的 .NET Framework 版本高于目标进程已加载的版本CLR 加载程序集失败。解决Hook DLL 的目标框架版本要小于等于目标进程的运行时版本。不确定就统一用 .NET Framework 4.5 或 4.6.1 编译兼容性最好。6. 进阶技巧用 Hook 做无侵入的调用链追踪前面讲的都是「拦截并改写」但 EasyHook 在生产环境里更常见的用法是「只观察不改写」——做无侵入的调用链追踪。这个技巧的核心是Hook 处理函数里记录调用栈然后原样透传给原始 API对目标进程完全透明。具体做法是在 Hook 处理函数里用System.Diagnostics.StackTrace抓当前调用栈但要注意Hook 处理函数本身会出现在栈顶需要跳过前几帧。下面是一个可复用的追踪片段static IntPtr HookCreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 只追踪特定后缀避免日志爆炸 if (lpFileName ! null lpFileName.EndsWith(.dat, StringComparison.OrdinalIgnoreCase)) { var stack new System.Diagnostics.StackTrace(1, false); // 跳过当前帧 var sb new System.Text.StringBuilder(); sb.AppendLine($[Trace] {DateTime.Now:HH:mm:ss.fff} 打开 {lpFileName}); for (int i 0; i Math.Min(stack.FrameCount, 5); i) { var method stack.GetFrame(i).GetMethod(); sb.AppendLine($ - {method.DeclaringType?.FullName}.{method.Name}); } // 异步写入避免阻塞目标线程 _logQueue.Enqueue(sb.ToString()); } return _originalCreateFileW(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); }参数说明StackTrace(1, false)的第一个参数是跳过帧数传 1 跳过 Hook 处理函数自身第二个参数false表示不抓文件行号抓行号会显著变慢生产环境建议关掉。Math.Min(stack.FrameCount, 5)限制栈深度避免深层递归调用把日志撑爆。验证追踪是否有效的方法在目标进程里打开一个.dat文件看注入器控制台是否打印出调用链。如果打印了但调用栈全是System命名空间的方法说明跳过帧数不对把StackTrace(1, false)改成StackTrace(2, false)再试。一个我自己的习惯所有 Hook 相关的代码我都会在文件头写一段注释标明「目标 API 签名、Hook 生效条件、原始地址获取顺序、ACL 设置方式」这四项。因为 Hook 代码的调试成本极高出问题时没有后悔药只能靠注释快速回忆当时的约束。EasyHook 这个库本身不复杂复杂的是目标进程的运行时环境和位数匹配把这两件事在 demo 阶段就摸清楚后面上生产会省很多血泪经验。希望帮到你。本文还有配套的精品资源点击获取
返回列表