ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Windows下拦截send函数:API Hook实现网络数据捕获与调试

2026/9/9 15:30:23 拓冰建站 浏览量
Windows下拦截send函数:API Hook实现网络数据捕获与调试 简介面向Windows底层网络开发者的实战资源核心围绕ws2_32.dll中send函数拦截这一主题讲解如何把发送数据写入名为OB的文件。资料适合具备C语言、C语言与Winsock编程基础、希望学习应用程序接口钩子或网络数据监控技术的开发者内容覆盖导入地址表挂接、Detours库替换思路和用户态钩子机制并强调实现过程中的稳定性、性能与合规风险。借助示例工程可以理解发送函数的套接字、缓冲区、长度与标志参数掌握在调用原始函数前截取数据并写入文件的完整流程。压缩包采用RAR压缩包格式共23个文件、整体约898KB基于VC6.0工程组织包含C源码、工程文件、编译链接产物、调试符号和说明文档还提供编译好的动态库与导入库既能阅读代码也能直接对照重建。已有1258人学习适合作为应用程序接口钩子入门和网络数据捕获的参考样板可从中拆分发送函数拦截、文件记录、DLL注入等关键代码段。 最近在调试一个网络客户端总想知道它到底往服务端发了什么数据。常规抓包工具要嘛要绑网卡要嘛对回环地址和部分 TLS 流量不友好折腾一圈下来最直接的办法反而是把目标进程里ws2_32.dll的send函数拦下来把每次发送的内容原样写到 ob 文件里。这个方案不光对 TCP 裸协议有效对 HTTP、自定义二进制协议同样适用而且能精确到“这个进程、这次调用、这批字节”可观测性比抓包还细。这篇文章就围绕这个需求把我这次的实现思路、完整代码和踩过的坑完整梳理一遍适合做 C/C 客户端开发、Windows 协议调试、逆向分析的同学参考。1. 需求拆解与整体设计1.1 send 函数是网络程序的“咽喉”send是 Winsock 2 里最核心的发送接口原型很简单int send(SOCKET s, const char* buf, int len, int flags);只要一个进程用标准 Winsock 发 TCP 数据无论上层用的是socket connect原始调用还是WinHTTP、libcurl、boost::asio封好的库最终都会汇聚到这个函数上。它接收三个关键信息socket 句柄、内存缓冲区指针、要发送的字节长度。所以思路就很清晰了只要在目标进程内部把send的调用入口劫持住在原始函数执行前把buf和len对应的字节抓下来写入我们的 ob 文件再转调原始send完成真正的网络发送拦截记录就完成了。整个过程对业务代码无侵入不需要改目标程序源码也不需要重新编译目标程序。这里要解释一个容易搞混的点UDP 发送走的是sendto不是send。如果你的目标程序是 UDP 协议比如有些游戏、音视频通话用的就是 UDP需要拦截的是sendto。这次我们聚焦send但实现思路完全一样把函数名替换成sendto同时多处理一个sockaddr*参数就可行。1.2 拦截方案怎么选替换DLL、IAT Hook、Inline Hook在 Windows 上要做 API 拦截常见的有三条路替换 ws2_32.dll、IAT Hook、Inline Hook典型实现是 Microsoft Detours。我在动手前简单对比过方案原理优点缺点替换 DLL自己做一份假的 ws2_32.dll放在程序目录优先加载理解简单系统 DLL 复制伪造容易踩坑比如“无法定位程序输入点”风险很高IAT Hook修改目标模块导入表中 send 的地址指向自己的函数实现可控、不需要框架、干净只对显式导入的函数有效动态 GetProcAddress 获取的调用拦不到Inline Hook / Detours改写 send 函数开头几条指令跳转到自定义函数最通用动态获取的也能拦对 CPU 架构和指令有要求要处理指令长度对齐出错容易崩我这个场景是调试自己开发的客户端和服务端程序目标进程里确实是通过#pragma comment(lib, ws2_32.lib)静态导入的send所以选 IAT Hook 就够用了。它的核心动作用一个生活化类比来说send函数的地址原本写在一个“通讯录”里程序每次打电话都要翻通讯录IAT Hook 做的就是悄悄把通讯录里“送快递”那一栏的电话号码改成你的号码别人打过去你接听事情办完再转给本人。1.3 写 ob 文件的设计要点拦截到的数据最终要落盘这里有两个设计上的关键点。第一个关键点是“保留真实的 send 函数指针”。我们的 Hook 函数最终还得调用原始send把数据发出去所以必须在替换 IAT 表前先保存原始函数地址。如果这一步漏了程序网络功能会直接废掉。第二个关键点是“日志写入不能走网络”。这一点非常反直觉如果你在 Hook 函数里调用了任何可能触发网络发送的函数比如用winsock初始化 Logger、或者误调用了原send就可能导致递归调用——自己拦自己栈溢出。我这里的做法是只在 Hook 函数里做文件写入绝不触发额外网络调用用fwrite流水式落盘。另外我在设计输出格式时没有只写裸数据而是加了个简单的头部把每次 send 的调用长度写进去这样回头做协议分析时能清楚区分数据边界哪怕 send 的数据是二进制也能看出分段在哪里。2. 核心细节与实操要点2.1 send 的签名与调用约定写 Hook 之前send的函数签名和调用约定必须确认清楚。Win32 下 Winsock 的导出函数大多声明为int PASCAL send(SOCKET s, const char* buf, int len, int flags);PASCAL在 Win32 里本质是__stdcall也就是说所有参数从右往左压栈被调函数负责清理栈。在 64 位系统上调用约定被统一成WINAPI实际上就是微软 64 位 ABI。为了兼容 32 位和 64 位我在代码里统一用WINAPI修饰自定义的 Hook 函数typedef int (WINAPI* SendFunc)(SOCKET s, const char* buf, int len, int flags); int WINAPI HookedSend(SOCKET s, const char* buf, int len, int flags);这里提醒一点不要在自定义函数里随便改动调用约定比如把WINAPI去掉。调用约定不匹配的话64 位下因为寄存器传参可能勉强能跑但 32 位下几乎必崩栈不平衡的报错查起来非常头大。2.2 IAT Hook 的完整实现手写版下面这段代码就是完整的手写 IAT HookDLL 加载后自动修改进程主模块的导入表并把日志写到send_hook.ob文件里。#include winsock2.h #include windows.h #include stdio.h #include string.h #pragma comment(lib, ws2_32.lib) typedef int (WINAPI* SendFunc)(SOCKET s, const char* buf, int len, int flags); static SendFunc g_OriginalSend NULL; static FILE* g_LogFile NULL; int WINAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { if (g_LogFile buf len 0) { fwrite([send] len, 1, 11, g_LogFile); char tmp[32] { 0 }; wsprintfA(tmp, %d, len); fwrite(tmp, 1, strlen(tmp), g_LogFile); fwrite(\n, 1, 1, g_LogFile); fwrite(buf, 1, len, g_LogFile); fwrite(\n, 1, 1, g_LogFile); fflush(g_LogFile); } return g_OriginalSend(s, buf, len, flags); } void PatchIAT(HMODULE hModule, const char* dllName, const char* funcName, void* pNewFunc, void** ppOldFunc) { if (!hModule) return; PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)hModule; if (pDos-e_magic ! IMAGE_DOS_SIGNATURE) return; PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)((BYTE*)hModule pDos-e_lfanew); if (pNt-Signature ! IMAGE_NT_SIGNATURE) return; IMAGE_DATA_DIRECTORY importDir pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir.Size 0) return; PIMAGE_IMPORT_DESCRIPTOR pDesc (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)hModule importDir.VirtualAddress); for (; pDesc-Name; pDesc) { const char* name (const char*)hModule pDesc-Name; if (_stricmp(name, dllName) ! 0) continue; PIMAGE_THUNK_DATA pThunk (PIMAGE_THUNK_DATA)((BYTE*)hModule pDesc-FirstThunk); PIMAGE_THUNK_DATA pOrgThunk (PIMAGE_THUNK_DATA)((BYTE*)hModule pDesc-OriginalFirstThunk); for (; pThunk-u1.Function; pThunk, pOrgThunk) { if (IMAGE_SNAP_BY_ORDINAL(pOrgThunk-u1.Ordinal)) continue; PIMAGE_IMPORT_BY_NAME pByName (PIMAGE_IMPORT_BY_NAME)((BYTE*)hModule pOrgThunk-u1.AddressOfData); if (strcmp(pByName-Name, funcName) ! 0) continue; DWORD oldProtect 0; VirtualProtect(pThunk-u1.Function, sizeof(void*), PAGE_READWRITE, oldProtect); if (ppOldFunc !*ppOldFunc) *ppOldFunc (void*)pThunk-u1.Function; pThunk-u1.Function (ULONG_PTR)pNewFunc; VirtualProtect(pThunk-u1.Function, sizeof(void*), oldProtect, oldProtect); return; } } } BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) { if (reason DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); char logPath[MAX_PATH] { 0 }; GetTempPathA(MAX_PATH, logPath); strcat_s(logPath, send_hook.ob); g_LogFile fopen(logPath, ab); HMODULE hExe GetModuleHandleA(NULL); PatchIAT(hExe, ws2_32.dll, send, (void*)HookedSend, (void**)g_OriginalSend); } else if (reason DLL_PROCESS_DETACH) { if (g_LogFile) { fclose(g_LogFile); g_LogFile NULL; } } return TRUE; }代码里有几个细节单独拎出来说一下。IMAGE_SNAP_BY_ORDINAL是必须判断的因为有些 DLL 导出函数序号是数字而不是字符串如果按字符串去比对pByName-Name遇到序号导入项时会读到非法内存直接崩溃。VirtualProtect用来把 IAT 表对应页改成可写。IAT 在 PE 加载后一般是只读页直接对pThunk-u1.Function赋值会触发访问违规。改完后再恢复原保护属性保持页面隔离的本意。原函数指针只在第一次找到时保存因为如果一个模块的 IAT 已经被覆盖再次执行 PatchIAT 时就不应该重复保存否则保存下来的是已变的钩子地址原始函数就丢了。所以我在赋值前加了个判断if (ppOldFunc !*ppOldFunc)。不是我在代码里演示的这样。2.3 更加省事的方案MinHook / Detours如果不想手写 IAT 遍历逻辑或者目标进程里send是动态获取的GetProcAddress(GetModuleHandleA(ws2_32.dll), send)那么手写 IAT Hook 就拦不到了这时候建议直接用 Inline Hook 库。我用过的两个比较顺手库特点Microsoft Detours微软官方出品功能全商用需要授权MinHook开源基于汇编级 Hook轻量适合拦截导出函数MinHook 的用法非常简洁核心就三步创建 Hook、启用 Hook、恢复 Hook。#include MinHook.h #pragma comment(lib, libMinHook-x64-v142-mtd.lib) typedef int (WINAPI* SendFunc)(SOCKET s, const char* buf, int len, int flags); SendFunc g_OriginalSend NULL; int WINAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { // 记录日志 return g_OriginalSend(s, buf, len, flags); } void InstallHook() { MH_Initialize(); MH_CreateHook(send, HookedSend, (void**)g_OriginalSend); MH_EnableHook(send); }这段代码比手写 IAT 短得多而且对“send 是否显式导入”不敏感。你在使用前先在项目里编译好对应架构x86/x64的 MinHook 静态库再包含头文件即可。我个人建议如果只是为了临时调试直接上 MinHook如果你想彻底搞清楚 Windows PE 加载机制手写一遍 IAT 是很有收获的。3. 实操过程与核心环节实现3.1 环境准备与编译我的开发环境是 Visual Studio 2022创建了一个空白的 C DLL 项目。有几个编译配置要提前注意目标架构一定要和被测程序一致。32 位程序用 x86 编译64 位程序用 x64 编译混用会导致 Hook 后立即崩溃。项目字符集选“多字节字符集”或者“使用 Unicode”代码里用了wsprintfA、fopen、strcat_s这些 A 结尾的 API避免宽窄字符混用。预处理器定义里不要多加_DEBUG类的东西避免运行时依赖 Debug CRT。最好选“/MT”静态运行时库注入到目标进程时不用额外携带运行时 DLL。编译完后你会得到一个send_hook.dll。这个 DLL 本身不能独立运行需要被注入到目标进程里。3.2 把 DLL 注入目标进程DLL 注入的方式有好几种我这里挑两个实际使用频率最高的方案。第一种是早期调试阶段最粗暴有效的用 Visual Studio 自带的调试器加载 DLL。打开“调试”菜单选择“进程附加”附加到目标进程后在“即时窗口”里执行LoadLibrary(LD:\\hook\\send_hook.dll);看到返回非零值就说明注入成功。之后可以用“调试”-“窗口”-“模块”确认send_hook.dll是否在列表中。第二种是写一个简单的注入器。核心代码其实就几行#include tlhelp32.h #include windows.h DWORD FindProcessIdByName(const wchar_t* name) { HANDLE snap CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); PROCESSENTRY32W pe { sizeof(pe) }; if (Process32FirstW(snap, pe)) { do { if (_wcsicmp(pe.szExeFile, name) 0) { CloseHandle(snap); return pe.th32ProcessID; } } while (Process32NextW(snap, pe)); } CloseHandle(snap); return 0; } void InjectIntoProcess(DWORD pid) { char dllPath[] D:\\hook\\send_hook.dll; HANDLE hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProcess) return; LPVOID remoteBuf VirtualAllocEx(hProcess, NULL, sizeof(dllPath), MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProcess, remoteBuf, dllPath, sizeof(dllPath), NULL); HMODULE hKernel32 GetModuleHandleA(kernel32.dll); LPTHREAD_START_ROUTINE pLoadLibrary (LPTHREAD_START_ROUTINE)GetProcAddress(hKernel32, LoadLibraryA); HANDLE hThread CreateRemoteThread(hProcess, NULL, 0, pLoadLibrary, remoteBuf, 0, NULL); WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); VirtualFreeEx(hProcess, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProcess); }这种方式方便但也有明显的副作用进程退出时如果 DLL 没有被FreeLibrary会在DLL_PROCESS_DETACH阶段做一些清理极端情况下可能造成进程挂起。所以注入器更多用于临时调试而不是生产环境。3.3 快速验证用 Frida 也能干这件事很多做逆向的同学可能更熟悉 Frida。对于“临时验证某个进程发送了什么”这个场景Frida 完全可以替代写 DLL 的整套流程而且不需要编译任何代码。以下这个脚本就能完成 send 拦截并把内容写到本地// send_hook.js const sendPtr Module.getExportByName(ws2_32.dll, send); Interceptor.attach(sendPtr, { onEnter(args) { const len args[2].toInt32(); const buf args[1].readByteArray(len); if (buf) { console.log(SEND len len data Array.from(new Uint8Array(buf)).map(b b.toString(16).padStart(2, 0)).join( )); } } });运行方式frida -p pid -l send_hook.js或者直接按进程名启动frida -n target.exe -l send_hook.jsFrida 的优势是零编译、零注入 DLL而且支持改脚本热重载。缺点是引入了额外运行时对反调试严格的进程不友好。如果是自己开发的客户端做协议联调查错我建议先用 Frida 快速定位确认问题确实出在 send 后再写 DLL 做长期日志。4. 常见问题与排查技巧实录4.1 无法定位程序输入点 gethostnamew 于动态链接库 ws2_32.dll 上这个报错我在早期用“替换 ws2_32.dll”方案时遇到过。这个报错的本质是系统在加载某个模块时PE 导入表里记录了要从 ws2_32.dll 导入gethostnamew这个函数结果加载器在当前加载的 ws2_32.dll 里找不到对应导出于是直接弹窗并终止进程。为什么会找不到因为你放了一个山寨的 ws2_32.dll 在程序目录里这个山寨 DLL 只实现了少数几个函数比如只处理了 send、recv其他函数没有转发到系统的真 DLL。进程加载时不只是为了你的 send 调用而加载 ws2_32.dll系统深层组件比如 resolver 也需要它提供gethostnamew、getaddrinfo、select等大量基础函数。gethostnamew就是典型例子很多进程启动时做网络初始化都会调用它。一旦 DLL 里没有这些基础导出加载器就会把整个模块视为“不可用”抛出上面那个报错。这也解释了为什么“完整替换系统 DLL”这个方案风险最高——你几乎是在重新实现整个 Winsock 库工程量太大。所以我的建议是尽量不要走替换 DLL 路线如果真的需要做全局拦截优先选用 Detours 或 MinHook 这种有保护的 Inline Hook它们不需要伪造系统 DLL也不影响其他未拦截函数的正常解析。如果已经遇到这个报错并且是开发环境可以先检查程序目录下是否有非系统的 ws2_32.dll把它改名或删除后重启程序通常会恢复。4.2 Hook 之后日志文件没有任何内容这是我遇到的最常见问题通常有三个原因按出现频率排序第一目标进程的 main exe 根本没有导入send。它可能只是间接调用了 ws2_32.dll 里的send真正导入方是它依赖的某个下层 DLL。比如程序用curl库curl 库内部链接 ws2_32.dll主模块 IAT 里不会有 send。解决方法是遍历所有已加载模块逐个 patch或者直接用 MinHook 拦截全局的send函数地址。第二程序使用的是WSASend而不是send。在高性能网络模型比如 IOCP中程序会更倾向WSASend它是send的扩展版本支持分散/聚集 IO。这个时候你要拦截的目标函数变成了WSASend而不是send。第三DLL 注入成功但日志路径没有写权限。如果你的目标进程是管理员权限运行而 DLL 在普通权限下打开某个目录fopen会失败。代码最好在启动时做一次空检查并记录错误信息到OutputDebugString或事件日志。4.3 Hook 后进程崩溃或卡死这种问题绝大多数出在“递归调用”或“重入”上。常见场景是Hook 函数里写日志时fwrite底层分配内存、做缓冲可能会触发堆锁而目标程序同时正在多线程大量调用 send两个线程在日志锁上竞争导致死锁。我的应对策略是给日志写入加一把独立的互斥锁同时把文件句柄的打开、写入、关闭都控制在这把锁内。另外日志写入绝不能调用原send来测试网络连通性也不要在 Hook 函数里调用任何可能再次进入网络层的 API。4.4 日志内容乱码或边界错乱如果发送的是二进制协议比如自定义结构体将buf当字符串直接写入文件时遇到0x00字节在查看日志时可能看起来像被截断了。另外一个逻辑层的“消息”可能被拆分成多次send调用也可能多个消息打包在一次send里。我的处理是在日志里只记录长度和原始字节。分析阶段再根据协议语义重新组装。如果后续需要更高级的流式重组可以在 Hook 层加上缓冲区累积逻辑比如按固定的“消息头长度字段”来切分但这就和具体协议相关了得针对项目改造。基础做法还是建议每次send一条记录原始数据完整保留。最后再分享一个小技巧从这次调试里我得到一个体会send拦截在本地调试阶段非常管用但生产环境不建议长期部署文件 IO 和锁竞争对网络吞吐还是有不小影响的。如果只是想知道某个第三方程序在发什么可以先试 Frida 脚本快速看一轮确认目标函数确实是 send 再写 DLL 长期挂载。如果程序走的是 IOCP 模型顺便把WSASend也一起拦掉否则你可能会看到“明明程序一直在发包日志却一片空白”的怪现象。最后日志文件要放在磁盘性能好的分区别放网络盘或安全软件实时扫描的目录不然会凭空增加延迟和很多莫名其妙的问题。本文还有配套的精品资源点击获取