ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Detours Hook实战:拦截ws2_32.dll的send函数并记录发送数据

Detours Hook实战:拦截ws2_32.dll的send函数并记录发送数据 简介面向 Windows 网络编程与系统安全开发者的 ws2_32.dll 函数拦截示例围绕拦截 send() 并将发送内容写入 OB 文件展开演示 API 钩子、IAT 替换等常见 hook 思路适合需要做网络监控、安全审计或数据抓取的进阶开发者学习。压缩包共 23 个文件包含 VC6.0 工程源码cpp、h、dsp/dsw、编译生成的 dll 与 lib、obj/pdb 等中间符号文件以及说明 txt整体约 898KB目录结构简洁便于直接打开工程阅读和调试。已有 1259 人学习下载。通过这份资料可以拿到完整的 send 拦截工程骨架、关键代码实现和生成物能结合源码理解 send() 参数含义、HOOK 时机、自定义回调逻辑以及如何把截获数据追加写入文件对研究 Winsock 底层调用链和后续扩展 recv() 拦截也有直接参考价值。1. 拦截 ws2_32.dll 的 send先搞清楚这个 ob 文件到底从哪来排查 Windows 下网络程序的问题时最让人头疼的就是“数据发出去没、发的内容对不对”。抓包工具能看到包但看不到应用层与 socket 之间的那一段日志打了又怕日志本身有误。我常干的一件事就是把 ws2_32.dll 的 send 函数拦下来把发送内容原样写到 ob 文件里也就是这种场景进程里的某个模块突然往外发了一串不明数据或者你怀疑自己写的客户端把拼接的报文发错了。这个项目不是新框架就是把 send 函数钩住记录每次发送的长度和原始字节。适合做客户端开发、协议调试、接口联调时快速定位发送侧问题的人也适合刚接触 Windows Hook 想找一个完整落地例子的新手。2. Hook 原理与函数签名动手前先把两条关键链路理清2.1 三种挂钩方式为什么我选了 inline hook拦截 ws2_32.dll 的 send网上常见三种思路转发器替换、IAT Hook、Inline Hook。前两种经常被混淆先分清。转发器替换是在 DLL 导出层面动手做一个同名 DLL 顶替 ws2_32.dll把 send 的导出地址指向你的实现。这个方案在早期 Win9x 时代很流行但现在的系统对 Known DLL 有保护替换整个 ws2_32.dll 需要处理系统文件权限和签名校验风险高很容易把系统搞挂我基本不碰。IAT Hook 是改调用方的导入地址表。进程加载时会把 ws2_32.dll 的 send 函数地址填进自己的 IAT我们把这一项改成自己的函数地址。这个方式改的是“调用方视角”不是 ws2_32.dll 本身。问题在于如果程序不是通过 IAT 调用 send而是运行时用 GetProcAddress 拿地址或者 ws2_32.dll 内部互相调用IAT Hook 就拦不住。Inline Hook 是直接改动 ws2_32.dll 中 send 函数开头几个字节改成一条跳转指令跳到自己的函数。无论对方怎么拿到 send 的地址只要落在原函数入口就会被劫持。缺点是要求你对目标机器指令长度、x86/x64 调用约定有把握好在有 Detours 这类库把修补细节封装好了。我给自己的程序和给别人做的排查工具都是优先用 Inline Hook。2.2 send 与 recv 的函数签名参数含义决定怎么记日志要拦截先要把原型背清楚。send 的签名是这样的int send(SOCKET s, const char* buf, int len, int flags);s 是 socket 句柄buf 是要发送的数据缓冲区len 是发送长度flags 一般传 0少数场景传 MSG_DONTWAIT 或 MSG_NOSIGNAL。返回值是实际发送的字节数失败返回 SOCKET_ERROR也就是 -1。拦 send 时我们最关心的参数是 buf 和 len直接 fwrite 下去就是完整的发送内容。recv 的签名几乎对称int recv(SOCKET s, char* buf, int len, int flags);区别在于 buf 是接收缓冲区由调用方分配recv 负责往里面填数据。所以拦 recv 时必须等 recv 返回之后再去读 buf返回 n 才是真正写入的字节数。而且 recv 可能因为缓冲区大于实际数据buf 里混着旧内存残留只读前 n 个字节就好。这两个签名在 32 位和 64 位下参数传递方式不一样。x86 下是栈传参x64 下前四个参数走 rcx、rdx、r8、r9。如果不用 Detours 而自己写裸汇编跳转注意区分x64 下原函数前几条指令可能包含参数传递指令不能简单覆盖 5 字节就完事。用 Detours 可以回避这个坑但你要知道它替你处理了什么。2.3 到底拦什么send 与 WSASend 的关系另一个容易忽略的点现在很多程序其实不走 send而是走 WSASend。WSASend 是 Winsock2 的异步发送接口签名是int WSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);它的数据通过 WSABUF 数组传递而不是单个 char*。如果你的目标程序是用 WSASend 发数据只挂 send 会什么都捞不到。常见的做法是 send 和 WSASend 都挂recv 和 WSARecv 也一起挂。后面章节先以 send 为主线讲完整流程扩展部分再补 WSASend 怎么处理多缓冲区。3. 完整实现Detours 挂接 send 与落盘 ob 文件3.1 环境准备与工程结构我用的方案是微软 Detours 库版本用 4.x。它能做函数尾跳、事务化安装和卸载比裸写 jmp 稳妥。工程结构上我建议建一个 DLL 工程通过注入方式挂到目标进程。这里有个关键认知Hook 代码必须跑在目标进程里不能单独做一个 exe 去改别的进程的 ws2_32.dll 内存那就成了外挂注入。合理做法是写一个 DLL被目标进程加载后在 DllMain 里装 Hook。# 环境准备以 Visual Studio 为例 # 1. 安装 Detours拿到 detours.h、detours.lib # 2. 新建一个 DLL 工程字符集选多字节或 Unicode 都行 # 3. 链接时加上 detours.lib # 4. 工程配置 - C/C - 代码生成 - 启用函数级链接是 (/Gy)Detours 的安装不是简单地 memcpy 修改字节它内部要申请内存、写跳转指令、处理原函数前几条指令的重定位。启用函数级链接是为了让 detours.lib 里的代码能正确地做函数级拆分避免 link 阶段把无关代码合并进来导致跳转偏移计算错误。这一步经常被忽略但真有人因为没开 /Gy 出现随机崩溃。3.2 DllMain 与安装时机DllMain 里做 Hook 安装有讲究。DETOUR 的事务操作要放在 DLL_PROCESS_ATTACH 里但不要在 DllMain 里做太重的事。Detours 官方示例都是在 DllMain 里直接调 DetourTransactionBegin但这个事务内部会遍历线程、修改内存保护属性一旦触发 loader lock 嵌套容易死锁。常见做法是只在 DllMain 里记录状态用第一个 send 调用来做懒安装但 Detours 自己处理了大部分同步问题所以直接写在 DllMain 里也可以前提是不要在里面 fopen、不要 WaitForSingleObject。#include windows.h #include detours.h #include stdio.h typedef int (WINAPI* SendFn)(SOCKET s, const char* buf, int len, int flags); // 保存原始函数地址 static SendFn RealSend NULL; // 日志文件句柄延迟到首次调用时打开 static FILE* g_logFile NULL; // 目标进程里打开日志文件 static void OpenLogFile() { if (g_logFile ! NULL) return; // 写到 exe 同目录下避免权限问题 char path[MAX_PATH] { 0 }; GetModuleFileNameA(NULL, path, MAX_PATH); char* dot strrchr(path, .); if (dot) *dot 0; strcat_s(path, _out.ob); g_logFile fopen(path, ab); }这里用 GetModuleFileNameA 拿目标进程 exe 的路径然后把扩展名替换成 _out.ob。为什么不用固定 C 盘路径因为目标进程可能是普通权限启动往 C 盘根目录写会被拦截写到 exe 同目录权限和路径都有保证。文件打开模式用 ab 追加二进制保证多次注入或多次运行不会覆盖旧日志。3.3 替换函数与写入 ob 文件替换函数是核心。它要做的三件事记录原始数据、调用原始 send、再记录返回值。顺序上有个细节是先写日志再调用原函数还是先调用再写我的建议是先写原始参数再调真实 send。因为 send 返回后buf 的内容理论上不该被修改但极端情况下对方可能复用缓冲区先写更保险。// 我们的替换函数 int WINAPI HookSend(SOCKET s, const char* buf, int len, int flags) { // 打开日志首次调用时初始化 OpenLogFile(); // 写本次发送的元信息和原始字节 if (g_logFile ! NULL) { DWORD tid GetCurrentThreadId(); ULONGLONG ts GetTickCount64(); fprintf(g_logFile, [T:%lu|TS:%llu|SOCK:%d|LEN:%d] , tid, ts, (int)s, len); fwrite(buf, 1, len, g_logFile); fwrite(\r\n, 1, 2, g_logFile); fflush(g_logFile); // 强制落盘防止进程崩溃丢日志 } // 调用原始 send int ret RealSend(s, buf, len, flags); // 可选把返回值也记上 if (g_logFile ! NULL) { fprintf(g_logFile, [RET:%d]\r\n, ret); fflush(g_logFile); } return ret; } // 安装 Hook BOOL InstallHook() { HMODULE hWs2 LoadLibraryA(ws2_32.dll); if (hWs2 NULL) return FALSE; RealSend (SendFn)GetProcAddress(hWs2, send); if (RealSend NULL) return FALSE; LONG result DetourTransactionBegin(); if (result ! NO_ERROR) return FALSE; DetourUpdateThread(GetCurrentThread()); DetourAttach((PVOID)RealSend, HookSend); result DetourTransactionCommit(); return result NO_ERROR; } BOOL WINAPI DllMain(HINSTANCE hinst, DWORD reason, LPVOID reserved) { if (reason DLL_PROCESS_ATTACH) { InstallHook(); } return TRUE; }几个参数再解释下。DetourAttach 的第一个参数是 PVOID* 类型必须传 RealSend 这个静态函数指针的地址Detours 会把它改成原始函数真正入口第二个参数是替换函数地址。这里有个关键RealSend 初始值必须是从 GetProcAddress 拿到的真正入口不能先赋一个空指针再让 Detours 填。事务提交后调用方调 send 时会先进 HookSendHookSend 里调 RealSend 时会走 Detours 生成的跳板绕过我们自己修改的那几条指令回到原函数剩余逻辑。日志格式里我加了时间戳和线程 ID这是排查多线程并发发送问题的关键。为啥不直接只存 buf因为没有线程号和时间两条日志混在一起根本分不清顺序也没法对应到代码里哪个线程发出去的。3.4 注入方式两种落地打法DLL 写好之后必须让目标进程加载它。两种方式最常用一是把 DLL 路径写进注册表 AppInit_DLLs二是用 CreateRemoteThread 配合 LoadLibrary 远程注入。AppInit_DLLs 需要目标进程加载 user32.dll 才会生效而且 Win8 以上默认启用 SecureBoot 签名校验不适合调试工具。我推荐远程注入或者调试器加载。// 远程注入核心逻辑目标进程是 x64 时用 LoadLibraryW HANDLE hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); LPVOID pathAddr VirtualAllocEx(hProcess, NULL, pathSize, MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProcess, pathAddr, dllPath, pathSize, NULL); HMODULE k32 GetModuleHandleA(kernel32.dll); LPTHREAD_START_ROUTINE loadLib (LPTHREAD_START_ROUTINE)GetProcAddress(k32, LoadLibraryW); HANDLE hThread CreateRemoteThread(hProcess, NULL, 0, loadLib, pathAddr, 0, NULL); WaitForSingleObject(hThread, INFINITE); GetExitCodeThread(hThread, hRemoteRet);注入这块注意位数匹配32 位进程必须注入 32 位 DLL64 位进程必须注入 64 位 DLL。判断 pid 对应进程位数最简单的方法是看它加载的模块里有没有 SysWOW64 目录的文件或者直接用 IsWow64Process。远程线程虽然老派但胜在可控、能看到注入结果比 SetWindowsHookEx 稳。4. 参数调整与扩展从日志落盘到协议过滤4.1 日志格式改造十六进制输出更直观字符串发送场景用 fwrite 直接写没问题但协议类数据经常带不可见字符比如 0x00、0x01写进文件再用记事本打开全是乱码。我一般会加一个十六进制导出模式把每个字节转成两位十六进制同时保留可打印字符的 ASCII 对照。// 把 buf 前 len 个字节按 hex 格式追加到日志 static void WriteHexDump(FILE* fp, const char* buf, int len) { for (int i 0; i len; i) { fprintf(fp, %02X , (unsigned char)buf[i]); if ((i 1) % 16 0) fprintf(fp, \r\n); } if (len % 16 ! 0) fprintf(fp, \r\n); }转换成 hex 后有两个好处一是 0x00 这种字节不会被文本编辑器当作字符串结束符二是方便和 Wireshark 里的 Hex 面板对拍。注意这里强转成 unsigned char 再打印否则 char 类型在 Windows 下默认 signed大于 0x7F 的字节会打印成 FFFFFF80 这种 8 位前缀日志就没法看了。这个坑我在第一次写时踩过整份日志全是重复的 FFFFFF排查了半天才发现是符号扩展问题。4.2 按端口和 IP 过滤只记目标服务的流量有时我们会同时连接多个服务日志里混着不同协议的数据。排查问题时只想看发往某一台服务器的内容这时候需要拿到 socket 对应的远端地址。getsockname 拿本地地址getpeername 拿远端地址。// 获取 socket 对应的远端端口 static int GetRemotePort(SOCKET s) { struct sockaddr_in addr; int addrLen sizeof(addr); if (getpeername(s, (struct sockaddr*)addr, addrLen) 0) { return ntohs(addr.sin_port); } return 0; }拿到了端口在 HookSend 里做个白名单判断只写指定端口的数据。这个过滤放在写日志之前能减少日志体积。但要注意一点getpeername 在 send 调用链里再走一次 Winsock 函数理论上会有性能开销对高频发送场景不友好。还有一种更轻量的方法维护一个 socket - 远端端口的映射表在 connect 和 accept 的 Hook 里更新。不过这个方案会增加代码量一般排查场景用 getpeername 就够了。4.3 扩展拦 recv 与 WSASend一套钩子覆盖双向数据只拦 send 只能看到请求看不到响应。排查协议问题时请求和响应对不上才是常态。扩展 recv 的 Hook 几乎一模一样唯一区别是读缓冲区的时机。recv 返回前buf 是内核在填我们不能抢着读。所以先调用原始 recv拿到返回值 nRet 后再读 buf 前 nRet 个字节。int WINAPI HookRecv(SOCKET s, char* buf, int len, int flags) { int ret RealRecv(s, buf, len, flags); if (ret 0) { OpenLogFile(); if (g_logFile ! NULL) { fprintf(g_logFile, [R|T:%lu|SOCK:%d|LEN:%d] , GetCurrentThreadId(), (int)s, ret); fwrite(buf, 1, ret, g_logFile); fwrite(\r\n, 1, 2, g_logFile); fflush(g_logFile); } } return ret; }WSASend 的区别在于数据不是 buf/len 两个参数而是 LPWSABUF 数组。一个 WSASend 调用可能携带多个缓冲区这是为了减少系统调用次数做分散写。记录时要循环遍历每个 WSABUFint WINAPI HookWSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine) { for (DWORD i 0; i dwBufferCount; i) { if (g_logFile ! NULL) { fprintf(g_logFile, [WS|SOCK:%d|BUF:%d|LEN:%lu] , (int)s, i, lpBuffers[i].len); fwrite(lpBuffers[i].buf, 1, lpBuffers[i].len, g_logFile); fwrite(\r\n, 1, 2, g_logFile); } } fflush(g_logFile); return RealWSASend(s, lpBuffers, dwBufferCount, lpBytesSent, dwFlags, lpOverlapped, lpCompletionRoutine); }注意 lpOverlapped 参数如果程序用了重叠 I/OWSASend 可能是异步的函数返回时数据还没真正发出。严格来说应该等完成回调再记录但那样复杂度骤增。调试场景下我们通常假设异步发送的数据内容不会被调用方立即修改先记录即可。真遇到异步缓冲区复用日志会出现“发送内容已被覆盖”的假象这时候输出里能看到同一地址数据前后不一致我一般会加 buf 地址进日志便于交叉验证。5. 避坑拦截 send 时最容易翻车的五个点5.1 函数签名不匹配导致栈不平衡进程直接崩溃现象Hook 装好后程序一调用 send 就崩或者紧接着的代码数据被莫名改写。原因替换函数和原函数参数不一致。比如把 send 的第三参数 len 误写成 int*调用约定从 WINAPIstdcall写成了 cdecl。调用方压栈后替换函数按错误签名弹栈栈指针错位返回地址被破坏。解决严格按照 ws2_32.dll 导出函数的声明去定义 typedef。send 就是int WINAPI send(SOCKET, const char*, int, int)一个字符都不能差。写完以后先做一次最小测试写一个 exe 循环 send 100 次看是否稳定再挂到真实目标上。5.2 DllMain 里做文件操作导致死锁现象注入后进程启动卡死或者注入成功但永远等不到第一个日志文件生成。原因DllMain 在 loader lock 下执行fopen/fprintf 底层会走堆分配和 CRT 初始化可能和加载器产生锁竞争。如果目标进程在启动早期注入这种死锁概率更高。解决在 DllMain 里只做 DetourTransaction 和地址查找。日志文件的打开挪到 HookSend 第一次被调用时用 OpenLogFile 里面的if (g_logFile ! NULL) return;做一次性初始化。这个模式叫懒初始化虽然第一次 send 会多几毫秒开销但稳。5.3 64 位和 32 位没有匹配Hook 静默失效现象目标进程是 64 位注入 32 位 DLLLoadLibrary 返回非零但 Hook 没生效或者注入时报“模块版本不匹配”。原因64 位进程的导入表和内存布局完全是另一套32 位 DLL 的 Detour 跳转写进 64 位进程的 ws2_32.dll 代码段机器码不兼容。解决先确定目标进程位数。x64 进程必须用 x64 编译的 DLLx86 进程用 x86 版。另外 Detours 的 lib 也要选对应版本链接错 lib 会出现诡异的内存访问冲突。我的做法是工程里同时编两个 DLL注入前用 IsWow64Process 判断后用不同路径加载。5.4 日志只记到一部分怀疑漏数据现象业务逻辑收发 100 条消息ob 文件里只有 30 条且缺的都是某种特定长度或特定 flags 的调用。原因程序可能走了 WSASend 而不是 send。很多开源的 HTTP 库、WebSocket 库在 Windows 上默认走 WSASend 提升性能。如果不知道这一点只挂 send 就等于漏掉大半流量。解决把 send、WSASend、recv、WSARecv 四个函数在同一个 DLL 里全部挂上统一走同一个日志文件。WSASend 的 Hook 要遍历 WSABUF 数组而不是只取第一个缓冲区。这样漏数据的可能性大幅降低剩下能漏的就只有内存映射文件或者内核驱动发送这类极端情况。5.5 日志写入和 flush 时机不对崩溃后文件是空的现象程序运行中文件有内容但崩溃后打开 ob 文件最后几条记录丢失甚至文件大小为 0。原因fwrite 写的是 CRT 缓冲区没有 fflush 就不会立即落盘。默认情况下 stdout 全缓冲文件流也是全缓冲。如果进程是崩溃或者被强杀缓冲区的数据就没了。解决每次写完日志立即 fflush。代价是写入次数多时性能变差对高频发送场景可以把 flush 改成每 N 次刷一次。日志文件打开方式用 ab 追加写FILE* 全局变量不用每次打开关闭但要注意 g_logFile 在多线程下需要加锁否则两条线程同时 fwrite 会交错。我加过 CRITICAL_SECTION 保护日志写入段这个不加的话高并发下日志内容偶发串行。6. 验证与进阶用时间戳和流量对拍确认日志可信Hook 写完了怎么确认日志内容没问题我通常拿 Wireshark 做交叉验证。同一个目标程序先跑一遍Wireshark 抓包同时让我们的 Hook 输出 ob 文件。然后对比两个文件里的数据内容。对比项Hook 日志Wireshark 抓包说明发送数据字节应用层原始 bufTCP 载荷理论上完全一致发送顺序按调用的时间戳按包序号TCP 重传会导致两边数量不一致发送时间GetTickCount64进程内时间pcap 时间戳误差取决于系统时钟本地端口日志里可加 getsockname抓包可见如果没加两边对应不上对拍时重点看两个指标字节数总和一致以及前 20 条记录的顺序一致。TCP 分段可能让一个 send 拆成两个包所以数量不一致不代表 Hook 错字节数总量一致才是硬指标。另外 Wireshark 里如果看到目标端口和日志记录端口不符那不是 Hook 问题是代码里 send 的 socket 和包不是同一个连接。进阶用法我会在日志里加发送时的调用栈摘要。做法是在 HookSend 里用 CaptureStackBackTrace 抓栈上地址再转成模块名 偏移。这样每个 send 日志旁边直接能看到是哪个函数发出来的数据排查时不用再去代码里到处找。// 输出调用栈模块名和偏移 void LogCallStack(FILE* fp) { void* frames[8] { 0 }; USHORT count CaptureStackBackTrace(0, 8, frames, NULL); for (USHORT i 0; i count; i) { DWORD64 addr (DWORD64)frames[i]; HMODULE hMod NULL; GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)addr, hMod); char modName[MAX_PATH] { 0 }; GetModuleFileNameA(hMod, modName, MAX_PATH); fprintf(fp, [%s0x%llX] , strrchr(modName, \\) 1, addr - (DWORD64)hMod); } fprintf(fp, \r\n); }CaptureStackBackTrace 的第二个参数是 skip最好填 2跳过 HookSend 自己和 Detours 跳板的帧直接输出调用方的位置。模块相对偏移比绝对地址有价值因为 DLL 每次加载基址可能不同绝对地址换个环境就没法开会话了。从那以后我每次做这类拦截工具都会强制把时间戳、线程 ID、调用栈三件套带上再开始查数据问题。日志只有内容没有上下文等于只解答了“发了什么”没解答“谁发的、什么时候发的”排查效率会差很多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进