简介这份资源面向具备一定 Windows 系统编程与网络编程基础、希望深入理解 API 钩子机制的开发者聚焦于拦截 ws2_32.dll 中的 send() 函数并将发送内容写入 OB 文件这一典型场景可用于网络监控、安全审计或性能分析类工具的学习与实验。压缩包共 23 个文件约 898KB包含 cpp 与 h 源码、dll 与 lib 库文件、def 导出定义、dsp 与 dsw 工程文件以及 pdb、obj、ilk 等调试编译中间产物和说明文本构成一套可直接用 VC6 打开编译的完整工程。资源已有 1265 人学习下载说明该钩子实现思路在同类学习中具有一定参考价值。读者可从中获得 send() 拦截的工程骨架、导入地址表替换与用户模式钩子的实现线索以及将捕获数据落盘为 OB 文件的处理逻辑便于对照理解钩子注册、原始函数转发与数据复制的完整流程并据此扩展 recv() 拦截或日志分析功能。1. 从一次抓包抓不到说起ws2_32.dll 拦截 send 到底能干什么有个做内网客户端排障的朋友遇到过这么个事业务程序发出去的报文在服务端对不上Wireshark 抓到的明文和程序日志里打印的内容差了一截日志是程序自己写的谁也不敢保证它没撒谎。最后他把目光落到了ws2_32.dll的send上——所有走 Winsock 的 TCP/UDP 发送最终都要经过这个导出函数在它入口处把缓冲区内容原样落盘才是真正意义上的“黑匣子”。这就是本文要拆的资源一份围绕ws2_32.dll拦截send函数、把发送内容写入.ob文件的实现代码包。它解决的是“程序说自己发了什么”和“网卡上实际出去什么”之间的信任缺口适合做协议逆向、客户端行为分析、加密前明文取证的安全从业者也适合想搞懂 API Hook 落地细节的 Windows 开发。send()和recv()这对函数是 Winsock 数据面的咽喉卡住这里收发两侧的原始字节就都握在手里了。2. 拦截 send 的三条技术路线为什么选 Inline Hook 而不是 IAT动手之前先把选型讲清楚不然代码抄下来也是玄学。Windows 上拦ws2_32.dll的导出函数常见做法有三条差别不在“能不能拦”而在“拦得全不全、藏得深不深、崩不崩”。2.1 IAT Hook、EAT Hook 与 Inline Hook 的边界IAT Hook 改的是调用方模块导入表里的函数地址把send的入口换成自己的函数。它实现简单但有个硬伤只对“通过导入表调用”的代码生效。现在不少程序动态LoadLibrary(ws2_32.dll)再GetProcAddress(send)或者干脆自己解析导出表IAT 里根本没有send这一项Hook 就落空了。EAT Hook 改的是ws2_32.dll自身导出表里的地址覆盖面比 IAT 广但同样绕不过“先取地址再内联调用”的场景而且改的是系统 DLL 的只读段得先改页属性。Inline Hook 直接改send函数开头的机器码插一条跳转指令到自己的处理函数执行完再跳回去。不管调用方怎么拿到地址只要最终call到send的入口就必然经过这里。代价是要处理指令长度、线程安全和还原逻辑翻车点最多。这份资源选的是 Inline Hook理由很直接取证场景要的是“全”宁可实现复杂一点也不能漏掉动态解析的调用。路线覆盖动态调用实现复杂度典型翻车点IAT Hook否低动态GetProcAddress绕过EAT Hook部分中改只读段、导出转发Inline Hook是高指令截断、多线程竞争2.2 send 的函数原型与参数含义拦截之前必须把原型吃透不然缓冲区长度算错写出来的.ob文件就是垃圾。send的声明是int WSAAPI send(SOCKET s, const char *buf, int len, int flags)四个参数里真正要落盘的是buf和len。buf是调用方传进来的发送缓冲区指针len是要发送的字节数注意它不一定等于strlen(buf)——二进制协议里buf中间完全可能有0x00用字符串函数去量长度是经典错误。返回值是实际发送的字节数可能小于len阻塞 socket 被信号打断、非阻塞 socket 缓冲区满所以记录时要以返回值为准而不是盲目相信传入的len。还有一个容易忽略的点send是WSAAPI调用约定在 x86 上是stdcallx64 上统一走微软 x64 调用约定。写跳板函数时调用约定必须和原函数一致否则栈会错位程序在send返回的瞬间就崩而且崩得毫无规律这种血泪经验只有踩过才知道。2.3 用代码把 Hook 装上去下面这段是核心安装逻辑思路是改send开头 5 个字节为E9相对跳转跳到我们的处理函数原指令备份到跳板里。x64 下相对偏移可能超出 2GB所以资源里用的是 12 字节的mov rax, imm64; jmp rax方案这里给出 x86 的 5 字节版本便于理解。#include windows.h #include stdio.h typedef int (WSAAPI *SendProto)(SOCKET, const char*, int, int); static SendProto g_origSend NULL; static BYTE g_backup[5]; // 备份 send 开头 5 字节 static BYTE* g_trampoline NULL; // 跳板备份指令 跳回原函数 // 把发送内容追加写入 .ob 文件 static void DumpToOb(const char* buf, int len) { HANDLE h CreateFileA(capture.ob, FILE_APPEND_DATA, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (h INVALID_HANDLE_VALUE) return; DWORD written 0; WriteFile(h, buf, (DWORD)len, written, NULL); // 二进制写不做编码转换 CloseHandle(h); } // 我们的替换函数签名必须和 send 完全一致 static int WSAAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { int ret g_origSend(s, buf, len, flags); // 先真正发出去 if (ret 0) DumpToOb(buf, ret); // 只记录实际发出的字节 return ret; }逻辑说明HookedSend先调用跳板里的原始send拿到真实返回值ret再按ret落盘。这个顺序很关键——如果先落盘再发送发送失败的内容也会被记进去取证就失真了。DumpToOb用FILE_APPEND_DATA打开文件多线程并发写时靠系统追加语义保证不互相覆盖但严格来说多线程下仍建议加锁后文避坑章节会展开。安装 Hook 的部分要改内存保护属性VirtualProtect把send所在页改成可写写入跳转再改回去BOOL InstallHook(void) { HMODULE hWs GetModuleHandleA(ws2_32.dll); if (!hWs) return FALSE; BYTE* pSend (BYTE*)GetProcAddress(hWs, send); if (!pSend) return FALSE; // 1. 分配跳板大小 备份指令 5 字节跳回 g_trampoline (BYTE*)VirtualAlloc(NULL, 16, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!g_trampoline) return FALSE; // 2. 备份原 5 字节并复制到跳板 memcpy(g_backup, pSend, 5); memcpy(g_trampoline, pSend, 5); // 3. 跳板末尾写“跳回 send5” g_trampoline[5] 0xE9; *(DWORD*)(g_trampoline 6) (DWORD)(pSend 5) - (DWORD)(g_trampoline 10); g_origSend (SendProto)g_trampoline; // 4. 改 send 开头为“跳到 HookedSend” DWORD oldProt 0; VirtualProtect(pSend, 5, PAGE_EXECUTE_READWRITE, oldProt); pSend[0] 0xE9; *(DWORD*)(pSend 1) (DWORD)HookedSend - (DWORD)(pSend 5); VirtualProtect(pSend, 5, oldProt, oldProt); FlushInstructionCache(GetCurrentProcess(), pSend, 5); return TRUE; }参数说明VirtualProtect的oldProt一定要保存并在最后还原否则目标进程的代码段长期处于可写状态既不合规也容易被其他检测盯上。FlushInstructionCache不能省x86 虽然硬件层面缓存一致但跨核执行时仍可能取到旧指令加上它成本极低。跳板偏移计算(DWORD)(pSend 5) - (DWORD)(g_trampoline 10)里的10是“跳板起始 5 字节备份 1 字节 E9 4 字节偏移”的末尾地址算错一个字节就是跳飞。3. 把发送内容写进 .ob 文件格式、并发与还原Hook 装上了只是第一步真正决定这份资源好不好用的是落盘这一环。.ob文件本身没有标准格式资源里把它当作“原始字节流容器”用好处是零解析成本坏处是丢了边界信息——你只知道字节顺序不知道哪一段属于哪一次send。所以实际落地时通常要在原始流外面包一层轻量记录头。3.1 记录格式设计与写入时机最省事的做法是每条记录前加 8 字节头4 字节长度 4 字节时间戳低 32 位后面跟len字节的原始数据。这样既保留了.ob的“裸字节”特性又能按记录切分回放。写入时机必须卡在原始send返回之后且只写ret 0的部分前面代码已经体现了这一点。如果业务是长连接高频小包每条都CreateFile/CloseHandle开销太大常见做法是进程内维护一个文件句柄首次调用时打开DllMain的PROCESS_DETACH里关闭。typedef struct { DWORD length; // 本次发送字节数 DWORD tick; // GetTickCount() 低 32 位用于排序 } ObRecordHeader; static void DumpRecord(const char* buf, int len) { static HANDLE hFile INVALID_HANDLE_VALUE; static CRITICAL_SECTION cs; static BOOL inited FALSE; if (!inited) { // 双检锁初始化避免多线程重复建锁 InitializeCriticalSection(cs); inited TRUE; } EnterCriticalSection(cs); if (hFile INVALID_HANDLE_VALUE) { hFile CreateFileA(capture.ob, FILE_APPEND_DATA, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); } if (hFile ! INVALID_HANDLE_VALUE) { ObRecordHeader hdr { (DWORD)len, GetTickCount() }; DWORD w 0; WriteFile(hFile, hdr, sizeof(hdr), w, NULL); WriteFile(hFile, buf, (DWORD)len, w, NULL); } LeaveCriticalSection(cs); }逻辑说明CRITICAL_SECTION保证多线程下“头 体”两次WriteFile不被其他线程插队否则文件里会出现头和体错位回放时长度字段读出来是天文数字。GetTickCount只有 49 天回绕长时间抓取要换成GetTickCount64或QueryPerformanceCounter这是资源里没写、但生产环境必须补的一环。3.2 卸载 Hook 与恢复现场拦截做完必须能干净还原否则目标进程退出时可能因为跳板内存被释放而崩在send里。还原逻辑就是把备份的 5 字节写回去再释放跳板void UninstallHook(void) { HMODULE hWs GetModuleHandleA(ws2_32.dll); BYTE* pSend (BYTE*)GetProcAddress(hWs, send); DWORD oldProt 0; VirtualProtect(pSend, 5, PAGE_EXECUTE_READWRITE, oldProt); memcpy(pSend, g_backup, 5); // 原样写回 VirtualProtect(pSend, 5, oldProt, oldProt); FlushInstructionCache(GetCurrentProcess(), pSend, 5); if (g_trampoline) { VirtualFree(g_trampoline, 0, MEM_RELEASE); g_trampoline NULL; } }注意还原顺序先写回指令再释放跳板。反过来的话在写回完成前如果还有线程正在跳板里执行跳板被释放就会访问违例。稳妥做法是还原前先挂起其他线程或者至少在单线程初始化阶段完成 Hook 安装与卸载资源里的示例是单线程演示直接搬到多线程业务里要补这一步。3.3 用 Python 把 .ob 拆回可读记录拿到.ob之后总得验证内容对不对写个几十行的解析脚本比肉眼翻十六进制靠谱import struct def parse_ob(path): with open(path, rb) as f: data f.read() off, records 0, [] while off 8 len(data): length, tick struct.unpack_from(II, data, off) off 8 if off length len(data): print(f截断记录: 期望 {length} 字节, 剩余 {len(data)-off}) break records.append((tick, data[off:offlength])) off length return records for tick, payload in parse_ob(capture.ob): print(tick, len(payload), payload[:32].hex())参数说明II是小端无符号双整型和 C 端DWORD在 x86/x64 上都是 4 字节小端字节序对不上会读出乱序长度。payload[:32].hex()只打印前 32 字节二进制协议里后面可能是大段密文全打印没意义。如果解析时频繁报“截断记录”八成是写入端没加锁或者进程被强杀导致最后一条没写完这正好是下一章要聊的坑。4. 避坑清单Inline Hook 拦 send 最容易翻车的五件事这套东西在实验室跑通和在真实业务进程里跑通中间隔着好几条命。下面五条是按“现象 → 原因 → 解决”整理的踩坑记录基本覆盖了从装 Hook 到落盘的全链路。4.1 程序启动即崩异常码 0xC0000005现象注入后目标进程秒退事件查看器里是访问违例崩在ws2_32.dll附近。原因多半是跳转偏移算错或者send开头 5 字节里包含了一条跨边界的指令——x86 变长指令集下send入口的前几条指令总长度未必正好 5 字节硬切 5 字节会把一条指令拦腰截断跳回去执行的就是半条指令。解决用轻量反汇编引擎如 Capstone从send入口逐条解码累加到长度 ≥5 为止备份和跳回都按实际长度算而不是写死 5。4.2 抓到的内容比实际发送的少一截现象.ob里记录的长度普遍小于业务日志里的长度。原因用了strlen(buf)而不是len参数二进制协议里遇到0x00就截断。解决严格用send的len入参和返回值任何字符串函数都不许碰缓冲区。这条是最高频的低级错误翻车率极高。4.3 多线程下 .ob 文件记录错位现象解析脚本报长度字段异常大或者记录边界对不上。原因多个线程同时进HookedSend两次WriteFile头、体之间被其他线程插入文件里出现“头A 头B 体A 体B”的交错。解决如 3.1 所示用临界区把“头体”包成原子写更彻底的做法是每条记录先拼进一块连续内存一次WriteFile写完。4.4 目标程序用了自己的 send 封装Hook 后行为异常现象Hook 装上后业务功能正常但某些加密流量抓出来是密文或者某些连接完全没记录。原因程序可能对send做了二次封装比如内部有个SafeSend先分片再调send你拦到的是分片后的数据也可能部分流量走的是WSASend而不是send。解决WSASend是另一条路径需要单独 Hook分片场景要在记录头里带上SOCKET句柄回放时按句柄重组。4.5 杀软/EDR 直接拦截注入现象注入器一运行就被拦或者 Hook 装完进程被挂起。原因改系统 DLL 代码段 远程注入这套组合是典型的高危行为特征。解决这条没有银弹合规前提下应优先在自有进程内做自 Hook把逻辑编进自己的模块启动时 Hook 自己进程的send避免跨进程注入调试阶段在隔离环境里做别在生产终端上试。5. 进阶把 send/recv 成对记录还原一次完整会话单拦send只能看到“出去什么”做协议分析时经常需要“谁在什么请求下回了什么”这就得把recv也拦上并且用SOCKET句柄把收发串起来。recv的原型是int WSAAPI recv(SOCKET s, char *buf, int len, int flags)和send的区别是缓冲区是出参数据在函数返回后才有效所以记录时机必须放在原始recv返回之后且只记录ret 0的部分。把 3.1 的记录头扩成 12 字节加一个方向标志和SOCKET值typedef struct { DWORD length; DWORD tick; DWORD sock; // SOCKET 句柄用于会话关联 BYTE dir; // 0send, 1recv BYTE pad[3]; // 对齐到 16 字节 } SessionRecord;回放时按sock分组、按tick排序就能拼出一条连接上的完整交互序列。这里有个细节SOCKET在 x64 上是 64 位UINT_PTR上面用DWORD存会截断实际落地要按目标架构选ULONG_PTR。另外recv的buf在调用前内容是未定义的千万别在调用前就去读它做记录那是读未初始化内存轻则垃圾数据重则崩溃。验证方法我一般走两步先用一个自写的回环客户端连本机监听端口发固定字符串跑一遍确认.ob里字节和预期完全一致再用真实业务进程跑拿 Wireshark 抓同一时段的包做交叉比对两边字节流对得上才算过关。比对时注意 TCP 会粘包拆包send一次不等于网卡上一段要比的是字节序列而不是分段边界。从那以后我每次做 API Hook 取证都强制先跑回环用例、再上真实进程而且一定在卸载 Hook 后让目标进程多跑几分钟确认没崩——这个习惯帮我挡掉了至少两次“卸载时释放跳板导致退出崩溃”的事故。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?