简介:围绕 Windows 平台 ws2_32.dll 中 send 函数的拦截与数据落盘,这份工程面向具备一定 C++ 与 Winsock 基础、希望理解 API 钩子与网络监控实现的中高级开发者。它给出可编译的 VC6 工程与核心源码,演示在 send 调用前后插入自定义逻辑,把待发送缓冲区内容写入 OB 文件,同时保留原始发送流程,便于对照学习 IAT 替换、导出定义与 DLL 构建等关键环节。压缩包共 23 个文件,约 898KB,包含 cpp/h 源码、dsp/dsw 工程文件、dll/lib/def 编译产物,以及 pdb/obj 等调试中间文件和 txt 说明,目录结构贴近早期 VC6 项目的真实组织方式。已有 1263 人学习。读者可从中获得完整的钩子工程骨架、send 拦截与数据记录思路、模块导出与工程配置参考,并据此延伸网络审计、协议分析与排错练习。
1. 从一个只能看到 TCP 流的调试现场说起
某开发者手里有个自研客户端,走的是私有二进制协议,抓包工具里只能看到一串没有边界的 TCP 流,服务端又不给日志。他最后没继续折腾抓包,而是把目标进程的ws2_32.dll换成自己的一个同名 DLL,在send()这一层把每次真正交给系统的字节原样写进一个.ob文件,事后按记录头切开一条条看。这份资源做的就是这件事:用导出转发的方式顶替系统同名模块,拦截send()/recv()并落盘。它不是通用抓包工具,而是一个可编译、可改的小型通信日志器,适合三类人:要逆私有协议的后端和客户端开发、想搞明白 DLL 代理与导出转发机制的 Windows 学习者、以及需要给自己产品留一份「黑匣子」通信记录的工程团队。
2. 技术选型:三条拦截路径的取舍与 ws2_32 代理转发的代价
动手之前先想清楚一件事:拦截send有不止一种做法,选错了后面全是返工。这一章把三条主流路径摊开对比,说清这份资源为什么选了「代理 DLL + 导出转发」这条最笨但最稳的路,以及它在什么情况下一定会失效——先知道边界,才不会白折腾一晚上。
2.1 代理转发、IAT Hook、inline Hook 的对比
拦截用户态网络调用的路子,本质上只有三个落点:模块、导入表、函数体。
| 方案 | 落点 | 对目标进程的侵入性 | 对WSASend/ 重叠 IO 的覆盖 | 典型失效场景 |
|---|---|---|---|---|
| ws2_32 代理 DLL(导出转发) | 加载器的模块搜索结果 | 零改动,只放一个文件 | 只要导出表写全,全覆盖 | exe 用绝对路径加载 DLL;应用目录只读 |
| IAT Hook | 目标 PE 的导入表 | 需要注入 + 改内存页 | 只能拦静态导入项,GetProcAddress动态取的漏掉 | 程序自己LoadLibrary后取地址 |
| inline Hook(函数体写 jmp) | 函数开头 5~12 字节 | 需要注入 +VirtualProtect | 全覆盖 | 有代码完整性校验;多线程改写存在竞态 |
代理 DLL 的优势在于它不需要注入、不需要改目标进程任何一字节内存,只依赖 Windows 加载器「先搜应用目录、再搜 System32」这个默认顺序。代价是:你必须把ws2_32.dll的导出表一个不落地复刻出来,少一个,程序启动就会报「无法定位程序输入点」。
IAT Hook 写起来最轻,改导入表里send那一项指向自己的函数就行,但它漏掉动态解析的情况。现在不少网络库是运行时LoadLibrary("ws2_32.dll")再GetProcAddress("send"),这条路直接绕过 IAT。inline Hook 覆盖面最全,但要在别人进程里改指令、处理指令长度对齐、还要考虑多线程写入时的可见性,血泪经验基本都堆在这上面。
所以选型的判断标准很简单:如果目标是「拿到一个能长期挂在某个客户端旁边跑的通信日志器」,代理 DLL 最合适;如果目标是「临时看一眼某个函数的入参」,inline Hook 上手更快。
2.2 代理 DLL 的完整加载链路
理解这条链路,后面所有坑都能自己推出来。假设目标程序是D:\app\client.exe,我们把编译好的ws2_32.dll放到D:\app\目录下,启动过程是这样的:
第一步,加载器读client.exe的导入表,发现需要ws2_32.dll。第二步,按默认搜索顺序找模块,应用目录排在 System32 之前,于是加载了D:\app\ws2_32.dll,也就是我们那个。第三步,程序调用WSAStartup,加载器在我们的导出表里找到这一项,发现是个转发器(forwarder),于是去加载转发目标模块,再把地址绑过去。
这里有个必须踩明白的点:转发器写的是模块名.函数名的字符串,加载器解析时会用同样的搜索顺序去加载这个模块名。如果我们在自己的.def里写send = ws2_32.send,它会找到已经加载的我们自己,然后无限递归。正确做法是把系统原版ws2_32.dll复制一份到应用目录,改名成ws2_32_orig.dll,转发目标写这个名字。这样加载器找ws2_32_orig.dll时命中的是真实实现,转发链就闭合了。
send和recv这两个我们要拦截的导出,不能用转发器,必须在自己的 DLL 里给出真实实现;其余几十个导出全部原样转发。混合写法会在 3.3 展开。
2.3 这套方案的边界:什么情况下它一定失效
先泼冷水。代理 DLL 不是万能的,以下几种场景它注定抓不到东西,提前判断能省掉大量排查时间。
第一类是走WinHTTP/WinINet的程序。HTTP 请求在用户态被winhttp.dll接走,最终交给内核的http.sys,进程内根本不发生ws2_32的send调用,你在这一层什么都写不出来。要抓这类流量得换落点。
第二类是 exe 用绝对路径加载模块的。有些程序为了防劫持,自己拼C:\Windows\System32\ws2_32.dll再LoadLibrary,这时候应用目录的优先级完全失效,我们的 DLL 根本没被加载。
第三类是位数不匹配。32 位程序只能加载 32 位 DLL,64 位只能加载 64 位。搞错的表现是进程直接起不来,报0xc000007b。
第四类是打包或受管控的运行环境,应用目录只读或者模块搜索路径被策略改写,DLL 放不进去或者放进去也不生效。
提示:判断当前程序到底加载了哪个
ws2_32.dll,最靠谱的办法是用 Process Monitor 过滤Load Image事件看路径,或者在自己的DllMain里往固定位置写一个标记文件,文件出现了就说明加载成功。
3. 工程结构与编译:用 dumpbin 生成全量导出表,让这个 DLL 能被加载
这一章把工程骨架搭起来。核心难点不在代码逻辑,而在导出表——ws2_32.dll有几十个导出,手写.def必漏,所以要用脚本从系统 DLL 里自动生成。同时说清编译参数、位数对齐和放置位置,因为这三样任何一样错了,表现都是「程序打不开」而不是「抓不到包」,很容易误判。
3.1 目录结构与文件职责
工程一共五个文件,不多,但每个都有明确职责。
| 文件 | 职责 | 关键点 |
|---|---|---|
ws2_32_proxy.cpp | 导出函数实现,send/recv/WSASend/WSARecv | 惰性拿到真实函数地址,绝不链接ws2_32.lib |
dump.cpp/dump.h | .ob记录格式定义与落盘 | 单次WriteFile写完整记录,加锁保护 |
ws2_32.def | 导出表,混合「转发」与「本 DLL 实现」 | 由脚本生成,别手写 |
dllmain.cpp | DllMain,只做最小初始化 | 严禁LoadLibrary、建线程、等锁 |
parse_ob.py | 事后解析.ob的脚本 | 按 24 字节头切分 |
把落盘逻辑单独拆一个文件是有必要的:send里只留一行调用,后面想换格式、加过滤、改成异步队列,都只动dump.cpp,不用碰导出函数。
3.2 用 dumpbin 生成全量导出转发清单
先看系统 DLL 里到底有多少导出,再用脚本生成.def。dumpbin是 VS 自带的,命令行里直接能用。
# 1. 抓取系统 ws2_32.dll 的全部导出名(64位从 System32 取,32位从 SysWOW64 取) $dump = & dumpbin /exports C:\Windows\System32\ws2_32.dll $names = $dump | ForEach-Object { # dumpbin 的导出行格式:序号 RVA Hint 名称 if ($_ -match '^\s+\d+\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\w+)\s*$') { $Matches[1] } } Write-Host "共找到 $($names.Count) 个导出" # 2. 需要在本地实现的函数,其余全部转发给改名后的原版 DLL $intercept = @('send', 'recv', 'WSASend', 'WSARecv') $body = $names | ForEach-Object { if ($intercept -contains $_) { " $_" } else { " $_ = ws2_32_orig.$_" } } # 3. 输出 .def,注意是 UTF-8 无 BOM $content = @("LIBRARY ws2_32", "EXPORTS") + $body [System.IO.File]::WriteAllLines("$PWD\ws2_32.def", $content, [System.Text.UTF8Encoding]::new($false))这段脚本的逻辑分三步:先用正则从dumpbin输出里只捞导出名那一列,^\s+\d+\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\w+)$匹配「序号 + RVA + Hint + 名字」这种四列格式,(\w+)就是函数名;然后按名单分流,要拦截的留空实现,其余写成名字 = ws2_32_orig.名字的转发形式;最后写成文件,必须是无 BOM 的 UTF-8,带 BOM 的.def会让link.exe把第一行当成奇怪的东西报错。
跑完应该看到 60 多个导出。数量对不上就说明正则没匹配全,dumpbin对某些导出会输出(forwarded to xxx)之类的内容,需要单独处理——ws2_32.dll里通常没有,但换个 DLL 就可能遇到。
3.3 .def 里混写「实现导出」与「转发导出」
生成的.def主体长这样,摘几行看结构:
LIBRARY ws2_32 EXPORTS ; ---- 本 DLL 内实现,不可写成转发,否则拦截无效 ---- send recv WSASend WSARecv ; ---- 其余一律转发给改名后的系统原版 ---- accept = ws2_32_orig.accept bind = ws2_32_orig.bind connect = ws2_32_orig.connect getaddrinfo = ws2_32_orig.getaddrinfo socket = ws2_32_orig.socket WSAStartup = ws2_32_orig.WSAStartupLIBRARY ws2_32这一行决定了生成模块的内部名,必须和被顶替的模块一致,否则某些程序的模块校验会失败。
send这类只写名字的行,要求link.exe能在符号表里找到名为send的符号。所以实现里必须用extern "C"关掉 C++ 名字修饰,否则符号会变成?send@@YAH...这种,链接阶段直接报错。
注意:x86 编译时函数是
__stdcall,导出名会被修饰成_send@16。这时候.def里要写成send = _send@16,冒号左边是导出名、右边是符号名。x64 没有这个问题,直接写send即可。搞混的表现是链接报unresolved external symbol send。
另外,转发器默认只按名字转发,序号会重新分配。如果有程序按序号导入,就得在.def里保留原始序号:send = ws2_32_orig.send @23。ws2_32.dll的序号在相邻系统版本间可能变,所以这种做法要谨慎,能按名字就不按序号。
3.4 编译参数、位数对齐与第一次验证
编译用 MSVC 的命令行,一条搞定:
# x64 版本;如果目标程序是 32 位,把 /MACHINE 换成 X86 并用 x86 的 cl cl /nologo /LD /O2 /EHsc /MT /D_USRDLL /D_WINDLL ^ ws2_32_proxy.cpp dump.cpp dllmain.cpp ^ /link /DEF:ws2_32.def /MACHINE:X64 /OUT:ws2_32.dll # 编译完立刻确认位数,这一步别省 dumpbin /headers ws2_32.dll | findstr "machine"参数逐个说:/LD生成 DLL;/O2开优化,拦截函数在热路径上,别用 Debug 版去跑业务;/MT静态链接 CRT,避免目标机器没装运行时;/DEF:指定导出定义文件;/MACHINE显式锁定位数,比依赖默认值可靠。
最要命的一点:绝对不要在工程里#pragma comment(lib, "ws2_32.lib")。导入库会把send的调用解析回模块名为ws2_32.dll的导入项,而那正好是我们自己,调用栈会无限递归直到爆栈。所有真实函数地址一律用GetProcAddress从ws2_32_orig.dll里取。
部署就三步:把系统ws2_32.dll复制到目标 exe 目录并改名ws2_32_orig.dll;把我们编译的ws2_32.dll放进去;启动程序。验证看两件事——程序能不能正常起来,以及目录下有没有出现客户端名_pid.ob。前者验证导出表完整,后者验证拦截链路通了。
4. 核心代码拆解:send 实现、24 字节记录头与 .ob 落盘
骨架搭好后,真正决定这份资源好不好用的是三件事:怎么安全拿到真函数地址、.ob用什么格式、以及落盘怎么既快又不撕裂。这一章逐个拆,代码可以直接抄。
4.1 拿到真正的 send:惰性初始化与 loader lock
真实函数地址不能放在DllMain里加载。DllMain执行时加载器锁还持有,此时调LoadLibrary会死锁,这是 Windows 上最经典的翻车现场之一。做法是惰性初始化,第一次进send时再取。
#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <windows.h> typedef int (WSAAPI *PFN_send)(SOCKET, const char*, int, int); typedef int (WSAAPI *PFN_recv)(SOCKET, char*, int, int); static HMODULE g_orig = nullptr; static PFN_send g_send = nullptr; static PFN_recv g_recv = nullptr; static BOOL CALLBACK init_real(PINIT_ONCE, PVOID, PVOID*) { // 此时已不在 DllMain,可以安全 LoadLibrary g_orig = GetModuleHandleA("ws2_32_orig.dll"); if (!g_orig) g_orig = LoadLibraryA("ws2_32_orig.dll"); if (g_orig) { g_send = (PFN_send)GetProcAddress(g_orig, "send"); g_recv = (PFN_recv)GetProcAddress(g_orig, "recv"); } return TRUE; } static void ensure_real() { static INIT_ONCE once = INIT_ONCE_STATIC_INIT; InitOnceExecuteOnce(&once, init_real, nullptr, nullptr); }INIT_ONCE是 Windows 上做一次性初始化的标准件,InitOnceExecuteOnce保证多线程同时首次调用时只有一个线程执行回调,其余线程阻塞等待,天然线程安全。先用GetModuleHandleA查一遍,是因为大多数程序启动就调WSAStartup,那时ws2_32_orig.dll已经被加载器带进来了,能省一次LoadLibrary的引用计数。
如果init_real返回后g_send还是空,说明ws2_32_orig.dll压根不在目录里,这时候不要硬撑,WSASetLastError(WSAENOTSOCK)后返回SOCKET_ERROR,让上层逻辑自己去报错。
4.2 .ob 记录格式:24 字节头 + 原始载荷
格式设计的目标只有一个:能按顺序切分,且切错时能立刻发现。所以固定长度头 + 长度前缀。
#pragma pack(push, 1) struct ObHeader { char magic[4]; // 固定 "OB01",用来识别格式版本 DWORD tick; // GetTickCount,只用于排序,不用于精确时间 UINT64 sock; // SOCKET 句柄值,用来区分不同连接 DWORD dir; // 1 = send,2 = recv DWORD len; // 紧随其后的真实载荷字节数 }; #pragma pack(pop) // sizeof(ObHeader) == 24#pragma pack(1)必须加,否则结构体会按 8 字节对齐填充到 32 字节,解析端算错偏移就全乱了。magic用一个版本号而不是简单魔数,是为了以后换格式时能兼容老文件:读到OB01用老解析器,读到OB02走新逻辑。
tick用GetTickCount而不是系统时间,因为这里只要相对顺序,GetTickCount便宜且不会因时区、校时跳变;真要绝对时间,把写入时间单独记在文件名或文件头里一次就够。
sock存句柄原始值而不是自增序号,好处是实现简单、不用维护映射表,坏处是句柄会被系统复用——一个连接关掉后,新连接可能拿到同一个数值。要在解析端区分,就得结合tick排序看。想更严谨就在connect拦截里分配自增 ID 建个映射,代价是得多拦一个导出。
4.3 send 与 recv 的实现,以及为什么必须补 WSASend
拦截函数本身很短,关键在返回值处理。
extern "C" __declspec(dllexport) int WSAAPI send(SOCKET s, const char* buf, int len, int flags) { ensure_real(); if (!g_send) { WSASetLastError(WSAENOTSOCK); return SOCKET_ERROR; } int ret = g_send(s, buf, len, flags); // 只有返回 > 0 才落盘,且只写 ret 字节,不是 len if (ret > 0 && buf) dump_record(s, 1, buf, ret); return ret; } extern "C" __declspec(dllexport) int WSAAPI recv(SOCKET s, char* buf, int len, int flags) { ensure_real(); if (!g_recv) { WSASetLastError(WSAENOTSOCK); return SOCKET_ERROR; } int ret = g_recv(s, buf, len, flags); if (ret > 0 && buf) dump_record(s, 2, buf, ret); return ret; }send的返回值是「实际被拷进内核发送缓冲区的字节数」,可能小于len。阻塞套接字上遇到大包会分片,非阻塞套接字上更常见。如果按len写,落盘数据会和实际发出的内容错位,后面解析全崩。recv同理,返回0表示对端关闭,返回SOCKET_ERROR表示出错,这两种都不该写记录。
send返回SOCKET_ERROR且WSAGetLastError()是WSAEWOULDBLOCK时,数据一个字节都没发出去,写进去就是脏数据,ret > 0的判断正好把它挡掉。
但只拦send/recv是不够的。现在大量客户端用 IOCP 或重叠 IO,走的是WSASend/WSARecv,send根本不会被调用。这是最常见的「代码没报错但 .ob 是空的」原因,所以WSASend必须一起拦。
extern "C" __declspec(dllexport) int WSAAPI WSASend(SOCKET s, LPWSABUF bufs, DWORD count, LPDWORD sent, DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) { ensure_real(); if (!g_wsasend) { WSASetLastError(WSAENOTSOCK); return SOCKET_ERROR; } int ret = g_wsasend(s, bufs, count, sent, flags, ov, cr); // 只有同步完成(无 OVERLAPPED)时才能立刻拿到真实长度 if (ret == 0 && ov == nullptr && sent && *sent > 0) { DWORD left = *sent; for (DWORD i = 0; i < count && left > 0; ++i) { DWORD n = bufs[i].len < left ? bufs[i].len : left; dump_record(s, 1, bufs[i].buf, (int)n); left -= n; } } return ret; }这里有个参数细节:WSASend的*sent是所有WSABUF加起来的总发送量,所以要在循环里按left逐段削减,不能每个 buffer 都写全。重叠调用(ov != nullptr)时函数立即返回,真实长度要等完成例程,简单的办法是暂时跳过并在完成回调里补写,复杂一点就自己包一层lpCompletionRoutine。
4.4 落盘的线程安全与体积控制
send会被多个工作线程并发调用,落盘必须保证两条:记录不撕裂、文件不无限涨。
static HANDLE g_file = INVALID_HANDLE_VALUE; static CRITICAL_SECTION g_cs; static volatile LONG g_inited = 0; static ULONGLONG g_bytes = 0; static const ULONGLONG MAX_BYTES = 64ull * 1024 * 1024; // 单文件上限 static void open_dump() { InitializeCriticalSection(&g_cs); // 此处非 DllMain,可以安全初始化 char base[MAX_PATH] = {0}; GetModuleFileNameA(nullptr, base, MAX_PATH); // DLL 里传 NULL 拿到的是宿主 exe 路径 char* dot = strrchr(base, '.'); if (dot) *dot = '\0'; char path[MAX_PATH] = {0}; wsprintfA(path, "%s_%lu.ob", base, GetCurrentProcessId()); g_file = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); } void dump_record(SOCKET s, DWORD dir, const char* data, int len) { if (len <= 0 || !data) return; if (InterlockedCompareExchange(&g_inited, 1, 0) == 0) open_dump(); if (g_file == INVALID_HANDLE_VALUE) return; ObHeader hdr; memcpy(hdr.magic, "OB01", 4); hdr.tick = GetTickCount(); hdr.sock = (UINT64)s; hdr.dir = dir; hdr.len = (DWORD)len; DWORD total = sizeof(ObHeader) + (DWORD)len; BYTE* block = (BYTE*)HeapAlloc(GetProcessHeap(), 0, total); if (!block) return; memcpy(block, &hdr, sizeof(ObHeader)); memcpy(block + sizeof(ObHeader), data, len); EnterCriticalSection(&g_cs); if (g_bytes < MAX_BYTES) { DWORD written = 0; WriteFile(g_file, block, total, &written, nullptr); // 头+载荷一次写入 g_bytes += written; } LeaveCriticalSection(&g_cs); HeapFree(GetProcessHeap(), 0, block); }FILE_APPEND_DATA打开的文件句柄,每次WriteFile都是追加语义,不需要自己SetFilePointer。把头和数据拼进同一块内存再一次性WriteFile,配合临界区,能保证任意时刻文件里都是完整的记录序列,即使进程被强杀也不会有半条头。
MAX_BYTES这个上限是必须的。一个跑着长连接的客户端,一晚上写出几十 GB 是很正常
本文还有配套的精品资源,点击获取