资讯动态

ws2_32.dll拦截send/recv:IAT Hook与.ob原始字节落盘

发布时间:2026/10/24 7:08:31 来源:尧图企业网站定制
简介围绕 Windows Socket 编程中 send() 函数拦截与数据落盘的实践工程面向具备 C 基础、正在学习 API Hook、网络监控与安全审计的开发者与逆向爱好者。工程通过替换导入地址表等方式挂钩 ws2_32.dll 中的 send()在原始调用前后捕获待发送缓冲区内容并写入 OB 文件同时保留原有发送逻辑可用于理解用户模式钩子的注册、执行与卸载流程。压缩包共 23 个文件、约 898KB包含 cpp 与 h 源码、dsp/dsw 工程配置、lib 与 def 导出定义以及 dll 成品和 pdb、obj、ilk 等编译调试产物另有 txt 说明文档辅助理解工程结构与编译方式。已有 1263 人学习下载。借助这份材料读者可编译运行观察钩子生效效果对照源码厘清拦截时机、缓冲区复制与文件写入的衔接并留意描述中提到的安全性、性能开销与合规边界为自建网络行为记录工具提供可复用的骨架。1. 从一次抓不到包的联调说起拦截 ws2_32.dll 的 send 到底解决什么联调时最难受的不是没日志而是日志和实际发出的字节对不上。客户端说自己发了 128 字节服务端只收到 96外部抓包工具里看到的又是另一层封装后的东西两边对不上号。这类问题最后只能回到进程内部ws2_32.dll 拦截 send 函数并把发送内容写到 .ob 文件说的就是这个项目——把 ws2_32.dll 拦截函数 send() recv() 拿到的原始缓冲区带上时间戳和 socket 句柄原样保存到磁盘。它解决的不是“网络通不通”而是“我这段代码到底往协议栈里增加了什么”。适合三类人做私有协议对接、要逐字节比对报文格式的排查自己程序里加密前后数据错位的以及想搞明白 Windows 用户态 API Hook 怎么写、怎么调试的开发者。整套东西不碰内核、不装驱动就是一个 DLL 加一个注入器编译出来几秒钟就能跑。2. 架构与选型IAT Hook 打底、Inline Hook 兜底的拦截方案2.1 为什么先改 IAT而不是一上来就写 Inline Hook要拦截 sendWindows 用户态有两条常见路径选哪条直接决定后面要花多少时间调试。第一条是 IAT Hook。PE 文件里有一张导入表记录着“我要用 ws2_32.dll 的哪个函数”。进程启动时加载器会把这张表里的每一项填成函数在内存里的真实地址。我们要做的就是把这张表里 send 对应的地址换成我们自己函数的地址。全程只涉及结构体解析和一次VirtualProtect不需要反汇编不需要考虑指令长度x86 和 x64 的代码几乎一样。第二条是 Inline Hook。直接改send函数开头那几个字节写一条跳转指令过去再把被覆盖的原始指令搬到一段可执行内存里执行完跳回来。它更强能拦到所有调用方但代价是要处理指令边界——x64 上一条jmp [rip0]加 8 字节地址就吃掉 12 字节这 12 字节里如果正好是半条指令跳回去就崩了。没有反汇编引擎比如一套长度解码器基本别想写对。我一般的选择是IAT Hook 先做能覆盖大部分场景代码量小、出错时现象明确Inline Hook 作为兜底只在发现 IAT 里压根没有 send 时才启用。这个项目最终也是这个结构——主路径走 IAT另外配一个定时重扫来兜住那些动态解析函数地址的程序。提示IAT Hook 有个天然边界它只对“编译时就写死要导入 send”的程序有效。如果目标程序是用GetProcAddress在运行时取地址的导入表里根本没有这一项怎么改都改不到。2.2 模块拆分注入器、Hook 核心、日志写入三块整份工程拆下来是四个部分职责很清晰改的时候不容易互相影响模块形态职责关键 API注入器injector.exe以挂起方式拉起目标进程把 DLL 路径写进去再放行CreateProcessW / VirtualAllocEx / CreateRemoteThreadHook 核心hookcore.dll解析导入表替换 send / recv 的表项GetModuleHandleW / VirtualProtect记录写入同 DLL 内组装定长头把原始缓冲区追加到 .ob 文件CreateFileW / WriteFile / CRITICAL_SECTION解析脚本obdump.py按格式切分 .ob打印成可读的收发流水struct / read数据流很短目标进程调用send→ 跳到我们的钩子函数 → 写一条记录头加原始负载 → 再调用原始send把数据真正发出去。整个链路里没有缓冲区拷贝以外的额外开销业务代码完全感知不到这也是为什么它适合挂在联调环境里长时间开着。2.3 .ob 日志格式设计定长头 变长体日志格式是这份工程里最容易被忽略、但最影响后续能不能用的部分。用文本行写比如printf(send: %s\n, buf)看着直观实际会出问题二进制协议里全是\0和不可见字符%s遇到\0直接截断UTF-8 与本地编码混在一起写出来再想还原原始字节就难了。所以格式定成“24 字节定长头 变长负载”字段类型说明magicuint32固定 0x3130424F用来校验对齐tickuint32GetTickCount 相对时间够定位先后顺序socketuint64连接句柄多连接并发时靠它分组directionuint160 表示 send1 表示 recvflagsuint16原始 flags保留下来便于判断 MSG_PEEK 之类的行为lengthuint32负载字节数用GetTickCount而不是SYSTEMTIME是因为前者只是一次读共享页的操作头部组装几乎零成本需要绝对时间的话在文件开头单独写一条启动基准就够了。socket 句柄必须带上——同一个进程里同时有五六条连接时不带句柄的日志就是一锅粥根本分不清哪段数据属于哪条会话。3. 核心代码拆解从导入表定位到 send 替换、数据落盘的完整链路3.1 定位 IAT 并改写 send 的函数指针先看最关键的一段遍历目标模块的导入表找到ws2_32.dll那一项再在它下面按函数名匹配send最后把 FirstThunk 里的地址换掉。// hook_iat.cpp —— 在指定模块的导入表里定位并替换函数地址 #include windows.h #include cstring typedef int (WSAAPI *SendFn)(SOCKET, const char*, int, int); static SendFn g_realSend nullptr; static bool PatchIatEntry(HMODULE target, const char* dllName, const char* funcName, void* newAddr, void** oldAddr) { auto base (PBYTE)target; auto dos (PIMAGE_DOS_HEADER)base; if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return false; auto nt (PIMAGE_NT_HEADERS)(base dos-e_lfanew); // e_lfanew 是 NT 头偏移 if (nt-Signature ! IMAGE_NT_SIGNATURE) return false; // 数据目录第 1 项是导入表存的是 RVA要加基址才是可用指针 auto dir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (dir.VirtualAddress 0) return false; auto imp (PIMAGE_IMPORT_DESCRIPTOR)(base dir.VirtualAddress); for (; imp-Name ! 0; imp) { const char* mod (const char*)(base imp-Name); if (_stricmp(mod, dllName) ! 0) continue; // DLL 名大小写不敏感 // OriginalFirstThunk 存的是函数名数组FirstThunk 存的是真正被调用的地址 auto oft (PIMAGE_THUNK_DATA)(base imp-OriginalFirstThunk); auto ft (PIMAGE_THUNK_DATA)(base imp-FirstThunk); for (; oft-u1.AddressOfData; oft, ft) { if (oft-u1.Ordinal IMAGE_ORDINAL_FLAG) continue; // 序号导入跳过 auto ibn (PIMAGE_IMPORT_BY_NAME)(base oft-u1.AddressOfData); if (strcmp((const char*)ibn-Name, funcName) ! 0) continue; DWORD oldProt 0; VirtualProtect(ft-u1.Function, sizeof(void*), PAGE_READWRITE, oldProt); if (oldAddr) *oldAddr (void*)ft-u1.Function; // 先把原地址存下来 ft-u1.Function (ULONG_PTR)newAddr; VirtualProtect(ft-u1.Function, sizeof(void*), oldProt, oldProt); return true; } } return false; }几个参数要盯住。target传的是主模块句柄用GetModuleHandleW(nullptr)取不是 ws2_32 自己的句柄——我们要改的是调用方的表。dllName必须写成ws2_32.dll但比较时用_stricmp因为目标程序里可能写成WS2_32.DLL。oldAddr是出口参数把原始函数地址带回来存进g_realSend不然后面转发就断了。还有个细节VirtualProtect改完一定要改回去。有些加固过的程序会校验自己导入表的页面属性你留个PAGE_READWRITE在那儿反而容易触发别的检查。3.2 钩子函数里做数据写入先记录再转发替换上去的钩子函数承担两件事写日志、调原始函数。顺序很重要先写后调——如果原始send返回错误比如连接已经被对端关了日志里至少还留着“我尝试发了什么”的现场。#pragma pack(push, 1) // 强制 1 字节对齐保证和脚本侧一致 struct ObRecord { uint32_t magic; // 0x3130424F uint32_t tick; // GetTickCount相对时间 uint64_t socket; // 连接句柄 uint16_t direction; // 0send 1recv uint16_t flags; // 原始 flags uint32_t length; // 负载长度 }; #pragma pack(pop) static HANDLE g_log INVALID_HANDLE_VALUE; static CRITICAL_SECTION g_cs; static __declspec(thread) int g_inHook 0; // 线程局部重入闸门 static void WriteRecord(uint16_t dir, SOCKET s, const char* buf, int len, int flags) { if (g_inHook) return; // 已经在钩子里了直接放行防止无限递归 g_inHook 1; ObRecord rec{ 0x3130424F, GetTickCount(), (uint64_t)s, dir, (uint16_t)flags, (uint32_t)len }; EnterCriticalSection(g_cs); if (g_log ! INVALID_HANDLE_VALUE) { DWORD written 0; WriteFile(g_log, rec, sizeof(rec), written, nullptr); WriteFile(g_log, buf, len, written, nullptr); // 原样落盘不做任何转码 } LeaveCriticalSection(g_cs); g_inHook 0; } static int WSAAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { WriteRecord(0, s, buf, len, flags); return g_realSend(s, buf, len, flags); // 转发给真正的 send }g_inHook这个闸门是防止递归用的。假如写入模块内部某个环节又触发了 socket 操作有些日志库会把错误上报到远端就会再次进到HookedSend再写日志再触发形成递归表现是 CPU 占满、文件迅速膨胀或者直接栈溢出。用__declspec(thread)做线程局部而不是全局标志是因为全局标志在多线程下会把别的线程的正常记录也一并丢掉。EnterCriticalSection保护的是两件事文件句柄本身以及“头 体”这两次WriteFile的原子性。不加锁的话两个线程同时写很可能出现 A 的头接上 B 的体解析脚本一读就错位。还有一处常见写法要避开不要记录len就直接完事。send返回的是实际发出的字节数非阻塞模式下可能小于len。要精确到“实际发出去多少”得在调完g_realSend之后补一条 8 字节的短记录把返回值带上。联调阶段追求精确定位的话这一步别省。3.3 recv 侧、线程安全与定时重扫recv侧的钩子和send几乎对称只是方向字段填 1而且要区分recv和WSARecv。注意recv的缓冲区是输出参数钩子里拿到的是“还没被填充”的内存所以记录动作必须放在调用原始函数之后用返回值当长度static int WSAAPI HookedRecv(SOCKET s, char* buf, int len, int flags) { int ret g_realRecv(s, buf, len, flags); // 先让数据真正填进 buf if (ret 0) WriteRecord(1, s, buf, ret, flags); return ret; }这里ret才是有效字节数用len会把一整块未初始化的栈内存写进日志文件里全是乱码排查时特别容易误判。关于动态解析函数地址的程序靠一个定时重扫来兜底起一个工作线程每 500 毫秒扫一遍导入表发现send那一项又变回原始地址说明中间被别的模块改过或者刚被动态填充就重新打补丁。这种做法比写 Inline Hook 简单得多代价是那 500 毫秒的窗口里可能漏掉几笔调用。真要覆盖GetProcAddress场景还是得在ws2_32.dll的导出表上做入口级的内联改写那部分代码量翻三倍建议先把 IAT 这套跑通再考虑。static DWORD WINAPI RescanThread(LPVOID) { auto mainMod GetModuleHandleW(nullptr); while (g_running) { void* cur nullptr; if (GetIatEntry(mainMod, ws2_32.dll, send, cur) cur ! HookedSend) { PatchIatEntry(mainMod, ws2_32.dll, send, (void*)HookedSend, g_realSend); } Sleep(500); } return 0; }4. 编译与注入把 DLL 送进目标进程并让 .ob 文件真的写出来4.1 工程配置里三个必须改的地方代码写对不代表编出来能用编译参数上翻车的次数不比写代码少。第一是位数。DLL 的位数必须和目标进程一致64 位进程里塞 32 位 DLLCreateRemoteThread会直接失败报的错误码还特别含糊。工程里通常建两个配置分别出 x86 和 x64别指望一个 DLL 通吃。第二是运行库。用/MT静态链接 CRT别用/MD。目标进程不一定装了对应版本的运行库DLL 一加载就因缺依赖而失败而LoadLibraryW的失败在远程线程里是静默的你只能看到WaitForSingleObject超时。第三是导出方式。不需要__declspec(dllexport)任何函数注入靠的是LoadLibraryWDLL 只要有个DllMain就行。入口里只做最小动作BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) { if (reason DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hInst); // 只起线程不在这里做任何加载或文件操作 CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr); } return TRUE; }InitThread里才去建文件、初始化临界区、打补丁。原因在第 5 章细说简单讲就是DllMain执行期间加载器锁还被占着在里面做任何可能触发 DLL 加载的动作都有死锁风险。4.2 注入器挂起启动、写入路径、再放行注入这一步要用“先挂起、后放行”的方式不能等目标进程跑起来再注入。理由很直接如果程序在启动阶段就已经建立了连接、发了第一包注入晚了就漏了。// injector.cpp —— 挂起方式启动目标进程注入 DLL 后恢复 STARTUPINFOW si{ sizeof(si) }; PROCESS_INFORMATION pi{}; CreateProcessW(Ltarget.exe, nullptr, nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, si, pi); // 关键挂起启动 SIZE_T bytes (dllPath.size() 1) * sizeof(wchar_t); LPVOID remote VirtualAllocEx(pi.hProcess, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); WriteProcessMemory(pi.hProcess, remote, dllPath.c_str(), bytes, nullptr); // LoadLibraryW 在 kernel32 里同机器上各进程地址一致可以直接取 auto load (LPTHREAD_START_ROUTINE)GetProcAddress( GetModuleHandleW(Lkernel32.dll), LoadLibraryW); HANDLE th CreateRemoteThread(pi.hProcess, nullptr, 0, load, remote, 0, nullptr); WaitForSingleObject(th, 5000); // 等 DllMain 返回 ResumeThread(pi.hThread); // 这时候才让目标进程真正跑起来CREATE_SUSPENDED是整段的重点。目标进程创建出来但不执行任何指令我们在这段时间里把 DLL 路径写进它的地址空间、远程调用LoadLibraryW等 DLL 加载完、钩子打好、日志文件建好再ResumeThread。这样目标进程的第一条 socket 调用就已经在监控之下了。WaitForSingleObject超时不代表失败只代表DllMain还没返回——如果我们的初始化逻辑写了耗时操作这里就会一直等。等 5 秒是经验值超过这个数基本说明初始化里卡住了回去查是不是在DllMain里做了文件 IO。4.3 验证先用已知报文对一遍字节写完别急着上真实业务先用一个十行的测试客户端打一遍// 发送固定内容便于逐字节核对 const char* payload HELLO-OB-0123456789; int ret send(sock, payload, (int)strlen(payload), 0); printf(send returned %d\n, ret);跑完之后用解析脚本把 .ob 拆开看import struct HDR struct.Struct(IIQHHI) # 24 字节对应 C 侧的 #pragma pack(1) with open(send_capture.ob, rb) as f: while True: head f.read(24) if len(head) 24: break magic, tick, sock, direction, flags, length HDR.unpack(head) body f.read(length) # 按 length 精确切负载 tag S if direction 0 else R print(f{tick:8} {tag} sock#0x{sock:x} {length}B {body[:64]!r})验证要看三件事第一length是否等于测试客户端打印的返回值第二body里的字节和payload是否完全一致一个字节都不能差第三sock句柄是否和代码里打印的连接句柄对得上。这三项都对上说明从 IAT 定位到数据写入的整条链路是通的。如果length对但内容不对八成是把len和返回值搞混了如果一条记录都没有先去确认 DLL 到底有没有加载成功——在目标进程里用任意一个能列模块的工具看一眼比猜快得多。5. 避坑与排查五个让日志空白或进程直接崩掉的现场5.1 目标进程一启动就卡死日志文件也建不出来现象注入器返回成功但目标进程界面永远不出来.ob文件大小是 0。原因初始化逻辑写在了DllMain里包括CreateFileW、InitializeCriticalSection甚至打补丁。DllMain执行时加载器锁没释放里面任何触发 DLL 加载包括 CRT 的懒初始化的动作都可能死锁。解决DllMain只保留DisableThreadLibraryCalls和一次CreateThread所有实际工作挪到工作线程里做。另外注意CreateThread在DllMain里调用是不推荐的更保守的写法是用一个未文档化的方式排队但对这类调试工具来说起线程已经足够。5.2 文件大小几百兆里面全是自己写自己的重复记录现象跑一分钟.ob就上 G拆开看是同一段数据反复出现。原因写入模块内部又触发了 socket 调用回到钩子函数再写日志形成递归或者钩子函数里用了一个会走网络的日志库。解决线程局部重入闸门g_inHook进钩子先判断、置位出钩子复位。同时把写入模块限制成只用WriteFile禁止在钩子路径上调用任何第三方库。5.3 64 位进程注入后远程线程立刻退出错误码很难懂现象CreateRemoteThread返回的句柄有效但WaitForSingleObject马上 signaledDLL 完全没加载。原因DLL 位数和目标进程位数不匹配。32 位 DLL 进 64 位进程LoadLibraryW直接失败反过来同理。解决编译两套注入器里先查目标进程位数IsWow64Process加上当前系统位数判断再选对应的 DLL 路径。这一步别偷懒用“先试 x64 失败再试 x86”的方式排查效率很低。5.4 部分连接一条记录都没有其他连接正常现象同一个进程里 A 功能有日志B 功能完全没有。原因B 功能用的不是send而是WSASend或者根本没走静态导入——用GetProcAddress动态取了地址甚至用了自带的网络封装库导入表里压根没有 ws2_32 那一项。解决先把WSASend/WSASendTo/WSARecv一起补上这几个是重叠 IO 场景的主力再开一个 500 毫秒的重扫线程兜住动态填充的情况。真要覆盖GetProcAddress只能上导出表入口的内联改写了。5.5 数据看起来是乱码完全对不上协议文档现象字节数对得上内容却是一堆随机数据。原因业务层用了 TLSsend抓到的是加密后的密文。这是 Hook 位置决定的——钩在传输层拿到的必然是已经加密的字节。另一个可能是协议里做了压缩看到的是压缩流。解决先确认加密发生在哪一层。如果加密在应用层自己做的把钩子往前挪钩在加密函数上如果是标准 TLS那用户态的send钩子只能看到密文得换思路比如直接在业务层加一个可开关的字节流写入点。这一点在动手之前就要想清楚不然写完才发现拿不到明文血泪经验。6. 二次开发把裸字节流改造成可用的协议解析与回放工具.ob里存的是“时间 句柄 方向 原始字节”这只是原料直接看还是费劲。真正好用的是在它上面再加两层。第一层是会话重组。按socket分组direction分成两条流再按tick排序就能拼出“客户端发了什么、服务端回了什么”的对话视图。这一步用 Python 三十行就能写完重点是把同一个socket句柄在关闭后可能被复用这件事处理掉——一旦发现同一句柄的tick出现大幅回退就当成新会话切开。我在第一次做的时候没处理这个结果两个不相干的会话被拼到一起对着“响应对不上请求”查了半天才发现是工具的问题。第二层是加协议描述。与其在代码里硬编码字段偏移不如把协议写成外部规则文件让解析脚本按规则切# proto.json 里描述一类报文magic 4 字节、cmd 2 字节、len 4 字节、body 变长 RULE { magic: (I, 0), cmd: (H, 4), len: (I, 6), body: (raw, 10) }这样新增一种报文只要加一条规则不用改解析逻辑也不用重新编译 DLL。二次开发里最值得投入的就是这一块——把“看字节”变成“看字段”效率差距是数量级的。再往上的改造方向有三个。一是加运行时过滤只记录指定socket或指定长度以上的包避免日志爆炸二是加环形缓冲先在内存里攒写到 64KB 再一次性写入磁盘减少WriteFile次数三是把记录格式改成“头 体”分开存放头写一个文件、体写另一个解析时用偏移索引大文件随机访问会快很多。验证改造有没有改坏我一般留一个固定的回放用例一段已知报文、一个预期字段解析结果跑一遍对比。这套东西不复杂但没有它每次动格式都得手工核对迟早出错。说个自己的教训。早些年写这类工具我总想着“先把钩子打上再说”日志格式随手定字段想到哪加到哪结果联调第三天要对比两周前的日志发现字段布局早就变了旧文件解析出来全是错位的乱码。从那以后我每次动记录结构都强制先加一个版本号字段并且把解析脚本和 DLL 一起提交。做底层数据抓取的工具格式兼容比功能多寡重要得多。按## 1. 从一次抓不到包的联调说起…到## 6. 二次开发…的顺序走一遍先跑通固定报文的验证再谈过滤和解析最后才碰内联改写。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