Windows下拦截ws2_32.dll的send函数实现数据落盘与协议分析

发布时间:2026/10/15 14:21:51
Windows下拦截ws2_32.dll的send函数实现数据落盘与协议分析
简介这份资源聚焦Windows网络编程中的API拦截技术面向具备一定C与Winsock基础、希望深入理解系统级钩子机制的中高级开发者。其核心目标是通过拦截ws2_32.dll中的send()函数将套接字发送的数据实时写入OB文件可用于网络监控、安全审计或性能分析等场景的学习与实验。压缩包共23个文件约898KB包含cpp与h源码、dll与lib编译产物、def导出定义、dsp与dsw工程文件以及pdb、obj、ilk等调试中间文件完整保留了VC6工程结构与构建痕迹便于直接编译调试或二次改造。资源已有1262人学习下载读者可从中掌握IAT替换或用户模式钩子的实现思路理解send()函数原型与调用流程并借鉴数据捕获、文件落盘及原始逻辑恢复的代码组织方式同时留意安全性、性能开销与合规性等实践要点。1. 从一次抓包翻车说起ws2_32.dll 拦截 send 到底能干什么有次排查一个老系统的通信问题抓包工具显示数据发出去了但服务端就是收不到完整内容。后来用 API 拦截的方式在send调用点直接落盘才发现是上层拼包时长度字段算错了——数据在进入协议栈之前就已经缺了一截。这件事让我意识到网络抓包工具看到的是“线上跑的车”而拦截ws2_32.dll的导出函数看到的是“装车前后的货”两者定位问题的层次完全不同。ws2_32.dll是 Windows 上 Winsock 2 的用户态实现库几乎所有走 TCP/UDP 的程序最终都会调用它导出的send、recv、WSASend、WSARecv等函数。所谓“拦截 send 函数并把发送内容写到 ob 文件里”本质是在目标进程内对ws2_32.dll的send入口做一次跳转改写让程序调用send时先进入我们自己的处理函数把缓冲区内容按自定义格式这里用.ob后缀追加落盘再决定是否放行到原始send。它解决的是“应用层实际交出了什么数据”这个黑匣子问题适合做协议逆向、私有格式分析、通信行为审计的从业者也适合想理解 API Hook 落地细节的开发者。2. 拦截原理与选型IAT Hook、Inline Hook 该用哪个2.1 为什么优先考虑 IAT Hook拦截ws2_32.dll的send常见做法有两类IAT Hook 和 Inline Hook。IAT Hook 改的是目标模块导入表里send那一项的地址把它指向自己的函数Inline Hook 改的是send函数体开头的机器码插入跳转指令。两者差别很实际。IAT Hook 的优点是稳定、可逆、不碰代码段目标函数升级或重定位时影响小缺点是只能拦截通过导入表调用的场景如果程序是运行时GetProcAddress动态取地址再调用IAT 里根本没有这一项就拦不到。Inline Hook 覆盖面更广动态调用也能拦但需要处理指令长度、热补丁、多线程竞争稍不注意就把进程搞崩属于血泪经验高发区。我一般会先判断目标程序怎么用send用调试器看调用点是不是call dword ptr [IAT 项]。如果是IAT Hook 足够如果发现是寄存器间接调用再上 Inline Hook。本文以 IAT Hook 为主线因为它可复现性最好新手照着做不容易翻车。2.2 拦截点的选择send 还是 WSASend很多程序并不直接调send而是走WSASend尤其是用了重叠 I/O 或完成端口的服务端。只拦send会漏数据。稳妥做法是同时挂send和WSASendWSASend的缓冲区是WSABUF数组需要按dwBufferCount逐个遍历不能只取第一个。另外要注意send的返回值语义返回SOCKET_ERROR时缓冲区内容未必全部发出但“应用层试图发送什么”这个信息仍然有价值所以落盘应该在调用原始send之前做而不是之后。这一点决定了后面代码里写文件的时机。2.3 落盘格式为什么用 .ob 而不是直接写文本.ob在这里是一个自定义二进制容器后缀不是某种标准格式。用它的原因是发送内容可能是二进制协议直接当文本写会遇到\0截断、编码错乱。常见做法是每条记录写成“长度 时间戳 socket 句柄 原始字节”的定长头加变长体这样后续用脚本解析不会丢边界。下面给出一个最小可用的记录结构。字段类型长度说明magicuint324固定 0x4F424731用于校验timestampuint648毫秒级时间戳socketuint648SOCKET 句柄值directionuint810 表示 send1 表示 recvdataLenuint324后续原始数据长度databytes变长原始发送/接收字节3. 动手实现从 DLL 工程到 send 落盘的完整步骤3.1 工程骨架与导出函数先建一个 Win32 DLL 工程导出两个函数供注入器调用InstallHook和UninstallHook。核心思路是在InstallHook里定位ws2_32.dll的导入表项并替换地址。下面是最小实现省略了错误分支的日志细节实际工程里建议每一步都判断返回值。// hook.cpp : 导出安装/卸载接口 #include windows.h #include cstdio typedef int (WSAAPI *send_t)(SOCKET, const char*, int, int); static send_t g_originalSend nullptr; static PVOID* g_iatSlot nullptr; static FILE* g_obFile nullptr; // 写一条 .ob 记录定长头 原始数据 static void WriteObRecord(SOCKET s, const char* buf, int len, unsigned char dir) { if (!g_obFile || len 0) return; unsigned int magic 0x4F424731; unsigned long long ts GetTickCount64(); unsigned long long sk (unsigned long long)s; unsigned int dlen (unsigned int)len; fwrite(magic, 4, 1, g_obFile); fwrite(ts, 8, 1, g_obFile); fwrite(sk, 8, 1, g_obFile); fwrite(dir, 1, 1, g_obFile); fwrite(dlen, 4, 1, g_obFile); fwrite(buf, 1, dlen, g_obFile); fflush(g_obFile); // 崩溃时尽量少丢数据 } // 替换后的 send先落盘再调用原始函数 static int WSAAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { WriteObRecord(s, buf, len, 0); return g_originalSend(s, buf, len, flags); }逻辑说明WriteObRecord按 3.3 节的表结构顺序写入fflush是为了在目标进程异常退出时尽量保留已捕获数据代价是性能下降高频发送场景可以改成缓冲批量刷。HookedSend先落盘再转发保证即使原始send失败我们也能看到应用层试图发什么。参数s是 socket 句柄buf是发送缓冲区len是长度flags原样透传。3.2 定位并改写 IAT 项安装 Hook 的关键是找到当前模块导入表里send的槽位。用GetModuleHandle拿自身基址解析 PE 头遍历导入描述符匹配ws2_32.dll后在其FirstThunk数组里按序号或名字找到send。static bool PatchIAT(HMODULE mod, const char* dllName, const char* funcName, PVOID newFunc, PVOID* oldFunc) { auto dos (PIMAGE_DOS_HEADER)mod; auto nt (PIMAGE_NT_HEADERS)((BYTE*)mod dos-e_lfanew); auto imp (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)mod nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress); for (; imp-Name; imp) { const char* name (const char*)((BYTE*)mod imp-Name); if (_stricmp(name, dllName) ! 0) continue; auto thunk (PIMAGE_THUNK_DATA)((BYTE*)mod imp-FirstThunk); auto orig (PIMAGE_THUNK_DATA)((BYTE*)mod imp-OriginalFirstThunk); for (; thunk-u1.Function; thunk, orig) { if (orig-u1.Ordinal IMAGE_ORDINAL_FLAG) continue; // 跳过序号导入 auto ibn (PIMAGE_IMPORT_BY_NAME)((BYTE*)mod orig-u1.AddressOfData); if (strcmp((const char*)ibn-Name, funcName) ! 0) continue; DWORD oldProtect; VirtualProtect(thunk-u1.Function, sizeof(PVOID), PAGE_READWRITE, oldProtect); *oldFunc (PVOID)thunk-u1.Function; thunk-u1.Function (ULONG_PTR)newFunc; VirtualProtect(thunk-u1.Function, sizeof(PVOID), oldProtect, oldProtect); return true; } } return false; }逻辑说明OriginalFirstThunk保存名字信息FirstThunk保存实际地址两者同步遍历。改写前必须VirtualProtect把该页改成可写否则会触发访问违例。oldFunc回传原始地址供HookedSend转发使用。参数mod传GetModuleHandle(nullptr)即可dllName传ws2_32.dllfuncName传send。3.3 安装、卸载与文件打开把上面两块拼起来InstallHook负责打开.ob文件并打补丁UninstallHook负责还原并关闭文件。还原时把保存的原始地址写回 IAT 槽位即可。extern C __declspec(dllexport) BOOL InstallHook() { if (!g_obFile) { fopen_s(g_obFile, capture.ob, ab); // 追加模式避免覆盖历史 if (!g_obFile) return FALSE; } HMODULE self GetModuleHandle(nullptr); return PatchIAT(self, ws2_32.dll, send, (PVOID)HookedSend, (PVOID*)g_originalSend); } extern C __declspec(dllexport) void UninstallHook() { if (g_iatSlot g_originalSend) { DWORD oldProtect; VirtualProtect(g_iatSlot, sizeof(PVOID), PAGE_READWRITE, oldProtect); *g_iatSlot (PVOID)g_originalSend; VirtualProtect(g_iatSlot, sizeof(PVOID), oldProtect, oldProtect); } if (g_obFile) { fclose(g_obFile); g_obFile nullptr; } }逻辑说明fopen_s用ab追加模式多次运行不会互相覆盖方便对比。g_iatSlot需要在PatchIAT成功时记录槽位地址上面示例为简洁未展开实际工程里应在PatchIAT内把thunk-u1.Function存到全局。卸载顺序是先还原 IAT 再关文件避免还原瞬间还有线程进入HookedSend写已关闭的文件句柄。3.4 注入与验证DLL 编译好后需要注入目标进程。常见做法是用CreateRemoteThread配合LoadLibrary或者用调试器附加后手动LoadLibrary。注入后调用InstallHook然后触发目标程序发数据观察capture.ob是否增长。验证时不要只看文件大小建议写个解析脚本按 3.3 节的表结构读回来确认 magic、长度、数据能对上。下面是一个 Python 解析片段。import struct with open(capture.ob, rb) as f: while True: head f.read(25) # 48814 if len(head) 25: break magic, ts, sk, direction, dlen struct.unpack(IQQBI, head) assert magic 0x4F424731, magic 不匹配文件可能被截断 data f.read(dlen) print(fts{ts} sock{sk} dir{direction} len{dlen} data{data[:32]!r})逻辑说明struct格式串IQQBI对应小端下的 uint32、uint64、uint64、uint8、uint32与写入顺序严格一致。assert用来在解析错位时立刻暴露而不是继续读出乱码。参数dlen来自文件头必须校验它不超过剩余文件长度否则恶意或损坏文件会导致内存问题。4. 避坑与排查拦截 send 最常见的五类翻车4.1 注入后进程直接崩溃现象注入 DLL 并调用InstallHook后目标进程秒退。原因通常是 IAT 页没有改可写就写入或者PatchIAT遍历时把序号导入项当成名字项解析读到非法地址。解决改写前一律VirtualProtect遍历时先判断IMAGE_ORDINAL_FLAG对序号导入直接跳过或单独处理。4.2 文件里只有头没有数据现象capture.ob有记录头但data为空或长度对不上。原因多半是send的len为 0或者程序实际走的是WSASend而只挂了send。解决在WriteObRecord里对len 0直接返回同时补挂WSASend按WSABUF数组逐个写。4.3 高频发送时目标程序卡顿现象拦截后程序吞吐明显下降。原因是每条记录都fflush磁盘 I/O 成了瓶颈。解决改成内存缓冲累计到一定字节数或定时刷盘对延迟敏感的场景可以只记录头部和长度不记录完整数据。4.4 多线程下记录交错现象解析时发现两条记录的数据粘在一起。原因是多个线程同时进入HookedSendfwrite序列不是原子的。解决在WriteObRecord里加临界区或互斥量保证“头 数据”作为一个整体写入或者每个线程写独立文件再合并。4.5 卸载后程序行为异常现象调用UninstallHook后程序发送失败。原因是还原 IAT 时写回了错误的原始地址或者文件已关闭但仍有线程在HookedSend里写。解决PatchIAT时务必保存原始地址到全局还原用保存值卸载前先确保没有新线程进入必要时用引用计数等待在途调用结束。5. 进阶技巧把 .ob 变成可检索的协议分析底稿抓到.ob只是第一步真正省时间的是把它变成能按 socket、按时间、按方向过滤的底稿。我一般会再写一个转换脚本把二进制记录转成 JSON Lines每行一条方便用jq或日志工具检索。下面这个脚本在解析基础上加了方向过滤和十六进制预览。import struct, json, sys def iter_records(path): with open(path, rb) as f: while True: head f.read(25) if len(head) 25: break magic, ts, sk, direction, dlen struct.unpack(IQQBI, head) if magic ! 0x4F424731: raise ValueError(记录头损坏停止解析) data f.read(dlen) if len(data) dlen: raise ValueError(数据被截断) yield {ts: ts, sock: sk, dir: direction, len: dlen, hex: data.hex(), raw: data} if __name__ __main__: src, dst sys.argv[1], sys.argv[2] with open(dst, w, encodingutf-8) as out: for r in iter_records(src): out.write(json.dumps(r, ensure_asciiFalse) \n)逻辑说明iter_records用生成器逐条读避免一次性把大文件读进内存。hex字段保留完整字节raw在 JSON 里会被转义实际用的时候可以只留hex。参数src是.ob路径dst是输出的 JSONL 路径。转完之后用jq select(.dir0) | .hex就能只看发送方向排查“到底发了什么”比翻二进制快得多。还有一个容易被忽略的点send的缓冲区在调用返回后可能被上层复用或释放所以落盘必须在调用原始send之前完成不能先转发再异步写。我早期图省事在另一个线程里写结果抓到的数据偶尔被覆盖排查了半天才定位到生命周期问题。从那以后我每次做 API 拦截都强制把“拷贝数据”放在转发之前哪怕多一次内存拷贝也不省这个步骤。希望帮到你。本文还有配套的精品资源点击获取