ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C# EasyHook实战:LocalHook与跨进程注入避坑指南

C# EasyHook实战:LocalHook与跨进程注入避坑指南

简介:C#EasyHook 远程函数拦截与注入的实战示例包,面向已掌握 C# 基础、希望理解 Windows 钩子机制与跨进程注入的中级开发者,可用于调试、性能分析及行为监控等场景。压缩包共 91 个文件,约 854KB,包含 17 个 cs 源码、15 个 dll 依赖、6 个 exe 与 6 个 config 配置,辅以 pdb 调试符号、resx/resources 窗体资源和 snk 签名密钥,覆盖本地钩子创建、目标方法替换、回调委托设置及 DLL 数字签名等关键环节。已有 915 人学习,适合按项目结构逐层对照。示例以拦截 ExitWindowsEx 等系统调用为线索,展示 LocalHook.Create、SetExclusiveACL 与 Install 的完整调用链,并保留多工程组织方式,便于读者直接运行观察钩子生效过程,理解注入失败与签名校验的排错思路。

1. 从一次崩溃说起:C# EasyHook 这套 demo 到底解决什么问题

有人在群里贴了一段 C# EasyHook 的代码,说编译过了、注入也返回成功,可目标进程两秒后自己退了,日志一个字没留下。这种场面我见过太多次——代码本身没错,错在没理解 EasyHook 的分层:它同时管着托管层(委托签名、封送、GC)和原生层(函数入口改写、跳转指令、线程屏障),任何一层对不上,表现都是"莫名其妙地崩"。EasyHook 做的事很直接:把目标函数的头几个字节改写成跳转,让调用先落到你写的托管委托里,你记完日志、改完参数,再放行到原始代码。它适合三类人:想给第三方库做接口 Mock 的测试同学、要给存量系统补调用链日志的维护同学、以及研究 Windows 行为但不想每次挂调试器的同学。这份 demo 的价值在于,它把 LocalHook 和 RemoteHook 两条路都摆出来了,照着跑一遍,你就知道该从哪条路进门。

2. 拆开这套 demo 的骨架:LocalHook、RemoteHook 和注入的三件套

EasyHook 这个名字容易被当成"又一个 C# 拦截库",其实它更像一个分层运行时。上层给你 LocalHook / RemoteHook 两个入口,下层是 Native 的 trampoline 和线程屏障。搞不清这两层的分界,代码写得再整齐也是碰运气。

2.1 两种形态:本进程拦自己,跨进程拦别人

LocalHook 的适用范围是当前进程。你在自己的程序里 hookCreateFileW、RegOpenKeyEx、Beep,被拦下来的都是本进程发起的调用。它的成本最低,不需要注入、不需要走 IPC,调试时还能直接断路点。缺点是看得见的范围只有自己:想观察另一个进程读了哪些文件,LocalHook 帮不上忙。

RemoteHook 面向跨进程场景,但它的名字有迷惑性。RemoteHook 不是"从外部拦别人",而是"先把自己的托管 DLL 注入目标进程,再在那个进程内部用 RemoteHook 建钩子"。所以跨进程这条路实际上是三个角色在配合:注入器进程、载荷 DLL、被注入的目标进程。载荷 DLL 在目标进程里跑起来之后,它干的事和 LocalHook 一模一样,区别只是它没有控制台,日志得靠 IPC 或者文件传出来。

选择上我有个朴素的标准:目标进程是你自己能改代码的,用 LocalHook;目标进程你改不了、甚至不是 .NET 写的,才上 RemoteHook。反过来,能用 LocalHook 解决的千万别上注入,注入引入了权限、位数、运行时版本三类问题,每一类都能让 demo 直接翻车。

2.2 三个工程的分工:注入器、载荷、共享契约

一个能跑的跨进程 demo 最少要拆成三个项目,这个拆分本身就是 EasyHook 的设计意图,硬塞进一个工程会很难受。

项目输出职责关键点
Injector控制台 exe找目标进程 PID、建立 IPC 服务端、调用RemoteHooking.Inject位数要能覆盖目标进程
Payload类库 dll实现IEntryPoint,在目标进程里装钩子、回传数据建议 AnyCPU,避免位数错配
Contract类库 dll放 IPC 接口定义,两边共同引用接口里的类型必须可序列化

