简介:这份资源聚焦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 必须编译成 x64;32 位程序就编译成 Win32,这个不能搞错,否则注入会失败。
编译命令用 MSBuild 的话大概是这样:
msbuild ws2_32_hook.sln /p:Configuration=Release /p:Platform=x64编译成功后会在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 加载进去。这种方式适合调试阶段,不适合自动化。
我一般会先写个简单的注入器,用OpenProcess+VirtualAllocEx+WriteProcessMemory+CreateRemoteThread这套组合拳。核心代码大概二十行:
// 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', errors='ignore')就能直接看内容。
4. 避坑与常见问题排查:那些我踩过的坑
4.1 Hook 装上后程序崩溃
现象:注入 DLL 后,目标程序几秒内直接闪退,没有任何错误提示。
原因:最常见的是跳转偏移算错了。0xE9后面跟的是相对偏移,不是绝对地址。如果你直接把hookFunc的地址写进去,跳转就会飞到完全无关的内存区域,程序必崩。另一个可能是 trampoline 没构造对,原始函数的前 5 个字节被破坏后没有正确恢复。
解决:在InstallHook里加日志,把targetFunc、hookFunc、计算出的offset都打印出来。然后用调试器附加到目标进程,在targetFunc地址下断点,单步跟一下跳转是否落到了hookFunc的入口。trampoline 的构造要确保原始字节完整复制,并且末尾的跳转地址是targetFunc + 5。
4.2 写文件导致程序卡顿
现象:Hook 生效后,目标程序网络延迟明显增加,甚至出现超时。
原因:每次send都调用CreateFile+WriteFile+CloseHandle,这三个操作都是系统调用,开销不小。如果目标程序发送频率很高,比如每秒几千次,磁盘 I/O 会成为瓶颈。
解决:改成「打开一次,持续写入」的模式。在DllMain的DLL_PROCESS_ATTACH里就把文件句柄打开,WriteToObFile里只调WriteFile,DLL_PROCESS_DETACH时再关闭。另外可以加一个内存缓冲区,攒够一定大小再批量写盘,进一步降低 I/O 次数。
4.3 多线程下数据错乱
现象:解析.ob文件时发现长度字段对不上,或者内容明显是两条消息混在一起。
原因:多个线程同时调send,WriteToObFile没有加锁,两个线程的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 逻辑,都强制走一遍「已知输入 → 抓取 → 比对」的流程,确认无误再拿去分析真实目标。希望这套代码和这些经验能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取