ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C# EasyHook:.NET 环境下安全可控的 Windows API 钩子实践

C# EasyHook:.NET 环境下安全可控的 Windows API 钩子实践

简介:本资源是一份面向C#中高级开发者的EasyHook远程钩子技术实践Demo,聚焦Windows平台下的函数拦截与跨进程代码注入场景,适用于软件调试、行为监控及性能分析等实际开发需求。压缩包共91个文件,含17个核心C#源码文件(.cs)、15个依赖DLL库、6个可执行程序(.exe)及配套配置文件(.config)、符号文件(.pdb)和项目工程文件(.csproj/.sln),完整呈现从本地钩子创建、远程注入到签名处理的全流程结构,854KB体积轻量易用。已有915人学习下载,资源目录清晰分层,涵盖控制台与Windows Forms双环境示例(如TestWindow、WindowsFormsApplication8),并包含ClassLibrary1类库模块与.suo/.user等IDE状态文件,便于直接导入VS调试运行。读者可快速掌握HookContext配置、ExitWindowsEx等系统API拦截、委托回调机制及DLL数字签名必要性等关键实践要点。

1. C# EasyHook 是什么:不是万能钩子,而是 Windows 用户态 API 拦截的“可控手术刀”

你有没有遇到过这种场景:某款闭源商业软件在启动时硬编码调用CreateFileW打开一个特定路径的日志文件,而你又不能改它的 exe?或者想在不修改源码的前提下,统计某个 .NET 程序里所有HttpClient.SendAsync的实际耗时与返回状态?这时候,写个全局键盘钩子或用 WinDbg 跟踪显然太重,而直接 patch 二进制又极易崩溃——C# EasyHook 就是为这类「精准、安全、托管环境友好」的用户态 API 拦截而生的。它不是 WinAPI Hook 的泛化封装,而是专为 .NET 开发者设计的轻量级、跨进程、支持 x86/x64 双平台的本地钩子框架。它把底层Detours风格的跳转指令注入、栈平衡、线程上下文保存等黑匣子逻辑全部封装进托管类库,让你用几行 C# 就能完成LoadLibraryW、MessageBoxA、NtWriteFile等数百个关键 API 的入口/出口拦截。适合正在做软件行为分析、兼容性适配、日志增强、沙箱监控或教学演示的 C# 工程师——尤其当你需要在 .NET 6+ 环境下稳定运行、且拒绝引入 C++/CLI 混合代码时,EasyHook 是目前少有的、真正可落地的纯托管侧钩子方案。


2. 为什么选 EasyHook 而不是其他方案:从 Detours 到 Microsoft Detours 的三重取舍

2.1 EasyHook 与原生 Detours 的本质差异:托管层抽象 vs 原生指令重写

Detours 是微软研究院开源的经典 API Hook 库,功能强大但完全基于 C/C++,需手动处理函数签名、调用约定(__stdcall / __fastcall)、栈清理、x64 寄存器保存等细节。而 EasyHook 的核心价值在于它不替代 Detours,而是构建在其之上的托管封装层。它通过LhInject启动远程线程,在目标进程中加载一个精简版的EasyHook32.dll或EasyHook64.dll(内含预编译的汇编 stub),再由该 DLL 加载你的 .NET 程序集(.dll)并反射调用你的IEntryPoint实现。这意味着:你写的钩子逻辑仍是纯 C#,但执行环境是目标进程的本机地址空间;EasyHook 自动帮你完成VirtualAllocEx+WriteProcessMemory+CreateRemoteThread全流程,你只需关注CreateFileW被调用时你想记录什么、是否放行、是否篡改参数。

提示:EasyHook 不是“无侵入”。它必须将你的钩子程序集(含所有依赖)完整复制到目标进程内存中执行。因此你的钩子 DLL 不能引用System.Windows.Forms等 UI 组件(目标进程可能无消息循环),也不能依赖未随同部署的第三方 NuGet 包(如Newtonsoft.Json必须CopyLocal=true并显式打包)。

