基于ws2_32.dll的send/recv函数拦截与数据捕获实战

简介:这份资源面向具备一定 Windows 系统编程与网络编程基础、希望深入理解 API 钩子机制的开发者,聚焦于拦截 ws2_32.dll 中的 send() 函数并将发送内容写入 OB 文件这一典型场景,可用于网络监控、安全审计或性能分析类工具的学习与实验。压缩包共 23 个文件,约 898KB,包含 cpp 与 h 源码、dll 与 lib 库文件、def 导出定义、dsp 与 dsw 工程文件,以及 pdb、obj、ilk 等编译调试中间产物和说明文本,构成一套可直接用 VC6 打开编译的完整工程。已有 1265 人学习下载。读者可从中获得钩子注入与 IAT 替换的实现思路、send 函数原型与数据捕获逻辑的对照代码,以及工程配置与编译链接的参考,便于在此基础上改造为自定义的数据记录或协议分析工具,并理解钩子对稳定性与性能的潜在影响。

1. 从一次抓不到包的排查说起:ws2_32.dll 拦截 send 到底解决什么问题

有些网络行为在应用层日志里根本看不到。某开发者接手一个老客户端,界面显示“发送成功”,服务端却收不到完整数据,抓包工具在系统层能看到流量,但业务层拿不到明文。最后定位到问题出在 Winsock 的发送链路上——真正决定“发了什么”的,是ws2_32.dll导出的send函数。这个项目的思路很直接:在ws2_32.dll这一层做函数拦截,把send的缓冲区内容原样落到.ob文件里,同时保留recv的拦截位,方便后续把接收方向也补上。它适合两类人:一类是想搞清楚 Windows 用户态 API Hook 怎么落地的新手,另一类是手里有现成客户端、需要在不改源码的前提下观察发送内容的开发者。关键词就三个:ws2_32.dll、拦截函数、send()与recv()。

2. 项目定位与技术选型:为什么选 Inline Hook 而不是改源码

2.1 这个项目到底在做什么

把一句话拆开看:目标模块是ws2_32.dll,目标是它导出的send,动作是拦截,产物是.ob文件。ws2_32.dll是 Windows 上几乎所有走 TCP/UDP 的用户态程序最终都会落到的地方,浏览器、游戏客户端、自研工具,只要用 Winsock,send就会被调用。所以在这一层做拦截,覆盖面比在某个具体业务函数上挂钩子大得多,而且不需要拿到对方源码。

.ob这个后缀不是标准格式,本质就是一个二进制落盘文件,项目里把它当成“原始发送记录”的容器:每次send被调用,就把缓冲区按字节写进去,前面可以带一个简单的长度头,方便回读时切分。选二进制而不是文本,是因为发送内容可能是协议帧、可能是压缩数据,转成文本会破坏原始字节,回放和比对就失真了。

技术路线上,这个项目走的是用户态 Inline Hook(也叫 trampoline hook):找到send函数入口,把开头若干字节改成跳转指令,跳到自己的处理函数,处理完再执行被覆盖的原始指令并跳回去。相比 IAT Hook,Inline Hook 不依赖调用方怎么导入函数,直接改函数体,对静态链接、动态GetProcAddress拿到的地址都有效。代价是要处理指令长度、内存保护和多线程竞争,这几块正是后面避坑章的重点。

2.2 技术栈与工程结构

项目是纯 C/C++ 的 Win32 工程,没有引入第三方 Hook 库,核心依赖只有系统自带的kernel32.dll、ws2_32.dll和编译器 intrinsic。常见做法是用 Visual Studio 建一个 DLL 工程,编译出注入用的模块,再由一个 loader 把 DLL 注入目标进程。目录大致是这样组织的:

ws2hook/ ├── src/ │ ├── dllmain.cpp // DllMain,挂载/卸载 Hook 的入口 │ ├── hook_engine.cpp // Inline Hook 的安装、 trampoline 管理 │ ├── hook_engine.h │ ├── send_hook.cpp // send 的替换函数,负责落盘 │ ├── recv_hook.cpp // recv 的替换函数(预留/可选) │ └── ob_writer.cpp // .ob 文件的写入与长度头封装 ├── loader/ │ └── injector.cpp // 远程线程注入 DLL └── include/ └── hook_types.h // 函数指针类型、共享结构体

这个结构的好处是职责清晰:hook_engine只管“怎么把函数入口改掉、怎么还原”,send_hook只管“拿到数据后干什么”,ob_writer只管“怎么写文件”。二次开发时想换落盘格式,只动ob_writer;想再加WSASend的拦截,只加一个 hook 文件,引擎不用改。

