简介:这份资源面向从事Windows网络编程、安全审计与协议分析的中高级开发者,聚焦Winsock中send()函数的拦截与数据落盘。通过API钩子或用户模式钩子替换导入地址表,在send()调用前后插入自定义逻辑,把待发送缓冲区内容写入OB文件后再放行原始调用,可用于网络监控、流量取证与性能分析等场景。压缩包共23个文件、约898KB,以cpp源码、h头文件、def导出定义、lib与dll二进制、pdb调试符号及dsp/dsw工程文件为主,另含ReadMe与说明文档,构成一套可直接编译调试的VC工程。已有1265人学习下载。读者可据此理解钩子注入与IAT改写思路,掌握send()参数捕获、缓冲区复制与文件写入的实现细节,并参考工程配置完成本地编译验证,同时留意稳定性、性能开销与合规边界。
1. 从一次抓不到包的排查说起:这个 DLL 拦截项目到底解决什么问题
线上有个老系统,客户端和服务端之间的报文总是对不上,日志里只有“发送成功”四个字,具体发了什么一个字都看不到。抓包工具装上以后发现流量走的是本机回环,工具压根抓不到;想改代码加日志,源码又不在手上。这种时候,最直接的办法就是在ws2_32.dll这一层做文章——把send函数拦下来,把发送缓冲区里的内容原样写进.ob文件,事后慢慢比对。这个项目干的就是这件事:通过拦截 Windows Socket 的核心导出函数send(顺带也能处理recv),在不改动业务代码的前提下,把网络收发的原始字节落盘成可读的.ob文件。适合谁?适合手里只有二进制、需要定位协议问题、或者想学习 API Hook 与 DLL 注入这套组合拳的开发者。它不是什么通用抓包替代品,而是一把针对“看不见的发送内容”的专用小刀,用对了场景非常省事。
2. 技术选型与工程结构:为什么是 ws2_32 加 inline hook
2.1 拦截点的选择逻辑
ws2_32.dll是 Windows 上 Winsock 2 的用户态入口,send、recv、WSASend、WSARecv这些函数都从这里导出。业务代码无论用什么网络库,最终大多会落到这几个导出函数上(除非直接走NtDeviceIoControlFile这类更底层的路径,那是另一个话题)。所以把拦截点放在ws2_32.dll的send上,覆盖面最广,改动成本最低。
这里有个关键决策:是替换整个 DLL(代理 DLL,把原 DLL 改名再转发),还是对已加载的ws2_32.dll做 inline hook?代理 DLL 的优点是干净、不需要写注入器,缺点是必须让目标进程从你的 DLL 加载,对已经跑起来的进程无能为力,而且ws2_32.dll被系统大量模块依赖,替换容易引发连锁问题。这个项目走的是 inline hook 路线:先注入,再在目标进程内改写send函数开头的几个字节,跳到自己的处理函数,处理完再跳回去。灵活,但要求对 x86/x64 的指令长度和内存保护有基本认识。
常见做法是用一个轻量 hook 库(比如 MinHook 这类)来管理 trampoline,避免自己手写指令长度解码器。我一般也会这么做,因为手写解码在遇到热补丁、短跳转、相对寻址时会翻车,血泪经验。
2.2 目录结构长什么样
一个能跑起来的工程,目录大致是这样组织的(不同人命名会有差异,但职责划分基本一致):
ws2hook/ ├── injector/ # 注入器,把 DLL 塞进目标进程 │ ├── main.cpp │ └── inject.cpp ├── hookdll/ # 被注入的 DLL,核心逻辑 │ ├── dllmain.cpp # DllMain,挂 hook 的入口 │ ├── hook_send.cpp # send/recv 的拦截与转发 │ ├── obwriter.cpp # .ob 文件写入 │ └── hook.h ├── common/ │ └── log.h └── build/ # 输出目录injector负责进程创建或附加,hookdll是真正干活的模块,obwriter把字节流按约定格式落盘。把写文件单独抽出来很重要,因为拦截函数运行在目标线程上下文里,任何阻塞都可能拖慢业务,写盘策略要能独立调整。
2.3 注入方式与加载时机
注入器常见两种做法:CreateRemoteThread+LoadLibrary,或者 APC 注入。前者简单直接,后者更隐蔽一些但对线程状态有要求。这个项目用前者就够了,代码短、好调试:
// inject.cpp 核心片段:远程加载 DLL bool InjectDll(DWORD pid, const std::wstring& dllPath) { HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProc) return false; // 1. 在目标进程分配内存,写入 DLL 路径 SIZE_T len = (dllPath.size() + 1) * sizeof(wchar_t); LPVOID remoteBuf = VirtualAllocEx(hProc, nullptr, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); WriteProcessMemory(hProc, remoteBuf, dllPath.c_str(), len, nullptr); // 2. 拿到 LoadLibraryW 地址(kernel32 在各进程映射基址一致) HMODULE hKernel = GetModuleHandleW(L"kernel32.dll"); LPTHREAD_START_ROUTINE loadAddr = (LPTHREAD_START_ROUTINE)GetProcAddress(hKernel, "LoadLibraryW"); // 3. 起远程线程执行 LoadLibraryW HANDLE hThread = CreateRemoteThread(hProc, nullptr, 0, loadAddr, remoteBuf, 0, nullptr); WaitForSingleObject(hThread, 5000); // 省略清理句柄 return true; }参数说明:pid是目标进程 ID,dllPath必须是目标进程能访问到的绝对路径(位数也要匹配,32 位注入器只能注入 32 位进程)。OpenProcess需要足够权限,普通权限下对高完整性进程会失败,这是后面避坑章节要展开的点。LoadLibraryW的地址之所以能直接用,是因为kernel32.dll在同类进程里的加载基址通常一致,但开了 ASLR 的现代系统上更稳妥的做法是在目标进程里重新GetProcAddress,这里为了代码短先简化。
2.4 DllMain 里挂 hook 的注意点
DLL 被加载后会进DllMain,但DllMain里能做的事很有限,加载器锁还没释放,贸然调用LoadLibrary或创建线程容易死锁。稳妥做法是在DllMain里只创建一个线程,把真正的初始化挪出去:
// dllmain.cpp BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hMod); // 不在加载器锁内做重活,交给独立线程 HANDLE h = CreateThread(nullptr, 0, InitHookThread, nullptr, 0, nullptr); if (h) CloseHandle(h); } return TRUE; }DisableThreadLibraryCalls关掉线程创建/销毁通知,减少无谓回调。InitHookThread里再去做MH_Initialize、定位send地址、安装 hook。这个顺序看着啰嗦,但不这么写,在某些宿主进程里会卡在启动阶段,现象是目标程序直接假死,很难查。
3. 核心拦截逻辑拆解:send 从进入到写盘的完整链路
3.1 定位 send 并安装 hook
send的地址要从ws2_32.dll的导出表拿,不能硬编码偏移,因为不同系统版本、不同补丁级别下偏移会变。安装 hook 的流程是:初始化 hook 引擎 → 创建 hook → 启用。
// hook_send.cpp typedef int (WSAAPI *SendFn)(SOCKET s, const char* buf, int len, int flags); static SendFn g_origSend = nullptr; bool InstallSendHook() { HMODULE hWs = GetModuleHandleW(L"ws2_32.dll"); if (!hWs) hWs = LoadLibraryW(L"ws2_32.dll"); if (!hWs) return false; void* target = (void*)GetProcAddress(hWs, "send"); if (!target) return false; if (MH_CreateHook(target, (LPVOID)&DetourSend, (LPVOID*)&g_origSend) != MH_OK) return false; return MH_EnableHook(target) == MH_OK; }g_origSend保存的是 trampoline 地址,也就是“原函数”的可调用入口,后面转发时必须走它,否则会自己调自己形成死循环。MH_CreateHook的第三个参数是输出参数,拿到的就是这枚 trampoline。MH_EnableHook才真正改写目标函数开头的字节,这一步会临时改内存保护属性,失败通常意味着目标页不可写或者被其他 hook 占了位置。
3.2 DetourSend 里做什么
拦截函数本身要尽量短:判断是否要记录、拷贝数据、交给写盘队列、调用原函数。顺序上有个细节——先调原函数还是先记录?如果先记录后调用,原函数失败时你也会记下一条“实际没发出去”的数据,容易误导排查。更合理的是先调用原函数,拿到返回值,再根据返回值决定记录多少字节。
int WSAAPI DetourSend(SOCKET s, const char* buf, int len, int flags) { int ret = g_origSend(s, buf, len, flags); // 先走真实发送 if (ret > 0 && buf) { // 只记录真正被内核接受的字节数 ObWrite(s, buf, ret, /*isSend=*/true); } return ret; }参数说明:s是套接字句柄,用来在.ob里区分不同连接;buf是发送缓冲区,注意它的生命周期只在本次调用内有效,所以ObWrite必须同步拷贝,不能只存指针;len是请求长度,ret是实际发送长度,阻塞模式下两者可能不等,非阻塞模式下ret还可能是SOCKET_ERROR。flags一般原样透传,不用管。
recv的拦截结构对称,只是方向标记不同,写盘时用isSend=false区分。注意recv的buf在调用前是空的,调用后才被填充,所以记录必须放在原函数返回之后,且只记录ret个字节。
3.3 .ob 文件的写入格式
.ob不是标准格式,本质是自定义的二进制日志。要让事后能解析,头部得带足够信息。一个够用的记录结构:
// obwriter.cpp #pragma pack(push, 1) struct ObRecordHeader { uint32_t magic; // 固定标识,比如 0x4F424C47 uint32_t tick; // GetTickCount,用于排序 uint64_t socketId; // 套接字句柄转成 64 位 uint32_t length; // 后续负载长度 uint8_t direction; // 1=send, 0=recv }; #pragma pack(push, pop) void ObWrite(SOCKET s, const char* data, int len, bool isSend) { ObRecordHeader hdr{}; hdr.magic = 0x4F424C47; hdr.tick = GetTickCount(); hdr.socketId = (uint64_t)(uintptr_t)s; hdr.length = (uint32_t)len; hdr.direction = isSend ? 1 : 0; // 加锁后追加写,避免多线程交错 std::lock_guard<std::mutex> lk(g_obMutex); fwrite(&hdr, sizeof(hdr), 1, g_obFile); fwrite(data, 1, len, g_obFile); fflush(g_obFile); }#pragma pack(1)保证结构体没有填充字节,跨编译器解析时不会错位。magic用来在文件里做重同步,万一中间有损坏还能往后找。socketId用句柄值,虽然句柄会被复用,但配合tick基本能还原时序。fflush每写一条就刷,是为了进程崩溃时不丢数据,代价是性能;如果目标程序吞吐大,应该改成缓冲加定时刷,这个取舍在改造章节再展开。
提示:
.ob文件建议按进程或按启动时间分文件,单个文件无限增长会让事后解析很痛苦,常见做法是超过固定大小就滚动新文件。
3.4 多线程与重入问题
send会被多个线程同时调用,写盘必须加锁,这一点上面的代码已经处理。更隐蔽的是重入:如果ObWrite内部调用了任何可能触发 socket 操作的函数(比如某些日志库会走网络),就会再次进入DetourSend,形成递归。规避办法是给每个线程设一个“正在记录”的标志,进入ObWrite前检查:
thread_local bool t_inHook = false; int WSAAPI DetourSend(SOCKET s, const char* buf, int len, int flags) { int ret = g_origSend(s, buf, len, flags); if (t_inHook) return ret; // 重入直接放行 t_inHook = true; if (ret > 0 && buf) ObWrite(s, buf, ret, true); t_inHook = false; return ret; }thread_local保证标志按线程隔离,不会误伤其他线程。这个细节不写,遇到日志库自己发数据时就是无限递归加栈溢出,现象是进程直接崩,dump 里全是DetourSend,第一次见会懵。
4. 跑起来:编译、注入与验证的完整步骤
4.1 编译环境与位数匹配
工程用 Visual Studio 打开,配置成 C++17,链接ws2_32.lib和 hook 库。最关键的一条:注入器和 DLL 的位数必须和目标进程一致。32 位注入器注入不了 64 位进程,反之亦然。判断目标位数可以用任务管理器看“平台”列,或者用dumpbin /headers看 PE 头。我一般直接编两套,x86和x64各一份,省得现场抓瞎。
编译顺序是先编hookdll,再编injector,因为注入器运行时需要 DLL 路径存在。输出目录统一到build/,注入器里把 DLL 路径做成命令行参数,不要写死。
4.2 注入并确认 hook 生效
注入器用法大致是:
injector.exe --pid 12345 --dll C:\work\ws2hook\build\hookdll.dll执行后看两个信号:一是目标进程没有崩,二是.ob文件开始出现并且有内容增长。如果进程崩了,先确认位数和 DLL 依赖(比如 hook 库的运行时是不是静态链接,动态链接的话目标进程可能找不到对应 CRT)。如果.ob文件一直为空,说明 hook 没挂上或者目标程序根本没走send(可能用了WSASend)。
验证 hook 是否真的生效,可以在DetourSend里加一行OutputDebugString,用调试器附加看输出;或者更直接,在InstallSendHook成功后写一个标记文件。别小看这个标记,现场排查时“到底挂没挂上”是最常卡住的一步。
4.3 解析 .ob 文件
写出来的.ob是二进制,得配一个解析脚本才能看。Python 读起来最省事:
import struct HDR = struct.Struct('<IIQIB') # magic, tick, socketId, length, direction with open('capture.ob', 'rb') as f: data = f.read() off = 0 while off + HDR.size <= len(data): magic, tick, sid, length, direction = HDR.unpack_from(data, off) if magic != 0x4F424C47: off += 1 # 失步,逐字节找下一个 magic continue off += HDR.size payload = data[off:off + length] off += length tag = 'SEND' if direction == 1 else 'RECV' print(f'[{tick}] {tag} sock={sid} len={length}') print(payload.hex(' '))struct的格式串必须和 C 端的#pragma pack(1)结构严格对应,<表示小端,I是 4 字节无符号,Q是 8 字节,B是 1 字节。失步时逐字节前移找magic,虽然慢但足够健壮。解析出来的 hex 再对照协议文档看字段,比在调试器里一帧帧翻内存舒服得多。
4.4 一个最小验证闭环
想确认整条链路通,不用拿复杂业务试。写一个几十行的测试程序,连本机某个端口,循环send固定字符串,然后注入。预期结果是.ob文件里出现对应条数的SEND记录,hex 和字符串对得上。这个闭环跑通,再上真实目标。测试程序自己写,别拿线上进程当第一个试验品,这是基本纪律。
5. 避坑与排查:那些让 hook 失效或进程崩溃的坑
5.1 注入成功但 .ob 文件为空
现象:注入器返回成功,目标进程也活着,但.ob文件不生成或一直是 0 字节。原因通常有三种:目标程序走的是WSASend而不是send(很多现代网络库默认用WSASend);hook 安装时GetProcAddress拿到的地址被其他安全软件先 hook 了,你的 trampoline 指向了别人的 detour;或者 DLL 加载了但初始化线程还没跑完你就去看文件了。解决:先加标记确认InstallSendHook的返回值,再补WSASend/WSARecv的拦截,最后确认目标进程实际调用的导出函数(可以用调试器在ws2_32导出上下断点观察)。
5.2 目标进程启动即崩溃
现象:注入后目标进程秒退,事件查看器里是访问冲突。原因多半是位数不匹配,或者 DLL 依赖的运行时库在目标环境缺失,也可能是DllMain里做了重活导致加载器死锁。解决:确认位数一致;把 hook 库和 CRT 改成静态链接;把初始化挪到独立线程(第 2 章的做法)。如果还崩,用 WinDbg 附加看崩溃栈,栈顶在LdrpInitialize附近基本就是DllMain的问题。
5.3 记录的数据长度对不上
现象:.ob里记录的len比业务日志里的发送长度小。原因是你记录的是ret(实际发送字节数),非阻塞 socket 下send可能只发出去一部分,剩余部分业务层会重发。这其实是正确行为,但如果你按“一次调用一条完整报文”去解析就会错位。解决:解析时按socketId聚合,把同一连接的连续SEND记录拼起来再按协议切分;或者在记录里额外带上调用序号,方便还原调用边界。
5.4 高吞吐下目标程序明显变慢
现象:注入后业务延迟上升,CPU 占用变高。原因是每条记录都fwrite加fflush,同步写盘在高速发送场景下就是瓶颈,加上全局锁让多线程串行化。解决:改成生产者-消费者,DetourSend只把数据拷进无锁队列或带锁的环形缓冲,单独一个写线程批量落盘;fflush改成按条数或按时间间隔触发。注意拷贝本身也有成本,超大报文要考虑截断策略,比如只记录前 N 字节加总长度。
5.5 卸载 hook 后程序行为异常
现象:调试时反复注入/卸载,几次之后目标程序收发错乱。原因是 hook 卸载不干净,trampoline 被释放但还有线程在执行旧代码,或者MH_DisableHook之后原函数开头的字节没完全恢复。解决:卸载前确保没有线程停留在 detour 里(可以加引用计数),卸载顺序是先MH_DisableHook再MH_RemoveHook再MH_Uninitialize,别跳步。生产环境一般不做运行时卸载,注入后随进程退出,省掉这类问题。
6. 二次开发:从“能记录”到“能筛选、能回放”的改造思路
拿到这个工程,最想改的地方通常是“记太多”和“看不懂”。原始实现是全量记录,真实业务跑十分钟.ob就几百兆,解析脚本跑起来也慢。我的做法是加一层过滤:在DetourSend里根据socketId或报文前几个字节做白名单,只记录关心的连接或协议头匹配的报文。过滤逻辑放在拷贝之前,避免无谓的内存搬运。
// 按端口或协议头过滤,示例:只记录以 0xEB 开头的报文 static bool ShouldRecord(const char* buf, int len) { if (len < 1) return false; return (uint8_t)buf[0] == 0xEB; }这个函数替换掉DetourSend里的无条件ObWrite调用即可。过滤条件最好做成可配置,比如从同目录的 ini 读,改规则不用重新编译 DLL,现场调试时这点很省事。
第二个改造方向是回放。.ob里存了方向和原始字节,写一个回放器把SEND记录按tick顺序重放到一个测试服务端,就能在没有原客户端的情况下复现问题。回放器要注意时序:直接按tick差值sleep最真实,但慢;压测场景下可以去掉等待,只保顺序。我一般做成两种模式,排查用真实时序,回归用快速模式。
第三个方向是给记录加“调用栈摘要”。在DetourSend里用CaptureStackBackTrace抓几帧,把模块基址和偏移存进记录头,事后用dbghelp符号化,就能知道是哪个业务模块发的这条数据。代价是每次调用多几微秒,建议做成开关,默认关。
| 改造点 | 改动位置 | 收益 | 代价 |
|---|---|---|---|
| 报文过滤 | DetourSend拷贝前 | 文件体积降一个量级 | 需要维护过滤规则 |
| 异步写盘 | ObWrite改队列 | 高吞吐下延迟平稳 | 崩溃可能丢最后几条 |
| 调用栈摘要 | 记录头扩展字段 | 定位发送方模块 | 每次调用额外开销 |
| 回放器 | 独立工具 | 无客户端复现问题 | 需要处理时序与连接状态 |
改这些之前,先把原始版本在一个可控的测试程序上跑通,确认.ob能正确解析,再动核心逻辑。我吃过亏:一上来就改异步写盘,结果队列实现有 bug,记录丢了一半,还以为是 hook 没生效,查了大半天。从那以后我每次改拦截层,都先用那个固定字符串的测试程序跑一遍,确认条数和内容完全对得上,再上真实目标。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取