news 2026/9/29 1:26:37

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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,绕不开三条路:微软官方的 Detours(C++)、开源的 MinHook(C++)、以及 EasyHook(.NET 友好)。很多人一上来就问「哪个最强」,其实这个问题问错了,应该问「我的目标进程是什么、我的注入代码用什么语言写」。

方案语言托管进程支持注入方式典型场景
DetoursC/C++需自己处理 CLRDLL 注入商业级、系统级 Hook
MinHookC/C++需自己处理 CLRDLL 注入轻量、x86/x64 通用
EasyHookC#/C++原生支持托管注入 + 原生注入.NET 上位机、快速验证

EasyHook 的核心价值在于:它把「注入目标进程」和「在目标进程里执行你的托管代码」这两件麻烦事封装好了。你写一个继承自EasyHook.IEntryPoint的类,编译成 DLL,然后通过RemoteHooking.Inject把它塞进目标进程,剩下的地址改写、trampoline 跳转、参数封送,库都替你处理了。这就是为什么做 C# 上位机的同行更愿意选它——不用为了一个 Hook 去啃 C++ 的 detour 汇编。

但要注意一个边界:EasyHook 的托管注入依赖 .NET Framework 运行时。如果目标进程是纯原生 C++ 程序且没装对应版本的 .NET Framework,托管注入会失败,这时只能退回到它的原生 API(LhInject系列)。这是选型时第一个要确认的点。

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在这个地址上写入跳转指令,把执行流引到我们的HookMessageBoxW;ThreadACL.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 DLL:

using 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.IpcCreateServer<FileHook>(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 阶段就摸清楚,后面上生产会省很多血泪经验。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 1:26:34

BK7238单芯片Wi-Fi+BLE共存原理与量产落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:30

红火蚁YOLO数据集:三格式对齐+时间分层划分的农业检测方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:28

酒店综合布线实战指南:从物理隔离到可追溯布线DNA

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:14

PHP文件包含漏洞实战:从LFI/RFI伪协议到防御加固

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:25:34

Unity双屏显示完全指南:Multi-Display方案原理与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:24:40

红外脉冲激光器电路分析与实操诊断指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华