ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于IAT Hook拦截ws2_32 send的明文抓包与协议分析

基于IAT Hook拦截ws2_32 send的明文抓包与协议分析

简介:聚焦 Windows 平台 Winsock 网络通信的钩子实践,这份资料以 ws2_32.dll 中 send() 函数的拦截与发送内容落盘为核心,面向网络安全、协议分析、系统编程学习者及需要搭建网络监控/审计原型的开发者,可用于理解 API Hook、IAT 替换、Detours 与用户模式钩子的基本思路,并观察发送数据在拦截后写入 OB 文件、再转交原始函数的过程。压缩包为 rar 格式,共 23 个文件、约 898KB,内含 cpp/h 源码、dll/lib/def 工程与导出文件、dsp/dsw 旧版 VC 工程,以及 pdb/obj/ilk 等调试中间产物,另附 ReadMe 与说明文本,便于对照工程配置、编译产物和调试符号进行排查。已有 1266 人学习。资料保留了从模块定义、源码实现到生成 DLL 的完整工程线索,适合作为 Winsock 钩子入门、课程实验与二次开发参考,也能帮助读者理解网络数据捕获类工具在工程组织与调试信息上的基本构成。

1. 抓包只看到乱码时,该去看 send() 那一瞬间

有个做客户端对接的同行吐槽:抓包工具里几百个 TCP 流,payload 全是看不懂的二进制,因为业务层在发出去之前自己加了一层加密,网卡上看到的永远是密文。真正还有明文的地方,是程序调用send()的那一瞬间——缓冲区里的字节还没被加密,也还没被拆包。这套拦截ws2_32.dll中send的实现就是干这个的:在函数入口把内容复制一份落到.obj二进制记录文件里,事后用脚本慢慢解析。适合三类人:调自研客户端协议的、对接老系统接口对不上字段的、以及想真正搞懂 API Hook 怎么落地而不是停留在概念的开发者。

2. 方案选型与工程骨架:IAT Hook、代理 DLL 和 Inline Hook 怎么选

动手之前先想清楚一件事:你要挂的是「某个进程里所有对 send 的调用」,而不是「系统全局」。这个前提决定了实现形态。下面三条路线我都写过一遍,结论写在对比里。

2.1 三条技术路线的实测取舍

代理 DLL(转发导出)。把自己的 DLL 命名成ws2_32.dll放到 exe 同目录,内部把几十个导出函数全部转发给系统目录里的真身,只把send换成自己的实现。优点是零注入、不用写注入器,进程启动就生效。缺点是它在真实环境里经常直接失效:ws2_32.dll属于系统 KnownDLLs 名单里的模块,隐式导入时加载器会强制从系统目录取,你放在 exe 旁边的那份根本轮不上。而且导出表要补全七十多个函数,漏一个,目标程序里GetProcAddress拿到 NULL 就是一个隐蔽的功能残缺。

IAT Hook。往目标进程注入一个 DLL,遍历主模块的导入表,把IMAGE_THUNK_DATA里send的地址改写成自己的函数地址。它的好处是只改数据不改指令,不碰代码段,稳定性好,反调试和完整性校验基本感知不到;坏处是只对「通过导入表调用」的路径有效,程序要是自己GetProcAddress("send")动态取地址就绕过去了。

Inline Hook。直接改send函数头五个字节写jmp,覆盖面最广,连 DLL 内部互相调用都能拦。但指令长度要反汇编算,多线程下改字节有竞态,还得处理被 Hook 函数太短放不下跳转的情况,翻车率高。

我最后选IAT Hook 为主、代理 DLL 为辅:主流程走注入 + 改导入表,遇到那种自己拼 shellcode 动态解析 API 的程序再补GetProcAddress的钩子。这套组合在教学和实际调试里都够用。

2.2 工程目录与模块划分

整个工程拆成三个可独立编译的产物,互不依赖编译期符号:

ws2dump/ ├── hook/ # 注入进目标进程的 DLL │ ├── dllmain.c # 入口,只做最小初始化 │ ├── iat.c # 导入表遍历与改写 │ ├── hooks.c # send / WSASend / sendto 的实现 │ ├── ring.c # 环形缓冲,生产者侧 │ ├── writer.c # 写盘线程,消费者侧 │ └── hook.def # 显式导出,避免名字修饰 ├── injector/ # 控制台注入器,x86/x64 各编一份 │ └── main.c └── tools/ └── parse_obj.py # 解析 .obj 记录,导出成文本/pcap