2.3 关键数据结构

拦截函数要保存原始函数地址,才能在处理完后继续调用原逻辑。项目里用一个结构体统一管理:

// hook_types.h typedef int (WSAAPI *send_t)(SOCKET s, const char *buf, int len, int flags); typedef int (WSAAPI *recv_t)(SOCKET s, char *buf, int len, int flags); typedef struct _HOOK_ENTRY { void* target; // 被 Hook 的函数地址,如 send void* trampoline; // 保存原始指令的跳板,调用它等于调用原函数 BYTE backup[16]; // 原始入口字节备份,用于卸载还原 size_t patch_len; // 实际覆盖的字节数 BOOL installed; } HOOK_ENTRY;

target是GetProcAddress(GetModuleHandleA("ws2_32.dll"), "send")拿到的地址;trampoline是一块可执行内存,里面放“被覆盖的原始指令 + 跳回 target+patch_len”的代码;backup存原始字节,卸载时原样写回。patch_len不能拍脑袋定,必须覆盖完整的指令,否则跳回去会落在半条指令中间,直接崩。

3. 核心模块拆解:从安装 Hook 到把 send 内容写进 .ob

3.1 安装 Inline Hook 的完整流程

安装一个 Hook 分四步:改内存保护、备份原始字节、写跳转、刷指令缓存。下面这段是hook_engine.cpp的核心,x86 用 5 字节E9相对跳转,x64 因为相对偏移只有 32 位、跨模块可能不够,常见做法是用 12 字节的mov rax, imm64; jmp rax。

// hook_engine.cpp(x86 版本,5 字节 E9 跳转) BOOL InstallHook(HOOK_ENTRY* e, void* detour) { DWORD oldProtect; // 1. 让目标页可写可执行 if (!VirtualProtect(e->target, e->patch_len, PAGE_EXECUTE_READWRITE, &oldProtect)) return FALSE; // 2. 备份原始字节,供 trampoline 和卸载使用 memcpy(e->backup, e->target, e->patch_len); // 3. 构造 trampoline:原始指令 + 跳回 target+patch_len e->trampoline = BuildTrampoline(e->target, e->backup, e->patch_len); // 4. 写入 E9 相对跳转:目标地址 = detour - (target + 5) BYTE* p = (BYTE*)e->target; p[0] = 0xE9; *(DWORD*)(p + 1) = (DWORD)((BYTE*)detour - (p + 5)); // 5. 恢复保护并刷新指令缓存 VirtualProtect(e->target, e->patch_len, oldProtect, &oldProtect); FlushInstructionCache(GetCurrentProcess(), e->target, e->patch_len); e->installed = TRUE; return TRUE; }

逻辑说明:VirtualProtect是必须的,代码段默认只读可执行,直接写会触发访问违例;BuildTrampoline要把被覆盖的完整指令搬过去,再补一条跳回原函数后续地址的指令,这样替换函数里调用trampoline就等于调用原send;FlushInstructionCache在 x86 上有时能侥幸不刷,但在多核和 x64 上不刷就是玄学崩溃的来源。参数上,patch_len在 x86 取 5,x64 取 12 或 14,取决于跳转写法,必须用反汇编确认覆盖的是整条指令。

3.2 send 替换函数与 .ob 落盘

替换函数是真正干活的地方。它先调用原始send把数据发出去,成功后再把缓冲区写文件——顺序很重要,先落盘再发送会导致“文件里有、实际没发出去”的假记录。

// send_hook.cpp static send_t real_send = NULL; // 指向 trampoline int WSAAPI Hooked_send(SOCKET s, const char *buf, int len, int flags) { int ret = real_send(s, buf, len, flags); // 先走原始发送逻辑 if (ret > 0) { // 只记录真正发出去的字节数,ret 可能小于 len ObWriteRecord(s, buf, ret); } return ret; } // ob_writer.cpp void ObWriteRecord(SOCKET s, const char* data, int len) { static HANDLE hFile = INVALID_HANDLE_VALUE; if (hFile == INVALID_HANDLE_VALUE) { hFile = CreateFileA("send_capture.ob", GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); } if (hFile == INVALID_HANDLE_VALUE) return; OB_HEADER hdr; hdr.socket = (UINT_PTR)s; // 记录来源 socket,便于区分连接 hdr.length = (UINT32)len; hdr.tick = GetTickCount(); DWORD written = 0; WriteFile(hFile, &hdr, sizeof(hdr), &written, NULL); WriteFile(hFile, data, len, &written, NULL); }

