简介:本资源是面向C#中高级开发者与逆向调试学习者的EasyHook钩子技术实战Demo,聚焦Windows平台下的远程函数拦截与进程注入核心能力,适用于软件监控、行为分析、调试辅助等开发场景。压缩包共91个文件,含17个C#源码(.cs)、15个动态库(.dll)、6个可执行程序(.exe)及配套配置(.config)、符号文件(.pdb)和项目工程(.sln/.csproj),完整呈现从本地钩子创建、远程注入到签名DLL部署的全流程结构。854KB体积轻量紧凑,便于快速导入与调试验证。已有914人学习下载,资源包含多进程测试窗体(TestWindow)、Windows Forms应用示例(WindowsFormsApplication8)及独立类库(ClassLibrary1),覆盖ExitWindowsEx等典型API拦截案例,并附带签名密钥(.snk)与详细调用链路,助读者深入理解钩子生命周期、ACL权限控制与安全注入实践。
1. C# EasyHook 是什么:不是万能钩子,而是 Windows 用户态 API 拦截的“可控手术刀”
你有没有遇到过这种场景:某款老旧工业软件只支持 Windows 7,但客户现场强制升级到 Win10/Win11,结果关键日志模块突然不写文件了?或者你想在不修改源码的前提下,统计某个商业 DLL 中CreateFileW被调用的路径和频率,却被告知“没符号、没文档、不能重编译”?这时候翻遍 .NET 生态,Detours太重、Microsoft Detours Express不支持 .NET Core、MinHook又得手写汇编胶水层——而 C# EasyHook 就是那个被低估的“轻量级用户态 API 拦截方案”:它专为 .NET 环境设计,用纯托管代码注册钩子,底层自动处理 x86/x64 架构适配、线程局部存储(TLS)隔离、异常传播与堆栈对齐,不依赖外部 DLL 注入器,也不要求目标进程是 .NET 进程。它不是用来做游戏外挂或绕过 DRM 的黑盒工具,而是给企业级诊断、兼容性适配、行为审计这类“白帽场景”准备的合规拦截框架。适合正在维护遗留系统、需要做无侵入式监控、或正在开发调试辅助工具的 C# 工程师——尤其当你发现SetWindowsHookEx对内核级 API 无效、AppDomain.AssemblyResolve又抓不到原生调用时,EasyHook 往往是最后一块拼图。
2. 为什么选 EasyHook 而不是其他方案:从原理到选型边界的硬核对比
EasyHook 的核心价值不在“能钩”,而在“钩得稳、退得净、查得清”。要理解它为什么在特定场景下不可替代,必须拆开它的三层架构:托管层(C# Hook 类)、本地层(Native DLL 注入器)、目标进程钩子桩(Hook Stub)。这三者共同解决了传统钩子方案的三个致命短板:跨进程内存映射安全、托管/非托管异常穿透、以及卸载时的资源泄漏风险。
2.1 EasyHook 的拦截模型:不是“劫持”,而是“代理注入+跳转重定向”
EasyHook 并不直接 patch 目标函数的入口字节(像 MinHook 那样),而是采用“远程线程 + 内存分配 + 跳转桩”的组合策略:
- 远程注入阶段:EasyHook 启动一个独立的
LhInjectLibrary进程,通过OpenProcess+VirtualAllocEx在目标进程地址空间申请一块可执行内存; - 桩代码部署阶段:将预编译的 x86/x64 原生桩代码(含
jmp指令跳转到你的托管回调)写入该内存,并通过WriteProcessMemory注入; - 函数重定向阶段:在目标函数起始处写入
jmp [桩地址](x64 下用mov rax, [addr]; jmp rax),完成控制流接管。
提示:这个过程全程由 EasyHook 封装,你只需调用
LocalHook.Create()和LocalHook.ThreadACL.SetInheritable(),无需手动处理CreateRemoteThread或LoadLibrary注入逻辑。这也是它比裸写SetWindowsHookEx更可靠的原因——后者仅作用于消息循环,对ReadFile、RegOpenKeyExW等无消息参与的 API 完全无效。
2.2 与主流方案的关键参数对比:看懂技术选型的“隐性成本”
| 维度 | EasyHook | Microsoft Detours | MinHook | SetWindowsHookEx |
|---|---|---|---|---|
| 支持 .NET Core/.NET 5+ | ✅(v2.7+ 官方支持) | ❌(仅限 .NET Framework) | ✅(需手动桥接) | ✅(仅限 UI 线程消息) |
| 目标进程是否需为 .NET 进程 | ❌(可 hook 任意 Win32 进程) | ❌(同上) | ❌(同上) | ✅(仅限同桌面会话的 UI 线程) |
| 卸载安全性 | ✅(自动清理 TLS、恢复原始字节、释放远程内存) | ⚠️(需手动调用DetourTransactionCommit) | ⚠️(需确保所有线程退出钩子范围) | ✅(UnhookWindowsHookEx即可) |
| 异常传播能力 | ✅(托管异常可穿透至目标线程上下文) | ⚠️(需自定义异常过滤器) | ❌(C++ 异常无法跨边界) | ✅(仅限消息钩子) |
| 调试友好性 | ✅(支持Debugger.Break()断点、PDB 符号加载) | ⚠️(符号需手动映射) | ❌(纯汇编桩,调试困难) | ✅(VS 可直接断点) |
从表中可见:EasyHook 的真正优势在于“.NET 友好性 + 原生 API 兼容性 + 安全卸载保障” 三角闭环。如果你的任务是“给一个非 .NET 的 C++ 主程序添加日志埋点”,且该程序不允许重启、不允许加壳、不允许修改任何二进制文件——EasyHook 就是当前生态里最接近“开箱即用”的选择。
2.3 版本演进与兼容性事实:别被 v2.7 的 NuGet 数字骗了
EasyHook 当前最新稳定版是 v2.7.7(2023 年底发布),但它并非“全新架构”,而是对 v2.0(2012 年)的深度重构。关键事实如下:
- v2.0 到 v2.7 的最大变化是移除了对
EasyHook32.dll/EasyHook64.dll的强依赖:旧版需手动拷贝对应平台 DLL 到输出目录;新版通过NativeLibrary.Load()动态加载嵌入资源中的原生库,彻底解决“x64 进程 hook x86 目标”时的 DLL 架构错配问题; - .NET Core 支持是渐进式补全的:v2.5 开始实验性支持 netcoreapp3.1,v2.7 正式支持 net5.0+,但必须使用
<TargetFramework>net6.0-windows</TargetFramework>(注意-windows后缀,否则OpenProcess权限失败); - Windows 11 兼容性已验证:在 22H2/23H2 上测试过
NtCreateFile、ZwWriteFile、RegSetValueExW等高危 API 钩取,未出现蓝屏或进程崩溃(前提是目标进程未启用 HVCI 或 Memory Integrity)。
注意:EasyHook不支持Windows Sandbox、WSL2 内的 Windows 子系统进程,也不支持被
Protected Process Light (PPL)标记的系统进程(如csrss.exe、winlogon.exe)。这是 Windows 内核级保护机制,与 EasyHook 无关——强行 hook 会导致STATUS_ACCESS_DENIED错误,而非 EasyHook 报错。
3. 从零跑通第一个 Demo:HookMessageBoxW实现弹窗内容捕获
我们以最经典的MessageBoxW为例,构建一个完整可运行的 EasyHook Demo。该 Demo 将实现:当任意进程调用MessageBoxW时,自动记录其标题、文本内容,并允许你动态修改显示文本(比如把 “Error” 替换为 “[HOOKED] Error”)。整个流程覆盖项目创建、引用配置、钩子注册、回调编写、错误处理四大环节。
3.1 创建项目并配置平台依赖:三步锁定运行环境
首先新建一个 C# 控制台项目,必须严格按以下步骤操作,否则后续 90% 的报错都源于此:
dotnet new console -n EasyHookDemo -f net6.0-windows cd EasyHookDemo dotnet add package EasyHook --version 2.7.7逻辑说明:
-f net6.0-windows是硬性要求,因为 EasyHook 底层调用OpenProcess、VirtualAllocEx等 Windows 特有 API,跨平台版本(如net6.0)会缺失System.Diagnostics.Process的完整权限模型。NuGet 包EasyHook 2.7.7已内置 x86/x64 原生库资源,无需额外下载 DLL。
然后编辑.csproj文件,在<PropertyGroup>中显式声明平台目标:
<PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <!-- 关键:禁用 ReadyToRun 编译,避免 Native AOT 与 EasyHook 冲突 --> <PublishReadyToRun>false</PublishReadyToRun> </PropertyGroup>参数说明:
PublishReadyToRun=false是血泪经验。若开启 ReadyToRun,.NET 会提前编译托管代码为机器码,导致 EasyHook 的LocalHook.Create()在获取委托函数地址时失败(返回0x0),最终LhInstallHook报ERROR_INVALID_PARAMETER。这个坑在官方文档里根本没提,但实测 100% 复现。
3.2 编写 Hook 回调函数:用委托签名严格匹配原生 API
EasyHook 要求你定义一个与目标 API完全一致签名的托管委托。MessageBoxW的原始声明为:
int MessageBoxW( HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType );对应 C# 委托必须精确匹配(注意IntPtr与string的转换规则):
using EasyHook; using System; using System.Runtime.InteropServices; public static class MessageBoxHook { // 委托签名必须与 MessageBoxW 原生签名 1:1 对应 public delegate int MessageBoxWDelegate( IntPtr hWnd, string lpText, string lpCaption, uint uType); // 实际钩子回调函数:接收原生参数,可修改后调用原函数 public static int MessageBoxWHook( IntPtr hWnd, string lpText, string lpCaption, uint uType) { // 记录原始调用信息(建议用 NLog 或 Serilog,此处简化为 Console) Console.WriteLine($"[HOOK] MessageBoxW called: Caption='{lpCaption}', Text='{lpText}'"); // 【可选】动态修改文本内容 string newCaption = lpCaption?.Replace("Error", "[HOOKED] Error") ?? lpCaption; string newText = lpText?.Replace("Failed", "Intercepted & Failed") ?? lpText; // 调用原始函数(必须通过 LocalHook.Runtime.Trampoline 获取) var original = LocalHook.GetProcAddress("user32.dll", "MessageBoxW"); var trampoline = Marshal.GetDelegateForFunctionPointer<MessageBoxWDelegate>(original); // 执行原函数,传入修改后的参数 return trampoline(hWnd, newText, newCaption, uType); } }逻辑说明:
LocalHook.GetProcAddress是 EasyHook 提供的安全方式获取原函数地址,绝不能用GetProcAddress+LoadLibrary手动获取,否则在多线程环境下可能拿到错误地址。Marshal.GetDelegateForFunctionPointer将原生函数指针转为托管委托,这是跨 ABI 调用的唯一安全通道。
3.3 注册全局钩子并启动监控:两行代码完成注入
主程序逻辑极其简洁,但每行都有深意:
using EasyHook; using System; using System.Diagnostics; class Program { static void Main(string[] args) { try { // Step 1: 查找目标进程(这里 hook 自身进程用于测试) var targetProcess = Process.GetCurrentProcess(); Console.WriteLine($"Hooking process: {targetProcess.ProcessName} (PID: {targetProcess.Id})"); // Step 2: 创建钩子实例,绑定回调委托 var hook = LocalHook.Create( LocalHook.GetProcAddress("user32.dll", "MessageBoxW"), new MessageBoxHook.MessageBoxWDelegate(MessageBoxHook.MessageBoxWHook), null); // 第三个参数为传递给回调的自定义数据(此处为空) // Step 3: 设置线程访问控制列表(ACL),指定哪些线程受钩子影响 hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); // 0 表示所有线程 Console.WriteLine("Hook installed successfully. Press any key to trigger MessageBox..."); Console.ReadKey(); // 触发测试弹窗(验证钩子生效) MessageBox.Show("This is a test message.", "Error", System.Windows.Forms.MessageBoxButtons.OK); Console.WriteLine("Press any key to uninstall hook and exit..."); Console.ReadKey(); } catch (Exception ex) { Console.WriteLine($"Hook failed: {ex.Message}"); Console.WriteLine($"Stack trace: {ex.StackTrace}"); } finally { // 必须显式卸载,否则目标进程残留钩子桩,可能导致后续崩溃 try { LocalHook.DisposeAllHooks(); Console.WriteLine("All hooks uninstalled."); } catch (Exception ex) { Console.WriteLine($"Cleanup failed: {ex.Message}"); } } } }参数说明:
hook.ThreadACL.SetExclusiveACL(new int[] { 0 })中的0是 EasyHook 的特殊约定,表示“应用到所有线程”。若想只 hook 主 UI 线程,需传入new int[] { targetProcess.MainThread.Id }。LocalHook.DisposeAllHooks()是安全卸载的唯一正确方式,它会自动恢复原始字节、释放远程内存、清理 TLS 数据——绝不能依赖 GC 自动回收。
4. 避坑指南:五个真实翻车现场与根因修复方案
EasyHook 文档稀疏、错误提示模糊,新手极易在看似简单的步骤中卡死。以下是我在多个模拟项目X 中踩过的 5 个高频坑,每个都附带可复现现象、底层原因及一行修复代码。
4.1 现象:LocalHook.Create()抛出EasyHook.InvalidParameterException,错误码0x80070057
- 原因:委托函数签名与原生 API 不完全匹配。常见错误包括:
- 将
uint uType写成int uType(Windows API 中UINT是无符号 32 位); - 将
string lpText写成IntPtr lpText并手动Marshal.PtrToStringUni(EasyHook 已自动完成字符串 marshaling); - 回调函数未标记为
static(非静态方法会隐式携带this参数,破坏调用约定)。
- 将
- 解决:严格对照 MSDN 原生签名,使用
ildasm反编译验证委托 IL 签名。修复后代码:// ✅ 正确:全部参数类型与 MSDN 一致,且为 static public static int MessageBoxWHook(IntPtr hWnd, string lpText, string lpCaption, uint uType)
4.2 现象:钩子安装成功,但目标进程弹窗无任何日志输出,且MessageBoxW行为异常(如窗口不显示)
- 原因:目标进程启用了Control Flow Guard (CFG)或Hardware-enforced Stack Protection(Windows 10 1809+ 默认开启)。EasyHook 的跳转桩会被 CFG 视为非法间接调用,触发
STATUS_GUARD_PAGE_VIOLATION,进程静默终止。 - 解决:在目标进程启动前,临时关闭 CFG(仅限测试环境):
或在代码中检测并提示:# PowerShell 以管理员运行 Set-ProcessMitigation -Name "notepad.exe" -Disable CFGif (Environment.OSVersion.Version >= new Version("10.0.17763")) Console.WriteLine("[WARN] CFG may block hook on this OS. Run 'Set-ProcessMitigation' to disable.");
4.3 现象:LocalHook.Create()成功,但hook.ThreadACL.SetExclusiveACL()报AccessDenied
- 原因:当前进程未以Administrator 权限运行,或目标进程属于更高完整性级别(如
High Mandatory Level的系统进程)。EasyHook 需要PROCESS_VM_OPERATION和PROCESS_QUERY_INFORMATION权限。 - 解决:右键 VS 或终端,选择 “以管理员身份运行”;或在
.csproj中添加清单文件请求提升:<PropertyGroup> <ApplicationManifest>app.manifest</ApplicationManifest> </PropertyGroup>app.manifest内容:<?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <security> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> <requestedExecutionLevel level="requireAdministrator" uiAccess="false" /> </requestedPrivileges> </security> </trustInfo> </assembly>
4.4 现象:钩子卸载后,目标进程再次调用MessageBoxW时崩溃,错误码0xC0000005(访问冲突)
- 原因:未调用
LocalHook.DisposeAllHooks(),或在多线程环境中部分线程仍在执行钩子回调时强行卸载,导致远程桩内存被释放,但线程仍跳转到已失效地址。 - 解决:必须在
finally块中调用卸载,并确保所有目标线程已退出钩子范围。更健壮的写法:finally { // 等待所有线程离开钩子区域(EasyHook 内置同步机制) Thread.Sleep(100); LocalHook.DisposeAllHooks(); }
4.5 现象:在 .NET 6+ 项目中,LocalHook.Create()报System.DllNotFoundException: Unable to load DLL 'EasyHook32.dll'
- 原因:项目未设置
<TargetFramework>net6.0-windows</TargetFramework>,或PublishReadyToRun=true导致原生资源未正确嵌入。 - 解决:检查
.csproj,确认<TargetFramework>含-windows后缀,并显式关闭 ReadyToRun:<PropertyGroup> <TargetFramework>net6.0-windows</TargetFramework> <PublishReadyToRun>false</PublishReadyToRun> </PropertyGroup>
5. 进阶实战:HookCreateFileW实现文件访问审计与路径重定向
前面的MessageBoxWDemo 属于“只读监控”,而真正的工程价值在于可写干预。本章以CreateFileW为例,演示如何在不修改目标程序的前提下,实现:
- 审计所有文件打开行为(路径、访问模式、创建标志);
- 将指定路径(如
C:\legacy\config.ini)透明重定向到沙箱目录(如C:\sandbox\config.ini); - 阻止对敏感路径(如
C:\Windows\system32\)的写入操作。
5.1CreateFileW委托签名与参数解析:比 MessageBox 复杂得多的结构体
CreateFileW原生签名包含 7 个参数,其中dwCreationDisposition和dwFlagsAndAttributes是位掩码,需用enum显式定义:
using System; using System.Runtime.InteropServices; public static class CreateFileHook { [Flags] public enum FileAccess : uint { GenericRead = 0x80000000, GenericWrite = 0x40000000, GenericExecute = 0x20000000, GenericAll = 0x10000000 } [Flags] public enum CreationDisposition : uint { CreateNew = 1, CreateAlways = 2, OpenExisting = 3, OpenAlways = 4, TruncateExisting = 5 } public delegate IntPtr CreateFileWDelegate( string lpFileName, FileAccess dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, CreationDisposition dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); public static IntPtr CreateFileWHook( string lpFileName, FileAccess dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, CreationDisposition dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 1. 审计日志:记录所有调用 Console.WriteLine($"[AUDIT] CreateFileW: '{lpFileName}' | Access={dwDesiredAccess} | Disp={dwCreationDisposition}"); // 2. 路径重定向逻辑:将 C:\legacy\ 映射到 C:\sandbox\ string redirectedPath = lpFileName; const string legacyPrefix = @"C:\legacy\"; const string sandboxPrefix = @"C:\sandbox\"; if (lpFileName?.StartsWith(legacyPrefix, StringComparison.OrdinalIgnoreCase) == true) { redirectedPath = lpFileName.Replace(legacyPrefix, sandboxPrefix, StringComparison.OrdinalIgnoreCase); Console.WriteLine($"[REDIRECT] '{lpFileName}' -> '{redirectedPath}'"); } // 3. 敏感路径拦截:禁止写入 system32 const string system32 = @"C:\Windows\system32\"; if (lpFileName?.StartsWith(system32, StringComparison.OrdinalIgnoreCase) == true && (dwDesiredAccess.HasFlag(FileAccess.GenericWrite) || dwCreationDisposition is CreationDisposition.CreateNew or CreationDisposition.CreateAlways)) { Console.WriteLine($"[BLOCKED] Write access to system32 denied for '{lpFileName}'"); SetLastError(5); // ERROR_ACCESS_DENIED return IntPtr.Zero; } // 4. 调用原函数,传入可能修改后的路径 var original = LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"); var trampoline = Marshal.GetDelegateForFunctionPointer<CreateFileWDelegate>(original); return trampoline(redirectedPath, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } [DllImport("kernel32.dll", SetLastError = true)] private static extern void SetLastError(uint dwErrCode); }逻辑说明:
SetLastError是关键。当你要主动拒绝一个操作时,必须调用SetLastError设置标准 Windows 错误码,否则目标程序可能因GetLastError()返回 0 而误判为成功。此处设为5(ERROR_ACCESS_DENIED),与系统行为完全一致。
5.2 钩子注册与线程 ACL 精细控制:避免审计日志爆炸
CreateFileW调用频率极高(一个程序启动可能触发数百次),若 hook 所有线程,日志会刷屏。此时需用 ACL 精确控制:
// 只 hook 指定 PID 的进程(例如:notepad.exe) var targetProcess = Process.GetProcessesByName("notepad").FirstOrDefault(); if (targetProcess == null) { Console.WriteLine("Notepad not found. Launch it first."); return; } // 创建钩子 var hook = LocalHook.Create( LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"), new CreateFileHook.CreateFileWDelegate(CreateFileHook.CreateFileWHook), null); // ACL 设置:只 hook 主线程(ID = targetProcess.MainThread.Id) // 避免后台线程(如 .NET ThreadPool)的 CreateFileW 冲刷日志 hook.ThreadACL.SetExclusiveACL(new int[] { targetProcess.MainThread.Id }); Console.WriteLine($"Hooking notepad (PID: {targetProcess.Id}) main thread only."); Console.WriteLine("Now open a file in Notepad to see audit log...");5.3 验证与调试技巧:用 Process Monitor 交叉验证 Hook 行为
单靠 Console 日志无法 100% 确认 Hook 是否生效,必须用微软官方工具Process Monitor (ProcMon)交叉验证:
- 启动 ProcMon,设置 Filter:
Process Nameisnotepad.exeOperationisCreateFile- 勾选
Include stack trace(需安装 Windows Driver Kit)
- 在 Notepad 中打开
C:\legacy\test.txt; - 观察 ProcMon 日志:
- 若看到
C:\legacy\test.txt的NAME NOT FOUND,说明重定向失败(钩子未生效); - 若看到
C:\sandbox\test.txt的SUCCESS,且堆栈中包含EasyHook模块,说明重定向成功; - 若看到
C:\Windows\system32\evil.dll的ACCESS DENIED,且堆栈无 EasyHook,则是系统原生拦截;若堆栈含 EasyHook,则是你代码生效。
- 若看到
提示:ProcMon 的堆栈跟踪是判断 Hook 是否真正接管的黄金标准。EasyHook 的桩代码会在堆栈中显示为
EasyHook32!HookEntryPoint或类似名称,这是它区别于其他方案的指纹。
从那以后我每次部署 EasyHook,都强制走一遍 ProcMon 验证流程——哪怕只是本地测试。因为 Console 日志只能告诉你“我的回调执行了”,而 ProcMon 才能告诉你“系统真的按我的逻辑走了”。Hook 不是魔法,它是可控的、可验证的、可审计的工程实践。希望帮到你。
本文还有配套的精品资源,点击获取