hook.dll只依赖kernel32和ws2_32,不引 CRT 的复杂部分,减少在目标进程里初始化失败的几率;injector.exe位数必须和目标进程一致,这一点后面会专门讲。三个产物分开的好处是:改记录格式只动writer.c和解析脚本,改钩子只动hooks.c,不会牵一发动全身。

2.3 .obj 落盘格式设计:让抓到的数据能被二次解析

很多人图省事直接fwrite(buf, 1, len, fp),结果文件里一堆裸字节,事后根本分不清哪段是哪个 socket、哪个方向、什么时候发的。我一开始也这么干,回头对协议的时候只能靠肉眼找特征串,血泪经验。

改成「定长头 + 变长体」的记录格式,每条记录自带元信息:

字段类型含义
magicuint32固定 0x44535757,用于扫描对齐
directionuint161=send,2=recv
sockIduint32逻辑 socket 编号,映射到四元组
tiduint32线程 ID,多线程乱序时靠它还原顺序
tickuint64GetTickCount64()毫秒时间戳
lengthuint32后续 payload 字节数

对应结构体:

#pragma pack(push, 1) // 关键:不加这个,编译器会插 4 字节填充 typedef struct _DUMP_HDR { unsigned int magic; // 0x44535757,解析脚本靠它找记录边界 unsigned short direction; // 1=send 2=recv unsigned int sockId; // 逻辑 socket 号 unsigned int tid; // 线程 ID unsigned long long tick; // 毫秒时间戳 unsigned int length; // payload 长度 } DUMP_HDR; // 合计 24 字节 #pragma pack(pop)

#pragma pack(1)是必须的。默认对齐下这个结构体是 24 字节没错,但一旦你后面加个uint16字段,默认对齐会把它撑到 28 字节,解析脚本按 24 字节读就全乱了,表现是解出来的length大得离谱然后越界。裸结构体直接写文件,永远是这类工具第一个翻车点。

文件打开方式用CreateFileW的FILE_APPEND_DATA而不是fopen("ab"),因为我们要在写盘线程里控制缓冲区,还要在进程退出的DLL_PROCESS_DETACH里安全关句柄,CRT 的文件对象在 DllMain 里操作容易出问题。

3. 核心链路拆解:从 IAT 表项替换到字节落进文件

一条 send 数据从产生到落盘,中间经过四个环节:导入表定位 → 钩子函数接管 → 环形缓冲入队 → 写盘线程落盘。任何一环出问题,现象都是「文件是空的」,所以每环都得能单独验证。

3.1 定位 ws2_32 的导入描述符并改写函数指针

遍历导入表的标准套路,重点是别漏掉OriginalFirstThunk为 0 的模块(绑定导入或某些加壳程序会这样):