逻辑说明:real_send在DllMain里通过InstallHook返回的trampoline赋值,不能直接指向原send,否则会再次进入自己的 Hook 形成死循环。ret > 0的判断是关键,send返回SOCKET_ERROR时缓冲区内容未必发出,记下来会污染数据。OB_HEADER里放 socket、长度、时间戳,回读时按“头 + 长度”循环切分即可还原每一次发送。参数flags原样透传,不要自作主张改,MSG_DONTROUTE之类的标志会影响发送行为。

3.3 recv 方向的拦截位

项目标题只提了send,但关键词里有recv(),工程里也留了recv_hook.cpp。接收方向的坑在于:recv返回后缓冲区才有数据,所以必须在调用原始recv之后读buf,且只能读ret个字节。

int WSAAPI Hooked_recv(SOCKET s, char *buf, int len, int flags) { int ret = real_recv(s, buf, len, flags); if (ret > 0) { ObWriteRecord(s, buf, ret); // 复用同一个写入器,靠 header 区分方向 } return ret; }

如果要把收发分开存,给OB_HEADER加一个direction字段(0 发送、1 接收)即可,回读脚本按字段分流。注意recv可能返回 0(对端关闭),这时不要写记录,否则文件里会多出一堆零长度条目。

3.4 注入与加载时机

DLL 编好后要进目标进程。常见做法是OpenProcess+VirtualAllocEx+WriteProcessMemory写LoadLibraryA的参数字符串 +CreateRemoteThread执行LoadLibraryA。加载时机上,DllMain里不要做重活,LoadLibrary期间加载器锁未释放,此时去GetProcAddress或创建文件有死锁风险。稳妥做法是在DllMain的DLL_PROCESS_ATTACH里只创建一个线程,把 Hook 安装放到那个线程里做:

BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(h); CreateThread(NULL, 0, InitHookThread, NULL, 0, NULL); } return TRUE; }

InitHookThread里再GetModuleHandleA("ws2_32.dll")、取send/recv地址、调InstallHook。这样避开了加载器锁,也避开了目标进程还没初始化 Winsock 时取不到模块的情况——必要时在初始化线程里轮询等待ws2_32.dll出现。

4. 跑起来:编译、注入与验证的完整步骤

4.1 编译环境与工程配置

用 Visual Studio 建两个工程:一个 DLL(ws2hook),一个控制台 loader(injector)。DLL 工程注意三点:平台工具集选本机装了的版本;字符集建议Multi-Byte,避免CreateFileA和宽字符混用;运行库如果目标进程不确定,选/MT静态链接,减少目标进程缺 CRT 依赖的概率。x86 和 x64 要分别编,32 位 loader 只能注入 32 位进程,位数不匹配注入会失败但错误码不一定直观。

编译命令用开发者命令行更省事:

cl /LD /MT /O2 src\dllmain.cpp src\hook_engine.cpp src\send_hook.cpp src\recv_hook.cpp src\ob_writer.cpp /Fe:ws2hook.dll cl /MT /O2 loader\injector.cpp /Fe:injector.exe

/LD生成 DLL,/MT静态运行库,/O2优化。如果开了/O2后发现 Hook 不稳定,先怀疑编译器把替换函数做了内联或尾调用优化,给Hooked_send加__declspec(noinline)排除。

4.2 注入并确认 Hook 生效

loader 的用法是传目标进程名或 PID:

injector.exe notepad.exe

注入成功后,让目标程序发起一次网络发送(比如访问一个 HTTP 接口),当前工作目录下会出现send_capture.ob。验证分两步:先看文件是否生成、大小是否随发送增长;再用十六进制工具打开,确认头部字段和后面的数据能对上。

# 查看文件大小变化 dir send_capture.ob # 十六进制预览前 64 字节 certutil -encodehex send_capture.ob preview.txt 4

certutil -encodehex是系统自带,不用装额外工具,4表示带偏移和 ASCII 列的格式。看到OB_HEADER里的 socket 值和长度字段合理,基本就说明链路通了。

4.3 回读 .ob 的解析脚本

落盘容易,回读才是验证数据正确性的关键。按“头 + 数据”循环切分:

import struct HDR = struct.Struct("<QII") # socket(8) + length(4) + tick(4) with open("send_capture.ob", "rb") as f: data = f.read() off = 0 while off + HDR.size <= len(data): sock, length, tick = HDR.unpack_from(data, off) off += HDR.size payload = data[off:off + length] off += length print(f"socket={sock} len={length} tick={tick} head={payload[:16].hex()}")

