尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C#全局钩子实战:EasyHook实现跨进程注入与键盘监听
简介这是一份面向C#开发者的EasyHook远程函数拦截/注入学习Demo适合想掌握Windows Hook机制、实现进程内或跨进程API监控与行为修改的进阶开发者。资源以完整示例工程为主围绕EasyHook的安装配置、创建钩子、启动注入、委托回调等核心环节展开并附有dll数字签名等注意事项可帮助读者快速跑通拦截ExitWindowsEx等API的完整流程。压缩包共91个文件包含17个cs源码文件、15个dll库、7个pdb调试符号、6个exe示例程序及config/project等相关工程文件整体约854KB目录结构清晰便于对照学习。目前已有913人学习/下载。通过该Demo读者能获得可直接编译运行的EasyHook项目代码、多个测试窗口工程与类库示例还能直观理解LocalHook与RemoteHooking的用法及ThreadACL过滤等细节适合作为二次开发的起点。1. EasyHook 到底是做什么的一个 C# 全局钩子 Demo 能解什么燃眉之急如果你和我一样是写 C# 桌面应用的大概率在某个深夜被“全局键盘监听”“全局鼠标事件捕获”“拦截其他进程的 API 调用”这类需求折磨过。常规的SetWindowsHookEx写起来不复杂但一碰到“我要在别的进程里执行我的回调代码”就翻车——托管代码怎么塞进非托管进程回调居然是stdcall指针谁来帮你管理生命周期这就是 EasyHook 存在的意义它是一个专门在 Windows 上做用户模式挂钩的开源库允许你用纯 C#或者 C# 混合 C注入到目标进程拦截并修改 API 调用或者捕获系统级事件。我最初接触 EasyHook 是因为要给一个老旧的模拟项目 X 加自动化测试需要读取另一个窗口控件的内容而那个窗口进程完全没有对外开放接口。当时用 SendMessage 只能拿到部分数据最后是 EasyHook 的全局钩子把读取逻辑注入进去才彻底解决。这篇笔记送给那些正在为跨进程拦截、全局事件捕获、托管代码注入挠头的开发者你不需要从零啃 Windows 底层文档照着下面的 Demo 走一遍就能明白这套库的边界在哪里。2. 挂钩原理与选型理由为什么是 EasyHook 而不是 SetWindowsHookEx 或 Detours2.1 EasyHook 的工作机制注入、宿主、钩子通道EasyHook 的核心思路可以概括为“两个文件、三个角色”。两个文件分别是你的注入器通常是一个可执行程序和被注入的钩子库一个类库 DLL。三个角色是发起注入的客户端、承载钩子代码的目标进程、以及负责传递消息的宿主通信通道。具体流程是客户端进程调用RemoteHooking.Inject把钩子 DLL 注入到目标进程 ID 指定的进程里。注入完成后目标进程内会加载 EasyHook 的运行时随即调用你指定的入口点Run方法。这个入口点可以执行任意托管代码——包括安装SetWindowsHookEx、创建事件监听、甚至使用 API 钩子拦截目标进程内特定的函数调用。所有从客户端到被注入进程之间的消息传递EasyHook 默认通过 IPC基于远程对象完成你不用手动设置命名管道或共享内存。一个最容易让新手误解的点是EasyHook 并不直接“拦截”底层调用它只是向外提供一个注入通道。你注入的代码要自己去调SetWindowsHookEx或者HookRuntimeInfo之类的东西。说直白点EasyHook 解决的是“怎么把托管代码弄进别的进程”这个最难的问题而进去之后干什么它给了你很大的自由。2.2 与 SetWindowsHookEx、Detours 的对比选型边界方案托管代码友好度全局钩子支持API 内联钩子开发成本稳定性SetWindowsHookEx原生低需要写 C支持键盘鼠标不支持中稳定但能力有限Detours微软低C 专用不支持支持高极稳EasyHook高纯 C# 可写支持支持低中需注意注入时序如果只做全局键盘鼠标钩子SetWindowsHookEx配合 C 也行但要在目标进程里跑 C# 委托就非常别扭。Detours 是微软官方库做内联钩子很强可是它的免费版只支持 x86而且托管调试一开就容易崩溃。EasyHook 的杀手锏就在于你不需要改变自己熟悉的 C# 开发流程就可以在任意用户态进程里安装钩子。它内部用了一个隐藏的宿主项目EasyHook 的 NativeDll把托管运行时引导进目标进程所以普通 32 位和 64 位进程都能支持。当然代价是注入的速度稍慢而且杀毒软件有时候会误报——这个后面避坑章节细讲。2.3 版本与运行环境参数我的 Demo 环境是 Windows 10 x64 .NET Framework 4.7.2 Visual Studio 2019EasyHook 选用的是 2.7.6 稳定版。如果你的项目是 .NET Core 或 .NET 5EasyHook 官方没有正式支持社区有第三方分支但我不建议在生产场景用因为注入原理依赖 .NET Framework 的 AppDomain 机制勉强移植容易出隐性问题。EasyHook 64位进程需要把目标进程的架构和注入器架构对齐最稳妥的做法是 AnyCPU 编译但实际运行时用 x86 生成物注入 x86 目标用 x64 注入 x64 目标千万别偷懒。3. 动手实现一个全局键盘钩子 Demo从注入器到钩子库的完整代码3.1 建立两个项目并配置引用在 Visual Studio 里新建解决方案包含两个项目HookInjector控制台应用负责启动钩子。GlobalHookLib类库包含钩子逻辑。两个项目都必须引用 EasyHook.dll并且GlobalHookLib还要引用EasyHook命名空间。关键配置GlobalHookLib的生成平台位数要和目标进程一致我直接把它编译为对应架构的类库注入器通过命令行参数传入目标进程 ID。先看GlobalHookLib的代码我定义了一个入口类using System; using System.Diagnostics; using System.Runtime.InteropServices; using EasyHook; namespace GlobalHookLib { public class MainHook : IEntryPoint { // 保存客户端传入的参数用于回调 private ChannelConfig _config; // 委托定义键盘钩子回调 private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); // 钩子过程实例防止 GC 回收导致回调崩溃 private static LowLevelKeyboardProc _proc HookCallback; // SetWindowsHookEx 的钩子句柄 private static IntPtr _hookId IntPtr.Zero; // 当前进程的模块句柄用于获取模块基址 private static IntPtr _moduleHandle; // User32.dll 中的 SetWindowsHookEx 函数 [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); // 常量定义 private const int WH_KEYBOARD_LL 13; public MainHook(RemoteHooking.Context context, ChannelConfig config) { _config config; Console.WriteLine(注入成功接收参数 config.Description); } public void Run(RemoteHooking.Context context, ChannelConfig config) { try { // 安装全局低级键盘钩子 using (var curProcess Process.GetCurrentProcess()) using (var curModule curProcess.MainModule) { _moduleHandle GetModuleHandle(curModule.ModuleName); } _hookId SetWindowsHookEx(WH_KEYBOARD_LL, _proc, _moduleHandle, 0); if (_hookId IntPtr.Zero) { int error Marshal.GetLastWin32Error(); throw new InvalidOperationException($安装钩子失败错误码: {error}); } // 告诉客户端钩子已安装好 RemoteHooking.WakeUpProcess(); // 保持进程线程存活等待钩子回调 while (true) { System.Threading.Thread.Sleep(1000); } } catch (Exception ex) { Console.WriteLine(钩子运行时异常 ex); throw; } } // 真正的回调函数每次键盘按键都会走到这里 private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode 0) { // 将 lParam 转换成结构体拿到按键信息 var info (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT)); Console.WriteLine($按键事件来自线程 {info.dwExtraInfo}扫描码 {info.scanCode}); } // 调用下一个钩子绝不能阻塞或修改异常 return CallNextHookEx(_hookId, nCode, wParam, lParam); } } [StructLayout(LayoutKind.Sequential)] public struct KBDLLHOOKSTRUCT { public uint vkCode; public uint scanCode; public uint flags; public uint time; public UIntPtr dwExtraInfo; } // 此类型用于跨进程传递配置参数必须可序列化 [Serializable] public class ChannelConfig { public string Description { get; set; } public int LogLevel { get; set; } } }代码逻辑说明IEntryPoint接口要求实现Run方法构造函数会在目标进程内被调用参数由注入端传递。SetWindowsHookEx传入线程 id 为 0表示设置全局钩子回调函数是静态委托防止实例被回收。KBDLLHOOKSTRUCT结构体必须与 Windows 定义的对应字段完全一致否则PtrToStructure会错位。代码中_hookId保存钩子句柄回调中调用CallNextHookEx放行按键。参数说明WH_KEYBOARD_LL常量为 13是低级键盘钩子识别码。SetWindowsHookEx的第三个参数需要传入当前模块句柄我用GetModuleHandle(curModule.ModuleName)获取。注意Run方法里的while(true)是为了让注入后的线程保持运行一旦线程退出钩子会被系统自动移除。这个循环里可以改造成WaitHandle方式便于后续卸载。3.2 注入器选择目标进程并发起注入下面看注入器端代码using System; using System.Diagnostics; using System.Threading; using EasyHook; using GlobalHookLib; namespace HookInjector { class Program { static void Main(string[] args) { if (args.Length 1) { Console.WriteLine(用法HookInjector.exe 进程ID); return; } int targetPid int.Parse(args[0]); string injectedDll typeof(MainHook).Assembly.Location; // 客户端回调对象用于接收来自注入进程的日志 RemoteHooking.IpcCreateServerServerInterface( GlobalHookIpc, System.Runtime.Remoting.LifetimeServices.CallRemotingService); // 注意IpcCreateServer 需要指定通道名这里简化写法 var config new ChannelConfig { Description 键盘钩子测试, LogLevel 1 }; // 注入并运行 string errorMessage null; try { RemoteHooking.Inject( targetPid, injectedDll, injectedDll, config, out errorMessage); Console.WriteLine(注入成功钩子已生效。按回车卸载并退出。); } catch (Exception ex) { Console.WriteLine(注入失败 ex.Message | errorMessage); return; } Console.ReadLine(); // 卸载调用远程对象的 Deinstall 方法 RemoteHooking.Uninject(targetPid, injectedDll); Console.WriteLine(已卸载。); } public class ServerInterface : MarshalByRefObject { public void Log(string msg) Console.WriteLine([目标进程] msg); } } }逻辑说明RemoteHooking.Inject的参数依次为进程 ID、注入 DLL 路径、目标 DLL 路径、传给入口的配置对象、错误信息输出。这里两个路径都指向同一个 DLL因为我在GlobalHookLib中定义了入口点。IpcCreateServer会创建一个 IPC 通道让目标进程可以调用Client端的方法我这里定义了ServerInterface用于把日志输出到控制台。参数说明IpcCreateServer的第一个参数是通道名称必须全局唯一否则会冲突。RemoteHooking.Inject在 x64 系统上自动选择注入位数前提是三个 DLL 都编译为对应架构。如果目标进程是 64 位你的GlobalHookLib也必须 64 位否则会出现“试图加载格式不正确”的异常。3.3 编译和运行流程两个项目协同的细节编译顺序先编译GlobalHookLib再把它的 DLL 复制到HookInjector的输出目录。最简单的方式是把两个项目放在同一个解决方案里然后右键HookInjector添加对GlobalHookLib的项目引用。这样运行时同名 DLL 会在输出目录下共存。运行前找一个记事本作为目标进程或者打开你本机的某个闲置进程。命令行执行HookInjector.exe 1234其中1234是记事本进程 ID。如果看到“注入成功”此时随便在记事本里打字控制台会刷出按键事件信息。常见问题杀毒软件会拦截Inject调用尤其是卡巴斯基/火绒需要临时关闭或添加信任。另外目标进程不能是受保护的进程比如某些系统进程或带 PPL 保护的安全软件EasyHook 无法注入。4. 避坑与常见问题我在这套 Demo 上翻过的四次车4.1 现象注入后目标进程直接崩溃事件查看器显示 .NET 运行时错误原因你的GlobalHookLib引用的 .NET Framework 版本低于目标进程的 CLR 版本不对实际原因通常是ChannelConfig类没有标记[Serializable]或者引用的类型没有实现 MarshalByRefObject。IoC 反序列化时找不到正确程序集导致 AppDomain 初始化失败。我当时把ChannelConfig放在GlobalHookLib命名空间里但注入器里也引用了同一个类两边编译版本不一致导致 CLR 解析程序集时随机翻车。解决把参数类定义在独立的.cs文件里并显式标注[Serializable]。两个项目都引用同一个源文件别复制粘贴。另外确保GlobalHookLib输出目录没有旧的残留版本。4.2 现象钩子安装成功但回调只执行一次就不再触发原因我在Run方法里用了using语句获取Process.MainModule导致模块句柄被释放。SetWindowsHookEx需要模块句柄在整个钩子生命周期内有效。如果传入的 hMod 无效低级钩子在回调中会因找不到模块而静默丢弃。解决不要在using里保存模块句柄改成静态字段保存。上面的代码已经改为_moduleHandle静态赋值并且在UnhookWindowsHookEx前都不能释放。4.3 现象注入日志显示成功但钩子回调收不到键盘事件原因目标进程本身是 64 位而你的HookInjector是 x86 编译生成。EasyHook 注入时位数不匹配它实际上没有把正确的宿主 DLL 加载进去但Inject却返回成功。这是 EasyHook 已知的坑如果注入器和目标位数不一致它会假装成功但实际 DLL 根本没加载。解决用CorFlags检查目标进程位数然后手动把注入器和GlobalHookLib都编译成对应位数。最稳妥的做法是生成两个版本HookInjector_x86.exe和HookInjector_x64.exe用批处理判断目标进程位数后调用对应版本。4.4 现象杀毒软件实时监控弹窗或者注入后进程无响应原因EasyHook 的注入原理是在远程进程里创建线程并加载一个未签名的原生 DLL非常像恶意软件行为。火绒的“行为沙箱”会直接拦截建线程操作导致注入线程被挂起目标进程 UI 无响应。解决给EasyHook32.dll和EasyHook64.dll及你的GlobalHookLib.dll做代码签名或者至少把输出目录加入杀毒软件白名单。开发调试期间我通常把杀毒软件的文件实时监控临时关闭但生产环境必须走签名流程否则用户机器上无法稳定运行。5. 从 Demo 到能用的进阶日志验证、条件过滤与钩子卸载的规范化很多新手以为钩子装上去就万事大吉实际上回调线程和 UI 线程的并发模型、性能损耗、卸载时序才是真正考验工程能力的地方。我后期在模拟项目 X 中把“打印每个按键”升级成了“仅在特定窗口标题下记录登录框的输入”这里分享三个进阶点。进阶点一使用 IPC 把日志和事件实时传回主程序。在回调里直接调Console.WriteLine只能看到控制台输出生产环境需要把按键数据回传给负责 UI 的进程。EasyHook 支持的 IPC 通道是双向的目标进程可以调用注入器宿主里的对象。我在ServerInterface中增加了一个方法public void ReportEvent(int eventType, int keyCode) { // 把事件转发到 UI 线程 if (Form1.Instance ! null) Form1.Instance.BeginInvoke(new Action(() Form1.Instance.listBox1.Items.Add($key{keyCode}))); }目标进程里写日志时这样调用// 在 Run 方法中保存客户端通道实例 _client RemoteHooking.IpcConnectClientClientInterface(GlobalHookIpc); _client.ReportEvent(1, info.vkCode);注意点IpcConnectClient的通道名必须与注入器里IpcCreateServer的通道名完全一致。锁的粒度尽量细不要在回调里做长耗时操作否则会拖慢系统键盘响应。进阶点二为钩子增加条件过滤。低级键盘钩子回调频率非常高每个按键事件都经过你的代码。如果不加过滤按一次 Ctrl 就能触发三次回调down、repeat、up大量日志会淹没真实信号。我的过滤策略是if (nCode 0) return CallNextHookEx(_hookId, nCode, wParam, lParam); // 只处理 WM_KEYDOWN257和 WM_SYSKEYDOWN261 uint msg (uint)wParam; if (msg ! 0x0101 msg ! 0x0104) return CallNextHookEx(_hookId, nCode, wParam, lParam); var info (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT)); // 忽略自动重复按键flags bit0 if ((info.flags 0x10) ! 0) return CallNextHookEx(_hookId, nCode, wParam, lParam); // 其余逻辑...这里的wParam值在WM_KEYDOWN时是 2570x101WM_SYSKEYDOWN是 2610x104。flags位 0 为 1 表示该键是上一次按键的自动重复配合对比scanCode可以过滤掉物理重复触发。如果你只关心组合键可以在这里维护一个“按下的键集合”在 down 事件加键up 事件移除键这样能准确判断 CtrlC 这类组合。进阶点三安全卸载钩子。卸载钩子不能简单Environment.Exit那样目标进程内的线程会残留。正确流程是先调用RemoteHooking.Uninject它会通知目标进程的 EasyHook 运行时执行Run方法尾部通过finally块定义的清理逻辑。我在GlobalHookLib里添加了静态方法Deinstallpublic static void Deinstall() { try { // 卸载钩子 if (_hookId ! IntPtr.Zero) { UnhookWindowsHookEx(_hookId); _hookId IntPtr.Zero; } // 断开 IPC _client.Dispose(); // 通知客户端 RemoteHooking.WakeUpProcess(); } catch (Exception ex) { // 记录异常但不抛给系统 } }并且把Run方法的主循环改成等待信号private static ManualResetEvent _stopEvent new ManualResetEvent(false); _loop new Thread(() { _stopEvent.WaitOne(); }); _loop.Start();收到卸载指令时设置_stopEvent线程退出后Run返回EasyHook 会继续后续清理。注意IEntryPoint的Run方法返回后EasyHook 会释放 AppDomain此时不能再用任何托管对象所以所有清理必须在返回前完成。从那次以后我每次做钩子项目都会强制走一遍“注入-验证-卸载”的完整流程并且在回调里输出一个加密时间戳确保卸载后真的没有残余线程。希望这份笔记能帮你在 EasyHook 的使用路上少踩几个我踩过的坑。本文还有配套的精品资源点击获取
RELATED

