☰
Windows API Hook实战:拦截ws2_32.dll send函数实现发送内容取证
2026/11/2 15:52:13 网站建设 项目流程

简介:这份资源面向具备一定 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 上是stdcall,x64 上统一走微软 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. 跳板末尾写“跳回 send+5” 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:off+length])) 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; // 0=send, 1=recv BYTE pad[3]; // 对齐到 16 字节 } SessionRecord;

回放时按sock分组、按tick排序,就能拼出一条连接上的完整交互序列。这里有个细节:SOCKET在 x64 上是 64 位UINT_PTR,上面用DWORD存会截断,实际落地要按目标架构选ULONG_PTR。另外recv的buf在调用前内容是未定义的,千万别在调用前就去读它做记录,那是读未初始化内存,轻则垃圾数据重则崩溃。

验证方法我一般走两步:先用一个自写的回环客户端(连本机监听端口,发固定字符串)跑一遍,确认.ob里字节和预期完全一致;再用真实业务进程跑,拿 Wireshark 抓同一时段的包做交叉比对,两边字节流对得上才算过关。比对时注意 TCP 会粘包拆包,send一次不等于网卡上一段,要比的是字节序列而不是分段边界。

从那以后我每次做 API Hook 取证,都强制先跑回环用例、再上真实进程,而且一定在卸载 Hook 后让目标进程多跑几分钟确认没崩——这个习惯帮我挡掉了至少两次“卸载时释放跳板导致退出崩溃”的事故。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询