struct的格式串必须和 C 端OB_HEADER的字段顺序、对齐方式一致。C 结构体默认按最大成员对齐,UINT_PTR在 x64 是 8 字节,所以用<QII并确认没有额外填充;如果对不上,解析出来的长度会是天文数字,循环直接越界。这也是为什么头结构里尽量用固定宽度类型,别用int这种随平台变的。

5. 避坑与排查:Inline Hook 最容易翻车的五个点

5.1 现象:注入后目标进程秒崩,无任何日志

原因:patch_len覆盖的不是完整指令。send入口前几条指令长度不固定,拍脑袋写 5 字节可能把一条 6 字节指令切成两半,跳回去落在指令中间,CPU 解码出非法指令。解决:用反汇编器(项目里可以集成一个轻量长度解码器,或手工用调试器看)确认覆盖边界,patch_len取到完整指令结束为止,x86 常见是 5 或 7,x64 用 12/14 字节跳转时同理。

5.2 现象:Hook 装上了,但目标程序发送时没进替换函数

原因:目标程序不是通过ws2_32.dll的send发数据,而是用了WSASend,或者静态链接了别的网络库。WSASend内部最终也会落到send,但如果你 Hook 的是导出表里的send而程序走的是WSASend的另一条路径,就可能绕开。解决:先确认目标导入表里有没有send;没有就改 HookWSASend,或者两个都挂。排查时用调试器在send入口下断点,看是否命中,比猜快得多。

5.3 现象:文件写出来了,但内容是乱的、长度对不上

原因:多线程并发调用send,WriteFile的“写头 + 写数据”不是原子操作,两个线程交错写,头和数据错位。解决:给ObWriteRecord加临界区,把两次WriteFile包起来;或者先在内存里拼好一整块再一次性写。临界区粒度要小,别把原始send调用也包进去,否则影响目标程序性能。

5.4 现象:卸载 Hook 后目标进程崩溃

原因:卸载时只把backup写回,但此时可能还有线程正停在 trampoline 里执行,或者指令缓存没刷。解决:卸载前先确认没有线程在 Hook 区域内(简单做法是卸载时机选在进程退出阶段,或加一个“已禁用”标志让替换函数直接透传),写回原始字节后同样调FlushInstructionCache。血泪经验是:调试期别频繁装卸,装一次跑完再看结果。

5.5 现象:x64 下跳转地址算错,跳到野地址

原因:E9的相对偏移是 32 位有符号数,detour和target距离超过 2GB 时偏移溢出。解决:x64 用绝对跳转mov rax, imm64; jmp rax(12 字节),或者用“就近分配 trampoline”的思路,在目标附近VirtualAlloc一块内存放中转跳转。别在 x64 上照抄 x86 的 5 字节写法,这是最常见的翻车点。

6. 二次开发:把落盘器换成协议解析器的改造思路

拿到这个项目后,多数人真正想要的不只是“把字节写进文件”,而是“边拦截边看懂内容”。改造的核心是把ob_writer这一层抽象出来,让send_hook不直接依赖文件写入,而是走一个回调链。具体做法:定义typedef void (*CAPTURE_CB)(SOCKET, const char*, int, int direction);,ObWriteRecord只是其中一个实现,再写一个ParseCallback注册进去,就能在不改 Hook 引擎的前提下加协议解析。

验证改造是否成功,别只看编译过没过。我的习惯是准备一个最小验证集:一条已知内容的 HTTP 请求、一段二进制帧、一次send返回错误的情况,三个用例跑完对比“拦截到的字节”和“实际发出的字节”是否逐字节一致。表格里是我一般会检查的几项:

检查项期望结果不符时先看哪
拦截字节数等于send返回值ret > 0判断是否漏写
多线程顺序每条记录头数据自洽临界区是否覆盖两次写
错误路径失败调用不产生记录是否误用len而非ret
卸载还原原函数可正常调用指令缓存是否刷新

还有一个具体技巧:给OB_HEADER加版本号和魔数(比如0x4F42484B),回读脚本先校验魔数再解析。这样文件被截断或格式升级时,脚本能明确报“格式不匹配”,而不是解析出一堆垃圾数据还以为是协议问题。我早期就吃过这个亏,拿着错位的长度字段调了半天协议,最后发现是头结构对齐错了。从那以后我每次改落盘格式,都强制先用魔数校验跑一遍最小验证集,再谈业务解析。希望帮到你。

本文还有配套的精品资源,点击获取