ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# easyHook 使用 demo:从零跑通第一个 API Hook

C# easyHook 使用 demo:从零跑通第一个 API Hook

简介:这份资源是面向C#开发者、调试与逆向分析学习者的EasyHook实战示例包,聚焦运行时远程函数拦截与DLL注入这一进阶主题。包内共91个文件,以17个cs源码、15个dll依赖库、6个exe可执行程序、3个csproj工程文件及2个sln解决方案为主,另含pdb调试符号、config配置、resx资源与snk签名文件,压缩包约854KB,结构完整可直接编译运行。示例演示了通过LocalHook.Create拦截user32.dll中ExitWindowsEx方法、设置ThreadACL独占ACL并安装钩子的完整流程,同时覆盖NuGet安装配置、本地与远程方法定义、回调委托获取以及DLL数字签名等关键环节,帮助读者理解钩子注入的签名要求与安全机制。目前已有912人学习下载,适合希望掌握跨进程拦截、性能监控与行为修改技术的中高级开发者参考实践。

1. C# easyHook 使用 demo:从零跑通第一个 API Hook

线上环境有个老系统,某个第三方 DLL 每次调用CreateFileW都会把路径写死成C:\ProgramData\...,运维那边又不敢动原文件。这种时候最省事的做法不是反编译重写,而是用 easyHook 在运行时把CreateFileW拦下来,把路径参数改掉再放行。easyHook 是 .NET 生态里做 API Hook 比较成熟的一个库,托管侧写起来比纯 C++ 的 Detours 舒服很多,适合做 C# 上位机、桌面工具、自动化测试这类场景的运行时拦截。

这篇笔记围绕「C# easyHook 使用 demo」这个主题,把托管 Hook 的完整链路走一遍:从环境准备、目标函数选择、Hook 代码怎么写、参数怎么改、到实际跑起来会翻车的几个点。读者最好有 C# 基础,知道 P/Invoke 和委托是什么,剩下的跟着步骤走就行。demo 本身不复杂,难的是把「为什么这么写」和「哪里会炸」讲清楚,这也是我踩过坑之后最想补上的部分。

2. easyHook 的托管 Hook 原理与选型理由

2.1 为什么是 easyHook 而不是 Detours 或 MinHook

做 API Hook 的库不少,Detours 是微软官方的,MinHook 轻量,easyHook 的定位在中间:它同时支持托管和非托管 Hook,托管侧用 C# 就能写,不需要单独编译一个 C++ 注入 DLL。对于 C# 上位机、WinForm/WPF 工具这类项目,团队里往往没有专门的 C++ 人手,easyHook 能省掉一整条工具链。

它的核心机制是在目标进程里注入一个托管运行时宿主,然后通过LocalHook或RemoteHook把目标函数的入口改成跳转指令,跳到我们自己的委托上。托管侧写 Hook 的时候,easyHook 帮你处理了 ABI 对齐、栈平衡这些脏活,你只要保证委托签名和原函数一致就行。

选型上我一般这么判断:如果只是 Hook 自己进程内的 Win32 API,用LocalHook就够了,不需要注入,调试也方便;如果要 Hook 别的进程,才需要RemoteHook加注入器。demo 阶段强烈建议先在本进程内跑通,把签名和参数搞对了再考虑跨进程。

2.2 环境准备与依赖安装

先建一个 .NET Framework 4.7.2 或 .NET 6 的控制台项目。easyHook 对 .NET Core/.NET 5+ 的支持一直有点玄学,稳妥起见 demo 用 .NET Framework 4.7.2,NuGet 上直接装EasyHook包即可。

# 在项目目录下用 dotnet CLI 装包(.NET Framework 项目用 VS 的包管理器控制台也行) dotnet add package EasyHook

装完之后确认两件事:一是EasyHook.dll和EasyHook32.dll/EasyHook64.dll都被复制到输出目录,二是项目平台目标要和系统位数对上。64 位系统上跑 AnyCPU 有时候会加载错 native dll,直接指定 x64 最省心。

提示:easyHook 的 native dll 必须和宿主进程位数一致,32 位进程加载 64 位 dll 会直接抛BadImageFormatException,这个报错信息很不直观,第一次遇到容易懵。

2.3 目标函数的选择:从 CreateFileW 说起

demo 选CreateFileW作为目标,原因是它签名清晰、调用频繁、参数好观察。它的原型是:

HANDLE CreateFileW( LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile );

对应到 C# 的 P/Invoke 声明,参数类型要一一对上:LPCWSTR用[MarshalAs(UnmanagedType.LPWStr)] string,HANDLE用IntPtr,DWORD用uint。这里有个容易翻车的点:lpFileName如果声明成string而不是IntPtr,在 Hook 里想改路径就得重新分配非托管内存,麻烦很多。demo 里我一般直接声明成IntPtr,需要读的时候用Marshal.PtrToStringUni,需要改的时候用Marshal.StringToHGlobalUni。

3. 写一个能跑的 LocalHook demo

3.1 声明原函数与 Hook 委托