相关推荐

Graph Normalization:面向图结构非均匀性的三层归一化方法

Graph Normalization:面向图结构非均匀性的三层归一化方法

1. 为什么图神经网络总在“邻居爆炸”和“信息稀释”之间反复横跳?你有没有遇到过这样的情况:训练一个图神经网络时,模型一开始收敛得挺快,几轮之后准确率就卡在某个平台期不动了;或者换了一组新数据,明明节…

📅 2026/10/17 0:58:52
海量数据存储处理黑科技:列式存储、压缩编码与冷热分层实战

海量数据存储处理黑科技:列式存储、压缩编码与冷热分层实战

1. 数据洪流下的工具箱思维干数据这行十来年,我越来越觉得,处理海量数据存储这件事,跟修车师傅拎工具箱是一个道理。你不可能拿一把扳手拧遍所有螺丝,也不可能指望一把锤子搞定发动机和内饰。数据量级一旦上了TB甚至PB&#xff0c…

📅 2026/10/17 0:58:52
热词“cua”全解析:从网络拟声词到IBM界面规范

热词“cua”全解析:从网络拟声词到IBM界面规范

说实话,第一次在评论区刷到满屏“cua”的时候,我愣了一下,还以为是输入法抽风了。连着看了几条视频才发现,这不是错字,而是很多人都在用的表达。这个三个字母的词,读起来自带一种干脆利落的画面感&#xff…

📅 2026/10/17 0:58:52
MORE NEWS

更多资讯

📰

基于MP2642GR-Z与STM32F303RC的两节串联电池主动均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Zephyr开发实战:用Shell和Settings实现设备用户管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

无电感电荷泵D类功放CS5250:原理、选型、PCB布局与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

两串电池组主动均衡方案:MP2642GR-Z与ATmega644P实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

kubectl-ssh:一条命令轻松登录任意Kubernetes节点

如果让我列一个 Kubernetes 日常排障里最磨人的环节,登录节点一定排在前三。节点 NotReady、Pod 卡在 Pending、容器运行时抽风……很多问题不跑到节点上看一眼根本定位不了。而问题恰恰就出在“到节点上看一眼”这几个字上:你要先查出节点对应的 IP&…

📰

UML考试复习指南:高频考点与答题套路详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