资讯详情

Windows API Hook实战:拦截ws2_32.dll的send函数实现网络数据抓取

📅 2026/10/15 13:54:27 | 华诺云谱 👁 阅读
Windows API Hook实战:拦截ws2_32.dll的send函数实现网络数据抓取
简介这份资源聚焦Windows网络编程中的API拦截技术面向具备一定C与Winsock基础、希望深入理解钩子机制的中高级开发者。核心内容围绕ws2_32.dll中的send()函数展开通过API钩子或用户模式钩子方式拦截数据发送过程并将捕获的发送内容写入OB文件可用于网络监控、安全审计与性能分析等场景。压缩包共23个文件约898KB包含cpp与h源码、dll与lib库文件、def导出定义、dsp与dsw工程文件以及pdb、obj、ilk等编译调试产物和说明文档构成一套可直接编译研究的完整工程。目前已有1262人学习下载。读者可从中掌握导入地址表替换、钩子处理程序注册、原始函数转发等关键实现思路并借助现成工程快速验证拦截效果理解数据捕获与文件落盘的完整链路为后续开发网络监控或协议分析工具打下实践基础。1. 从一次抓包需求说起为什么要拦截 ws2_32.dll 的 send有个做安全测试的朋友遇到过这么个场景某客户端程序走的是私有协议Wireshark 抓到的全是加密后的二进制流根本看不出业务逻辑。他想知道程序到底往服务器发了什么于是想到了最直接的办法——在ws2_32.dll这一层把send函数拦下来把发送缓冲区的内容原样 dump 到文件里。这个思路听起来简单但真动手写的时候坑比想象中多函数地址怎么拿、参数怎么解析、多线程下怎么保证不丢数据、写文件会不会拖慢原程序……每一个都是实打实要解决的问题。这份资源给的就是一套完整的实现代码核心目标很明确拦截ws2_32.dll导出的send函数在原始调用发生之前把待发送的数据写入.ob文件同时不影响程序本身的网络通信。它适合两类人一是做协议逆向、安全分析、外挂检测的开发者需要观察目标进程的真实网络行为二是想学习 Windows API Hook 技术的人拿这个当练手项目比看纯理论文章强得多。技术栈上它走的是经典的 Inline Hook 路线用 C 写依赖 Windows API 和少量汇编编译出来是个 DLL通过注入或劫持的方式加载进目标进程。2. 整体架构与核心模块拆解从 DLL 加载到数据落盘2.1 项目目录结构与模块职责拿到源码后先别急着看 Hook 那部分把目录结构理清楚后面读代码会顺很多。这套代码的组织方式比较典型大致分这么几块ws2_32_hook/ ├── src/ │ ├── dllmain.cpp // DLL 入口负责初始化 Hook │ ├── hook_engine.cpp // Inline Hook 的核心实现 │ ├── hook_engine.h │ ├── send_intercept.cpp // send 函数的拦截逻辑与数据写文件 │ └── send_intercept.h ├── include/ │ └── win_headers.h // 统一包含 Windows 头文件避免顺序问题 ├── build/ │ └── (编译输出目录) └── README.mddllmain.cpp是入口DllMain里在DLL_PROCESS_ATTACH时调用InstallHook()在DLL_PROCESS_DETACH时调用UninstallHook()。hook_engine.cpp负责最底层的字节替换把目标函数开头几个字节改成跳转指令指向我们自己的函数。send_intercept.cpp则是业务层拿到send的参数后把buf指向的内存写进.ob文件然后再调用原始send。这种分层的好处是如果你想拦截recv或者WSASend只需要在send_intercept.cpp旁边加个新文件Hook 引擎不用动。2.2 Inline Hook 的核心原理与代码实现Inline Hook 的本质就一句话修改目标函数开头的机器码让它跳转到我们的函数。x86 和 x64 下跳转指令的写法不一样这份代码里用的是最通用的方式——先保存原始字节再写入jmp指令。// hook_engine.cpp 核心片段 bool InstallHook(void* targetFunc, void* hookFunc, void** originalFunc) { // 1. 保存目标函数开头 5 个字节x86 下 jmp 需要 5 字节 BYTE jmpCode[5] { 0xE9, 0x00, 0x00, 0x00, 0x00 }; DWORD oldProtect; // 2. 计算跳转偏移目标地址 - 当前地址 - 5 DWORD offset (DWORD)hookFunc - (DWORD)targetFunc - 5; memcpy(jmpCode 1, offset, 4); // 3. 保存原始字节到全局变量供 trampoline 使用 memcpy(g_originalBytes, targetFunc, 5); // 4. 修改内存保护属性允许写入 VirtualProtect(targetFunc, 5, PAGE_EXECUTE_READWRITE, oldProtect); // 5. 写入 jmp 指令 memcpy(targetFunc, jmpCode, 5); // 6. 恢复内存保护 VirtualProtect(targetFunc, 5, oldProtect, oldProtect); // 7. 构造 trampoline原始字节 jmp 回原函数5 的位置 // 这样我们的 hook 函数可以调用 originalFunc 来执行原始逻辑 *originalFunc BuildTrampoline(targetFunc, g_originalBytes); return true; }这段代码有几个关键点需要说清楚。第一0xE9是 x86 架构下近跳转的操作码后面跟 4 字节相对偏移。偏移的计算方式是目标地址 - 当前地址 - 指令长度这里的指令长度是 5。第二VirtualProtect这一步不能省代码段默认是只读可执行的直接写会触发访问违例。第三BuildTrampoline是这套实现里最容易被忽略的部分——它把原始的前 5 个字节复制到一块新内存里再在那块内存末尾加上跳回原函数第 6 字节的指令。这样当我们的 hook 函数想调用原始send时直接调 trampoline 就行不会陷入无限递归。参数说明targetFunc是ws2_32.dll里send的真实地址通过GetProcAddress获取hookFunc是我们自己写的拦截函数originalFunc是输出参数返回 trampoline 的地址。2.3 send 拦截函数与数据写文件逻辑Hook 装好之后每次目标程序调用send都会先走到我们的函数里。这个函数的签名必须和原始send完全一致否则栈会乱。// send_intercept.cpp int WINAPI Hooked_send(SOCKET s, const char* buf, int len, int flags) { // 1. 先写文件记录发送内容 if (buf ! nullptr len 0) { WriteToObFile(buf, len); } // 2. 调用原始 send保证网络通信不受影响 return g_originalSend(s, buf, len, flags); } void WriteToObFile(const char* data, int len) { // 用临界区保证多线程下写文件不交错 EnterCriticalSection(g_csFile); HANDLE hFile CreateFileA( send_dump.ob, FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr ); if (hFile ! INVALID_HANDLE_VALUE) { DWORD written 0; // 先写长度再写内容方便后续解析 WriteFile(hFile, len, sizeof(len), written, nullptr); WriteFile(hFile, data, len, written, nullptr); CloseHandle(hFile); } LeaveCriticalSection(g_csFile); }这里有几个设计决策值得展开。第一为什么先写文件再调用原始send因为如果先调用原始send数据可能已经被异步发送出去了而我们的写文件操作如果失败就丢掉了这次记录。先写文件能保证只要send被调用数据就一定被记录。第二文件格式用的是「长度 内容」的二进制格式而不是直接追加原始字节。这样做的好处是解析的时候能准确知道每条消息的边界不会因为 TCP 粘包问题把两条消息混在一起。第三临界区是必须的因为一个进程里可能有多个线程同时调send不加锁的话写文件会交错数据就废了。.ob文件本身没有固定格式就是二进制流你可以用十六进制编辑器打开看也可以写个简单的解析脚本按「4 字节长度 N 字节内容」的方式读出来。3. 编译、注入与运行验证让代码真正跑起来3.1 编译环境与项目配置这套代码在 Visual Studio 下编译最省事用 2019 或 2022 都行。新建一个「动态链接库 (DLL)」项目把src和include里的文件加进去然后改几个配置项。第一字符集设成「使用多字节字符集」因为代码里用的是CreateFileA和char*如果设成 Unicode 会有一堆类型不匹配的报错。第二运行库建议选「多线程 (/MT)」这样编译出来的 DLL 不依赖 VC 运行时注入到别的进程里不容易出问题。第三如果你要拦截的是 64 位程序那 DLL 必须编译成 x6432 位程序就编译成 Win32这个不能搞错否则注入会失败。编译命令用 MSBuild 的话大概是这样msbuild ws2_32_hook.sln /p:ConfigurationRelease /p:Platformx64编译成功后会在build目录下生成ws2_32_hook.dll这个就是我们要注入的文件。3.2 注入方式与加载时机DLL 写好了怎么让它进到目标进程里常见做法有三种按复杂度从低到高排第一种是「劫持加载」把 DLL 改名成ws2_32.dll放到目标程序目录下。Windows 加载 DLL 时有个搜索顺序程序目录优先级很高所以目标程序会先加载我们的 DLL。但这种方式有个问题——我们的 DLL 里必须转发所有原始ws2_32.dll的导出函数否则程序调用其他网络函数时会找不到入口。这份代码里没有包含转发逻辑所以这种方式需要你自己补一个.def文件。第二种是「远程线程注入」用CreateRemoteThread在目标进程里调LoadLibrary加载我们的 DLL。这种方式通用性强但需要写一个注入器而且对某些有保护的程序可能失败。第三种是「调试器附加」直接用调试器启动目标程序在LoadLibrary上设断点手动把 DLL 加载进去。这种方式适合调试阶段不适合自动化。我一般会先写个简单的注入器用OpenProcessVirtualAllocExWriteProcessMemoryCreateRemoteThread这套组合拳。核心代码大概二十行// injector.cpp 核心逻辑 HANDLE hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); const char* dllPath C:\\path\\to\\ws2_32_hook.dll; size_t pathSize strlen(dllPath) 1; LPVOID remoteMem VirtualAllocEx(hProcess, nullptr, pathSize, MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProcess, remoteMem, dllPath, pathSize, nullptr); LPVOID loadLibAddr GetProcAddress( GetModuleHandleA(kernel32.dll), LoadLibraryA); HANDLE hThread CreateRemoteThread(hProcess, nullptr, 0, (LPTHREAD_START_ROUTINE)loadLibAddr, remoteMem, 0, nullptr); WaitForSingleObject(hThread, INFINITE);参数说明pid是目标进程 ID可以用任务管理器查也可以用CreateToolhelp32Snapshot遍历进程列表拿到。dllPath必须是绝对路径因为远程进程的工作目录和你的不一样用相对路径会找不到文件。3.3 运行验证与数据解析注入成功后目标程序的所有send调用都会被记录到send_dump.ob文件里。这个文件默认生成在目标进程的工作目录下如果你不确定在哪可以在WriteToObFile里把路径改成绝对路径比如C:\\dump\\send_dump.ob。验证是否生效最简单的办法是看文件有没有变大。如果目标程序在持续发数据文件大小应该一直在涨。如果文件是空的那说明 Hook 没装上或者send根本没被调用。解析.ob文件用 Python 几行就够了import struct with open(send_dump.ob, rb) as f: while True: len_bytes f.read(4) if not len_bytes: break length struct.unpack(I, len_bytes)[0] data f.read(length) print(f[{length} bytes] {data[:64].hex()})这段脚本按「4 字节小端长度 内容」的格式循环读取每次打印前 64 字节的十六进制。如果你知道协议是文本的把data[:64].hex()改成data[:64].decode(utf-8, errorsignore)就能直接看内容。4. 避坑与常见问题排查那些我踩过的坑4.1 Hook 装上后程序崩溃现象注入 DLL 后目标程序几秒内直接闪退没有任何错误提示。原因最常见的是跳转偏移算错了。0xE9后面跟的是相对偏移不是绝对地址。如果你直接把hookFunc的地址写进去跳转就会飞到完全无关的内存区域程序必崩。另一个可能是 trampoline 没构造对原始函数的前 5 个字节被破坏后没有正确恢复。解决在InstallHook里加日志把targetFunc、hookFunc、计算出的offset都打印出来。然后用调试器附加到目标进程在targetFunc地址下断点单步跟一下跳转是否落到了hookFunc的入口。trampoline 的构造要确保原始字节完整复制并且末尾的跳转地址是targetFunc 5。4.2 写文件导致程序卡顿现象Hook 生效后目标程序网络延迟明显增加甚至出现超时。原因每次send都调用CreateFileWriteFileCloseHandle这三个操作都是系统调用开销不小。如果目标程序发送频率很高比如每秒几千次磁盘 I/O 会成为瓶颈。解决改成「打开一次持续写入」的模式。在DllMain的DLL_PROCESS_ATTACH里就把文件句柄打开WriteToObFile里只调WriteFileDLL_PROCESS_DETACH时再关闭。另外可以加一个内存缓冲区攒够一定大小再批量写盘进一步降低 I/O 次数。4.3 多线程下数据错乱现象解析.ob文件时发现长度字段对不上或者内容明显是两条消息混在一起。原因多个线程同时调sendWriteToObFile没有加锁两个线程的WriteFile交错执行导致文件内容错位。解决用临界区CRITICAL_SECTION或者互斥量保护写文件操作。临界区比互斥量轻量适合这种短时间的锁。注意初始化临界区要在DllMain里做而且DllMain里不能调CreateFile等可能引发死锁的函数所以文件句柄的打开要放在第一次写文件时惰性初始化或者用个标志位控制。4.4 拦截不到 recv 或 WSASend现象send能拦到但recv和WSASend完全没反应。原因这份代码只 Hook 了send一个函数。recv和WSASend是ws2_32.dll里另外的导出函数地址不同需要单独 Hook。另外有些程序用的是WSASend而不是send你只拦send自然抓不到。解决在InstallHook里对recv、WSASend、WSARecv分别调用一次每个函数配一个独立的 hook 函数和 trampoline。注意WSASend的参数是LPWSABUF数组解析方式跟send不一样要按WSABUF结构体逐个取buf和len。4.5 目标程序有反注入保护现象注入器返回成功但 DLL 里的DllMain根本没执行或者执行后立刻被卸载。原因一些程序会检测自身模块列表里有没有可疑 DLL或者检测ws2_32.dll的代码段是否被修改。一旦发现异常就主动退出或恢复原始字节。解决这种情况没有通用解法只能针对性对抗。常见思路是延迟 Hook——等程序初始化完成后再装 Hook避开启动时的检测窗口。或者用硬件断点代替 Inline Hook不改代码段但实现复杂度高很多。如果只是做分析可以考虑在虚拟机里跑配合调试器手动下断点不注入 DLL。5. 二次开发与扩展从 send 到全协议抓取这套代码的扩展性其实不错核心的 Hook 引擎和业务逻辑是分开的想加新功能不用动底层。我一般会从三个方向改拦截更多函数、改变数据存储方式、增加过滤条件。拦截更多函数是最直接的需求。比如你想同时抓recv就在send_intercept.cpp旁边加个recv_intercept.cpp写一个签名匹配的Hooked_recv然后在InstallHook里多调一次。注意recv的返回值是实际接收到的字节数写文件的时候要用返回值而不是传入的len因为len是缓冲区大小实际收到的可能更少。int WINAPI Hooked_recv(SOCKET s, char* buf, int len, int flags) { int ret g_originalRecv(s, buf, len, flags); if (ret 0) { WriteToObFile(buf, ret); // 用返回值不是 len } return ret; }数据存储方式也可以改。现在写的是裸二进制解析起来麻烦。你可以改成写 SQLite每条记录存socket、方向、时间戳、数据四个字段后面用 SQL 查询方便得多。或者写 JSON Lines每行一个 JSON 对象用jq就能直接过滤。改的时候注意性能SQLite 要开 WAL 模式并批量提交不然写入速度跟不上。过滤条件是我觉得最实用的改造。默认全量记录会把文件撑得很大而且大部分数据可能不是你关心的。可以在WriteToObFile里加个判断只记录特定端口或特定长度的数据包。比如只抓 80 端口的 HTTP 流量void WriteToObFile(SOCKET s, const char* data, int len) { sockaddr_in addr; int addrLen sizeof(addr); if (getpeername(s, (sockaddr*)addr, addrLen) 0) { if (ntohs(addr.sin_port) ! 80) { return; // 不是 80 端口跳过 } } // ... 原有写文件逻辑 }验证改造是否成功我习惯用一个小技巧先拿一个自己写的测试程序跑一遍这个程序明确知道发了什么、收了多少然后对比.ob文件里的记录是否完全一致。测试程序里发 100 条已知内容的消息解析后逐条比对一条不差才算通过。这个习惯是从一次惨痛经历来的——当时没做验证就直接上生产环境结果发现多线程下丢了将近三成数据排查了一整天才定位到是临界区没覆盖到文件句柄的初始化。从那以后我每次改完 Hook 逻辑都强制走一遍「已知输入 → 抓取 → 比对」的流程确认无误再拿去分析真实目标。希望这套代码和这些经验能帮到你少走点弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