Contract 这个小项目经常被省掉,后果是注入器直接引用 Payload,Payload 又反过来引用注入器,编译期就打结了。正确姿势是把接口单独拎出来,比如IFileMonitor里只放void OnFileOpen(string path, uint access)这样的方法,Injector 实现它,Payload 通过IpcConnectClient<IFileMonitor>调用它。接口里的参数尽量用基础类型和 string,自定义类要能被序列化,否则连上的那一刻就抛异常。

还有一个常被忽略的点:Payload 输出目录里的EasyHook.dll、EasyHook32.dll、EasyHook64.dll要跟着走。很多人只拷了自己编译出来的 dll,注入时提示找不到模块,其实缺的是这几个原生依赖。

2.3 参数与边界:ACL、调用约定和运行时版本

ThreadACL是 EasyHook 里最容易被误用的参数。SetExclusiveACL(new int[] { 0 })的意思是"列表里的线程不触发钩子",而这里的 0 不是ProcessThread.Id,是 EasyHook 内部的线程索引,0 代表当前线程。写成SetExclusiveACL(new int[] { Process.GetCurrentProcess().Threads[0].Id })是典型的翻车写法,因为线程 ID 会变,而且根本不在一个编号体系里。

为什么需要这个 ACL?因为钩子函数里通常要调用原函数,如果当前线程没被排除,这次调用会再次落进钩子,无限递归直到栈溢出。EasyHook 内部有线程屏障做兜底,但屏障只保证不死锁,不保证你想要的语义。所以建完钩子第一件事就是设 ACL,而且要在任何目标函数被调用之前设。

调用约定也必须对齐。x86 上 Win32 API 基本是StdCall,x64 只有一种调用约定,写了也白写但不会报错。托管侧委托要标[UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Unicode, SetLastError = true)],CharSet决定字符串怎么封送,SetLastError决定你能否在钩子里读到上一次错误码。少了SetLastError,Marshal.GetLastWin32Error()拿到的永远是 0,排查起来能把人逼疯。

运行时版本是硬边界。EasyHook 的托管注入依赖 .NET Framework 的运行时宿主机制,目标进程得能承载对应版本的 Framework。纯 .NET Core / .NET 5+ 的进程走不通托管注入这条路,硬试的话注入器会返回成功但载荷根本跑不起来。目标机没装对应 Framework 也是同样表现。这条边界没有绕过办法,只能换方案:要么给目标进程换成 Framework 版本,要么放弃托管注入,改用原生层的手段。

3. 手写第一个托管 Hook:把 CreateFileW 的参数原样打出来

理论说完了,落到代码。第一个例子我建议选CreateFileW,理由是它在系统里调用频次高、参数可读性强、返回值是个句柄方便验证。跑通之后你会发现,EasyHook 的 API 表面只有四五个调用,真正的活都在签名和封送里。

3.1 签名对齐:委托声明决定成败

原生函数的原型必须先翻译成 C# 委托,这一步错了后面全错。CreateFileW的 Win32 原型是七个参数、返回 HANDLE,翻译过来长这样:

// lpSecurityAttributes 和 hTemplateFile 传 IntPtr 即可,demo 里用不到 // SetLastError = true 必须有,否则钩子里读不到错误码 [UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Unicode, SetLastError = true)] private delegate IntPtr CreateFileWDelegate( string lpFileName, // LPCWSTR,用 string 让运行时做封送 uint dwDesiredAccess, // DWORD uint dwShareMode, // DWORD IntPtr lpSecurityAttributes,// LPSECURITY_ATTRIBUTES uint dwCreationDisposition, // DWORD uint dwFlagsAndAttributes, // DWORD IntPtr hTemplateFile); // HANDLE

几个容易写错的地方:dwDesiredAccess是uint不是int,用int在某些参数上会拿到负数;CharSet必须和函数名的 W 后缀一致,用CreateFileA就该配Ansi;返回值和hTemplateFile都是IntPtr,用int在 64 位进程里会截断句柄。如果你 hook 的是Beep这种返回bool的,记得加[return: MarshalAs(UnmanagedType.Bool)],否则 C 的 BOOL 是四字节,托管 bool 是一字节,返回值会读错。

3.2 完整可抄:从取地址到钩子生效

下面这段是完整控制台程序,可以直接新建一个 .NET Framework 控制台项目,引 EasyHook 包后跑起来:

