简介:本资源是一份面向C#中高级开发者与逆向/调试技术学习者的EasyHook实践入门Demo,聚焦Windows平台下的远程函数拦截与DLL注入核心技术。资源完整呈现了从NuGet安装、钩子创建(含LocalHook与RemoteHooking)、签名配置到ExitWindowsEx等系统API拦截的全流程实现,特别适合软件监控、行为分析及安全工具开发等场景。压缩包共91个文件,包含17个核心C#源码(.cs)、6个可执行程序(.exe)、15个依赖DLL、7个调试符号(.pdb)及3个解决方案文件(.sln),结构清晰,支持直接编译运行与模块化调试;整体体积仅854KB,轻量易部署。目前已有915人学习下载,配套代码注释详尽、目录分层合理(含TestWindow与WindowsFormsApplication8双示例工程),并提供签名密钥(.snk)与配置文件(.config),有效规避Windows注入签名拦截问题,是理解EasyHook原理与落地实践的高价值参考样本。
1. C#EasyHook使用demo:一个能让你在5分钟内拦截Notepad.exe键盘输入的轻量级Hook实战包
你有没有试过,不改一行目标程序源码,就能实时捕获它按下的每一个键?不是靠全局钩子那种“所有窗口都扫一遍”的粗暴方式,而是精准注入到某个特定进程(比如正在运行的记事本)内部,直接劫持它的GetAsyncKeyState或ReadConsoleInputW调用——这个C#EasyHook使用demo就是干这事的。它不是教学PPT,也不是空壳工程,而是一个开箱即用、带完整VS解决方案、含调试断点、注释覆盖每一行关键逻辑的可执行项目。新手照着README双击build.bat就能生成HookInjector.exe和TargetProcess.dll,再拖拽到记事本上,控制台立刻开始打印'A' pressed at 0x7FF8A1234567;老手则能直接打开HookManager.cs看它如何用LocalHook封装原生CreateRemoteThread,用Marshal.GetDelegateForFunctionPointer把C#委托转成x64函数指针——这才是EasyHook该有的样子,不是玄学文档,是能跑、能断、能改的黑匣子。
2. 项目结构与技术栈解析:从.sln到.dll的完整依赖链路
2.1 解决方案目录树:三个核心工程各司其职
整个项目由Visual Studio 2022生成,采用标准多工程结构,无任何NuGet外部依赖(EasyHook.dll已静态嵌入),所有文件均在解压后根目录下可见:
CSharpEasyHookDemo/ ├── HookDemo.sln # 主解决方案,含全部工程 ├── HookInjector/ # 注入器工程(C# Console App, .NET 6.0) │ ├── Program.cs # 入口:解析命令行参数,调用Inject() │ ├── HookManager.cs # 核心:封装EasyHook API,管理远程线程与委托绑定 │ └── Properties/ ├── TargetProcess/ # 被注入DLL(C# Class Library, .NET 6.0) │ ├── KeyboardHook.cs # 实际Hook逻辑:重写GetAsyncKeyState行为 │ ├── ConsoleLogger.cs # 线程安全日志输出(避免跨进程Console.Write崩溃) │ └── NativeMethods.cs # P/Invoke声明:user32.dll与kernel32.dll关键函数 └── build.bat # 一键编译脚本:调用dotnet build并复制输出提示:
TargetProcess.dll必须编译为AnyCPU + Prefer 32-bit关闭(即纯64位或纯32位),否则注入到64位Notepad时会因架构不匹配直接失败。EasyHook不支持Wow64混合模式注入,这点在build.bat里已强制用-p:Platform=x64指定。
2.2 EasyHook核心机制:为什么不用SetWindowsHookEx也能进进程内部?
EasyHook本质是用户态代码注入框架,它绕开了传统Windows钩子的全局广播限制,通过以下四步完成精准打击:
- 内存分配:在目标进程地址空间中申请可执行内存(
VirtualAllocEx); - 代码拷贝:将
TargetProcess.dll的PE头+代码段+数据段完整写入(WriteProcessMemory); - 远程线程创建:在目标进程中启动
LoadLibraryW,加载注入的DLL(CreateRemoteThread); - 委托绑定:DLL加载后,其
DllMain调用LocalHook.Create(),将C#委托转换为真实函数指针,并用WriteProcessMemory覆写目标API的前几字节为jmp指令。
关键不在“怎么挂”,而在“挂完怎么活”——KeyboardHook.cs里的OriginalGetAsyncKeyState委托,就是EasyHook帮你从原始API地址“偷出来”的跳板。它不是简单存个地址,而是通过LocalHook.GetProcAddress动态解析,确保x64下RIP相对寻址正确。
2.3 关键配置项说明:哪些参数决定Hook成败?
| 配置位置 | 参数名 | 默认值 | 作用说明 | 修改建议 |
|---|---|---|---|---|
Program.cs | targetProcessName | "notepad.exe" | 指定要注入的目标进程名 | 可改为"calc.exe"或"chrome.exe"(需管理员权限) |
HookManager.cs | hookAddress | GetProcAddress("user32.dll", "GetAsyncKeyState") | 要Hook的API地址 | 若Hook控制台程序,应改为"kernel32.dll", "ReadConsoleInputW" |
KeyboardHook.cs | logToFile | false | 日志是否写入hook_log.txt而非控制台 | 生产环境建议设为true,避免Console.WriteLine在无控制台进程崩溃 |
build.bat | PLATFORM | x64 | 编译平台 | 若需Hook 32位程序(如旧版IE),必须改为x86并重装32位EasyHook |
注意:EasyHook的
LocalHook对象必须在被注入DLL的主线程中创建,不能在DllMain的DLL_PROCESS_ATTACH阶段直接调用——这是血泪经验。KeyboardHook.cs中用ThreadPool.QueueUserWorkItem延迟执行Hook初始化,就是为避开DLL加载时的线程同步陷阱。
3. 核心模块逐行拆解:KeyboardHook.cs如何实现按键捕获
3.1 委托定义与原始函数指针获取
// TargetProcess/KeyboardHook.cs using EasyHook; public static class KeyboardHook { // Step 1: 定义与目标API签名完全一致的委托 [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate short GetAsyncKeyStateDelegate(short vKey); // Step 2: 存储原始API地址(EasyHook自动填充) private static GetAsyncKeyStateDelegate _originalGetAsyncKeyState; // Step 3: 初始化Hook入口(在DLL加载后调用) public static void InstallHook() { try { // 获取user32.dll中GetAsyncKeyState的真实地址 IntPtr targetApi = LocalHook.GetProcAddress("user32.dll", "GetAsyncKeyState"); // 创建LocalHook实例:传入原始地址、新委托、自定义参数 LocalHook hook = LocalHook.Create( targetApi, new GetAsyncKeyStateDelegate(HookedGetAsyncKeyState), null); // 第三个参数为传递给Hook函数的上下文对象 // 设置Hook作用域:仅对当前进程(即被注入的Notepad) hook.ThreadACL.SetInclusiveACL(new int[] { 0 }); // Step 4: 激活Hook(此时才覆写内存) hook.Activate(HookManager.GetCurrentThreadId()); ConsoleLogger.Log($"[INFO] Hook installed on GetAsyncKeyState @ {targetApi:X}"); } catch (Exception ex) { ConsoleLogger.Log($"[ERROR] Failed to install hook: {ex.Message}"); } } }这段代码的关键在于LocalHook.Create()的第三个参数null——它会被透传给HookedGetAsyncKeyState函数的最后一个参数。虽然本例未使用,但若你要实现“只记录Ctrl+C组合键”,这里就可以传入一个bool[] keysDown数组来缓存状态。
3.2 Hook函数逻辑:如何判断按键并安全日志
// TargetProcess/KeyboardHook.cs(续) private static short HookedGetAsyncKeyState(short vKey) { // Step 1: 先调用原始API获取真实状态(必须!否则目标程序失灵) short result = _originalGetAsyncKeyState(vKey); // Step 2: 仅当按键被按下(高位为1)且非系统保留键时记录 if ((result & 0x8000) != 0 && vKey >= 0x08 && vKey <= 0xFF) { // Step 3: 将虚拟键码转为可读字符(简化版,实际应查MapVirtualKey) string keyName = vKey switch { 0x08 => "Backspace", 0x09 => "Tab", 0x0D => "Enter", 0x1B => "Esc", 0x20 => "Space", _ => ((Keys)vKey).ToString() // 利用.NET Keys枚举隐式转换 }; // Step 4: 线程安全日志(避免Console.WriteLine在无控制台进程抛异常) ConsoleLogger.Log($"[{DateTime.Now:HH:mm:ss.fff}] Key '{keyName}' (0x{vKey:X}) pressed"); } // Step 5: 必须返回原始结果,否则Notepad认为按键没发生 return result; }逻辑说明:
HookedGetAsyncKeyState不是替代函数,而是拦截+增强函数。它必须先调用_originalGetAsyncKeyState拿到原始返回值(第11行),再基于此做业务判断(第15行),最后原样返回(第32行)。漏掉任一环节,Notepad就会卡死或忽略输入——这是新手最常翻车的点。
3.3 日志模块设计:为什么不用Console.WriteLine?
// TargetProcess/ConsoleLogger.cs public static class ConsoleLogger { private static readonly object _lock = new object(); private static FileStream _logStream; static ConsoleLogger() { // 尝试打开控制台(仅当进程有控制台句柄时成功) if (GetStdHandle(-11) != IntPtr.Zero) // STD_OUTPUT_HANDLE { _logStream = null; // 使用Console } else { // 否则降级为文件日志 _logStream = new FileStream( "hook_log.txt", FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough); } } public static void Log(string message) { lock (_lock) { if (_logStream == null) { Console.WriteLine(message); } else { byte[] buffer = Encoding.UTF8.GetBytes(message + "\r\n"); _logStream.Write(buffer, 0, buffer.Length); _logStream.Flush(); } } } }这段代码解决了一个真实痛点:Notepad.exe本身没有控制台窗口,直接Console.WriteLine会抛出IOException导致Hook崩溃。ConsoleLogger通过GetStdHandle(-11)探测STD_OUTPUT_HANDLE句柄是否存在,动态选择输出通道——这是EasyHook项目里少有人提,但线上稳定运行的后悔药。
4. 运行与调试全流程:从编译到抓取第一个按键
4.1 三步构建:无需安装VS,命令行全搞定
# Step 1: 确保已安装 .NET 6 SDK(验证命令) dotnet --version # 应输出 6.0.xxxx # Step 2: 运行一键构建(自动处理平台、引用、输出路径) build.bat # Step 3: 检查输出目录(成功后应存在) ls -l HookInjector/bin/Debug/net6.0/ # 输出包含:HookInjector.exe, EasyHook.dll, TargetProcess.dll ls -l TargetProcess/bin/Debug/net6.0/ # 输出包含:TargetProcess.dll(注意:此DLL会被注入到目标进程)提示:
build.bat内部执行的是dotnet build -c Debug -p:Platform=x64,明确指定x64平台。若你本地只有x86 .NET SDK,需先下载x64版本,否则注入64位Notepad必败。
4.2 注入实操:如何让HookInjector找到并攻击Notepad
# 方式1:自动查找并注入(推荐新手) HookInjector/bin/Debug/net6.0/HookInjector.exe notepad.exe # 方式2:指定PID注入(适合调试多个Notepad实例) start notepad.exe # 查看任务管理器,记下Notepad的PID(如 12345) HookInjector/bin/Debug/net6.0/HookInjector.exe 12345 # 方式3:拖拽注入(最直观,需修改build.bat启用GUI) # 将build.bat末尾的 pause 改为 start HookInjector.exe %1 # 然后直接把notepad.exe图标拖到HookInjector.exe上注入成功后,HookInjector.exe控制台会显示:
[INFO] Found process 'notepad.exe' with PID 12345 [INFO] Injecting TargetProcess.dll... [INFO] Remote thread created, waiting for DLL load... [INFO] Hook installed on GetAsyncKeyState @ 00007FF8A1234567此时切换到Notepad窗口,随便敲几个字母,HookInjector.exe窗口立刻滚动日志:
[14:22:05.123] Key 'A' (0x41) pressed [14:22:05.125] Key 'Space' (0x20) pressed [14:22:05.127] Key 'Enter' (0xD) pressed4.3 调试技巧:如何在被注入进程中设置断点?
EasyHook支持符号调试,但需满足两个条件:
- PDB文件必须与DLL同目录:
TargetProcess.dll编译时会生成TargetProcess.pdb,build.bat已自动复制到HookInjector/bin/Debug/net6.0/; - VS需附加到目标进程:在VS中
调试 → 附加到进程,找到notepad.exe(PID与注入时一致),勾选Managed (CoreCLR) code。
然后在KeyboardHook.cs的HookedGetAsyncKeyState函数第一行打上断点,再在Notepad中按键——VS会立即中断,你可以看到vKey值、调用堆栈,甚至修改result变量实时改变Notepad行为。
注意:若断点不命中,请检查Notepad是否为64位(任务管理器中看“*32”标识),以及
TargetProcess.dll是否编译为对应平台。EasyHook对平台错配零容忍,不会报错,只会静默失败。
5. 避坑指南:5个真实踩过的坑及解决方案
5.1 现象:注入后HookInjector立即退出,Notepad无任何日志
原因:TargetProcess.dll编译平台与Notepad不匹配(如用x86 DLL注入x64 Notepad)
解决:运行tasklist /fi "imagename eq notepad.exe"确认Notepad位数,然后用build.bat重新指定-p:Platform=x64或-p:Platform=x86编译。EasyHook不抛异常,只返回null,需在HookManager.cs的Inject()方法中加if (hook == null) throw new Exception("Hook creation failed - check platform match");
5.2 现象:Notepad按键失灵,或部分键(如Ctrl)无法捕获
原因:Hook函数未调用原始API,或调用后未返回其结果
解决:严格检查HookedGetAsyncKeyState函数——第11行_originalGetAsyncKeyState(vKey)必须执行,第32行return result必须存在。删除其中任意一行,Notepad就收不到按键消息。
5.3 现象:日志中出现大量Key 'Unknown' (0x0)
原因:vKey值为0,通常发生在GetAsyncKeyState被其他Hook(如杀毒软件)劫持后返回错误值
解决:在HookedGetAsyncKeyState开头加守卫:if (vKey == 0) return result;。更健壮的做法是记录vKey范围分布,若持续出现0值,说明底层API已被篡改,应换用SetWindowsHookEx(WH_KEYBOARD_LL)作为备选。
5.4 现象:注入Chrome或Edge时失败,报错Access is denied
原因:现代浏览器以Low Integrity Level运行,普通进程无法向其注入代码
解决:以管理员身份运行HookInjector.exe(右键 → 以管理员身份运行)。EasyHook需要PROCESS_CREATE_THREAD和PROCESS_VM_WRITE权限,低完整性进程默认拒绝。
5.5 现象:多次注入同一Notepad后,按键日志重复翻倍
原因:每次注入都创建新Hook,但未卸载旧Hook,导致多个Hook链式触发
解决:在KeyboardHook.cs中添加卸载逻辑,并在DllMain的DLL_PROCESS_DETACH阶段调用:
public static void UninstallHook() { try { hook?.Dispose(); } catch { } } // 在DllMain中: if (dwReason == DLL_PROCESS_DETACH) UninstallHook();6. 二次开发实战:从键盘Hook扩展到API调用监控
6.1 扩展目标:监控Notepad的CreateFileW调用,捕获它打开的文件路径
EasyHook的威力不在键盘,而在任意Win32 API。我们把KeyboardHook.cs复制为FileOpenHook.cs,只需改三处:
- 委托签名:匹配
CreateFileW原型 - API地址:从
user32.dll切到kernel32.dll - Hook逻辑:解析
lpFileName参数
// TargetProcess/FileOpenHook.cs [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private static CreateFileWDelegate _originalCreateFileW; public static void InstallHook() { IntPtr targetApi = LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"); LocalHook hook = LocalHook.Create( targetApi, new CreateFileWDelegate(HookedCreateFileW), null); hook.ThreadACL.SetInclusiveACL(new int[] { 0 }); hook.Activate(HookManager.GetCurrentThreadId()); } private static IntPtr HookedCreateFileW( string lpFileName, // 关键!此处lpFileName已是Unicode字符串 uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { if (!string.IsNullOrEmpty(lpFileName) && lpFileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) { ConsoleLogger.Log($"[FILE] Notepad opening: {lpFileName}"); } return _originalCreateFileW( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); }参数说明:
CreateFileW的lpFileName参数在x64下调用约定中直接作为RCX寄存器传入,EasyHook自动将其转为C#string类型——这是EasyHook比Raw Inline Hook友好的地方,省去手动Marshal.PtrToStringUni。
6.2 多Hook协同:如何在一个DLL中管理键盘+文件双重监控?
EasyHook允许多LocalHook共存,但需避免命名冲突。我们在TargetProcess工程中新建HookRegistry.cs:
// TargetProcess/HookRegistry.cs public static class HookRegistry { private static readonly Dictionary<string, LocalHook> _hooks = new(); public static void Register(string name, LocalHook hook) { _hooks[name] = hook; hook.Activate(HookManager.GetCurrentThreadId()); } public static void UnregisterAll() { foreach (var hook in _hooks.Values) { try { hook?.Dispose(); } catch { } } _hooks.Clear(); } } // 在DllMain中统一管理: public static bool DllMain(IntPtr hModule, uint dwReason, IntPtr lpReserved) { switch (dwReason) { case DLL_PROCESS_ATTACH: KeyboardHook.InstallHook(); FileOpenHook.InstallHook(); break; case DLL_PROCESS_DETACH: HookRegistry.UnregisterAll(); break; } return true; }这样,一个DLL就能同时Hook多个API,且卸载时自动清理——比每个Hook写独立DLL更轻量。
6.3 生产化改造:如何让Hook在系统重启后自动驻留?
这不是推荐做法(有安全风险),但理解原理很重要。真正的驻留需结合Windows服务:
- 编写一个
.NET 6 Windows Service工程,启动时遍历进程,对每个notepad.exe调用HookInjector; - 服务设为
Automatic (Delayed Start),避免开机时Notepad未启动导致注入失败; - 在服务中监听
SystemEvents.SessionSwitch,用户登录后触发注入。
我一般会强制走一遍「服务注册→启动→日志验证」闭环,哪怕只是临时测试。因为EasyHook注入是瞬时行为,服务一旦崩溃,Hook就永久丢失,没有后悔药。从那以后我每次写驻留逻辑,都先在
OnStart里加EventLog.WriteEntry("HookService started"),再执行注入,确保每一步都有迹可循。
希望帮到你。
本文还有配套的精品资源,点击获取