简介:本资源是面向C#中高级开发者的学习型Demo项目,聚焦EasyHook远程函数拦截与进程注入技术,适用于软件调试、行为监控、性能分析等实战场景。压缩包共91个文件,含17个核心C#源码(.cs)、15个依赖DLL、6个可执行程序(.exe)及配套配置文件(.config)、符号文件(.pdb)和签名密钥(.snk),完整呈现从钩子定义、远程注入到数字签名的全流程实践结构,总大小854KB。已有914人学习下载,说明其在Windows底层开发学习者中具有较高参考价值。读者可直接运行示例工程(如拦截ExitWindowsEx)、复用本地/远程钩子创建模板、理解ACL线程过滤机制,并掌握未签名DLL被系统拦截时的解决方案,特别适合需要突破常规API调用、深入理解Windows Hook原理的开发者快速上手与二次开发。
1. C#EasyHook使用demo:一个能让你在5分钟内拦截Notepad.exe键盘输入的轻量级Hook实战包
你有没有试过,想偷偷看看某个桌面程序内部到底在读什么内存、调用什么API、往哪写日志,但又不想重编译、不改源码、不依赖调试器?这个C#EasyHook使用demo就是干这事的——它不是教学PPT,也不是空泛概念,而是一个开箱即用的Visual Studio解决方案,包含3个可独立运行的Demo工程:InjectAndHook(远程注入+API Hook)、LocalHook(进程内Hook)、KeyLoggerDemo(真实捕获Notepad按键并实时打印到控制台)。它用的是EasyHook这个老牌、稳定、纯托管封装的Windows用户态Hook库,底层走的是LhInject + Remote Thread + APC机制,但对外只暴露几行C#代码。适合刚接触逆向辅助开发的.NET开发者,也适合需要快速验证Hook逻辑的中高级工程师。如果你正卡在“怎么让自己的DLL被目标进程加载”“为什么SetInEntryPoint返回失败”“Hook后原函数调用崩溃”这类问题上,这个demo里每一步都有对应解法。
2. 环境准备与项目结构解析:从VS2022到x64/x86双平台兼容性设计
2.1 开发环境硬性要求:.NET Framework 4.7.2 + Windows SDK 10.0 + EasyHook 2.7.7115
这个demo不是.NET Core/.NET 5+项目,它明确依赖.NET Framework 4.7.2及以上版本。原因很实在:EasyHook 2.7.x系列对.NET Core的支持极弱,其核心注入器EasyHook32.dll/EasyHook64.dll是用C++/CLI编译的,强绑定Framework运行时。我本地实测过,在VS2022中新建一个.NET 6.0控制台项目,哪怕强行NuGet安装EasyHook,也会在LocalHook.Create()阶段抛出System.IO.FileNotFoundException: Could not load file or assembly 'EasyHook, Version=2.7.7115.0...'——因为EasyHook的托管部分和非托管注入器之间有强版本签名绑定,跨Framework版本会直接拒载。
提示:务必在VS2022中新建项目时选择「Windows 桌面 → 控制台应用(.NET Framework)」,目标框架选「.NET Framework 4.7.2」或更高。不要选“.NET”开头的模板。
EasyHook本身需手动下载二进制包(官方GitHub Release页提供),不能仅靠NuGet。原因是NuGet包只含托管DLL(EasyHook.dll),缺少关键的EasyHook32.dll和EasyHook64.dll——这两个是真正执行远程线程注入和APC调度的原生模块,必须和你的可执行文件放在同一目录下。本demo已将它们预置在lib\子目录,并通过.csproj中的<Content>标签自动复制到输出目录:
<!-- 在每个Demo项目的.csproj中 --> <ItemGroup> <Content Include="lib\EasyHook32.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> <Content Include="lib\EasyHook64.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup>2.2 项目三级目录结构:为什么KeyLoggerDemo必须拆成Client/Server两层
整个解决方案共4个项目,结构如下:
CSharpEasyHookDemo.sln ├── KeyLoggerDemo.Client # 启动Notepad并注入Hook DLL的“控制端” ├── KeyLoggerDemo.Server # 被注入的目标DLL,含实际Hook逻辑(C#编写) ├── InjectAndHook # 演示远程注入+IAT Hook(Hook MessageBoxA) └── LocalHook # 演示当前进程内Hook(Hook Sleep)重点说KeyLoggerDemo。它不是单个exe,而是Client/Server分离设计——这是EasyHook的强制范式。Client进程(如KeyLoggerDemo.Client.exe)负责:
- 启动目标进程(
notepad.exe) - 调用
RemoteHooking.Inject()把KeyLoggerDemo.Server.dll注入进去 - 通过命名管道或WCF与Server通信(本demo用的是
NamedPipeServerStream)
Server进程(即被注入的DLL)则必须继承RemoteHooking.IEntryPoint接口,并实现Main()方法。这个方法会在目标进程上下文中被调用,是Hook逻辑的真正入口:
// KeyLoggerDemo.Server/Program.cs public class Main : IEntryPoint { private KeyboardHook _hook; public void Main(RemoteHooking.IContext context, string channelName) { // 1. 建立与Client的通信管道 var pipe = new NamedPipeClientStream(".", channelName, PipeDirection.InOut); pipe.Connect(); var formatter = new BinaryFormatter(); // 2. 安装键盘Hook(WH_KEYBOARD_LL) _hook = new KeyboardHook(); _hook.KeyPressed += (k) => { // 3. 将按键事件序列化后发给Client formatter.Serialize(pipe, k); pipe.Flush(); }; } }这种分离不是炫技,而是Windows安全机制倒逼的设计:注入后的DLL运行在目标进程地址空间,它不能直接弹窗、不能访问Client的Console,所有跨进程交互必须显式建立通道。这也是新手最容易卡住的地方——以为写完Server就完了,结果Client收不到任何消息,其实是管道没连通或序列化失败。
2.3 平台目标一致性:为什么x64 Notepad只能被x64 Client注入
EasyHook的注入器有严格的位数匹配规则:EasyHook32.dll只能注入32位进程,EasyHook64.dll只能注入64位进程。而Windows默认的notepad.exe在Win10/11中已是64位(位于C:\Windows\System32\notepad.exe),若你的Client项目目标平台设为x86,调用RemoteHooking.Inject()时会直接抛出EasyHook.RemoteHooking.Exceptions.InjectionFailedException: Failed to inject into target process.
验证方法很简单:打开任务管理器 → 详细信息页 → 查看notepad.exe的“平台”列。如果是“64位”,Client就必须设为x64;如果想Hook旧版32位Notepad(如从SysWOW64启动),Client就得切回x86。
注意:VS中项目属性 → “生成”选项卡 → “目标平台”必须与目标进程一致。同时勾选“允许不安全代码”,因为EasyHook内部大量使用
unsafe指针操作。
3. 核心Hook模块逐行拆解:以KeyLoggerDemo为例看WH_KEYBOARD_LL如何落地
3.1 Server端:KeyboardHook类封装了SetWindowsHookEx的全部脏活
KeyLoggerDemo.Server项目里的KeyboardHook.cs是本demo最值得细读的文件。它没有用Win32 API裸调SetWindowsHookEx(WH_KEYBOARD_LL, ...),而是基于EasyHook的LowLevelKeyboardProc委托做了三层封装:
// KeyLoggerDemo.Server/KeyboardHook.cs public class KeyboardHook : IDisposable { private const int WH_KEYBOARD_LL = 13; private LowLevelKeyboardProc _proc; private IntPtr _hookId = IntPtr.Zero; public event Action<KeyboardEventArgs> KeyPressed; public KeyboardHook() { _proc = HookCallback; // 关键:委托实例必须长期存活,不能被GC回收! } public void Install() { using (var curProcess = Process.GetCurrentProcess()) using (var curModule = curProcess.MainModule) { // GetModuleHandle返回当前DLL模块句柄(非exe!) _hookId = SetWindowsHookEx( WH_KEYBOARD_LL, _proc, GetModuleHandle(curModule.ModuleName), // 这里传的是Server.dll句柄 0); // 全局Hook,不指定线程ID } if (_hookId == IntPtr.Zero) throw new InvalidOperationException($"SetWindowsHookEx failed. Error: {Marshal.GetLastWin32Error()}"); } private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0 && (wParam == (IntPtr)0x0100 || wParam == (IntPtr)0x0104)) // WM_KEYDOWN or WM_SYSKEYDOWN { var kbStruct = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam); var args = new KeyboardEventArgs { KeyCode = kbStruct.vkCode, ScanCode = kbStruct.scanCode, IsExtended = (kbStruct.flags & 0x01) != 0, Time = kbStruct.time, ExtraInfo = kbStruct.dwExtraInfo }; KeyPressed?.Invoke(args); // 触发事件,由Client订阅处理 } // 必须调用CallNextHookEx,否则其他Hook链断裂 return CallNextHookEx(_hookId, nCode, wParam, lParam); } public void Uninstall() { if (_hookId != IntPtr.Zero) { UnhookWindowsHookEx(_hookId); _hookId = IntPtr.Zero; } } // P/Invoke declarations... [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UnhookWindowsHookEx(IntPtr hhk); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr GetModuleHandle(string lpModuleName); }这段代码有3个极易翻车的点:
委托生命周期陷阱:
_proc = HookCallback这行看似简单,但如果_proc是局部变量或未被类成员强引用,GC可能在Hook安装后立即回收它,导致后续HookCallback地址失效,SetWindowsHookEx静默失败。本demo用类字段_proc持有委托,确保其生命周期覆盖整个Hook周期。模块句柄必须是DLL自身:
GetModuleHandle(curModule.ModuleName)传的是KeyLoggerDemo.Server.dll的模块名,不是notepad.exe的。因为WH_KEYBOARD_LL是全局Hook,但回调函数必须驻留在被注入的DLL中,所以hMod参数必须指向该DLL的映射基址。CallNextHookEx不能省:很多新手Hook后发现系统快捷键(如Alt+Tab)失灵,就是因为没调用
CallNextHookEx。它负责把消息继续传递给Hook链中的下一个处理器,跳过它等于截断整个Windows消息分发。
3.2 Client端:如何可靠接收Server发来的按键事件
Client端的Program.cs核心逻辑是建立命名管道监听,并反序列化按键事件:
// KeyLoggerDemo.Client/Program.cs static void Main(string[] args) { var notepad = Process.Start("notepad.exe"); // 生成唯一管道名,避免冲突 var pipeName = $"KeyLogger_{notepad.Id}"; // 注入Server DLL(注意:路径必须是绝对路径!) RemoteHooking.Inject( notepad.Id, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "KeyLoggerDemo.Server.dll"), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "KeyLoggerDemo.Server.dll"), pipeName); // 启动管道服务端,等待Server连接 using (var server = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) { server.WaitForConnection(); // 阻塞直到Server调用pipe.Connect() var formatter = new BinaryFormatter(); while (true) { try { // 反序列化KeyboardEventArgs对象 var keyEvent = (KeyboardEventArgs)formatter.Deserialize(server); Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Key: {keyEvent.KeyCode} (Scan: {keyEvent.ScanCode})"); } catch (IOException) // Server断开连接 { break; } catch (SerializationException ex) // 反序列化失败 { Console.WriteLine($"Deserialize failed: {ex.Message}"); break; } } } }这里有个血泪经验:RemoteHooking.Inject()的第二个和第三个参数都必须是绝对路径。传相对路径(如"KeyLoggerDemo.Server.dll")在某些环境下会注入失败,因为EasyHook的注入器在目标进程中执行时,其工作目录是notepad.exe的目录(C:\Windows\System32),而非你的Client exe目录。所以必须用Path.Combine(...)拼出完整路径。
另外,BinaryFormatter已被标记为过时(.NET 5+中默认禁用),但本demo基于.NET Framework 4.7.2,仍可安全使用。若你打算迁移到.NET Core,必须替换为System.Text.Json或MessagePack,且需确保KeyboardEventArgs类标记[Serializable]并有无参构造函数。
4. 避坑指南:5个真实踩过的坑及根治方案
4.1 现象:RemoteHooking.Inject()抛出InjectionFailedException,错误码126(找不到指定模块)
原因:EasyHook的原生注入器(EasyHook32.dll/EasyHook64.dll)未与Client可执行文件放在同一目录,或文件权限被杀毒软件拦截。
解决:
- 确认
bin\Debug\目录下存在两个DLL,且文件属性“只读”未勾选; - 临时关闭Windows Defender实时保护(设置 → 隐私和安全 → Windows 安全中心 → 病毒和威胁防护 → 管理设置 → 实时保护 → 关闭);
- 在VS中右键项目 → “属性” → “生成” → 勾选“首选32位”(仅当目标为x86时),避免因平台混淆导致加载失败。
4.2 现象:Hook成功安装,但KeyPressed事件从未触发
原因:低级键盘Hook(WH_KEYBOARD_LL)要求调用线程拥有消息循环(message pump),而EasyHook注入后的Server DLL是在DllMain中执行Main()方法,此时目标进程(Notepad)的主线程并未处于GetMessage()循环中,导致Hook回调无法派发。
解决:
- 在Server的
Main()方法末尾添加一个阻塞式消息循环(非推荐但有效):// 在KeyboardHook.Install()之后 Application.Run(new ApplicationContext()); // 启动一个空的WinForms消息循环 - 更优雅的做法是:Server不主动启动循环,而是由Client通过管道发送“StartHook”指令,Server收到后再调用
Install(),并确保此时Notepad已进入正常UI状态。
4.3 现象:Client反序列化时报SerializationException: Unable to find assembly 'KeyLoggerDemo.Server...'
原因:BinaryFormatter反序列化时需要加载KeyLoggerDemo.Server.dll来构造KeyboardEventArgs类型,但该DLL不在Client进程的GAC或加载上下文中。
解决:
- 在Client项目中添加对
KeyLoggerDemo.Server项目的项目引用(而非仅引用DLL); - 或在Client的
App.config中配置<runtime><assemblyBinding>,显式指定程序集加载路径; - 最彻底方案:将
KeyboardEventArgs定义移到一个独立的、Client和Server都引用的SharedContract.dll中。
4.4 现象:Hook后Notepad卡死或崩溃,事件日志显示Application Hang
原因:HookCallback中执行了耗时操作(如Console.WriteLine、网络请求、复杂计算),阻塞了Windows消息队列,导致Notepad UI线程无法响应Paint/Timer等消息。
解决:
HookCallback内只做最轻量的事:打包数据、写入管道缓冲区;- 将耗时处理(如日志写入、网络上报)放到Client端的独立线程中;
- 在Server端使用
ThreadPool.QueueUserWorkItem()异步处理,但要注意KeyboardEventArgs对象必须深拷贝,避免跨线程访问原始lParam指针。
4.5 现象:x64 Client注入x64 Notepad成功,但Hook回调中GetModuleHandle返回NULL
原因:curModule.ModuleName返回的是KeyLoggerDemo.Server.dll的文件名(如"KeyLoggerDemo.Server.dll"),但在64位进程中,GetModuleHandle要求传入完整模块名(不含路径和扩展名),且大小写必须完全匹配。
解决:
- 不要依赖
curModule.ModuleName,改用Path.GetFileNameWithoutExtension(Assembly.GetExecutingAssembly().Location)获取纯模块名; - 或更稳妥:在
Main()方法中直接硬编码模块名(因Server DLL名称固定):_hookId = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle("KeyLoggerDemo.Server"), 0);
5. 二次开发实战:如何把KeyLoggerDemo改成“剪贴板监控器”
5.1 需求分析:Hook什么API?为什么选GetClipboardData而非轮询
想监控剪贴板内容变化,最直观的想法是起一个定时器,每500ms调用一次Clipboard.GetText()。但这有两大缺陷:
- 效率低:即使用户没复制,也在空转;
- 漏检:两次轮询之间发生的复制操作会被错过。
真正的做法是Hook剪贴板相关API。Windows剪贴板操作最终都会经过user32.dll的GetClipboardData、SetClipboardData、OpenClipboard等函数。其中GetClipboardData是读取动作的必经之路,Hook它即可精准捕获每一次粘贴行为。
EasyHook支持IAT(Import Address Table)Hook,即修改目标进程导入表中user32.dll!GetClipboardData的地址。相比Inline Hook,IAT Hook更稳定、无需处理指令长度、不破坏原有代码段。
5.2 Server端改造:新增ClipboardHook类并Hook IAT
在KeyLoggerDemo.Server项目中新建ClipboardHook.cs:
// KeyLoggerDemo.Server/ClipboardHook.cs public class ClipboardHook : IDisposable { private LocalHook _hook; private readonly Func<uint, IntPtr> _originalGetClipboardData; public event Action<string> ClipboardTextChanged; public ClipboardHook() { // 1. 获取原始GetClipboardData函数地址(从user32.dll) var user32 = LoadLibrary("user32.dll"); var originalAddr = GetProcAddress(user32, "GetClipboardData"); _originalGetClipboardData = Marshal.GetDelegateForFunctionPointer<Func<uint, IntPtr>>(originalAddr); // 2. 创建IAT Hook,目标进程的IAT中所有对GetClipboardData的引用都将跳转至此 _hook = LocalHook.Create( LocalHook.GetProcAddress("user32.dll", "GetClipboardData"), new GetClipboardDataDelegate(HookCallback), this); _hook.ThreadACL.SetExclusiveACL(new int[] { 0 }); // 应用于所有线程 } public void Install() => _hook.IsActive = true; private IntPtr HookCallback(uint uFormat) { // 先调用原函数获取数据句柄 var result = _originalGetClipboardData(uFormat); // 如果是CF_TEXT格式,且返回非空句柄,则读取文本内容 if (uFormat == 1 && result != IntPtr.Zero) { try { // GlobalLock获取内存指针 var ptr = GlobalLock(result); if (ptr != IntPtr.Zero) { var text = Marshal.PtrToStringAnsi(ptr); ClipboardTextChanged?.Invoke(text); GlobalUnlock(result); } } catch { /* 忽略读取失败 */ } } return result; // 必须返回原函数结果,否则粘贴功能失效 } public void Uninstall() => _hook.Dispose(); // P/Invoke... [DllImport("kernel32.dll")] private static extern IntPtr LoadLibrary(string lpFileName); [DllImport("kernel32.dll")] private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); [DllImport("kernel32.dll")] private static extern IntPtr GlobalLock(IntPtr hMem); [DllImport("kernel32.dll")] private static extern bool GlobalUnlock(IntPtr hMem); private delegate IntPtr GetClipboardDataDelegate(uint uFormat); }关键点说明:
LocalHook.Create()的第一个参数是LocalHook.GetProcAddress(...),它返回的是user32.dll中GetClipboardData的真实地址,EasyHook会据此定位目标进程IAT中的对应条目;HookCallback中必须先调用_originalGetClipboardData,再处理业务逻辑,最后返回其结果。如果直接返回IntPtr.Zero,所有粘贴操作都会失败;GlobalLock/GlobalUnlock是操作剪贴板内存块的标准流程,不可省略。
5.3 Client端适配:复用现有管道,扩展事件类型
修改Client端的反序列化逻辑,使其能区分键盘事件和剪贴板事件。最简单的方式是定义统一事件基类:
// SharedContract/Events.cs (新建共享项目) [Serializable] public abstract class BaseEvent { public DateTime Timestamp { get; set; } } [Serializable] public class KeyboardEvent : BaseEvent { /* 原KeyboardEventArgs字段 */ } [Serializable] public class ClipboardEvent : BaseEvent { public string Text { get; set; } public uint Format { get; set; } }然后在Server端Main()中同时安装两个Hook,并通过同一管道发送不同类型的事件对象。Client端用BinaryFormatter.Binder动态判断类型,分发处理。
从那以后我每次做Hook二次开发,都强制走一遍“Hook函数签名核对 → 原函数调用验证 → 返回值透传测试 → 多线程压力测试”四步流程。尤其
GetClipboardData这种高频API,必须在Notepad连续复制100次文本,确认无崩溃、无漏报、无性能抖动。EasyHook的稳定性经得起考验,但你的Hook逻辑是否健壮,得自己一关关过。希望帮到你。
本文还有配套的精品资源,点击获取