static BOOL PatchIAT(HMODULE hMod, const char* dllName, const char* funcName, PROC pNew, PROC* ppOld) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod; if (dos->e_magic != IMAGE_DOS_SIGNATURE) return FALSE; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hMod + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return FALSE; DWORD impRva = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress; if (impRva == 0) return FALSE; PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)hMod + impRva); for (; imp->Name != 0; ++imp) { const char* mod = (const char*)((BYTE*)hMod + imp->Name); if (_stricmp(mod, dllName) != 0) continue; // OriginalFirstThunk 可能为 0,此时退回用 FirstThunk 找名字会失败, // 只能按序号或直接跳过,这里先处理常规情况 if (imp->OriginalFirstThunk == 0) continue; PIMAGE_THUNK_DATA orig = (PIMAGE_THUNK_DATA)((BYTE*)hMod + imp->OriginalFirstThunk); PIMAGE_THUNK_DATA iat = (PIMAGE_THUNK_DATA)((BYTE*)hMod + imp->FirstThunk); for (; iat->u1.Function != 0; ++iat, ++orig) { if (IMAGE_SNAP_BY_ORDINAL(orig->u1.Ordinal)) continue; // 按序号导入,跳过 PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME)((BYTE*)hMod + orig->u1.AddressOfData); if (strcmp((const char*)ibn->Name, funcName) != 0) continue; DWORD oldProt = 0; // 导入表通常位于只读页,直接写会触发访问违例 VirtualProtect(&iat->u1.Function, sizeof(ULONG_PTR), PAGE_READWRITE, &oldProt); *ppOld = (PROC)iat->u1.Function; // 保存原地址,钩子里要回调 iat->u1.Function = (ULONG_PTR)pNew; // 换成我们的函数 VirtualProtect(&iat->u1.Function, sizeof(ULONG_PTR), oldProt, &oldProt); return TRUE; } } return FALSE; }

几个参数的实际意义:hMod传GetModuleHandleW(NULL)就是主模块,但很多程序把网络层封在自研的net.dll里,这时候要对每个已加载模块都跑一遍,用EnumProcessModules或CreateToolhelp32Snapshot拿模块列表循环调用。ppOld一定要保存,钩子函数执行完必须把原始调用转发出去,否则目标的业务逻辑断在半路。

VirtualProtect记得把旧属性改回去。我见过只改不回的实现,跑起来没问题,但目标进程后续碰这块内存时行为就诡异了,属于典型的「今天正常明天炸」的玄学 bug。

3.2 send / WSASend 的钩子实现与重入保护

钩子函数本身要极简。记录动作是「抄一份再转发」,绝不能改变返回值、阻塞时间或者错误码:

static int (WSAAPI *g_real_send)(SOCKET, const char*, int, int) = NULL; static __declspec(thread) int t_inHook = 0; // 线程局部重入标记 int WSAAPI Hook_send(SOCKET s, const char* buf, int len, int flags) { // 重入保护:如果记录逻辑内部又不小心调用了 send,直接放行 if (t_inHook == 0 && len > 0) { t_inHook = 1; PushRecord(s, DIR_SEND, (const BYTE*)buf, len); // 只入队,不做 IO t_inHook = 0; } return g_real_send(s, buf, len, flags); // 原样转发 }

__declspec(thread)的t_inHook不是可有可无的装饰。写盘线程里如果调用了任何会进到ws2_32的函数(比如为了拿 socket 对端地址调getpeername,底层又走了某些路径),没有这个标记就会无限递归,栈溢出直接崩掉目标进程。这是最容易被忽略的一条。

len > 0也要判断。send传 0 长度是合法调用,某些框架用它探活,你按 0 长度去 malloc 有可能返回 NULL 然后 memcpy 崩掉。

只挂send是不够的。现代程序大量使用WSASend(重叠 IO、分散聚集缓冲区)和sendto(UDP)。WSASend的缓冲区是LPWSABUF数组加dwBufferCount,要循环拼接:

int WSAAPI Hook_WSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine) { if (t_inHook == 0) { t_inHook = 1; for (DWORD i = 0; i < dwBufferCount; ++i) { if (lpBuffers[i].len > 0) PushRecord(s, DIR_SEND, (const BYTE*)lpBuffers[i].buf, lpBuffers[i].len); } t_inHook = 0; } return g_real_WSASend(s, lpBuffers, dwBufferCount, lpNumberOfBytesSent, dwFlags, lpOverlapped, lpCompletionRoutine); }

注意lpNumberOfBytesSent和lpBuffers的所有权:钩子里绝对不能改这两个,也不能在异步完成前释放lpBuffers,否则目标进程用完就是野指针。所以PushRecord里必须做深拷贝,把字节复制到自己管理的缓冲区。

3.3 线程安全的环形缓冲:别让磁盘 IO 拖慢目标进程

钩子函数里绝不能直接写文件。一次业务 send 可能只有几十微秒,你加一次WriteFile就是几百微秒,高频调用的程序直接卡成幻灯片。用固定大小的环形槽位,生产者只做拷贝和置位:

#define SLOT_COUNT 4096 // 必须是 2 的幂,取模用位与 typedef struct { volatile LONG ready; // 0=空闲 1=已写入待消费 unsigned int sockId; unsigned short dir; unsigned int tid; unsigned long long tick; unsigned int len; BYTE* data; // 堆上的深拷贝 } SLOT; static SLOT g_slots[SLOT_COUNT]; static volatile LONG g_writeIdx = 0; static volatile LONG g_readIdx = 0; void PushRecord(SOCKET s, unsigned short dir, const BYTE* buf, unsigned int len) { if (len > 65536) len = 65536; // 超大包截断,避免内存爆掉 LONG idx = InterlockedIncrement(&g_writeIdx) - 1; // 缓冲满了就丢弃本条,绝不阻塞业务线程 if (idx - g_readIdx >= SLOT_COUNT) return; SLOT* slot = &g_slots[idx & (SLOT_COUNT - 1)]; slot->data = (BYTE*)malloc(len); if (!slot->data) return; memcpy(slot->data, buf, len); slot->len = len; slot->dir = dir; slot->sockId = MapSocketId(s); // socket 句柄 → 稳定编号 slot->tid = GetCurrentThreadId(); slot->tick = GetTickCount64(); InterlockedExchange(&slot->ready, 1); // 最后一步才置 ready,保证可见性顺序 }