using System; using System.IO; using System.Runtime.InteropServices; using EasyHook; namespace LocalHookDemo { internal static class Program { [UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Unicode, SetLastError = true)] private delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private static CreateFileWDelegate _original; // 指向原始实现 private static LocalHook _hook; private static IntPtr CreateFileWHandler( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 先存错误码,调用原函数后可能被覆盖 int lastError = Marshal.GetLastWin32Error(); Console.WriteLine($"[Hook] {lpFileName} access=0x{dwDesiredAccess:X8}"); IntPtr handle = _original(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); // 还原错误码,避免影响调用方的判断逻辑 Marshal.SetLastError(lastError); return handle; } private static void Main() { // 1. 拿到目标函数地址(EasyHook 会自行处理转发导出) IntPtr target = LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"); // 2. 先建立指向原始实现的委托 _original = (CreateFileWDelegate)Marshal.GetDelegateForFunctionPointer( target, typeof(CreateFileWDelegate)); // 3. 创建钩子,第三个参数是回调对象,demo 里不需要 _hook = LocalHook.Create(target, new CreateFileWDelegate(CreateFileWHandler), null); // 4. 排除当前线程,防止钩子里调用原函数时递归 _hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); Console.WriteLine("钩子已生效,按任意键结束"); File.ReadAllText(Path.Combine(Environment.CurrentDirectory, "appsettings.json")); Console.ReadKey(); // 5. 卸载时先摘钩子再释放 _hook.Dispose(); _hook = null; } } }

逻辑链条是:取地址 → 建原始委托 → 建钩子 → 设 ACL → 触发 → 卸载。第 2 步和第 3 步的顺序不能反,必须先把原始委托拿到手,因为一旦LocalHook.Create执行,目标地址的头几个字节就被改写了,此时再用GetDelegateForFunctionPointer拿到的可能已经不是干净的原函数入口。

LocalHook.Create的第三个参数是 callback 对象,用于Hook属性变化时的回调,绝大多数 demo 传 null 就行,不用纠结。

3.3 回调里的写法:调原函数、改参数、别递归

钩子函数内部只有三件事可做:记录、修改、放行。记录就是你刚写的 Console 输出;修改是指改参数或者改返回值;放行就是调用_original。顺序上建议先记录再放行,因为有些函数执行完参数缓冲区会被改写,事后看就看不准了。

改参数有个细节要注意:字符串参数在托管侧是string类型,你想改内容只能重新赋值,比如把lpFileName换成另一个路径再传给_original,这是有效的,因为封送发生在调用_original的那一刻。但如果参数是StringBuilder,那是 in/out 缓冲区,得在调用后用ToString()读回来。传值类型如uint,直接改局部变量再传即可。

性能上有个坑:string封送会分配托管内存,如果你的目标函数每秒被调用几万次(比如RegQueryValueExW),GC 压力会明显上升。我的做法是高频率函数一律改成IntPtr接收,需要看内容时用Marshal.PtrToStringUni按需转换,不需要就直接透传,开销几乎为零。

还有一点必须提醒:钩子函数里绝对不能抛异常。异常会穿过原生栈帧,行为未定义,轻则进程崩,重则崩得没有 dump。所有可能出错的地方都包 try/catch,catch 里也别调 Console,写个静态字段计数就行。

3.4 卸载顺序:Dispose 之后就别再碰

卸载只有一行_hook.Dispose(),但顺序错了照样出问题。正确顺序是:先Dispose钩子,再把_original置空,最后退出。如果先置空_original再 Dispose,这中间的时间窗口里如果有别的线程调用了目标函数,钩子仍然生效,一进去就空引用,进程直接没了。

Dispose内部做的是把目标地址的头几个字节还原。这一步在某些场景下会失败,比如目标函数所在的内存页被标记为只读、或者有其他工具抢先改过。失败会抛HookException,捕获它至少能让你知道是还原失败而不是崩溃。

另外提醒一句,SetExclusiveACL设的线程列表是全局状态,进程里多个钩子共享。如果你在同一进程里先后建了三个钩子,每个都设一遍SetExclusiveACL(new int[] { 0 }),结果是后设的覆盖先设的,但语义上差别不大。真正要注意的是别在多线程环境里乱调,ACL 的修改不是原子的,建议所有钩子初始化完成后再统一设一次。

4. 跨进程注入:注入器和载荷 DLL 怎么对上暗号

跨进程这条路的复杂度不在 API,在于你要同时调试两个进程、两种位数、两套异常。我见过太多人卡在"注入返回成功但什么都没发生",本质上是对载荷的启动时机和参数绑定没有概念。

4.1 InjectionEntryPoint 是注入入口,不是 Main

载荷 DLL 被注入后,EasyHook 不会去找Main,它只认实现了IEntryPoint的类,然后反射调用Run方法。所以载荷项目的骨架是固定的:

using EasyHook; namespace Payload { public class InjectionEntryPoint : IEntryPoint { private IFileMonitor _channel; // 第一个参数固定是 IContext,后面按注入时传的参数顺序绑定 public void Run(RemoteHooking.IContext context, string channelName) { _channel = RemoteHooking.IpcConnectClient<IFileMonitor>(channelName); _channel.OnLog($"[Payload] 已进入目标进程 {context.InTargetPID}"); InstallHooks(); RemoteHooking.WakeUpProcess(); // 唤醒被挂起的目标进程 while (true) System.Threading.Thread.Sleep(500); } } }

RemoteHooking.Inject默认会把目标进程挂起,等你调WakeUpProcess才继续跑。这个设计是为了让你有时间装钩子,避免目标进程在你还没准备好时就执行了关键调用。忘了调WakeUpProcess的表现是目标进程界面卡死,很多人以为是注入失败,其实是挂起没解除。

Run的参数绑定规则是:IContext之后按注入时传的params object[]顺序匹配,类型必须能转换。传string就绑string,传int就绑int,个数对不上会抛异常。第一次调试时建议只传一个 channelName,参数越少越好排查。

4.2 注入器端:Inject 调用与两个路径

注入器要做的事就是找 PID、建 IPC 服务端、调 Inject:

using System.Diagnostics; using System.IO; using EasyHook; // 1. 找目标进程 var target = Process.GetProcessesByName("notepad")[0]; // 2. 准备载荷 DLL 的绝对路径,32/64 位各传一份(demo 用同一个 AnyCPU 包) string payloadPath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "Payload.dll"); if (!File.Exists(payloadPath)) throw new FileNotFoundException("载荷 DLL 不在输出目录", payloadPath); // 3. 建立 IPC 服务端,channelName 由 EasyHook 生成并回填 string channelName = null; RemoteHooking.IpcCreateServer<IFileMonitor>(ref channelName, System.Runtime.Remoting.WellKnownObjectMode.Singleton); // 4. 注入,第四个参数之后原样传给载荷的 Run RemoteHooking.Inject( target.Id, payloadPath, // 目标为 32 位时用这个 payloadPath, // 目标为 64 位时用这个 channelName);

Inject的签名里那两个路径参数,分别对应 x86 和 x64。如果你的载荷编译成 AnyCPU,两处传同一个路径没问题;如果载荷编译成 x64,注入 32 位进程时 EasyHook 会挑 x86 那个路径,加载一个 x64 的 DLL,结果是BadImageFormatException。所以载荷最省心的做法就是 AnyCPU。

另一个隐藏条件是权限。RemoteHooking.Inject底层要打开目标进程、分配内存、创建远程线程,对普通用户进程一般没问题,但对同用户下的高完整性进程或者服务进程,需要注入器自己提权。提权失败的表现是AccessDeniedException,错误信息里会带目标 PID,看着搜就行。

4.3 载荷端:在目标进程里装钩子并回传

载荷跑到目标进程里之后,代码和 LocalHook 几乎一样,唯一的区别是日志不能 Console 输出(目标进程可能连控制台都没有),必须走 IPC:

private LocalHook _hook; private CreateFileWDelegate _original; private void InstallHooks() { IntPtr target = LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"); _original = (CreateFileWDelegate)Marshal.GetDelegateForFunctionPointer( target, typeof(CreateFileWDelegate)); _hook = LocalHook.Create(target, new CreateFileWDelegate(Handler), null); _hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); } private IntPtr Handler(string path, uint access, uint share, IntPtr sa, uint disp, uint flags, IntPtr tpl) { try { _channel.OnFileOpen(path, access); // 走 IPC 回传给注入器 } catch { // IPC 断了也不能让目标进程崩,吞掉 } return _original(path, access, share, sa, disp, flags, tpl); }

IPC 这块,EasyHook 自带IpcCreateServer/IpcConnectClient这套通道,底层建立在一个较老的远程通道机制上,.NET Framework 里开箱可用,.NET Core 上不可用。如果你的注入器是 Core 写的,就得自己用命名管道重新实现一份 Contract,接口定义保持不变,实现层换掉即可。我一般在这种场景下直接把 IPC 换成NamedPipeServerStream,简单可控,还省掉了序列化的限制。

Handler里的 try/catch 不是可有可无的。IPC 通道随时可能因为注入器退出而断开,此时_channel.OnFileOpen会抛异常,如果没接住,异常穿过原生栈帧,目标进程立刻死给你看。这种"注入器关了,目标进程也崩了"的现象,排查起来特别费时间。

4.4 位数匹配与 WOW64:传错库的直接后果

位数是跨进程注入的第一道门槛。规则很简单:载荷 DLL 的位数必须和目标进程一致。64 位注入器可以注入 32 位目标,EasyHook 内部会通过一个辅助服务中转,但前提是你传了正确的 x86 载荷路径。反过来,32 位注入器注入不了 64 位目标,会直接抛异常。

WOW64 环境下还有个细节:Environment.Is64BitProcess判断的是当前进程,不是目标进程。判断目标进程位数要用IsWow64Process,或者干脆看目标进程加载的模块里有没有wow64.dll。这个判断写错了,你会一直在用 x64 的载荷去注入 32 位进程,然后对着BadImageFormatException发呆。

最后一条边界:目标进程如果是 .NET Core 或者 .NET 5+ 应用,托管注入这条路走不通,前面说过原因。这时候要么换目标,要么放弃 EasyHook 的注入层,只借用它的 LocalHook 能力设计一个"由目标进程自己加载"的方案。硬刚没有意义。

5. 避坑排查:五个让 demo 跑不起来的坑

这一节全是血泪经验。下面五条对应五种不同的失败表现,现象、原因、解决一一列出,遇到问题先对照现象找。

5.1 现象:钩子装上了,但一次都没触发

现象是LocalHook.Create和SetExclusiveACL都没报错,控制台也打印了"钩子已生效",可你就是看不到任何一条日志。用调试器在 Handler 里下断点也不命中。

原因通常是取地址取错了。LocalHook.GetProcAddress("kernel32.dll", "CreateFileW")在 64 位系统上返回的可能是转发导出的地址,而实际实现在kernelbase.dll里。如果你自己用GetModuleHandle+GetProcAddress拼地址,没处理转发,就会 hook 到一个只有几条跳转指令的桩上,而真实调用路径根本不经过那里。

解决办法是统一用LocalHook.GetProcAddress,它内部处理了转发。如果确实要自己取地址,先调GetProcAddress,再判断返回的地址是不是落在导出表之外,必要时用LoadLibrary("kernelbase.dll")重新取一次。改完后再验证,日志立刻就出来了。

5.2 现象:注入成功,目标进程两秒后自己退出

注入器返回成功,目标进程起来后界面闪一下直接消失,事件查看器里只有一条无信息的应用崩溃记录。

原因基本可以锁定在载荷的Run方法里。最常见的是在Run里直接抛了异常,比如 IPC 连不上、或者调用了目标进程里不存在的类型。异常没有被捕获,直接终结了目标进程。还有一种情况是Run执行完就返回了,载荷线程退出,EasyHook 的运行时跟着清理,钩子失效,目标进程在无钩子状态下继续跑可能触发别的逻辑。

解决办法是给整个Run包一层 try/catch,catch 里把异常信息写到临时文件,同时调RemoteHooking.WakeUpProcess()保证目标进程不被一直挂起。调试阶段可以先不装钩子,只写一行日志验证载荷能不能活下来,能活下来再加钩子。

5.3 现象:第一次调用正常,第二次栈溢出

第一次调用目标函数,日志打印正常,程序继续跑;第二次调用时进程直接崩溃,dump 里看到调用栈无限重复。

原因是没有设 ACL,或者设错了。钩子函数里调用_original时,如果当前线程没被排除,这次调用又会落进钩子,然后再调_original,无限递归。EasyHook 的线程屏障能挡住一部分,但挡不住所有情况,尤其是跨线程调用时。

解决办法是在LocalHook.Create之后立刻SetExclusiveACL(new int[] { 0 }),而且 0 就是当前线程索引,别填ProcessThread.Id。如果你的钩子会被多线程触发,每个需要跳过钩子的线程都要加进列表,或者改用SetInclusiveACL明确白名单。

5.4 现象:路径里的中文变成乱码,返回值错乱

日志里打印的文件路径中文全是问号,或者某个 API 的返回值时对时错,完全没规律。

原因有两层。中文乱码是CharSet选错了,CreateFileW是宽字符版本,必须配CharSet.Unicode,配成Ansi就会按单字节解析,中文自然废掉。返回值错乱多半是委托签名里类型宽度不对,典型的是把IntPtr写成int,64 位进程里高 32 位被截断,句柄就成了随机数。

解决办法是把委托声明逐个参数和 Win32 原型对照一遍,HANDLE、LPVOID、SIZE_T一律IntPtr,DWORD一律uint,BOOL记得加[MarshalAs(UnmanagedType.Bool)]。改完重新编译,别指望运行时能自愈。

5.5 现象:挂调试器就正常,脱机就崩

本地调试怎么跑都没问题,发给别人或者双击直接运行时,注入器报"找不到模块"或者目标进程起不来。

原因是路径和依赖。调试时工作目录是bin\Debug,所有依赖都在;直接运行时工作目录可能变成桌面或者别的地方,payloadPath用的相对路径就失效了。还有一种情况是EasyHook32.dll/EasyHook64.dll没被复制到输出目录,NuGet 包默认会带,但如果你的项目配置了CopyLocal=false或者手工引用,就漏了。

解决办法是所有路径一律用AppDomain.CurrentDomain.BaseDirectory拼绝对路径,并且在注入前先File.Exists检查一遍。依赖文件可以在项目里加一个生成后事件,把 EasyHook 的原生 DLL 明确复制到输出目录,避免换机器就翻车。

6. 进阶验证:用 Hook 做接口 Mock 和耗时统计,以及怎么证明它真的生效

跑通 demo 只是入门,真正有价值的是把它变成验证工具。我常用的两个场景,一个是给第三方库做 Mock,一个是给成熟系统补调用耗时统计。

Mock 的玩法是:钩住目标函数,不调_original,直接返回构造好的结果。比如某个授权校验函数返回bool,测试环境里钩住它固定返回true,就能绕开真实依赖跑通业务链路。要注意的是返回值的类型必须和委托声明一致,bool加MarshalAs(UnmanagedType.Bool),指针返回IntPtr.Zero还是构造的缓冲区,取决于调用方怎么用,乱返回会崩在调用方。

耗时统计的写法是在钩子前后各取一次高精度计数器,把差值累加到一个静态字段里:

private static long _totalTicks; private static long _callCount; private static readonly long _freq = Stopwatch.Frequency; private static IntPtr TimedHandler(string path, uint access, uint share, IntPtr sa, uint disp, uint flags, IntPtr tpl) { long start = Stopwatch.GetTimestamp(); try { return _original(path, access, share, sa, disp, flags, tpl); } finally { Interlocked.Add(ref _totalTicks, Stopwatch.GetTimestamp() - start); Interlocked.Increment(ref _callCount); } }

Interlocked不能省,钩子会被多线程并发触发,普通+=会丢计数。统计结果每十秒打一次,用_totalTicks * 1000.0 / _freq / _callCount算出平均耗时,单位毫秒。

验证钩子是否真的生效,光看日志不够,因为日志可能来自别的地方。我一般用三重交叉验证:一是在 Handler 里维护一个计数器,退出时打印总数,和业务侧统计的调用次数对比,数量级对得上才算数;二是故意在 Handler 里返回一个非法值,看调用方是否报错,报错说明钩子确实接管了;三是用 Process Monitor 之类的工具观察原始行为是否被改变。三者都对上,才敢说这个钩子是真的在工作。

最后说个习惯。从那以后我每次写 EasyHook 的 demo,都会先写一个"空钩子"版本:Handler 里只调_original,什么都不做,先确认它能稳定跑满一千次不崩,再往里加日志和逻辑。这个习惯帮我省掉了至少一半的排查时间,因为绝大多数崩溃都发生在钩子建立和卸载的瞬间,跟业务逻辑毫无关系。希望帮到你。

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

RELATED READING

延伸阅读

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