简介这是一份基于 C# 语言 EasyHook 库的远程函数拦截与注入入门示例工程适合需要监控、调试或修改其他进程行为的 .NET 开发者也可作为学习 Windows 钩子机制与远程注入技术的起点。压缩包共用 91 个文件包含 C# 源代码、编译好的动态库与可执行程序、程序调试符号、配置文件以及完整的解决方案和项目工程文件可以直接打开编译运行并对照示例代码理解钩子创建、回调委托绑定、线程访问控制列表设置以及注入启动等关键步骤。示例演示了拦截系统调用时的处理逻辑能够直观看到钩子触发前后的执行流程与返回值变化便于反复修改试验。文件类型覆盖开发、构建与运行需求可执行文件用于启动演示动态库提供注入支撑源码便于阅读修改配置则帮助快速还原环境整体目录结构清晰。资源还体现了程序集数字签名对注入成功率的影响并配有相关配置说明可避免常见踩坑问题。压缩包约 854KB轻量易用已有 912 人学习下载。1. easyHook 在 C# 里到底是什么先分清“进程内钩子”和“跨进程注入”“C# easyHook 使用 demo”这个标题第一眼容易让人误以为是个游戏辅助项目但其实 easyHook 是一套把 Windows API 钩子能力封装成 .NET 可调用接口的库。你用 C# 写几行代码就能把 user32.dll、kernel32.dll 里某个导出函数“截住”在它执行前改写参数、篡改返回值、记录日志或者干脆阻止它往下走。它最常见的落地场景是你自己写工具C# 上位机做自动化回归测试、给老程序加无障碍辅助、无侵入式埋点监控以及协议追踪和分析。适合的读者是带着具体产品需求、想在 Windows 上做进程级调试和跨进程监控的 C# 开发者而不是刚学完语法就来问钩子怎么写的人。搞懂这个库核心是把“进程内 LocalHook”和“跨进程 RemoteHooking”拆开理解下面逐个讲。2. 先跑通最小 demo用 LocalHook 钩住 GetCurrentProcessId 并改返回值2.1 包管理与运行环境做一个最小闭环用控制台程序就够了。推荐 .NET Framework 4.7.2 起步因为它和 easyHook 的注入器配合最顺.NET 6/8 也能跑但要额外注意运行时兼容性新手不建议从跨平台项目开始试。在 Visual Studio 的“程序包管理器控制台”里执行Install-Package EasyHook -ProjectName HookDemo选稳定正式版别碰带 Preview 的包。装完之后项目输出目录里会多出EasyHookDll32.dll和EasyHookDll64.dll两个原生文件这两个文件决定你最后能不能在某一个进程位数下跑通。项目平台目标不要直接用 AnyCPU我一般会先在实验阶段固定成 x64确认钩子能触发后再去考虑 32 位场景。输出目录一定要确认同时存在这两个原生 DLL不然 LocalHook 创建时会直接抛BadImageFormatException或者加载失败异常。2.2 最小 LocalHook 代码挂接、回调、卸载新建一个控制台项目把下面这段代码完整放进去。这个 demo 选择拦截GetCurrentProcessId因为它无参数、无副作用是验证钩子机制的最小对象。using System; using System.Runtime.InteropServices; using EasyHook; namespace HookDemo { internal class Program { // 导入要钩住的系统 API调用它时会被 easyHook 截获 [DllImport(kernel32.dll)] private static extern uint GetCurrentProcessId(); // 委托签名必须和 Win32 API 的调用约定一致 [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint GetCurrentProcessIdDelegate(); // 委托要用静态字段保存防止垃圾回收把回调回收掉 private static GetCurrentProcessIdDelegate _hookDelegate; // 钩子回调目标进程内每次调用 GetCurrentProcessId 都会先到这里 private static uint GetCurrentProcessIdHook() { Console.WriteLine([hook] 有人调用 GetCurrentProcessId返回值已被改为 8888); return 8888; } private static void Main() { // 1. 拿到 kernel32.dll 中 GetCurrentProcessId 的导出地址 IntPtr procAddr LocalHook.GetProcAddress(kernel32.dll, GetCurrentProcessId); // 2. 创建本地钩子第三个参数传 null 表示不需要回调对象上下文 _hookDelegate new GetCurrentProcessIdDelegate(GetCurrentProcessIdHook); LocalHook hook LocalHook.Create(procAddr, _hookDelegate, null); // 3. 主动调用一次系统 API验证钩子是否生效 uint pid GetCurrentProcessId(); Console.WriteLine(当前进程 PID pid); Console.WriteLine(按回车卸载钩子并退出...); Console.ReadLine(); // 4. 卸载钩子释放原生资源 hook.Dispose(); } } }这段代码的流程是先用LocalHook.GetProcAddress(kernel32.dll, GetCurrentProcessId)拿到系统函数在内存里的导出地址然后LocalHook.Create把这个地址重定向到你自己的委托上。之后进程内任何代码调用GetCurrentProcessId都会先进GetCurrentProcessIdHook返回 8888。Main 里通过DllImport声明的GetCurrentProcessId()自然也被拦住了所以控制台输出的 PID 不是真实值而是 8888。LocalHook.Create的参数需要解释一下第一个参数是要钩住的函数地址第二个参数是回调委托每次函数被调用时触发第三个参数是回调上下文对象easyHook 会把它和钩子实例绑定在一起一般传null也行但委托实例一定要用字段持有否则 .NET 垃圾回收后回调地址就失效了这是第一次写 easyHook 最常翻车的地方。2.3 验证方法与线程 ACL 参数调整跑起来之后控制台先打印[hook] 有人调用 GetCurrentProcessId返回值已被改为 8888然后打印当前进程 PID 8888。看到这个输出说明钩子已经成功替换了函数执行路径。按回车后钩子被卸载进程正常退出。这里有个容易被忽略的线程控制接口LocalHook.ThreadACL。hook 默认会对进程内所有线程的调用生效但在 C# 上位机这种多线程环境里你往往只想要主线程或者某个工作线程触发钩子。常见做法是用ThreadACL.SetExclusiveACL(new[] { 0 })排除安装钩子的当前线程或者用SetInclusiveACL只放行指定线程。0是 easyHook 对调用线程的约定值不是系统线程 ID。如果你在 demo 里尝试钩住MessageBoxW需要额外注意委托签名要带上CharSet.Unicode。我建议新手第一次验证还是用GetCurrentProcessId这种无参数 API因为不用处理字符串和重入问题最容易建立信心。3. 跨进程注入目标进程RemoteHooking 的注入器与入口点代码3.1 注入链路是什么LocalHook 只能改自己进程的 API 调用但很多实际需求是你想把钩子装到别的进程里比如上位机监控一个被测软件的函数调用。easyHook 的跨进程方案叫 RemoteHooking链路分三环注入器负责发起注入目标进程负责接收注入入口点类负责安装钩子并保持运行。注入器调用RemoteHooking.Inject之后easyHook 会在目标进程中启动 .NET 运行时加载你指定的托管程序集实例化实现IEntryPoint的类然后调用这个类的Run方法。整个过程不需要目标程序里有任何预先埋好的代码这一点对做黑盒测试和自动化监控非常有利也是 easyHook 比单纯用 C 写 Hook 更省事的核心原因。3.2 注入器端按进程 ID 发起注入先在同一个控制台项目里写注入器它读取目标进程 PID发起注入后等待using System; using EasyHook; internal class Injector { private static void Main(string[] args) { if (args.Length 1) { Console.WriteLine(用法Injector.exe 目标进程ID); return; } int pid int.Parse(args[0]); string channelName hook_channel_ Guid.NewGuid().ToString(N); // 参数顺序目标PID、注入选项、通道名、x64程序集路径、x86程序集路径、注入参数 RemoteHooking.Inject( pid, InjectionOptions.DoNotRequireStrongName, channelName, typeof(HookEntry).Assembly.Location, typeof(HookEntry).Assembly.Location, channelName); Console.WriteLine(注入完成通道名 channelName); Console.ReadLine(); } }RemoteHooking.Inject的参数比 LocalHook 明显多。第二个参数InjectionOptions.DoNotRequireStrongName表示不需要强命名程序集一般项目直接用这个选项。第四、第五个参数看起来都是同一份程序集路径这是 easyHook 留给 64 位和 32 位的两个入口参数你传同一个托管 DLL 路径就可以底层会按目标进程的位数去选择。最后一个channelName是注入参数会传给目标进程里HookEntry的构造函数。这里是初学者最困惑的点为什么有channelName和最后的channelName两个一样的东西前者是 easyHook 内部 IPC 用的通道名后者是你自定义传给入口点的参数。两者完全可以不同但常见例子为了方便经常写同一个字符串。3.3 目标端入口点IEntryPoint 实现同一个项目里需要有一个公共类实现IEntryPoint注入器才能把它加载进目标进程using System; using System.Runtime.InteropServices; using System.Threading; using EasyHook; public class HookEntry : IEntryPoint { private LocalHook _hook; [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint GetCurrentProcessIdDelegate(); // 注入成功后构造函数里安装钩子 public HookEntry(RemoteHooking.IContext context, string channelName) { IntPtr addr LocalHook.GetProcAddress(kernel32.dll, GetCurrentProcessId); _hook LocalHook.Create(addr, new GetCurrentProcessIdDelegate(GetCurrentProcessIdHook), null); } private static uint GetCurrentProcessIdHook() { // 返回一个魔数目标进程里任何代码拿到这个值就知道钩子生效了 return 0xDEAD; } public void Run(RemoteHooking.IContext context, string channelName) { // Run 不能退出退出后 LocalHook 会被释放钩子自动失效 while (true) { Thread.Sleep(1000); } } }这里的关键是把钩子安装放在构造函数里构造函数执行完后 easyHook 会调用Run而Run必须是一个死循环或者长期驻留的循环。一旦Run返回入口点对象被回收LocalHook随之释放目标进程的 API 就恢复原状了。在实际项目里Run里可以放 IPC 消息循环用来接收注入器下发的指令比如热卸载钩子、切换回调逻辑。这个 demo 里先用Thread.Sleep(1000)占住线程能达到验证目的。3.4 注入器与目标进程的位数匹配问题跨进程注入的坑主要出在位数上。比如注入器是 x64目标进程是 x86那么 easyHook 加载的EasyHookDll64.dll根本没法和 32 位目标进程握手。现象是Inject返回成功但入口点类从未被实例化日志里也没有任何输出。在实验阶段我会用任务管理器先确认目标进程位数再决定注入器编译成 x64 还是 x86。更保险的办法是让注入器自己判断目标进程位数using System; using System.Diagnostics; using System.IO; int pid int.Parse(args[0]); using (var proc Process.GetProcessById(pid)) { bool is64Bit !Environment.Is64BitOperatingSystem || (proc.Modules.Count 0 proc.Modules[0].FileName.Contains(SysWOW64) false); }这段判断不完美但能帮你快速筛掉最常见的位数错误。真正严谨的判断要用IsWow64ProcessAPI 或查询进程 PEB 结构线上工具我一般会写成独立工具函数而不会在注入器里写这么一段略粗糙的代码。4. C# 委托签名、线程安全与模块跳转不踩这几个硬细节就白写4.1 UnmanagedFunctionPointer 与导出函数签名要逐字节对上easyHook 的钩子本质上是一个“委托替换”你的回调必须和被钩函数有完全一致的调用约定和数据结构。很多刚开始写 C# 钩子的同学只关注参数个数忽略CallingConvention.StdCall和CharSet.Unicode结果回调一触发就内存访问违例目标进程瞬间闪退。拿钩住user32.dll!MessageBoxW举例正确的委托声明是这样的[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Unicode)] private delegate int MessageBoxDelegate(IntPtr hWnd, string text, string caption, uint type); // 钩子回调记录并吞掉消息框返回 IDOK private static int MessageBoxHook(IntPtr hWnd, string text, string caption, uint type) { Console.WriteLine($[hook] 已拦截 MessageBoxW原始文本{text}); return 1; // IDOK不真正弹窗 }CallingConvention.StdCall对应 Win32 里绝大多数 API 的调用约定CharSet.Unicode对应MessageBoxW的宽字符版本。如果你钩的是MessageBoxA就要把CharSet改成Ansi。钩错字符集之后字符串参数会乱码最坏情况是读内存越界直接崩溃。我见过最隐蔽的错误是钩住MessageBoxW却声明成int返回值。Win32 API 返回int是对的但有些人从 MSDN 复制 C 签名时看错成BOOL然后回调返回true/false虽然在机器码层面true会被当成 1但语义全拧了。尽量逐字对照 Win32 API 文档写委托不要凭印象。4.2 回调线程安全与重入锁、日志和耗时操作钩子回调运行在调用者的线程上。也就是说如果有三个线程同时调用GetCurrentProcessId您的回调可能被三个线程同时执行。回调里一旦写了非线程安全的集合操作就会出现奇怪的数据错乱。稳妥做法是给回调加锁并保持回调体内的操作足够轻量private static readonly object Gate new object(); private static int _callCount; private static uint GetCurrentProcessIdHook() { lock (Gate) { _callCount; Console.WriteLine($[hook] 第 {_callCount} 次调用); } return 8888; }锁内只做计数和日志不做文件写入、不做数据库访问、不等待其他线程的句柄。C# 多线程场景里最糟糕的钩子写法是回调里ManualResetEvent.WaitOne()等 UI 线程返回值而 UI 线程又恰好触发了这个 API直接死锁。easyHook 社区里被问最多的问题就是“为什么钩子一装上目标程序就卡死”十有八九是回调里做了阻塞等待。如果你需要把钩到的数据转发到别的线程处理正确姿势是把数据塞进ConcurrentQueueT然后在独立消费线程里慢慢写日志。回调里最多做一次Enqueue和一次TryDequeue边界判断不能再多。4.3 用反射和模块枚举时的边界处理easyHook 的GetProcAddress(user32.dll, MessageBoxW)只能拿到导出函数地址函数地址本身不包含模块路径。有些同学写钩子时想校验目标进程是不是加载了某个特定 DLL会反射遍历进程模块using System.Diagnostics; using System.Linq; string[] moduleNames Process.GetCurrentProcess().Modules .CastProcessModule() .Select(m m.ModuleName) .ToArray();这段代码在注入器里没问题但在钩子回调里调用就要小心。Process.GetCurrentProcess().Modules每次枚举都会触发原生模块列表快照性能很差而且某些受保护模块会抛Win32Exception。正确做法是“安装钩子前枚举一次把结果缓存成字段”回调里只读取缓存不重复枚举。另外一个反射相关的坑是钩子的委托实例不能用反射动态生成。有些同学想用Expression构造一个和 API 签名相同的动态委托来减少样板代码但UnmanagedFunctionPointer要求委托类型在编译期确定动态生成的委托没有稳定的函数指针easyHook 拿到地址后会在调用时崩溃。老老实实给每个 API 写一个静态委托不要在这个地方做过度抽象。5. 避坑清单CLR 注入失败、进程闪退、钩子不触发的排查记录5.1 现象RemoteHooking.Inject 返回后目标进程直接闪退原因大部分是位数不匹配或者目标进程缺少 VC 运行库。easyHook 的原生 DLL 依赖msvcr100.dll或更高版本的 VC Runtime精简版 Windows 上特别容易缺这个。另一个原因是目标进程有Image File Execution Options调试器附加注入器没有管理员权限时会被系统拦截。解决先用 x64/x86 配对确认位数再安装对应版本的“Visual C Redistributable”。如果目标进程是管理员权限启动的注入器也必须以管理员身份运行否则CreateRemoteThread会在权限检查处直接失败表现就是注入后目标进程闪退。5.2 现象注入时报 “CLR injection failed” 或 “Access is denied”原因目标进程可能是一个已经退出的僵尸 PID也可能是一个受保护进程比如开启了 Protected Process Light 的系统服务。easyHook 的注入机制需要向目标进程写入内存并创建远程线程这类进程会拒绝操作。解决先用任务管理器确认目标进程还活着并且在“详细信息”里看一下是否标了“受保护”。对于普通业务软件最常见原因其实是 PID 过期先打开目标程序再解析 PID不要从配置文件里拿一个早已退出的旧 PID。然后右键注入器“以管理员身份运行”一般Access is denied就消失了。5.3 现象钩子回调里只写了日志都能卡死目标界面原因钩子回调运行在目标进程的某个线程上如果你在回调里调用Control.Invoke或者Dispatcher.BeginInvoke并从 UI 线程同步等待结果而 UI 线程恰好又在调用被钩住的 API就构成了死锁。日志本身不慢慢的是你把日志写到了网络磁盘或者同步 IO 流上。解决回调里不要做任何 UI 操作。先把日志写到内存队列再由后台线程批量 flush 到本地文件。如果一定要刷新界面用PostMessage或ThreadPool.QueueUserWorkItem异步通知绝不等待回调结果。这个习惯在上位机场景里特别重要否则你会看到目标界面每隔几秒就卡顿一次鼠标都拖不动。5.4 现象钩子装了但回调从不触发原因你钩的是导出函数地址但目标程序可能通过Ordinal导入也可能直接调用了该 DLL 内部未导出的实现函数。例如很多程序调kernel32!CreateFileW时真正走的是ntdll!NtCreateFile你钩住 kernerl32 的导出表某些调用路径根本不经导出表。解决先用 API Monitor 或 Process Monitor 确认目标进程确实调用到了你钩的那个导出函数。如果是导入表 Ordinal 调用easyHook 的GetProcAddress拿不到有效地址你需要改用入口点钩子方案或者换一个更底层、更稳定的 API 来钩。实际开发里我一般先写一个探针进程反复调用目标 API确认钩子能触发再去接真实业务避免陷入“我代码没问题为什么没反应”的玄学排查。6. 进阶自验探针定时器、热卸载与“先日志后拦截”的习惯6.1 用探针定时器验证钩子是否真正生效跨进程注入之后你无法直接看到目标进程内部的输出所以要在钩子代码里加一个自动探针。这个探针会定时调用被钩住的 API并把结果和期望值对比这样钩子是否生效就有了客观依据。private static System.Threading.Timer _probeTimer; private static readonly object ProbeLock new object(); private static void StartProbe() { _probeTimer new System.Threading.Timer(_ { lock (ProbeLock) { uint result GetCurrentProcessId(); if (result 0xDEAD) { Console.WriteLine([probe] 钩子生效返回值 0xDEAD); } else { Console.WriteLine($[probe] 钩子未生效真实 PID {result}); } } }, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); }_probeTimer必须存成字段否则定时器对象被垃圾回收后回调不再执行。探针定时器只用于验证阶段不要放在生产环境里一直跑它会额外制造每秒几次的 API 调用拉高目标进程的 CPU 占用。6.2 热卸载钩子的正确姿势需要让钩子停下来时调用LocalHook.Dispose()即可但要保证不是在钩子回调自身里调用。如果你从注入器的 IPC 通道收到命令最好把“卸载”动作丢给一个新的ThreadPool线程执行而不是在目标进程的任意线程上执行。ThreadPool.QueueUserWorkItem(_ { _hook?.Dispose(); _hook null; });这样能避免目标线程正在执行回调时释放LocalHook造成访问违例。卸载之后目标进程的 API 恢复原样但如果你再次注入同一个进程此前注册的静态委托可能会残留必要时先重启目标进程再注入。6.3 我一线的落地习惯我自己做这类工具的习惯是先让钩子回调只打日志跑满一天确认它既不影响性能也不触发异常再往上加参数改写和返回值篡改。别一上来就做拦截逻辑否则出了问题你分不清是 easyHook 的锅还是业务代码的锅。这个库真正适合的是自动化测试、辅助功能和诊断工具而不是绕过商业软件授权这类灰色需求。如果你能从 GetCurrentProcessId 换成自己业务里的关键 API再把回调里的 Console.WriteLine 换成结构化日志文件这就已经是一个能交付的埋点方案了。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
FLUKE 1775电能质量诊断实战指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:25:00
揭秘io4cj测试体系:HLT、LLT、UT与FUZZ模糊测试四层保障实战解析 揭秘io4cj测试体系:HLT、LLT、UT与FUZZ模糊测试四层保障实战解析 【免费下载链接】io4cj 一个IO处理库 项目地址: https://gitcode.com/Cangjie-TPC/io4cj
io4cj 是仓颉开源 IO 库(定位对标知名 Okio),它的可靠不只是代码优… · 2026/9/25 1:25:00
格行SP970随身WiFi刷机全攻略:去云控、备份与救砖 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:25:00
ODAC1120320Xcopy_32bit:可复位Oracle连接基线环境详解 简介:本资源是面向.NET开发者与Oracle数据库运维人员的32位ODAC远程连接环境配置包,专为解决Windows平台下C#、ASP.NET等应用稳定连接Oracle数据库的部署难题。包内含ODAC 11.2.0.3.20核心组件(OLEDB、Oracle Managed Data Access、ASP.NET适… · 2026/9/25 4:23:47
J-Link隐藏技能:用VCOM虚拟串口一根线搞定调试与日志 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 4:23:47
videocache4cj LRU缓存清理策略详解:TotalSize与TotalCount怎么选 videocache4cj LRU缓存清理策略详解:TotalSize与TotalCount怎么选 【免费下载链接】videocache4cj 一个支持边播放边视频缓存库,输入视频的URL就可方便快捷的实现视频边下边播功能 项目地址: https://gitcode.com/Cangjie-TPC/videocache4cj video… · 2026/9/25 4:23:41
Cobalt Strike 4.5部署配置与红队实战避坑指南 简介:Cobalt Strike 4.5是面向渗透测试、红队评估与安全研究的C2框架,支持HTTP/HTTPS/DNS/SMB等多种协议上线主机,内置提权、凭据导出、端口转发、Socket代理、Office攻击、文件捆绑、钓鱼等功能,并可调用Mimikatz等外部工具完成内… · 2026/9/25 4:23:35
宇树G1机器人SSH远程连接与网络调试实战指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 4:23:29
创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:31
MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:37