ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# EasyHook实战:DLL注入与API钩子实现send/recv监控

C# EasyHook实战:DLL注入与API钩子实现send/recv监控 简介EasyHook 是 C# 环境下实现运行时远程函数拦截与注入的成熟库这份 demo 面向需要监控、调试或修改其他进程行为的 .NET 开发者覆盖钩子创建、安装、回调与 DLL 注册等核心流程。包内含 91 个文件以 cs 源文件、dll 库文件、exe 可执行程序及 config 配置为主另带 pdb 调试符号与工程文件压缩包仅 854KB适合快速上手与二次开发已有 912 人下载学习。示例演示了拦截 user32.dll 中 ExitWindowsEx 方法的具体写法包括 LocalHook.Create、SetExclusiveACL、Install 等关键 API 的使用并在回调中实现退出拦截。资源还提供 WindowsFormsApplication8、ClassLibrary1 等可运行配套工程便于对比注入端与被注入端代码理解签名、线程 ACL 与钩子注入机制。对想用 C# 进行 API Hook、RPC 调用或程序行为分析的开发者这份压缩包含清晰的工程示例与 DLL 签名提示是直接可用的参考资料。1. C# EasyHook 到底能干什么先搞清楚 DLL 注入和 API 钩子C# 的 EasyHook 是一个能在纯托管代码里完成 DLL 注入和 API 钩子的库最常见的落地场景是给没有源码的程序补监控、给协议调试加日志、给上位机软件做接口插桩。我第一次认真用 EasyHook是在一个只有 exe 的旧组件上它内部走 TCP 和外部设备通信但看不到收发内容与其反复抓包不如直接钩住 ws2_32.dll 的 send/recv把数据在 C# 里打出来。这个思路后来被我复制到很多地方私有协议解析、测试桩、进程行为审计都适用。它解决的核心问题就一句话在函数被调用的瞬间插一段自己的 C# 代码拿到参数、返回值再决定放行还是篡改。适合已经能写简单 C# 程序、但卡在“怎么进到别人进程里”这一步的人不需要先把 C# 高级编程整本啃完。2. 把 EasyHook 接入 C# 工程环境配置与第一个本地钩子2.1 为什么不建议一上来就碰 RemoteHookEasyHook 分两层最常用的是LocalHook它只钩当前进程内的 API不需要额外服务、不需要管理员权限一个控制台程序就能跑通。RemoteHook是把 DLL 注入到别的进程后再钩听起来更厉害但它依赖一个以管理员权限运行的 Windows 服务还牵扯目标进程位数、CLR 版本、权限级别一大堆问题。我见过不少人第一次装 EasyHook 就直接去注入外部进程结果卡在服务安装上两小时最后发现其实连LocalHook的机制都没理解。所以我强烈建议先用 LocalHook 把“钩子怎么装、回调怎么触发、原函数怎么放行”这条链路跑通再决定要不要碰远程注入。多数上位机监控需求其实用 LocalHook 就能覆盖一半场景监控的是自己进程里的 API 行为。安装很直接NuGet 包名就叫EasyHook。装完后项目里会出现三个关键文件EasyHook.dll托管主库、EasyHook32.dll和EasyHook64.dll原生注入模块。如果要用远程注入还会多一个EasyHookSvc.exe服务程序。用 LocalHook 时只要确保两个原生 DLL 被复制到输出目录就行我在 .NET Framework 4.x 工程里一般会把平台目标从 AnyCPU 改成 x64 或 x86 明确指定避免运行时去猜位数。2.2 最小 Demo钩住 MessageBoxW下面这个例子是你能写出来的最简单的完整钩子。它钩住user32.dll的MessageBoxW在弹窗出现前在控制台打印一句日志然后原样放行。using System; using System.Diagnostics; using System.Runtime.InteropServices; using EasyHook; namespace EasyHookDemo { public class MessageBoxHook { // 钩子实例必须保存为字段防止被 GC 回收 private LocalHook _hook; // 与 user32!MessageBoxW 完全一致的委托签名 private delegate int MessageBoxWDelegate(IntPtr hWnd, string text, string caption, uint uType); public void Install() { IntPtr module GetModuleHandle(user32.dll); IntPtr addr GetProcAddress(module, MessageBoxW); if (addr IntPtr.Zero) throw new InvalidOperationException(GetProcAddress failed: Marshal.GetLastWin32Error()); // 创建本地钩子回调是当前类里的实例方法 _hook LocalHook.Create(addr, new MessageBoxWDelegate(OnMessageBoxW), this); // 0 是当前线程的特殊值这里排除当前线程其他线程的弹窗都会进回调 _hook.ThreadACL.SetExclusiveACL(new[] { 0 }); } private int OnMessageBoxW(IntPtr hWnd, string text, string caption, uint uType) { Console.WriteLine($[Hook] 进程 {Process.GetCurrentProcess().Id} 弹窗{caption} - {text}); // 直接调用原 APIEasyHook 会在当前线程做重入识别不会再次进入本回调 return MessageBoxW(hWnd, text, caption, uType); } [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern IntPtr GetModuleHandle(string name); [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern IntPtr GetProcAddress(IntPtr module, string name); [DllImport(user32.dll, CharSet CharSet.Unicode)] private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint uType); } }这段代码有几个必须说明的细节。delegate的签名必须和 C 层的函数原型严格一致MessageBoxW的参数是 HWND、LPCWSTR、LPCWSTR、UINT对应到托管就是 IntPtr、string、string、uint这里错了不会在编译期报警只会在运行到弹窗时把栈搞崩。LocalHook.Create传入的回调对象this会在回调触发时作为上下文带回后续可以从HookRuntimeInfo里取但这里先不管它。ThreadACL是 EasyHook 的线程过滤机制SetExclusiveACL(new[] { 0 })表示“除了当前安装线程其余线程全部生效”这个设计在 Demo 阶段非常重要它阻止了钩子安装过程自身触发回调造成递归。如果你希望当前线程也生效把线程 ID 换成进程内枚举出来的其他线程即可。2.3 部署细节原生 DLL 与依赖项处理很多新手第一次运行就报BadImageFormatException或者提示找不到 EasyHook32.dll。这类问题的根源通常是平台位数错位。EasyHook 托管部分编译成 AnyCPU 没问题但原生部分是独立文件x64 进程要加载 EasyHook64.dllx86 进程要加载 EasyHook32.dll。项目输出目录里必须同时保留这两个文件EasyHook 会根据当前进程位数自动挑。另外如果你用的是 .NET Core 或 .NET 53.x 版本的 EasyHook 很容易撞上原生加载问题我的一般做法是老老实实在 .NET Framework 4.7.2 或 4.8 的控制台/WinForms 工程里跑先验证逻辑再考虑迁移。安装钩子后不是立刻就能触发EasyHook 确实要求你显式设置一次 ThreadACL否则钩子不会被激活。很多 demo 代码里只用SetExclusiveACL但没解释原因这里补一句这是 EasyHook 故意设计的保护措施目的是避免还没初始化完就被系统 API 回调。所以凡是创建了LocalHook的地方紧跟着必须调用一次 ACL 设置这是硬性要求不是可选项。3. 实际拦截业务 API以监听端口程序的 send/recv 为例3.1 确定要 Hook 的 API 和签名钩完 MessageBoxW 之后你应该明白“钩子”本质上是替换函数入口的前几条指令让它跳到你的托管回调。可业务场景里没有人会去钩弹窗玩大家真正关心的是网络收发、文件读写、注册表操作这些数据敏感 API。这里选网络收发做例子是因为很多监听端口程序、上位机通信模块本质都是一堆 socket 调用而抓包工具给不出进程内部的语义信息。比如设备主动上报一条报文抓包能看到 TCP 流但分不清是哪条业务线程发的、对应哪个 socket。钩住 send/recv 后你可以在回调里拿到 socket 句柄、数据缓冲区、长度和 flags实时串成完整的通信记录。先看签名。ws2_32.dll里的send原型是int send(SOCKET s, const char* buf, int len, int flags);对应到 C# 委托时最保险的方式是去掉char*的字符串思维直接用IntPtr接收buflength 用 int。不要试图在委托签名里写byte[]虽然 P/Invoke 在调用方可以帮你自动封送但钩子回调发生在原生调用栈上EasyHook 是按纯指针语义去对你的委托参数的一旦参数布局不合就不是崩溃的问题而是直接破坏被钩进程的栈。用 IntPtr 是零风险方案这也是 EasyHook 官方示例一贯的写法。3.2 钩住 send 和 recv完整代码下面这个类实现了对进程内所有 socket 收发数据的拦截。安装线程会被排除业务线程照常触发。using System; using System.Runtime.InteropServices; using EasyHook; public class SocketHook { private LocalHook _sendHook; private LocalHook _recvHook; private delegate int SendDelegate(IntPtr socket, IntPtr buffer, int length, int flags); private delegate int RecvDelegate(IntPtr socket, IntPtr buffer, int length, int flags); public void Install() { IntPtr ws2 GetModuleHandle(ws2_32.dll); IntPtr sendAddr GetProcAddress(ws2, send); IntPtr recvAddr GetProcAddress(ws2, recv); if (sendAddr IntPtr.Zero || recvAddr IntPtr.Zero) throw new InvalidOperationException(ws2_32 API address not found); _sendHook LocalHook.Create(sendAddr, new SendDelegate(OnSend), this); _recvHook LocalHook.Create(recvAddr, new RecvDelegate(OnRecv), this); // 安装线程排除其余线程全部监听 _sendHook.ThreadACL.SetExclusiveACL(new[] { 0 }); _recvHook.ThreadACL.SetExclusiveACL(new[] { 0 }); } private int OnSend(IntPtr socket, IntPtr buffer, int length, int flags) { byte[] data new byte[length]; Marshal.Copy(buffer, data, 0, length); Console.WriteLine($[send] socket{socket} len{length}, first16{BitConverter.ToString(data, 0, Math.Min(16, length))}); // 放行调用原始 send return send(socket, buffer, length, flags); } private int OnRecv(IntPtr socket, IntPtr buffer, int length, int flags) { // 先让数据落到缓冲区 int received recv(socket, buffer, length, flags); if (received 0) { byte[] data new byte[received]; Marshal.Copy(buffer, data, 0, received); Console.WriteLine($[recv] socket{socket} len{received}, first16{BitConverter.ToString(data, 0, Math.Min(16, received))}); } return received; } [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern IntPtr GetModuleHandle(string name); [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern IntPtr GetProcAddress(IntPtr module, string name); [DllImport(ws2_32.dll, SetLastError true)] private static extern int send(IntPtr socket, IntPtr buffer, int length, int flags); [DllImport(ws2_32.dll, SetLastError true)] private static extern int recv(IntPtr socket, IntPtr buffer, int length, int flags); }这里最关键的一点是OnRecv里先调用原始的recv再从 buffer 复制数据。因为 recv 是“写入型”API只有它真正把数据从网络栈拷贝到缓冲区之后你才能拿到有效内容。而OnSend里的 buffer 是调用方传入的数据已经就位所以可以在放行前直接拷贝。复制时我做了长度截断避免用BitConverter.ToString把超大报文整个打出来刷屏。日志里带上 socket 句柄是为了后续按连接维度做聚合同一个 socket 的 send/recv 在测试网络通信问题时非常有用。如果要把这个 Demo 改造成监听端口程序你需要做的不是钩listen/accept——监听端口是服务端的事数据收发仍然发生在 send/recv。更实用的做法是再钩一个closesocket当它被调用时把所有缓冲掉的数据落盘。closesocket 的签名同样简单int closesocket(IntPtr socket)钩子回调里取当前 socket 的日志队列导出即可。3.3 从回调拿出有效数据指针读取与长度控制回调里拿到的 buffer 是一个非托管指针你绝对不能在回调里直接去遍历指针。标准做法是用Marshal.Copy一次性拷到 byte[]再走托管逻辑。注意两个容易翻车的地方第一是 length 可能不可信如果 API 调用方传了一个坏长度new byte[length]会抛异常而这个异常会沿着钩子传播到原生调用栈上后果通常是进程直接退出。我一般会在回调外再包一层 try/catch只记录异常不中断原 API。第二是 send 函数实际发送的字节数并不一定等于 length返回值为 -1 时要用Marshal.GetLastWin32Error()查原因。这里体现出一条原则钩子回调里的一切操作都要极力降低对原程序的干扰宁可丢日志也不能抛异常。4. 权限、位数与签名EasyHook 常见问题与避坑排查4.1 钩子一装上进程就崩溃报访问冲突现象创建LocalHook之后目标 API 第一次被调用进程直接挂掉事件日志里是0xC0000005访问冲突。 原因最常见的是委托签名和原生 API 不匹配。C 层的int send(SOCKET, const char*, int, int)你非写成int send(IntPtr, byte[], int, int)托管调用约定和栈布局对不上原函数返回时栈指针已经偏了。其次是GetProcAddress返回空但你没判断就传给LocalHook.Create。 解决先把签名核对表列出来指针就是 IntPtr整数就是 int布尔就是 int套接字句柄就是 IntPtr不要用自定义结构体。创建钩子前对addr IntPtr.Zero做一次显式判断。如果换了签名仍崩把目标 API 换成 MessageBoxW 跑通一遍确认是环境问题还是签名问题。4.2 钩子装完了回调一次都没触发现象程序正常运行日志文件里一条钩子记录都没有。 原因大概率是 ThreadACL 把真正干活儿的线程排除了。SetExclusiveACL(new[] { 0 })排除的是安装钩子的线程如果你的业务线程恰好和安装线程是同一个或者 API 只在某个固定线程上调用你把它排掉了自然不触发。 解决把 ACL 改成SetInclusiveACL把目标线程的 ID 放进去。不知道线程 ID 就先在回调里打日志看Thread.CurrentThread.ManagedThreadId但注意 ManagedThreadId 不是 OS 线程 ID要不直接先SetExclusiveACL(new int[0])让所有线程进入回调观察日志里出现哪些线程名再收窄到具体的 ACL 范围。这里的教训是ACL 配置本身就成了业务逻辑的一部分调试钩子第一步永远是验证 ACL。4.3 远程注入时提示安装服务失败现象使用RemoteHook注入到其他进程异常信息里出现Failed to install service或Service already running。 原因EasyHook 远程注入依赖一个名为 EasyHookSvc 的 Windows 服务这个服务必须在管理员权限下完成注册和启动。如果你的宿主程序没有以管理员身份运行或者杀毒软件拦截了服务创建都会报这个错。 解决把入口程序设置为requireAdministrator右键以管理员身份运行一次让服务自动注册。如果已经注册过但状态异常在服务管理器里找到 EasyHookSvc 手动重启。注意第三方杀毒软件经常把 EasyHook 的注入行为判定为风险操作如果你只是在自己机器上做开发验证把项目目录加入排除列表能省很多时间。4.4 远程注入到别的进程提示 Access Denied现象服务安装成功但注入时抛异常说没有权限访问目标进程。 原因目标进程要么是更高权限级别的进程比如以管理员运行的另一个程序要么是系统级进程要么是位数不一致。32 位程序注入 64 位进程会直接失败这不是权限问题是架构问题。 解决把注入程序和宿主程序都统一为 x64或者都统一为 x86确保注入程序以管理员身份运行尽量先拿一个自己写的测试进程练手不要一上来就处理系统进程。4.5 回调里做了 UI 操作整个程序卡死现象钩子回调里更新界面控件比如把日志写进 ListView程序过一会儿就假死或者直接死锁。 原因回调运行在被钩 API 的调用线程上很多 API 是在 UI 线程或系统工作线程里调用的你在回调里执行阻塞操作、弹窗、等待锁就等于在别人家的厨房里动火必然把整个消息循环堵住。比如在 send 的回调里弹MessageBox如果此时 UI 消息循环被 send 的调用方线程占用就会互相等待。 解决回调里永远只做两件事把数据拷贝出来、把数据放进无锁队列。消息框、日志写入、界面刷新全部丢给独立后台线程。这也是 EasyHook 实战项目里最常见的架构回调负责采集队列负责缓冲消费线程负责展示。5. 从 Demo 到实战线程安全与钩子生命周期5.1 回调的线程模型你永远不知道下一帧在哪个线程EasyHook 的回调不是统一在某个固定线程跑的它直接寄生在目标 API 调用方的线程上。在 send/recv 场景里有的 socket 由线程 A 发数据、线程 B 收数据两个回调可能同时执行。如果按 Demo 里那样直接Console.WriteLine并发一高控制台输出会交错报文字符串会被撕开。我把回调里的操作分成两类纯函数操作和外部副作用操作。纯函数操作包括 Marshal.Copy、CPU 计算、入队这些可以安全地在任意线程做外部副作用操作包括文件写入、UI 刷新、日志输出这些必须收口到固定消费线程。这条规则比任何锁都重要。5.2 用 ConcurrentQueue 缓冲避免在回调里做 IO下面这段代码是把上一章的 SocketHook 改造成线程安全版本的核心。回调里只负责拷贝字节并入队后台线程每 50 毫秒批量落盘一次。using System; using System.Collections.Concurrent; using System.IO; using System.Threading; using System.Threading.Tasks; public class SocketLogCollector { private readonly ConcurrentQueueSocketLogEntry _queue new ConcurrentQueueSocketLogEntry(); private readonly CancellationTokenSource _cts new CancellationTokenSource(); private readonly string _logPath; public SocketLogCollector(string logPath) { _logPath logPath; Task.Run(() FlushLoop(_cts.Token)); } public void Enqueue(string direction, IntPtr socket, byte[] data) { _queue.Enqueue(new SocketLogEntry { Direction direction, Socket socket, Data data, Timestamp DateTime.UtcNow }); } private void FlushLoop(CancellationToken token) { while (!token.IsCancellationRequested) { while (_queue.TryDequeue(out SocketLogEntry entry)) { File.AppendAllText(_logPath, ${entry.Timestamp:O} {entry.Direction} socket{entry.Socket} len{entry.Data.Length}{Environment.NewLine}); } Thread.Sleep(50); } } public void Dispose() { _cts.Cancel(); // 落盘剩余日志 while (_queue.TryDequeue(out SocketLogEntry entry)) { File.AppendAllText(_logPath, ${entry.Timestamp:O} {entry.Direction} len{entry.Data.Length}{Environment.NewLine}); } _cts.Dispose(); } public class SocketLogEntry { public string Direction; public IntPtr Socket; public byte[] Data; public DateTime Timestamp; } }ConcurrentQueue是无锁的入队操作在回调线程上不会长时间阻塞。FlushLoop用TryDequeue批量清空队列再一次性写入文件把高频钩子回调产生的碎片 IO 合并成了低频批量 IO。Dispose 里在取消令牌之后又做了一次清空是为了保证程序退出时最后几条日志不丢。这里要注意File.AppendAllText每次都会开文件日志量大时仍然拖速度更极致的做法是攒够 100 条或 1KB 再写但这个粒度对绝大多数监控程序已经够用。5.3 钩子的生命周期LocalHook 必须保持强引用我见过最多的“钩子时灵时不灵”问题根因是LocalHook对象被 GC 回收了。EasyHook 的钩子安装之后你在代码里创建的LocalHook实例如果失去了强引用下一轮垃圾回收就会把钩子卸载但被替换的 API 入口不一定能立刻恢复程序可能继续跑只是不再触发回调也可能在某个瞬间崩溃。正确做法是把所有 Hook 实例保存在和程序生命周期等长的字段里比如静态变量、窗体成员变量。如果你在一个方法里局部创建了LocalHook方法一结束钩子就没了这对刚上手的人来说非常反直觉。5.4 ThreadACL 的动态控制ThreadACL 不只是安装时设一次就完它在运行期还能改。比如你要做动态监控开关默认不钩业务线程等用户点击“开始监听”时再调用SetInclusiveACL加入当前所有工作线程。反过来停止监听时调用SetExclusiveACL把所有线程排除。这个能力在监听端口程序里特别有用能实现在线启停而不影响被监控进程。我用过的顺手的做法是钩子常驻ACL 作为开关。这样避免了反复创建销毁钩子的开销也少了很多卸载竞态问题。5.5 多钩子协调同一个调用链上的 API 怎么避免双重日志如果同时钩了send和WSASend而程序内部先调用WSASend再由它调用send你会在两个回调里拿到同一份数据。解决办法是给日志条目加一个去重时间窗同一 socket、同一长度、间隔 1 毫秒内的数据只保留一条。更多情况下我建议先只钩最底层的 API也就是所有上层变体最终都会汇聚的那个入口。在 ws2_32 里send和WSASend是两个并列入口基础版程序往往只调其中一种你先钩出来抓真实日志看看走了哪条再去补另一条。6. 进阶重入保护与钩子生效验证的两个实用技巧6.1 用线程静态标志做重入保护EasyHook 对同一线程的递归调用有基本保护但在某些间接回调场景下仍然不可靠。比如你钩了recv而某条业务逻辑在 recv 的调用链里又主动调了一次 recv这是同一线程嵌套你无法保证 EasyHook 的跳板每次都正确识别。我的做法是给回调加一道自旋锁式的标记[ThreadStatic] private static bool _inCallback; private int OnRecv(IntPtr socket, IntPtr buffer, int length, int flags) { if (_inCallback) return recv(socket, buffer, length, flags); _inCallback true; try { int received recv(socket, buffer, length, flags); if (received 0) { byte[] data new byte[received]; Marshal.Copy(buffer, data, 0, received); _collector.Enqueue(recv, socket, data); } return received; } finally { _inCallback false; } }[ThreadStatic]保证每个线程都有自己的标志位不会互相污染。进入回调先检查标志若已处于回调中就直接调用原函数避免套娃。这个技巧在钩CreateFile、RegQueryValueEx这类可能被系统组件间接调用的 API 时尤其重要不加这层保护调试一次递归事故的时间足够你再写完三套监控程序。6.2 钩子安装后的自检流程我每次装完钩子都会跑一遍自检不验证就上线迟早要出事故。自检逻辑很简单安装钩子后开一个不被 ACL 排除的线程主动调用一次目标 API然后检查日志队列里是否有对应记录。比如用 MessageBoxW 做目标时自检线程弹一个无边框测试窗自动在回调里关闭用 send 做目标时创建一个 UDP socket 向 127.0.0.1 发一个字节看回调里有没有捕获到。这个习惯是从一次翻车里捡回来的我有一次把SetExclusiveACL和SetInclusiveACL用反了监控程序上线后整整一上午一条日志都没有后台线程和消费逻辑全部白跑。从那以后每个钩子安装完成的第一个动作永远是主动调一次目标 API看到自检日志进入队列才敢说钩子真正生效。希望这个习惯也能帮到你少走弯路。本文还有配套的精品资源点击获取
返回列表