先写 P/Invoke 声明和委托类型。委托签名必须和原函数完全一致,返回值、参数个数、参数类型一个都不能差,否则 Hook 上去之后栈会乱,表现是进程直接崩或者返回值莫名其妙。

using System; using System.Runtime.InteropServices; internal static class NativeMethods { // 原函数声明,用于调用原始逻辑 [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern IntPtr CreateFileW( IntPtr lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); // Hook 委托,签名必须和上面完全一致 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] public delegate IntPtr CreateFileWDelegate( IntPtr lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); }

UnmanagedFunctionPointer里的CallingConvention.StdCall是关键,Win32 API 默认就是 StdCall,写错了 Hook 调用时参数会错位。SetLastError = true也要带上,不然Marshal.GetLastWin32Error拿不到真实错误码。

3.2 用 LocalHook.Create 挂上 Hook

接下来创建 Hook 实例,把目标函数地址和我们的委托关联起来。LocalHook.Create的第一个参数是目标函数地址,用GetProcAddress拿;第二个参数是 Hook 委托;第三个参数是回调上下文,demo 里传null就行。

using EasyHook; public class FileHook { private LocalHook _hook; private NativeMethods.CreateFileWDelegate _original; private NativeMethods.CreateFileWDelegate _hookDelegate; public void Install() { // 拿到 kernel32 里 CreateFileW 的真实地址 IntPtr kernel32 = NativeMethods.GetModuleHandle("kernel32.dll"); IntPtr target = NativeMethods.GetProcAddress(kernel32, "CreateFileW"); // Hook 委托指向我们自己的实现 _hookDelegate = HookImpl; // 创建 LocalHook,第三个参数是回调上下文,这里不需要 _hook = LocalHook.Create(target, _hookDelegate, null); // 只 Hook 当前线程,避免影响其他线程造成不可预期行为 _hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); // 保存原始函数委托,用于放行 _original = (NativeMethods.CreateFileWDelegate)Marshal.GetDelegateForFunctionPointer( target, typeof(NativeMethods.CreateFileWDelegate)); } private IntPtr HookImpl( IntPtr lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { string path = Marshal.PtrToStringUni(lpFileName) ?? string.Empty; // 把写死的路径替换成我们想要的路径 if (path.StartsWith(@"C:\ProgramData\", StringComparison.OrdinalIgnoreCase)) { string newPath = path.Replace(@"C:\ProgramData\", @"D:\Redirected\"); IntPtr newPtr = Marshal.StringToHGlobalUni(newPath); try { return _original(newPtr, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } finally { Marshal.FreeHGlobal(newPtr); } } // 不匹配的路径原样放行 return _original(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } }

几个参数说明一下。ThreadACL.SetExclusiveACL(new int[] { 0 })里的0是线程 ID,表示只 Hook 主线程,这样调试的时候不会因为其他线程也走 Hook 逻辑而干扰判断。Marshal.GetDelegateForFunctionPointer把原始地址转成委托,这是放行的关键,不调用它就没法走回原逻辑。

Marshal.StringToHGlobalUni分配的非托管内存一定要在finally里释放,否则每次 Hook 都漏一块内存,跑久了进程内存会涨得很难看。这是血泪经验,第一次写的时候忘了释放,跑了一晚上内存涨到 2G。

3.3 触发 Hook 并验证效果

装好 Hook 之后,随便调一次CreateFileW就能看到效果。demo 里直接调File.OpenRead触发,因为 .NET 内部最终会走到CreateFileW。

class Program { static void Main(string[] args) { var hook = new FileHook(); hook.Install(); // 触发一次 CreateFileW,路径会被重定向 try { using (var fs = File.OpenRead(@"C:\ProgramData\test.txt")) { Console.WriteLine("打开成功,长度:" + fs.Length); } } catch (Exception ex) { Console.WriteLine("打开失败:" + ex.Message); } Console.ReadLine(); } }

验证的时候在HookImpl里加一句Console.WriteLine(path),能看到实际传入的路径。如果路径被成功替换,说明 Hook 生效了。注意File.OpenRead内部可能走的是CreateFileW的宽字符版本,如果发现 Hook 没触发,先确认目标进程是不是真的调了这个 API,可以用 Process Monitor 抓一下调用栈。

4. easyHook 实战避坑与常见问题排查

4.1 Hook 装上了但没触发

现象:代码没报错,LocalHook.Create也返回了实例,但HookImpl里的断点就是不进。

原因通常有三个:一是目标函数地址拿错了,比如GetProcAddress返回IntPtr.Zero但没检查;二是ThreadACL设置的范围不对,当前线程不在 Hook 列表里;三是目标进程调用的其实是另一个 API,比如CreateFileA而不是CreateFileW。

解决:先检查GetProcAddress的返回值,为零就说明模块名或函数名写错了。然后把SetExclusiveACL换成SetInclusiveACL试试,或者干脆不设 ACL 让所有线程都 Hook。最后用 Process Monitor 确认目标进程实际调用的 API 名称。

4.2 进程崩溃或返回值异常

现象:Hook 装上之后进程直接崩,或者函数返回值变成一堆乱码。

原因:委托签名和原函数不一致,最常见的是CallingConvention写成了Cdecl,或者参数类型对不上,比如把uint写成了int。栈不平衡的时候,返回地址会被读错,进程就崩了。

解决:把委托签名和 P/Invoke 声明逐字对比,确保参数个数、类型、顺序、调用约定完全一致。SetLastError也要和原函数声明保持一致。如果还是崩,先用一个参数最简单的 API 比如GetTickCount练手,确认 Hook 机制本身没问题再换复杂函数。

4.3 内存泄漏与句柄泄漏

现象:跑一段时间后进程内存持续上涨,或者句柄数不断增加。

原因:在 Hook 里分配了非托管内存但没释放,比如Marshal.StringToHGlobalUni之后忘了FreeHGlobal;或者 Hook 里打开了文件/句柄但没关闭。

解决:所有非托管分配都用try/finally包起来,确保释放。Hook 逻辑里尽量不做重资源操作,如果必须做,用using或者显式释放。可以用性能监视器观察进程的Private Bytes和Handle Count,跑个几万次调用看曲线是否平稳。

4.4 多线程环境下的竞态

现象:单线程跑得好好的,一上多线程就偶发崩溃或者 Hook 失效。

原因:LocalHook的 ACL 是按线程管理的,如果多个线程同时进出 Hook,而 Hook 逻辑里有共享状态没加锁,就会出问题。另外_original委托在多线程下调用本身是安全的,但如果 Hook 逻辑里改了共享变量就要小心。

解决:Hook 逻辑尽量做成无状态的,只做参数转换和放行。如果必须有状态,用lock或者Interlocked保护。ACL 设置上,如果业务允许,直接 Hook 所有线程反而更简单,避免线程漏 Hook 的问题。

4.5 目标进程是 64 位但加载了 32 位 dll

现象:LocalHook.Create抛BadImageFormatException或者直接找不到 native dll。

原因:easyHook 的 native dll 分 32 位和 64 位,宿主进程位数和 dll 位数不匹配就加载失败。

解决:在项目属性里把平台目标设成和宿主进程一致,x64 进程就用 x64,不要用 AnyCPU。输出目录里确认EasyHook32.dll和EasyHook64.dll都在,easyHook 会根据进程位数自动选,但前提是两个都在。

5. 进阶:用 RemoteHook 跨进程拦截与验证技巧

LocalHook只能 Hook 自己进程,实际工作中更常见的是要 Hook 别人的进程,比如那个写死路径的第三方 DLL 跑在独立进程里。这时候要用RemoteHook,配合RemoteHooking.Inject把托管宿主注入目标进程。

跨进程 Hook 的代码结构和 LocalHook 差不多,区别在于 Hook 逻辑要写在一个继承自EasyHook.IEntryPoint的类里,注入的时候通过RemoteHooking.Inject传参。注入器这边大概长这样:

// 注入器:把 Hook 宿主注入目标进程 int targetPid = 1234; // 目标进程 ID RemoteHooking.Inject( targetPid, InjectionOptions.Default, typeof(FileHookEntryPoint).Assembly.Location, // 32 位宿主 dll typeof(FileHookEntryPoint).Assembly.Location, // 64 位宿主 dll null, // 通道名,不需要就传 null null // 传给宿主的参数 );

InjectionOptions.Default是默认注入选项,如果目标进程有反注入保护,可能需要换InjectionOptions.NoService之类的选项,但那些属于对抗范畴,demo 阶段用默认的就行。注入成功之后,目标进程里会加载你的宿主 dll,Run方法被调用,在里面装 Hook 即可。

验证跨进程 Hook 有没有生效,最直接的办法是在 Hook 逻辑里往一个日志文件写记录,然后看目标进程运行的时候日志有没有新增。也可以用DebugView这类工具看OutputDebugString的输出,但要注意目标进程的调试输出权限。

一个我常用的技巧:在 Hook 里记录调用次数和参数摘要,跑一段时间后统计哪些路径被拦截得最多。这样既能验证 Hook 生效,又能顺便摸清目标进程的行为模式。如果发现某个路径频繁被拦截但替换后目标进程行为异常,说明那个路径可能有特殊语义,不能无脑替换。

跨进程 Hook 的坑比 LocalHook 多得多,比如目标进程位数、权限、反注入、托管运行时版本冲突等等。我的习惯是先用 LocalHook 在本进程把逻辑跑通,确认签名和参数处理没问题,再原样搬到 RemoteHook 的宿主里。这样出问题的时候能快速定位是 Hook 逻辑的问题还是注入环节的问题。另外注入之前一定要确认目标进程的位数,32 位注入器只能注入 32 位进程,64 位同理,搞错了会直接失败。

最后说个习惯:每次写 Hook 之前,先用 Process Monitor 把目标进程的 API 调用抓一遍,确认要 Hook 的函数确实被调用了、调用频率大概多少、参数长什么样。这一步花五分钟,能省掉后面半小时的瞎猜。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进