InterlockedIncrement保证每个生产者拿到唯一槽位,InterlockedExchange(&slot->ready, 1)放在最后,写盘线程看到ready == 1时数据一定已经写完(x86 上是有序的,跨平台要加内存屏障)。缓冲满了直接丢,这是刻意的取舍:调试工具丢几条记录没关系,卡死目标业务才是事故。

MapSocketId是个小技巧:直接用SOCKET句柄值当标识,程序重启后句柄会复用,跨会话根本对不上。做法是维护一张小表,第一次见到某个句柄时调一次getpeername拿对端 IP 和端口,分配一个自增编号,后续都用编号。这样.obj里sockId=3就稳定对应「到 10.0.0.5:8080 那条连接」,多连接并发时能直接分文件看。

3.4 写盘线程与文件句柄的生命周期

消费者线程在初始化时用CreateThread拉起,循环取出槽位、拼头、写文件:

static HANDLE g_hDump = INVALID_HANDLE_VALUE; static volatile LONG g_stop = 0; static volatile LONG g_flushCounter = 0; static DWORD WINAPI WriterThread(LPVOID param) { while (InterlockedCompareExchange(&g_stop, 1, 1) == 0) { LONG r = g_readIdx; SLOT* slot = &g_slots[r & (SLOT_COUNT - 1)]; if (InterlockedCompareExchange(&slot->ready, 1, 1) == 1) { DUMP_HDR hdr; hdr.magic = 0x44535757; hdr.direction = slot->dir; hdr.sockId = slot->sockId; hdr.tid = slot->tid; hdr.tick = slot->tick; hdr.length = slot->len; DWORD written = 0; WriteFile(g_hDump, &hdr, sizeof(hdr), &written, NULL); WriteFile(g_hDump, slot->data, slot->len, &written, NULL); free(slot->data); InterlockedExchange(&slot->ready, 0); InterlockedIncrement(&g_readIdx); // 每 64 条刷一次盘,兼顾吞吐和"进程被强杀时不丢太多" if ((InterlockedIncrement(&g_flushCounter) & 0x3F) == 0) FlushFileBuffers(g_hDump); } else { Sleep(1); // 没数据就退让,别把 CPU 空转满 } } FlushFileBuffers(g_hDump); return 0; }

FlushFileBuffers的频率是性能和数据完整性的权衡点。每条都刷,高频发送场景磁盘直接成瓶颈;完全不刷,进程一崩全丢。64 条一次是我试下来比较舒服的值,如果你的目标是排查崩溃前最后几条报文,那就把FILE_FLAG_WRITE_THROUGH打开、每条都刷,接受性能损失。

句柄的关闭放在DllMain的DLL_PROCESS_DETACH里,同时把g_stop置 1 并等写盘线程退出。这里有个坑:DLL_PROCESS_DETACH时进程可能已经在拆内存管理器,free和堆操作不保证安全,所以更稳的做法是DLL_PROCESS_DETACH里只做FlushFileBuffers和CloseHandle,不释放队列。

4. 编译、注入与第一次跑通:确认拦截真的生效

代码写完只是开始,这套东西的失败率有一半在编译配置和注入环节。

4.1 用 .def 和编译选项区分 x86 / x64

钩子 DLL 必须显式导出,而且要按 C 名字导出,否则目标进程导入表里的send就找不到。加一个.def:

LIBRARY hook EXPORTS HookEntry @1

只需要导出你自己的入口函数,因为send是通过改 IAT 指的,不需要出现在导出表里。编译命令按目标位数走,MSVC 环境下分别用 x86 和 x64 的本机工具命令行:

:: 32 位版本,给 32 位目标进程用 cl /LD /O2 /MT /D_USRDLL hook\*.c /link /DEF:hook\hook.def /OUT:hook32.dll :: 64 位版本,给 64 位目标进程用 cl /LD /O2 /MT /D_USRDLL hook\*.c /link /DEF:hook\hook.def /OUT:hook64.dll

/MT静态链接 CRT 是刻意的:目标进程里可能已经加载了别的 CRT 版本,动态链接容易出现运行库冲突导致 DLL 加载失败,错误码还只有个126,查半天。/O2保留,钩子函数越短越好,别为了调试方便用/Od上生产。

编完用dumpbin /exports hook32.dll看一眼导出表,确认HookEntry在,名字没被修饰成_HookEntry@4之类。

4.2 注入器:CreateRemoteThread + LoadLibraryW

最省事的注入方式,四步:开进程 → 远程分配写路径 → 远程线程调LoadLibraryW→ 等返回值。

int wmain(int argc, wchar_t** argv) { if (argc < 3) return 1; DWORD pid = wcstoul(argv[1], NULL, 10); const wchar_t* dllPath = argv[2]; // 用绝对路径,避免目标进程工作目录不同 HANDLE hProc = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, pid); if (!hProc) return 2; SIZE_T bytes = (wcslen(dllPath) + 1) * sizeof(wchar_t); void* pRemote = VirtualAllocEx(hProc, NULL, bytes, MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProc, pRemote, dllPath, bytes, NULL); // 同位数下 kernel32 在各进程的基址一致,可以直接取本地的函数地址 HMODULE hK32 = GetModuleHandleW(L"kernel32.dll"); LPTHREAD_START_ROUTINE pfnLoad = (LPTHREAD_START_ROUTINE)GetProcAddress(hK32, "LoadLibraryW"); HANDLE hThread = CreateRemoteThread(hProc, NULL, 0, pfnLoad, pRemote, 0, NULL); WaitForSingleObject(hThread, 5000); // 等 DLL 加载完 DWORD exitCode = 0; GetExitCodeThread(hThread, &exitCode); // 返回的是远程 LoadLibrary 的 HMODULE CloseHandle(hThread); VirtualFreeEx(hProc, pRemote, 0, MEM_RELEASE); CloseHandle(hProc); return exitCode != 0 ? 0 : 3; // 0 表示 DLL 加载失败 }

OpenProcess的权限位一个都不能少,少了哪个就是ERROR_ACCESS_DENIED,很多人卡在这里以为是权限不够需要提权,其实是权限位写漏。dllPath用绝对路径,因为远程LoadLibraryW的相对路径解析基准是目标进程的当前目录,不是注入器的目录,这是最常见的「注入成功但没生效」原因之一。GetExitCodeThread的返回值等于远程模块句柄,为 0 说明 DLL 加载失败,可以配合Process Monitor看是哪一步失败。

目标进程要是 64 位的,注入器也必须是 64 位,否则LoadLibraryW地址算错,CreateRemoteThread直接返回错误或把目标打死。这一点在 4.1 里编 DLL 的时候就要一起决定。

4.3 验证:如何确认钩子挂上了而不是“看起来挂上了”

注入返回 0 不等于钩子生效。我的验证顺序是三层,每层都能独立定位问题:

第一层,DLL 是否加载。用任意模块查看工具看目标进程的模块列表里有没有hook32.dll,或者注入器返回的exitCode非 0。

第二层,IAT 是否改写。在PatchIAT成功时OutputDebugString输出一行日志,用调试器附加看输出。更直接的办法是在Hook_send里第一次被调用时往文件写一个启动标记,文件都没生成说明没进过钩子。

第三层,数据是否合理。跑一个最简单的自测程序,自己WSAStartup后往本机回环地址发一段固定字符串,看.obj里能不能按记录格式解出来,length和你发的字节数是否一致。

三层都过了才算通。跳过前两层直接看文件内容,一旦是空的,你根本分不清是没挂上、挂上了没触发、触发了没落盘还是落了盘解析错了。

5. 避坑记录:五个把目标进程搞崩的瞬间

5.1 注入与加载阶段

现象:注入器返回成功,但目标进程立刻退出或界面卡死。原因:在DllMain里做了LoadLibrary、CreateThread加WaitForSingleObject、或者解析导入表。DllMain执行时持有加载器锁,任何等待都会死锁。 解决:DllMain里只做DisableThreadLibraryCalls(hModule)和保存模块句柄,真正的初始化放到一个CreateThread出来的线程里异步做,或者延迟到第一次导出函数被调用时用InitOnceExecuteOnce做一次性初始化。

现象:CreateRemoteThread返回ERROR_BAD_LENGTH或注入后进程崩溃。原因:注入器和目标进程位数不匹配,32 位注入器往 64 位进程写LoadLibraryW地址,写进去的是截断的低 32 位。 解决:注入器按 x86/x64 各编一份,运行时用IsWow64Process判断目标位数再选对应 DLL,位数不一致直接报错退出,别硬试。

5.2 运行与落盘阶段

现象:钩子确实被调用了,但.obj里一条记录都没有,或者只有前几条。原因:写盘线程抢不到槽位,或者PushRecord里malloc失败静默返回。更隐蔽的一种是把InterlockedExchange(&slot->ready, 1)提前到了memcpy之前,写盘线程读到ready时数据还是脏的,length可能是 0。 解决:把ready置位严格放在所有字段赋值之后;在PushRecord里对malloc失败和缓冲满各打一个计数,退出时输出,能看到到底丢了多少。

现象:抓到的length和实际业务日志对不上,解出来是乱码或半截包。原因:钩的位置不对。目标程序用的可能是带 TLS 的套接字,send里收到的已经是密文;也可能是先做了自定义压缩再发。 解决:先在钩子里把内容打印成十六进制看特征,如果开头是0x16 0x03之类,说明你抓到的是加密层。往上找业务层的发送函数去钩,或者干脆接受这个位置,用目标进程自己的解密逻辑在recv侧还原。

现象:程序跑了几小时内存持续增长,最后被系统杀掉。原因:WSASend的重叠 IO 场景下,lpBuffers指向的缓冲区在异步完成前被目标释放,或者你的PushRecord深拷贝了但消费者线程因为某个槽位ready卡死永不前进,后面的槽位全部堆积。 解决:深拷贝长度设上限(我用的 64KB),超长截断;消费者线程加超时保护,发现g_readIdx长时间不前进就强制跳过并记一条丢弃日志。

现象:.obj文件里记录顺序和实际发送顺序完全对不上,还原协议时逻辑错乱。原因:多线程并发发送,环形缓冲按入队顺序写盘,但真正的发送顺序由网络的完成顺序决定,而且tick精度只有毫秒,同一毫秒内无法区分先后。 解决:每条记录带上tid,同线程内顺序可信;跨线程用业务层自己的序列号字段排序。真要严格定序,就得在钩子里加全局序号InterlockedIncrement,代价是多一次原子操作。

6. 二次开发:把“能抓到”改成“能对得上”

这套东西跑通之后,真正的价值在改造上,我按投入产出排了个序。

第一优先级是补全钩子覆盖。只挂send大概只能拿到六成流量,把WSASend、sendto、WSASendTo、recv、WSARecv一起挂上,再给GetProcAddress加一层壳(拿到"send"时返回你的钩子,同时把原地址存起来),覆盖面能到九成以上。GetProcAddress的钩子实现和PatchIAT几乎一样,只是换了个函数名,复用度很高。

第二优先级是加过滤条件。全量落盘的文件动辄几百兆,实际排查往往只关心某个端口或某段特征串。在PushRecord里加一层判断:sockId对应的端口白名单、payload 前 N 字节的魔术字匹配、或者长度区间。过滤放在入队前,比落盘后再用脚本筛效率高一个量级。参数从 DLL 同目录的一个配置文件读,改配置不用重新编译,这在现场调试时特别省事。

第三优先级是把.obj转成通用格式。写个几十行的 Python 脚本按 24 字节头扫描,把 send 方向和 recv 方向分别导出成两个文件,或者拼成 pcap 的链路层帧,直接丢进抓包工具里和真实抓包对照看——哪一层加了壳、哪一层做了压缩,一眼就清楚。我一般还会顺手导一份「时间戳 + sockId + 长度」的索引,排查卡顿类问题时先看这个,比翻二进制快得多。

一个容易忽略的改造点是退出时的完整性。目标进程多数时候不是优雅退出的,用户直接关窗口或者被任务管理器结束。想尽量保住数据,就用FILE_FLAG_WRITE_THROUGH打开句柄,并给写盘线程设一个合理的批量大小,宁可慢一点也别丢。还有一种做法是双缓冲:主文件写到一定大小就滚动切分,这样即使最后一段损坏,前面的分片还是完整的。

我最早写这类工具的时候图省事,所有初始化都塞在DllMain里,结果在第一个目标程序上就把人家卡死在启动画面,排查了一整天才反应过来是加载器锁。从那以后我每次给这类工具加新钩子,都会先在一份最小 demo 上把「注入 → 挂上 → 落盘 → 解析」整条链路走通,确认没问题再往真实目标里塞。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进