简介:这份资源面向Windows网络编程与系统安全方向的学习者,聚焦ws2_32.dll中send()函数的拦截与数据落盘技术。通过API钩子或用户模式钩子,在send()调用前后插入自定义逻辑,把待发送的数据写入OB文件,可用于网络监控、安全审计与性能分析等场景,属于需要一定C++与Winsock基础的中高级实践素材。压缩包共23个文件,约898KB,包含cpp与h源码、dll与lib编译产物、def导出定义、dsp与dsw工程文件,以及pdb、obj、ilk等调试中间文件,另有ReadMe与说明文档辅助理解工程结构。目前已有1261人学习下载。读者可据此研究导入地址表替换、钩子注册与数据捕获的完整实现思路,并参考源码中的工程配置与调试信息,快速搭建自己的拦截实验环境,同时留意安全性、性能开销与合规性问题。
1. 从一次抓包失败说起:为什么要拦截 ws2_32.dll 的 send
某次排查一个老客户端的上行数据异常,Wireshark 抓到的全是加密后的字节流,业务字段一个都认不出来。客户端本身没有日志开关,也没有调试符号,唯一能确定的是它走的是标准 Winsock 的send。这时候最直接的办法不是去逆向整个协议栈,而是在ws2_32.dll这一层把send拦下来,把每次发送的原始缓冲区原样落盘成 ob 文件,再离线慢慢对。
这个思路属于 API Hook 里最经典的一类:用户态 IAT/Inline Hook 网络发送函数。它解决的核心问题是——当上层没有可观测性、下层又是密文时,如何在数据离开进程之前拿到明文。适合做协议分析、客户端行为审计、自动化测试回放、以及老系统排障的工程师。前提是你对目标进程有合法的调试或测试权限,本文所有操作都在自建的模拟项目上完成。
2. 拦截点怎么选:ws2_32 导出表、IAT 与 Inline Hook 的取舍
2.1 先搞清楚 send 到底从哪来
Windows 上应用层调用send,最终解析到ws2_32.dll的导出函数。但要注意,ws2_32.dll里的send往往只是个转发桩,真正干活的是mswsock.dll或内核的afd.sys。这意味着你 Hook 的位置不同,拿到的时机和稳定性完全不同。
常见做法有三种:
| 方式 | 拦截位置 | 优点 | 缺点 |
|---|---|---|---|
| IAT Hook | 目标模块导入表 | 实现简单,不改函数体 | 只能拦本进程、本模块的静态导入 |
| Inline Hook | ws2_32!send函数头 | 覆盖所有调用方 | 需要改内存属性,易被检测 |
| 导出表 Hook | 替换导出地址 | 影响面大 | 现代系统有保护,风险高 |
我一般优先选 IAT Hook,因为它对目标进程的侵入最小,出问题也好回滚。只有当目标用GetProcAddress动态取地址、IAT 里根本没有send时,才退回到 Inline Hook。
2.2 IAT Hook 的最小实现
核心逻辑是:找到目标模块的导入表,定位ws2_32.dll对应的IMAGE_IMPORT_DESCRIPTOR,再遍历它的FirstThunk数组,找到指向send的那一项,把地址换成我们自己的函数。
// iat_hook.cpp : 在目标进程内替换 IAT 中的 send 地址 #include <windows.h> #include <psapi.h> #include <cstdio> // 保存原始 send,转发时要用 typedef int (WSAAPI *SendFn)(SOCKET, const char*, int, int); static SendFn g_originalSend = nullptr; // 我们的替换函数:先落盘,再转发 int WSAAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { if (buf && len > 0) { // 追加写入 ob 文件,二进制模式 FILE* fp = fopen("send_capture.ob", "ab"); if (fp) { fwrite(buf, 1, len, fp); fclose(fp); } } return g_originalSend(s, buf, len, flags); } // 在指定模块的 IAT 中查找并替换目标函数 bool HookIat(HMODULE targetModule, const char* dllName, const char* funcName, void* newFunc, void** oldFunc) { auto base = (BYTE*)targetModule; auto dos = (IMAGE_DOS_HEADER*)base; auto nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew); auto importDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir.VirtualAddress == 0) return false; auto desc = (IMAGE_IMPORT_DESCRIPTOR*)(base + importDir.VirtualAddress); for (; desc->Name; ++desc) { const char* modName = (const char*)(base + desc->Name); if (_stricmp(modName, dllName) != 0) continue; auto thunk = (IMAGE_THUNK_DATA*)(base + desc->FirstThunk); auto origThunk = (IMAGE_THUNK_DATA*)(base + desc->OriginalFirstThunk); for (; thunk->u1.Function; ++thunk, ++origThunk) { if (origThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) continue; auto importByName = (IMAGE_IMPORT_BY_NAME*)(base + origThunk->u1.AddressOfData); if (strcmp((const char*)importByName->Name, funcName) != 0) continue; DWORD oldProtect; VirtualProtect(&thunk->u1.Function, sizeof(void*), PAGE_READWRITE, &oldProtect); *oldFunc = (void*)thunk->u1.Function; thunk->u1.Function = (ULONG_PTR)newFunc; VirtualProtect(&thunk->u1.Function, sizeof(void*), oldProtect, &oldProtect); return true; } } return false; }逻辑说明:HookIat先解析 PE 头拿到导入表目录,再逐个比对 DLL 名,命中后遍历 thunk 数组,用OriginalFirstThunk里的名字确认是send,最后改FirstThunk里的实际地址。参数上,targetModule是目标 exe 或 dll 的模块句柄,dllName传"ws2_32.dll",funcName传"send",oldFunc回传原始地址供转发。
注意:改 IAT 前必须
VirtualProtect把该页改成可写,改完立刻恢复原属性,否则容易触发访问异常。
2.3 什么时候必须上 Inline Hook
如果目标程序用GetProcAddress(LoadLibrary("ws2_32.dll"), "send")拿地址,IAT 里就没有这条记录,IAT Hook 会静默失败。判断方法很简单:Hook 后跑一遍,如果 ob 文件一直是空的,而抓包显示确实有发送,那基本就是动态取址。
这时改用 Inline Hook,在send函数开头写一个跳转。x64 下常用 12 字节的mov rax, addr; jmp rax,x86 下用 5 字节jmp rel32。写之前同样要VirtualProtect,并且要处理指令长度对齐——如果函数头前几个字节正好是一条完整指令的中间,直接覆盖会崩。稳妥做法是用长度反汇编器(如 Zydis)算出至少覆盖 5 或 12 字节的完整指令边界再写。
3. 把发送内容写成 ob 文件:缓冲、线程安全与格式设计
3.1 为什么不能直接 fwrite 了事
send可能被多个线程同时调用,直接fopen/fwrite/fclose会有两个问题:一是频繁开关文件开销大,二是多线程下写入交错,ob 文件里的数据块会互相穿插,离线根本没法还原。
我一般用一个独立的写入线程加环形缓冲:Hook 函数只负责把(socket, len, data)拷进队列,后台线程按顺序落盘。这样 Hook 路径极短,对目标进程的性能影响可控。
// capture_writer.cpp : 单线程落盘,避免多线程交错 #include <windows.h> #include <vector> #include <mutex> struct SendRecord { SOCKET s; int len; std::vector<char> data; }; static std::vector<SendRecord> g_queue; static std::mutex g_mtx; static HANDLE g_event = CreateEvent(nullptr, FALSE, FALSE, nullptr); // Hook 里调用:只入队,不落盘 void EnqueueSend(SOCKET s, const char* buf, int len) { SendRecord rec; rec.s = s; rec.len = len; rec.data.assign(buf, buf + len); { std::lock_guard<std::mutex> lk(g_mtx); g_queue.push_back(std::move(rec)); } SetEvent(g_event); } // 后台线程:批量取出并追加写入 DWORD WINAPI WriterThread(LPVOID) { FILE* fp = fopen("send_capture.ob", "wb"); if (!fp) return 1; while (true) { WaitForSingleObject(g_event, 100); std::vector<SendRecord> local; { std::lock_guard<std::mutex> lk(g_mtx); local.swap(g_queue); } for (auto& r : local) { // 每条记录前写 8 字节头:4 字节 socket + 4 字节长度 fwrite(&r.s, sizeof(SOCKET), 1, fp); fwrite(&r.len, sizeof(int), 1, fp); fwrite(r.data.data(), 1, r.data.size(), fp); } fflush(fp); } fclose(fp); return 0; }逻辑说明:EnqueueSend在 Hook 上下文里只做内存拷贝和入队,把耗时 IO 甩给WriterThread。每条记录带一个 8 字节头,记录 socket 句柄和长度,离线解析时先读头再读体,就能精确切分每次send。
参数上,g_event用自动重置事件,队列空时线程最多阻塞 100ms,避免空转。fflush每次批量写完调用一次,保证进程崩溃时已入队的数据尽量落盘。
3.2 ob 文件的格式约定
ob 文件本身没有标准,我习惯用「长度前缀 + 原始字节」的 TLV 变体。上面代码里用的是固定 8 字节头,简单够用。如果还要记录时间戳和方向,可以扩成:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 4 | socket 句柄 |
| 4 | 4 | 数据长度 len |
| 8 | 8 | 时间戳(FILETIME) |
| 16 | len | 原始发送字节 |
解析脚本按这个表读就行,不需要额外索引文件。好处是追加写入天然支持,进程中途重启也不会破坏已有记录。
3.3 注入方式与加载时机
Hook 代码要跑在目标进程里,常见注入方式有CreateRemoteThread+LoadLibrary、SetWindowsHookEx、以及 APC 注入。我一般用CreateRemoteThread加载一个自写的 dll,在DllMain的DLL_PROCESS_ATTACH里做 IAT Hook。
加载时机很关键:必须在目标调用send之前完成 Hook。如果目标一启动就发数据,注入晚了会漏掉前几条。稳妥做法是在进程创建时就挂起主线程,注入完再恢复。用调试器附加时,可以在LoadLibrary("ws2_32.dll")返回处下断点,此时 IAT 已填充但业务还没开始发。
4. 避坑与排查:拦截 send 最常见的五个翻车点
4.1 ob 文件一直是空的
现象:注入成功,进程正常运行,但send_capture.ob大小为 0。
原因:目标用GetProcAddress动态取send地址,IAT 里没有这条导入记录,IAT Hook 根本没命中。
解决:先用dumpbin /imports或 PE 工具确认目标是否静态导入ws2_32.dll。如果没有,改用 Inline Hook,或者在GetProcAddress上也挂一层。
4.2 目标进程一注入就崩溃
现象:注入后目标立刻退出,事件查看器里有访问冲突。
原因:Inline Hook 覆盖函数头时没有按指令边界对齐,把一条完整指令从中间截断,CPU 执行到残缺指令直接异常。
解决:用长度反汇编器算出覆盖 5 或 12 字节所需的完整指令数,只在这些指令的边界处写入跳转。IAT Hook 一般不会崩,如果崩了先检查VirtualProtect是否失败。
4.3 抓到的数据比实际发送的少
现象:ob 文件里有记录,但和抓包对比,字节数对不上。
原因:send返回值小于len是正常的,TCP 发送缓冲区满时只发出去一部分。我们的 Hook 在调用原始send之前就落盘了全部len,看起来「多」了;反过来如果只记录返回值,就会「少」。
解决:明确记录策略——记录请求发送的完整缓冲区,还是记录实际发送成功的字节数。协议分析通常要前者,流量统计要后者。在 ob 头里加一个标志位区分。
4.4 多线程下 ob 文件数据错乱
现象:离线解析时长度字段读出天文数字,后续全部错位。
原因:多个线程同时fwrite,两个记录的头和体交叉写入。
解决:用第 3 章的队列加单写入线程方案。如果不想起线程,至少给fwrite加一把全局锁,但性能会差很多。
4.5 杀软或保护机制拦截注入
现象:注入工具报错,或注入后 dll 被卸载。
原因:部分安全软件会监控CreateRemoteThread和WriteProcessMemory。
解决:这是环境问题,不是代码问题。在自建的测试环境里做,或者用调试器附加的方式替代远程注入。本文所有操作都应在你有权限的模拟项目上进行。
5. 进阶:从 ob 文件回放到协议字段提取的一个实用技巧
抓到 ob 文件只是第一步,真正省时间的是把它变成可读的协议流。我常用的做法是写一个小的解析器,按第 3 章的格式切出每条记录,再按 socket 分组,模拟 TCP 流的重组。
# parse_ob.py : 解析 ob 文件并按 socket 分组输出 import struct def parse_ob(path): streams = {} with open(path, 'rb') as f: while True: header = f.read(8) if len(header) < 8: break sock, length = struct.unpack('<II', header) data = f.read(length) if len(data) < length: break streams.setdefault(sock, []).append(data) return streams if __name__ == '__main__': streams = parse_ob('send_capture.ob') for sock, chunks in streams.items(): total = sum(len(c) for c in chunks) print(f'socket {sock:#x}: {len(chunks)} sends, {total} bytes') # 把前 64 字节按 hex 打印,快速看协议头 for c in chunks[:3]: print(' ', c[:64].hex())逻辑说明:struct.unpack('<II', header)按小端读 4 字节 socket 和 4 字节长度,和写入端严格对应。按 socket 分组后,同一连接的多次send按时间顺序排列,方便看协议交互。参数上,如果写入端用了 16 字节头(带时间戳),这里要同步改成<IIQ。
一个实用技巧:很多协议的第一个send里就带着魔数或版本号。先把每个 socket 的第一条记录的前 16 字节 hex 打出来,往往一眼就能认出是 HTTP、自定义二进制还是 protobuf。认出魔数之后,再针对性地写字段解析,比盲目翻整个 ob 文件快得多。
我自己的习惯是,每次做这类拦截,先在模拟项目上跑通「注入 → 发送 → 落盘 → 解析」这条最小闭环,确认 ob 文件能正确切分,再去碰真实目标。血泪经验是:不要一上来就对着生产进程 Inline Hook,先拿记事本级别的小程序验证 Hook 逻辑,能省掉大量「注入就崩、崩了不知道哪崩」的玄学时间。希望帮到你。
本文还有配套的精品资源,点击获取