资讯详情 基于ws2_32.dll的send/recv函数拦截与数据捕获实战
📅 2026/11/2 2:25:17
简介这份资源面向具备一定 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 的用户态程序最终都会落到的地方浏览器、游戏客户端、自研工具只要用 Winsocksend就会被调用。所以在这一层做拦截覆盖面比在某个具体业务函数上挂钩子大得多而且不需要拿到对方源码。.ob这个后缀不是标准格式本质就是一个二进制落盘文件项目里把它当成“原始发送记录”的容器每次send被调用就把缓冲区按字节写进去前面可以带一个简单的长度头方便回读时切分。选二进制而不是文本是因为发送内容可能是协议帧、可能是压缩数据转成文本会破坏原始字节回放和比对就失真了。技术路线上这个项目走的是用户态 Inline Hook也叫 trampoline hook找到send函数入口把开头若干字节改成跳转指令跳到自己的处理函数处理完再执行被覆盖的原始指令并跳回去。相比 IAT HookInline 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是一块可执行内存里面放“被覆盖的原始指令 跳回 targetpatch_len”的代码backup存原始字节卸载时原样写回。patch_len不能拍脑袋定必须覆盖完整的指令否则跳回去会落在半条指令中间直接崩。3. 核心模块拆解从安装 Hook 到把 send 内容写进 .ob3.1 安装 Inline Hook 的完整流程安装一个 Hook 分四步改内存保护、备份原始字节、写跳转、刷指令缓存。下面这段是hook_engine.cpp的核心x86 用 5 字节E9相对跳转x64 因为相对偏移只有 32 位、跨模块可能不够常见做法是用 12 字节的mov rax, imm64; jmp rax。// hook_engine.cppx86 版本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原始指令 跳回 targetpatch_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就等于调用原sendFlushInstructionCache在 x86 上有时能侥幸不刷但在多核和 x64 上不刷就是玄学崩溃的来源。参数上patch_len在 x86 取 5x64 取 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 编好后要进目标进程。常见做法是OpenProcessVirtualAllocExWriteProcessMemory写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 建两个工程一个 DLLws2hook一个控制台 loaderinjector。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 的用法是传目标进程名或 PIDinjector.exe notepad.exe注入成功后让目标程序发起一次网络发送比如访问一个 HTTP 接口当前工作目录下会出现send_capture.ob。验证分两步先看文件是否生成、大小是否随发送增长再用十六进制工具打开确认头部字段和后面的数据能对上。# 查看文件大小变化 dir send_capture.ob # 十六进制预览前 64 字节 certutil -encodehex send_capture.ob preview.txt 4certutil -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(fsocket{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 或 7x64 用 12/14 字节跳转时同理。5.2 现象Hook 装上了但目标程序发送时没进替换函数原因目标程序不是通过ws2_32.dll的send发数据而是用了WSASend或者静态链接了别的网络库。WSASend内部最终也会落到send但如果你 Hook 的是导出表里的send而程序走的是WSASend的另一条路径就可能绕开。解决先确认目标导入表里有没有send没有就改 HookWSASend或者两个都挂。排查时用调试器在send入口下断点看是否命中比猜快得多。5.3 现象文件写出来了但内容是乱的、长度对不上原因多线程并发调用sendWriteFile的“写头 写数据”不是原子操作两个线程交错写头和数据错位。解决给ObWriteRecord加临界区把两次WriteFile包起来或者先在内存里拼好一整块再一次性写。临界区粒度要小别把原始send调用也包进去否则影响目标程序性能。5.4 现象卸载 Hook 后目标进程崩溃原因卸载时只把backup写回但此时可能还有线程正停在 trampoline 里执行或者指令缓存没刷。解决卸载前先确认没有线程在 Hook 区域内简单做法是卸载时机选在进程退出阶段或加一个“已禁用”标志让替换函数直接透传写回原始字节后同样调FlushInstructionCache。血泪经验是调试期别频繁装卸装一次跑完再看结果。5.5 现象x64 下跳转地址算错跳到野地址原因E9的相对偏移是 32 位有符号数detour和target距离超过 2GB 时偏移溢出。解决x64 用绝对跳转mov rax, imm64; jmp rax12 字节或者用“就近分配 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回读脚本先校验魔数再解析。这样文件被截断或格式升级时脚本能明确报“格式不匹配”而不是解析出一堆垃圾数据还以为是协议问题。我早期就吃过这个亏拿着错位的长度字段调了半天协议最后发现是头结构对齐错了。从那以后我每次改落盘格式都强制先用魔数校验跑一遍最小验证集再谈业务解析。希望帮到你。本文还有配套的精品资源点击获取