
简介这份资源聚焦Windows网络编程中的API拦截技术面向具备一定C与Winsock基础、希望深入理解系统级Hook机制的开发者。核心内容围绕拦截ws2_32.dll中的send()函数展开通过自定义钩子捕获套接字发送的数据并写入OB文件同时保留原始send()逻辑以保证网络通信正常可用于网络监控、协议分析与数据抓取等场景。压缩包共23个文件约898KB包含cpp与h源码、dll与lib编译产物、def导出定义、dsp与dsw工程文件以及pdb、obj、ilk等调试中间文件构成一套可直接编译调试的完整VC6工程。资源已有1260人学习读者可借此掌握IAT替换或Detours钩子的实现思路理解函数拦截的注入流程与数据落盘方式并参考说明文档排查编译与运行问题是研究Winsock底层拦截的实用范例。1. 从一次抓包失败说起为什么要拦截 ws2_32.dll 的 send线上有个老系统客户端和服务端之间的报文协议没有文档抓包工具抓到的又全是加密后的二进制。更麻烦的是这套程序在发包前做了一层自定义混淆Wireshark 里看到的字节流和业务字段对不上。当时第一反应是找源码结果这套东西是十年前外包交付的源码早就找不到了。最后能走通的路只有一条在进程内部把ws2_32.dll的send函数拦下来在数据进入协议栈之前把明文缓冲区 dump 到.ob文件里。ws2_32.dll是 Windows 上 Winsock 2 的用户态实现库几乎所有走 TCP/UDP 的网络程序最终都会调用它导出的send、recv、WSASend、WSARecv这些函数。send函数是同步发送的入口参数里带着待发送数据的指针和长度只要在它真正执行前把lpBuffer指向的内存按iSendSize读出来写文件就能拿到最原始的发送内容。.ob文件在这里就是一个自定义后缀的二进制落盘文件不参与任何解析纯粹做原始字节的持久化方便后续离线分析。这套方案适合三类人做协议逆向但拿不到源码的、需要审计第三方程序外发数据的、以及排查线上诡异发包行为又不想改业务代码的。它不需要重新编译目标程序也不需要管理员权限去装驱动属于用户态就能落地的轻量方案。下面把我实际跑通的路径拆开讲。2. 拦截 send 的三条技术路线与选型理由2.1 为什么优先选 IAT Hook 而不是 API Monitor 类工具拦截send常见有三条路IAT Hook、Inline Hook、以及用调试器或 ETW 做被动监控。被动监控不改代码但拿不到调用瞬间的完整缓冲区遇到分片发送还会丢上下文Inline Hook 直接改函数开头几个字节跳转威力大但容易被完整性校验和杀软盯上而且 x64 下指令长度对齐很麻烦。IAT Hook 改的是导入地址表。目标程序在 PE 加载时会把用到的ws2_32.dll函数地址填进自己的 IAT 表我们只要把表里send那一项替换成自己的函数地址程序调用send时就会先走到我们的代码。它的好处是不动ws2_32.dll本体只改调用方进程的内存影响面可控恢复也简单——把原地址写回去就行。对于「只想看发送内容」这个诉求IAT Hook 的性价比最高。提示如果目标程序是动态LoadLibraryGetProcAddress拿send地址IAT 里可能没有这一项这种情况才需要退回到 Inline Hook。2.2 用 Detours 还是手写 IAT 遍历微软的 Detours 库封装好了DetourAttach/DetourDetach几行就能挂上但它对静态链接和某些加壳程序支持一般而且引入了一个不小的依赖。我一般手写 IAT 遍历逻辑透明出问题好定位。核心就是解析目标进程的 PE 结构找到导入表遍历到ws2_32.dll对应的IMAGE_IMPORT_DESCRIPTOR再在它的FirstThunk数组里找到send的项替换地址。手写遍历需要处理 32 位和 64 位的差异32 位用IMAGE_THUNK_DATA3264 位用IMAGE_THUNK_DATA64函数名在IMAGE_IMPORT_BY_NAME里。判断函数名时用strcmp比对Name字段即可注意send和sendto、WSASend要区分开别误伤。2.3 注入方式DLL 注入的最小可行路径Hook 代码得跑在目标进程里所以要先注入一个 DLL。常见做法是CreateRemoteThreadLoadLibrary把 DLL 路径写进目标进程内存再让远程线程调用LoadLibraryA加载它。DLL 的DllMain里在DLL_PROCESS_ATTACH时执行 IAT HookDLL_PROCESS_DETACH时还原。// injector.cpp —— 最小注入器仅用于把 hook.dll 送进目标进程 #include windows.h #include cstdio int main(int argc, char** argv) { if (argc 3) { printf(usage: injector pid dll_path\n); return 1; } DWORD pid atoi(argv[1]); const char* dllPath argv[2]; HANDLE hProc OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProc) { printf(OpenProcess failed: %lu\n, GetLastError()); return 1; } // 在目标进程里分配空间存放 DLL 路径 SIZE_T len strlen(dllPath) 1; LPVOID remoteMem VirtualAllocEx(hProc, NULL, len, MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProc, remoteMem, dllPath, len, NULL); // 拿到 LoadLibraryA 地址让远程线程去调用它 LPTHREAD_START_ROUTINE loadLib (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA(kernel32.dll), LoadLibraryA); HANDLE hThread CreateRemoteThread(hProc, NULL, 0, loadLib, remoteMem, 0, NULL); WaitForSingleObject(hThread, INFINITE); VirtualFreeEx(hProc, remoteMem, 0, MEM_RELEASE); CloseHandle(hThread); CloseHandle(hProc); return 0; }这段注入器的逻辑说明OpenProcess拿目标进程句柄VirtualAllocEx在对方地址空间开一块内存放 DLL 路径CreateRemoteThread让目标进程自己调用LoadLibraryA加载我们的 DLL。参数上PROCESS_ALL_ACCESS权限较大实际生产里按最小权限给PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE就够。WaitForSingleObject等加载完成避免 DLL 还没挂上就退出。3. 把 send 的缓冲区写进 .ob 文件Hook 实现与落盘细节3.1 替换函数签名与原始函数指针保存send的原型是int WSAAPI send(SOCKET s, const char* buf, int len, int flags)WSAAPI就是__stdcall。我们的替换函数必须签名完全一致否则栈会错乱。原始地址要存到一个全局函数指针里转发时用。// hook.cpp —— IAT Hook 核心 #include winsock2.h #include windows.h #include cstdio typedef int (WSAAPI *send_t)(SOCKET, const char*, int, int); static send_t real_send nullptr; static FILE* g_obFile nullptr; static CRITICAL_SECTION g_lock; // 把发送缓冲区按原始字节追加写入 .ob 文件 static void DumpToOb(const char* buf, int len) { if (!g_obFile || !buf || len 0) return; EnterCriticalSection(g_lock); // 先写 4 字节长度头再写内容方便离线切分 fwrite(len, sizeof(int), 1, g_obFile); fwrite(buf, 1, len, g_obFile); fflush(g_obFile); LeaveCriticalSection(g_lock); } int WSAAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { DumpToOb(buf, len); return real_send(s, buf, len, flags); // 转发给真正的 send }逻辑说明HookedSend先落盘再转发保证即使后续send失败我们也拿到了程序「想发」的内容。DumpToOb里加了CRITICAL_SECTION因为多线程程序可能并发调用send不加锁会导致.ob文件里长度头和内容错位这是血泪经验。写长度头是为了离线解析时能准确切分每条报文否则纯字节流粘在一起没法还原边界。参数说明len是字节数不是字符数遇到多字节编码别按字符算flags一般原样透传不要自己改MSG_DONTROUTE之类的标志改了会改变发送行为。3.2 IAT 遍历与地址替换// 遍历目标模块的导入表把 send 换成 HookedSend static bool HookIAT(HMODULE module) { BYTE* base (BYTE*)module; IMAGE_DOS_HEADER* dos (IMAGE_DOS_HEADER*)base; IMAGE_NT_HEADERS* nt (IMAGE_NT_HEADERS*)(base dos-e_lfanew); IMAGE_IMPORT_DESCRIPTOR* imp (IMAGE_IMPORT_DESCRIPTOR*) (base nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress); for (; imp-Name; imp) { const char* dllName (const char*)(base imp-Name); if (_stricmp(dllName, ws2_32.dll) ! 0) continue; IMAGE_THUNK_DATA* thunk (IMAGE_THUNK_DATA*)(base imp-FirstThunk); for (; thunk-u1.Function; thunk) { FARPROC* func (FARPROC*)thunk-u1.Function; if (*func (FARPROC)real_send) continue; // 已替换过 // 通过名字判断是不是 send IMAGE_IMPORT_BY_NAME* name (IMAGE_IMPORT_BY_NAME*)(base thunk-u1.AddressOfData); if (strcmp((char*)name-Name, send) 0) { real_send (send_t)*func; // 保存原始地址 DWORD old; VirtualProtect(func, sizeof(FARPROC), PAGE_READWRITE, old); *func (FARPROC)HookedSend; // 替换 VirtualProtect(func, sizeof(FARPROC), old, old); } } } return real_send ! nullptr; }逻辑说明先定位导入表筛出ws2_32.dll再遍历它的FirstThunk数组。AddressOfData指向IMAGE_IMPORT_BY_NAME里面存着函数名。找到send后先把原地址存进real_send再用VirtualProtect把 IAT 项改成可写替换成HookedSend最后恢复页保护属性。这一步不做VirtualProtect会直接触发访问违例是新手最容易翻车的地方。参数说明IMAGE_DIRECTORY_ENTRY_IMPORT是导入表在数据目录里的索引FirstThunk是加载后已被系统填好真实地址的数组OriginalFirstThunk才是名字表别搞混。64 位下IMAGE_THUNK_DATA的字段是u1.Function结构体自动适配不用手写两套。3.3 .ob 文件的格式约定与打开时机.ob文件我一般约定成「长度头 原始字节」的重复序列长度头固定 4 字节小端。这样离线写个 Python 脚本就能按struct.unpack(i, f.read(4))逐条读出来。文件在DllMain的DLL_PROCESS_ATTACH里用fopen(path, wb)打开路径可以写死也可以从环境变量读避免权限问题。BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) { if (reason DLL_PROCESS_ATTACH) { InitializeCriticalSection(g_lock); g_obFile fopen(C:\\dump\\send_capture.ob, wb); HookIAT(GetModuleHandle(NULL)); // 只 Hook 主模块 } else if (reason DLL_PROCESS_DETACH) { if (g_obFile) fclose(g_obFile); DeleteCriticalSection(g_lock); } return TRUE; }逻辑说明GetModuleHandle(NULL)拿主模块句柄只 Hook 主模块的 IAT。如果目标程序把网络调用放在某个 DLL 里需要遍历所有已加载模块用EnumProcessModules逐个 Hook。fopen用wb二进制模式Windows 下不加b会把\n转成\r\n二进制数据直接被破坏这个坑我踩过。4. 避坑与排查拦截 send 时最容易翻车的五件事4.1 现象程序一启动就崩溃报 0xC0000005原因IAT 项所在内存页默认是只读的直接写会触发访问违例。解决替换前后必须用VirtualProtect改成PAGE_READWRITE改完再恢复原属性。注意VirtualProtect的最后一个参数是输出参数别传 NULL。4.2 现象.ob 文件里内容错位长度头和实际数据对不上原因多线程并发调用send两个线程同时写文件长度头和内容交叉。解决加临界区或互斥量把「写长度头 写内容」作为一个原子操作。用CRITICAL_SECTION比Mutex轻进程内够用。4.3 现象抓到的内容和 Wireshark 里看到的不一样原因程序可能调用的是WSASend而不是send或者用了sendto。WSASend走的是WSABUF数组缓冲区指针在结构体里和send完全不同。解决先确认目标程序实际调用的是哪个导出函数必要时把WSASend、sendto一起 Hook各自处理参数结构。4.4 现象Hook 挂上了但一直没数据原因目标程序在DllMain之前就已经完成了网络初始化或者send地址是通过GetProcAddress动态拿的IAT 里根本没有这一项。解决改用 Inline Hook或者 HookGetProcAddress本身在它返回send地址时做手脚。4.5 现象杀软直接拦截注入器原因CreateRemoteThreadWriteProcessMemory是典型注入行为特征。解决这是方案本身的边界生产环境要提前和运维沟通加白名单或者改用调试器附加的方式做被动拦截。别在没授权的情况下对生产机器动手。5. 进阶让 .ob 文件可检索以及一个验证 Hook 是否生效的技巧.ob文件本身是裸二进制时间一长就没法对应是哪次会话。我的习惯是在每条记录的长度头前面再加 8 字节时间戳GetTickCount64和 4 字节 socket 句柄这样离线分析时能按时间排序、按连接分组。格式变成「8 字节时间 4 字节 socket 4 字节长度 内容」Python 侧用struct.unpack(Qii, ...)一次读头。多花几行代码换来的是后期排查效率翻倍。验证 Hook 是否真的生效有个不依赖日志的土办法在HookedSend里对len做一个累加计数程序退出时把总字节数写到一个单独的.txt里和.ob文件大小对一下。如果.ob大小约等于「总字节数 记录数 × 头部长度」说明每条都落盘了如果明显偏小就是有并发丢写或者fflush没跟上。这个对账习惯帮我抓到过一次临界区漏加的问题。再进阶一点如果目标程序发送频率极高每条都fflush会拖慢性能。可以改成攒够 64KB 或超过 200ms 没新数据再刷盘用一个小缓冲区加定时判断。但要注意进程被强杀时缓冲区里的数据会丢所以关键会话还是老老实实每条刷。最后说个我自己的教训早期做这类拦截我总想一次把send、recv、WSASend、WSARecv全挂上结果四个函数的参数结构各不相同代码越写越乱调试时根本分不清是哪条路径出的问题。后来改成一次只挂一个函数跑通、对账、确认无误再挂下一个反而快得多。做底层拦截克制比贪多重要。希望帮到你。本文还有配套的精品资源点击获取