2.2 对比 Microsoft Detours SDK(2023 版):许可、维护与 .NET 集成成本

Microsoft Detours SDK 当前最新版(v4.0.1)采用 MIT 许可,但其 C++ 接口与 .NET 互操作仍需手写 P/Invoke 和 Marshal 处理。例如 hookReadFile,你要定义:

typedef BOOL (WINAPI *pfnReadFile)( HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead, LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped);

再声明pfnReadFile OriginalReadFile = nullptr;,最后在DetourAttach中传入函数指针。而 EasyHook 中,你只需写:

public static bool ReadFile( IntPtr hFile, IntPtr lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped) { // 你的逻辑 return OriginalReadFile(hFile, lpBuffer, nNumberOfBytesToRead, out lpNumberOfBytesRead, lpOverlapped); }

EasyHook 自动生成委托、自动绑定原始函数地址、自动处理out参数封送——这是它对 .NET 工程师最实在的减负。代价是:它不支持__thiscall成员函数 Hook(因 C# 无此调用约定),也不支持内联 Hook(Inline Hook),仅支持 IAT(导入表)和 EAT(导出表)两种方式,覆盖 95% 的常见场景,但无法拦截ntdll.dll内部未导出的NtCreateFile等函数(除非你先用 EasyHook hookLdrLoadDll动态加载ntdll后再二次 hook)。

2.3 与 Fody.Costura、Mono.Cecil 等 IL 注入方案的本质区别:运行时 vs 编译时

Fody.Costura 是在编译后修改 IL,将依赖 DLL 打包进主程序集,属于静态注入;Mono.Cecil 更底层,可重写方法体、插入 IL 指令。它们都要求你有目标程序的源码或至少能反编译出可用的.pdb。而 EasyHook 是纯运行时动态注入:你无需任何源码,只要知道目标进程 PID 或进程名(如"notepad.exe"),就能在它运行中实时注入钩子。这使得 EasyHook 成为逆向分析、兼容性测试、灰盒测试的首选——比如你发现某旧版工业软件在 Windows 11 上因GetVersionExW返回值变更而崩溃,你可以用 EasyHook 在其调用前强行返回OSVERSIONINFOEXW.dwMajorVersion = 10,验证是否为系统版本判断逻辑缺陷,全程无需停服、无需重启。


3. 从零跑通第一个 EasyHook Demo:注入 notepad 并拦截 MessageBoxA

3.1 环境准备与项目结构:两个工程,一份配置

你需要创建两个独立的 C# 项目:

  • Injector(控制台应用):负责查找目标进程、注入钩子 DLL、启动钩子逻辑。
  • HookLibrary(类库):包含所有钩子函数、IEntryPoint实现、以及被拦截 API 的原始委托声明。

注意:两个项目必须使用相同的目标框架(如net6.0),且HookLibrary必须设为Class Library类型(不能是Console App)。EasyHook 不支持 .NET Core 3.1 以下版本,且net6.0-windows是推荐目标框架(启用 Windows 特定 API)。

Injector 工程配置(.csproj)
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <UseWindowsForms>false</UseWindowsForms> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="EasyHook" Version="2.7.7115" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\HookLibrary\HookLibrary.csproj" /> </ItemGroup> </Project>
HookLibrary 工程配置(.csproj)
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net6.0-windows</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="EasyHook" Version="2.7.7115" /> </ItemGroup> </Project>

提示:EasyHookNuGet 包(v2.7.7115)是当前最稳定、文档最全的版本。不要尝试 v3.x(实验性,API 不兼容),也不要手动下载 GitHub 上的源码编译——官方 nuget.org 发布包已预编译好EasyHook32.dll/EasyHook64.dll,并正确设置Copy Local = true,省去你手动拷贝 native dll 的麻烦。

3.2 HookLibrary 核心代码:实现 IEntryPoint 与 MessageBoxA 钩子

在HookLibrary中新建MainEntryPoint.cs,实现IEntryPoint接口:

using System; using System.Runtime.InteropServices; using EasyHook; namespace HookLibrary { public class MainEntryPoint : IEntryPoint { private LocalHook _messageBoxHook; public MainEntryPoint(RemoteHooking.IContext context, string channelName) { // 此构造函数由 EasyHook 在目标进程中反射调用 } public void Run(RemoteHooking.IContext context, string channelName) { try { // 1. 获取 MessageBoxA 的原始地址(从 user32.dll) IntPtr targetFunc = LocalHook.GetProcAddress("user32.dll", "MessageBoxA"); // 2. 创建钩子委托,指向我们自定义的 MessageBoxA 函数 var hookDelegate = LocalHook.Create( targetFunc, new DMessageBoxA(MessageBoxA), this); // 3. 设置钩子作用域:仅对当前线程(ThreadID=0 表示全部线程) hookDelegate.ThreadACL.SetExclusiveACL(new int[] { 0 }); _messageBoxHook = hookDelegate; // 4. 阻塞等待,保持钩子激活(否则线程退出钩子即失效) RemoteHooking.WakeUpRemoteObject(); System.Threading.Thread.Sleep(System.Threading.Timeout.Infinite); } catch (Exception ex) { // 日志必须写入文件,不能 Console.WriteLine(目标进程无控制台) System.IO.File.AppendAllText(@"C:\temp\hook_log.txt", $"[Error] {DateTime.Now:HH:mm:ss} {ex.Message}\r\n"); } } // 委托定义:必须与 MessageBoxA 原型严格一致 [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Ansi)] private delegate int DMessageBoxA( IntPtr hWnd, string lpText, string lpCaption, uint uType); // 钩子函数主体 private int MessageBoxA(IntPtr hWnd, string lpText, string lpCaption, uint uType) { // 记录被拦截的调用(写入文件,避免 UI 线程阻塞) var log = $"[{DateTime.Now:HH:mm:ss}] MessageBoxA: '{lpText}' | Caption: '{lpCaption}'\r\n"; System.IO.File.AppendAllText(@"C:\temp\hook_log.txt", log); // 可选:篡改参数,例如把所有提示框标题强制改为 "[HOOKED]" // lpCaption = "[HOOKED]"; // 调用原始函数(必须!否则 MessageBox 不会弹出) return LocalHook.GetOriginal< DMessageBoxA >(targetFunc)(hWnd, lpText, lpCaption, uType); } } }

逻辑说明:Run()方法在目标进程(notepad)的上下文中执行。LocalHook.Create()是核心,它生成一个跳转 stub,将user32.dll!MessageBoxA的第一条指令替换为跳转到你的MessageBoxA函数。GetOriginal<T>()是 EasyHook 提供的“后悔药”——它返回一个委托,指向被 hook 前的原始函数地址,确保你能在日志后继续执行原逻辑。参数uType是按钮类型(MB_OK,MB_YESNO等),hWnd是父窗口句柄,这些都原样透传。

3.3 Injector 主程序:定位 notepad 并注入

在Injector的Program.cs中编写注入逻辑:

using System; using System.Diagnostics; using EasyHook; namespace Injector { class Program { static void Main(string[] args) { try { // 1. 查找正在运行的 notepad 进程(可替换为任意进程名) var process = Process.GetProcessesByName("notepad").FirstOrDefault(); if (process == null) { Console.WriteLine("未找到 notepad 进程,请先启动记事本"); return; } Console.WriteLine($"找到 notepad 进程,PID={process.Id}"); // 2. 构建钩子 DLL 的绝对路径(必须是完整路径!) string injectionLibrary = System.IO.Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "HookLibrary.dll"); // 3. 执行注入:启动远程线程,加载 HookLibrary.dll 并调用其 MainEntryPoint.Run RemoteHooking.Inject( process.Id, injectionLibrary, // 钩子程序集路径 injectionLibrary, // 依赖程序集路径(此处与主程序集相同) null, // 传递给 Run() 的 channelName 参数(本例未用) process.Id.ToString()); // 传递给 Run() 的第二个参数(本例用作 PID 字符串) Console.WriteLine("注入成功!现在在 notepad 中尝试触发 MessageBox(例如按 Ctrl+O 打开文件对话框)"); Console.WriteLine("日志将输出到 C:\\temp\\hook_log.txt"); Console.ReadKey(); } catch (Exception ex) { Console.WriteLine($"注入失败: {ex.Message}"); Console.ReadKey(); } } } }

参数说明:RemoteHooking.Inject()第一参数是目标 PID;第二、三参数必须是钩子程序集的绝对路径(EasyHook 不支持相对路径或嵌入资源);第四个参数channelName用于 IPC 通信(本例未启用);第五个参数是任意字符串,将作为Run()方法的第二个参数传入,可用于传递配置。injectionLibrary必须指向编译后的HookLibrary.dll文件(位于bin\Debug\net6.0-windows\下),而非项目文件夹。

3.4 编译与运行:关键路径与权限检查

  1. 编译顺序:先编译HookLibrary,再编译Injector。确保Injector的bin\Debug\net6.0-windows\目录下存在HookLibrary.dll和EasyHook.dll(NuGet 自动复制)。
  2. 目标进程权限:notepad.exe默认以当前用户权限运行,Injector必须以相同用户、相同完整性级别运行。若notepad是管理员启动的,Injector也必须右键“以管理员身份运行”,否则OpenProcess会失败(错误码 5:Access Denied)。
  3. 日志路径:C:\temp\hook_log.txt需要提前创建C:\temp文件夹,并确保当前用户有写入权限。EasyHook 不会自动创建父目录。
  4. 触发验证:启动notepad→ 运行Injector.exe→ 在notepad中按Ctrl+O(打开文件对话框会内部调用MessageBoxA显示错误提示)→ 查看C:\temp\hook_log.txt是否有新日志。

4. 避坑指南:五个血泪经验换来的常见问题排查清单

4.1 现象:Injector 报错 “Failed to inject into process: Access is denied (Exception from HRESULT: 0x80070005)”

原因:目标进程与 Injector 运行在不同完整性级别(UAC 隔离)。例如notepad以管理员运行,而Injector是普通用户权限,Windows 会阻止低权限进程向高权限进程写内存。
解决:右键Injector.exe→ “以管理员身份运行”。也可在Injector.csproj中添加<ApplicationManifest>app.manifest</ApplicationManifest>并在app.manifest中设置<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />,强制提升。

4.2 现象:注入成功,但hook_log.txt无任何内容,目标进程也无异常

原因:HookLibrary.dll未被正确加载到目标进程,或Run()方法抛出未捕获异常导致线程立即退出。EasyHook 在目标进程中静默失败,不会向 Injector 报错。
解决:在Run()方法最开头添加日志:System.IO.File.WriteAllText(@"C:\temp\hook_start.txt", "Hook started");。若该文件存在但hook_log.txt为空,说明钩子函数未被触发,检查targetFunc地址是否为IntPtr.Zero(GetProcAddress失败),确认user32.dll是否已加载(用 Process Explorer 查看 notepad 的模块列表)。

4.3 现象:MessageBoxA钩子生效,但CreateFileW钩子无效,GetLastError()返回 126(找不到指定模块)

原因:CreateFileW位于kernel32.dll,但某些进程(尤其是 .NET 程序)可能通过ntdll.dll的NtCreateFile直接调用,绕过kernel32的封装。GetProcAddress("kernel32.dll", "CreateFileW")返回IntPtr.Zero。
解决:改用GetModuleHandle("kernel32.dll")确认模块已加载;或改 hookntdll.dll!NtCreateFile(需UnmanagedFunctionPointer(CallingConvention.StdCall)且参数为IntPtr数组,更复杂);最稳妥是同时 hookkernel32.dll!CreateFileW和kernel32.dll!CreateFileA。

4.4 现象:钩子 DLL 中引用了Newtonsoft.Json,注入后目标进程崩溃,事件查看器报错 “CLR: Managed Debugging Assistant 'FatalExecutionEngineError'”

原因:Newtonsoft.Json.dll未随HookLibrary.dll一同注入。EasyHook 只注入你指定的injectionLibrary文件,其依赖项(如Newtonsoft.Json.dll)不会自动复制。目标进程内存中缺少该 DLL,Assembly.LoadFrom失败。
解决:在HookLibrary.csproj中添加:

<ItemGroup> <ReferenceCopyLocalPaths Include="$(NuGetPackageRoot)newtonsoft.json\13.0.3\lib\net6.0\Newtonsoft.Json.dll" /> </ItemGroup>

并确保Newtonsoft.Json.dll被复制到bin\Debug\net6.0-windows\下,与HookLibrary.dll同目录。注入时,EasyHook 会自动扫描同目录下的所有.dll并一并注入。

4.5 现象:Injector成功注入,notepad中弹出 MessageBox,但hook_log.txt里只有一条日志,后续调用不再记录

原因:MessageBoxA是 UI 线程函数,而Run()方法中Thread.Sleep(Timeout.Infinite)阻塞的是注入的远程线程,但 EasyHook 的钩子 stub 是在 MessageBox 调用时动态执行的,与该线程无关。真正的问题是:Run()方法执行完后,远程线程退出,_messageBoxHook对象被 GC 回收,钩子自动卸载。
解决:必须在Run()方法末尾永远不返回。正确做法是使用System.Threading.EventWaitHandle等待信号,或像示例中一样用Thread.Sleep(Timeout.Infinite)。切勿让Run()方法自然结束。


5. 进阶技巧:如何安全 hook .NET 方法(非 WinAPI)并获取调用栈

5.1 为什么不能直接 hookSystem.IO.File.OpenRead?—— JIT 编译与内联的现实

EasyHook 是 WinAPI 层 Hook,它拦截的是kernel32.dll!CreateFileW这样的本地函数。而System.IO.File.OpenRead是 .NET 托管方法,其 IL 代码在运行时由 JIT 编译为本地机器码,且常被内联(Inlining)优化——即OpenRead的逻辑直接嵌入调用方方法体,没有独立的函数地址可供GetProcAdress查找。因此,试图LocalHook.Create(GetProcAddress("clr.dll", "File_OpenRead"), ...)必然失败。

解决思路:必须 hook JIT 编译后的托管方法对应的本地地址,这需要借助 .NET Profiling API 或ICorProfilerInfo。但 EasyHook 不提供此能力。可行的替代路径有两条:

  • 路径一(推荐):Hook 底层 WinAPI(如CreateFileW),然后通过GetThreadContext+StackWalk64在钩子函数中手动解析调用栈,定位上层托管方法名。
  • 路径二(轻量):利用System.Diagnostics.StackTrace在托管环境中获取调用者,但需确保钩子函数本身在托管线程中执行(EasyHook 的钩子函数默认在目标进程的任意线程中执行,不保证是托管线程)。

5.2 实战:在CreateFileW钩子中解析调用栈,识别 .NET 调用方

修改HookLibrary中的CreateFileW钩子(需新增kernel32.dll引用):

using System; using System.Diagnostics; using System.Runtime.InteropServices; using EasyHook; // 新增 WinAPI P/Invoke [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr hObject); [DllImport("dbghelp.dll", SetLastError = true)] private static extern bool StackWalk64( uint MachineType, IntPtr hProcess, IntPtr hThread, ref STACKFRAME64 StackFrame, IntPtr ContextRecord, IntPtr ReadMemoryRoutine, IntPtr FunctionTableAccessRoutine, IntPtr GetModuleBaseRoutine, IntPtr TranslateAddress); [StructLayout(LayoutKind.Sequential)] private struct STACKFRAME64 { public ulong AddrPC; public ulong AddrReturn; public ulong AddrFrame; public ulong AddrStack; public ulong AddrBStore; public IntPtr FuncTableEntry; public ulong Params[4]; public bool Far; public bool Virtual; public ulong Reserved[3]; public ulong KdHelp[10]; } // 在 CreateFileW 钩子中调用 private void LogCaller() { try { // 1. 获取当前线程 ID uint threadId = (uint)GetCurrentThreadId(); // 2. 打开当前线程句柄(需 PROCESS_QUERY_INFORMATION 权限) IntPtr hThread = OpenThread(0x00000400, false, threadId); // THREAD_QUERY_INFORMATION if (hThread == IntPtr.Zero) return; // 3. 初始化 CONTEXT 结构(x64) var context = new CONTEXT64(); context.ContextFlags = 0x0010001F; // CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS if (!GetThreadContext(hThread, ref context)) { CloseHandle(hThread); return; } // 4. 初始化 STACKFRAME64 var stackFrame = new STACKFRAME64(); // ... (此处省略大量初始化代码,实际项目应使用成熟的 StackWalk 封装库如 MiniDumpWriter) // 5. 关键:调用 StackWalk64 循环遍历栈帧,提取每个返回地址 // 然后用 SymFromAddr 获取符号名(需加载 PDB,对 .NET 程序通常不可行) // 更实用的做法:用 DbgHelp 的 SymInitialize + SymFromAddr,但需目标进程有调试符号 // 替代方案:记录 RSP/RIP,用外部工具(如 WinDbg)附加分析 System.IO.File.AppendAllText(@"C:\temp\stack_trace.txt", $"[Stack] Thread={threadId}, RIP={context.Rip:X16}, RSP={context.Rsp:X16}\r\n"); } catch { /* 忽略栈遍历失败 */ } finally { if (hThread != IntPtr.Zero) CloseHandle(hThread); } }

这段代码展示了真实世界中的妥协:StackWalk64在生产环境极其脆弱——它依赖dbghelp.dll版本匹配、符号路径正确、且目标进程未开启 CFG(Control Flow Guard)。对于 .NET 方法识别,更可靠的做法是放弃栈解析,转而 hookCreateFileW后,结合文件路径特征做业务判断。例如,若拦截到C:\MyApp\logs\app_2024.log,就标记为“应用日志写入”;若拦截到C:\Windows\System32\drivers\etc\hosts,则标记为“系统配置读取”。模式匹配比符号解析更鲁棒。

5.3 安全卸载钩子:避免目标进程残留与资源泄漏

EasyHook 的钩子默认在远程线程退出时自动卸载,但若你想主动控制(例如 Injector 收到用户输入后优雅退出),需在Injector中添加 IPC 通信:

  1. 在HookLibrary的Run()方法中,创建命名管道服务器:
using (var server = new NamedPipeServerStream("EasyHookControl", PipeDirection.In)) { await server.WaitForConnectionAsync(); // 收到消息后,调用 _messageBoxHook.Dispose() 并退出 Run() }
  1. 在Injector中,注入后连接该管道并发送命令:
using (var client = new NamedPipeClientStream(".", "EasyHookControl", PipeDirection.Out)) { await client.ConnectAsync(); using (var writer = new StreamWriter(client)) { await writer.WriteLineAsync("UNLOAD"); } }

但注意:NamedPipeServerStream在 .NET 6+ 中需System.IO.Pipes命名空间,且目标进程必须是桌面交互式进程(notepad符合)。对于服务进程,应改用EventWaitHandle或内存映射文件(MemoryMappedFile)。

从那以后我每次写 EasyHook 项目,都强制走一遍「三步验证」:
① 先用Process Explorer确认目标进程已加载EasyHook32/64.dll;
② 再检查C:\temp\hook_start.txt是否生成,确认Run()已执行;
③ 最后触发一次目标 API,看hook_log.txt是否追加,排除钩子函数未命中。
这三步花不了两分钟,却能避开 80% 的“注入成功但没效果”的玄学问题。希望帮到你。

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

RELATED READING

延伸阅读

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