简介面向具备Windows Socket与C基础的中高级开发者这份压缩包聚焦ws2_32.dll中send()函数的拦截与发送内容写入OB文件围绕API Hook、IAT替换及网络数据监控审计场景提供一套可编译研究的工程实例。包内共23个文件以cpp、h源码和def导出定义为主同时包含dll、lib、exp等编译链接产物以及dsp、dsw工程配置、pdb、ilk调试符号和ReadMe说明文本较完整地保留了VC6时期的项目结构与构建痕迹压缩包约898KB。已有1266人学习下载。读者可从中了解Winsock发送调用的组织方式、钩子代码与原始send()函数的衔接思路以及将发送缓冲区内容写入OB文件的实现线索也可借助调试符号和工程文件排查编译链接问题适合网络监控、安全审计与API Hook入门研究者参考。1. 抓不到原文的字节流为什么要针对 ws2_32 里的 send 进行拦截某开发者排查一个自定义二进制协议Wireshark 里 TCP 段一个不少可应用层字段全被上层自己加的混淆搅成一团对着十六进制数猜了两天没结论。后来把 ws2_32.dll 的 send 拦下来每次调用把原始缓冲区原样写进 .ob 文件才把「哪个 socket、第几毫秒、发了多少字节、内容是什么」固定清楚。这个资源做的就是这件事在 ws2_32 的导出函数上做拦截把 send/recv 的入参缓冲区按帧落盘成裸二进制配套注入器、卸载逻辑和解析脚本。它适合协议逆向、客户端行为核对、以及需要拿到加密层之下原始缓冲区的调试场景不适合拿来做线上生产环境的长期埋点。2. 三条拦截路线的取舍IAT、Inline 与代理 DLL 各能覆盖多少调用动手前必须先回答一个问题钩子挂在哪决定了你会漏掉多少流量。同一个进程里send可能被三种方式调用——静态导入走 IAT、GetProcAddress动态取地址后直接调用、或者干脆走WSASend这条分散聚集路径。三条路线对这三类调用的覆盖面完全不同选错了就是日志一片空白却查不出原因。2.1 拦截点对比改导入表、改指令首字节、换模块IAT Hook 的做法是遍历目标模块的导入表把ws2_32.dll!send对应的那个函数指针换成自己的地址。它实现简单、无需处理指令长度卸载也干净缺点很明确只对走 IAT 的调用生效程序一旦用GetProcAddress拿地址钩子形同虚设而且它改的是调用方的表进程里十个模块导入 send 就要改十处。Inline Hook 走的是另一条路直接把ws2_32.dll里send函数开头几个字节改成跳转指令跳到自己的处理函数再把被覆盖的原始指令搬进一段可执行内存常说的 trampoline处理完跳回去。它拦的是函数本体谁调用都能抓到包括动态取地址的。代价是要处理指令长度对齐、多线程并发改写、以及反汇编引擎对这些首字节的解析。代理 DLL 最粗暴也最彻底把系统目录里的ws2_32.dll复制一份改名成ws2_32_orig.dll自己写一个同名 DLL 放进 exe 目录所有导出函数用.def文件转发给原 DLL只在需要拦的那几个函数里插一段记录逻辑。对调用方完全透明连GetProcAddress都绕不过去。路线覆盖静态导入覆盖 GetProcAddress覆盖 WSASend 等变体需要改目标文件主要风险IAT Hook是否需单独挂否漏调用且要逐模块改Inline Hook是是需单独挂否指令长度处理、并发挂载代理 DLL是是是是导出序号必须完整、KnownDLLs 可能拦截我的选择一般是 Inline Hook用成熟库处理 trampoline自己只关心参数和落盘。代理 DLL 留着当兜底方案遇到那些一启动就把 ws2_32 拖进内存、注入窗口极短的程序时再换过去。提示三条路线都要考虑位宽。32 位进程只能加载 32 位 DLL用 64 位注入器去灌 32 位目标远程线程的LoadLibraryW会返回 0错误码 193看起来像「注入成功但没反应」。2.2 落盘格式选型为什么是裸二进制而不是文本日志很多人第一反应是fprintf(f, %s\n, buf)写完就发现三个问题。第一发送缓冲区里大概率有\0和各类控制字符按字符串写会截断按%s打印出来是一堆乱码。第二一旦做转义比如十六进制打印体积翻三倍解析时还要反向解码中间任何一次手抖都会让字节对不上。第三文本写入涉及格式化、编码转换、缓冲刷新在 send 这种高频路径上开销很难压下去。定长头加变长体的二进制帧解决的就是这三点帧头里写明长度负载直接memcpy原始字节不做任何转义读的时候按长度切就行。扩展名用.ob而不是.log或.bin一是避免被各类日志采集工具当成文本抓走改坏二是避免被通用二进制分析工具按已知格式误解析三是在文件管理器里一眼能认出来这是自己产出的东西。2.3 工程骨架一个 DLL、一个注入器、一个解析脚本整个工程拆成三块各自独立编译互不依赖。文件/模块作用编译目标关键编译选项hookdllDLL 工程挂载钩子、组织帧、写盘x86 与 x64 各一份/MT 静态链接 CRT禁用增量链接ob_format.h帧结构的唯一定义头文件#pragma pack(1) static_assertinject.exe远程线程注入与 DLL 同一位宽不需要额外库parse_ob.py解析与回放校验Python 3只用标准库 structDLL 的入口不要写重逻辑DllMain里拿到的环境是加载器锁持有状态此时调LoadLibrary、等锁、甚至部分 CRT 函数都可能死锁。常见做法是DllMain里只记下模块句柄真正的初始化放在导出函数或者一个新建线程里注入器拿到模块句柄后再显式调一次。// dllmain.cpp —— 入口只做最小动作初始化交给导出函数 #include windows.h #include hook.h static HMODULE g_self nullptr; BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { if (reason DLL_PROCESS_ATTACH) { g_self hModule; // 目标进程线程多时关掉线程级回调能省一大批无谓开销 DisableThreadLibraryCalls(hModule); } else if (reason DLL_PROCESS_DETACH) { // lpReserved ! nullptr 表示进程正在退出此时只 flush不做卸载 HookShutdown(lpReserved ! nullptr); } return TRUE; } // 注入器 LoadLibrary 返回后再调这个避免在加载器锁里做重活 extern C __declspec(dllexport) int StartHook(const wchar_t *obPath) { return HookInit(obPath) ? 0 : 1; }这段代码的关键点在两个地方。DisableThreadLibraryCalls不是可选项目标进程如果开了几十个线程每次线程创建销毁都会进一次DllMain白白增加挂死概率。HookShutdown带一个参数区分「正常卸载」和「进程退出」后者情况下堆和文件句柄可能已经处于半失效状态只做FlushFileBuffers就够别去调MH_Uninitialize之类的重活。3. 把 send 拆开看原型对齐、帧封装与 .ob 落盘的完整实现钩子代码写错九成死在函数原型上。Winsock 的导出函数在 x86 下是__stdcall参数从右往左压栈、被调用方清栈你把它声明成__cdecl调用返回那一刻栈指针就歪了现象是钩子进去出不来或者出来之后调用方莫名其妙访问违例。x64 下虽然统一了调用约定但参数类型宽度写错一样出事最典型的是用int接SOCKET在 64 位进程里句柄被截断成低 32 位。3.1 原型照抄 winsock2.h一个字节都别改不要凭记忆写原型。打开winsock2.h把send、recv、WSASend、WSARecv、sendto、recvfrom的声明原样搬过来只在前面加typedef和函数指针名。// hook_api.h —— 原型抄自 winsock2.h只加了函数指针包装 #pragma once #include winsock2.h #include ws2tcpip.h // WSAAPI 在 x86 上是 __stdcall在 x64 上为空必须原样保留 typedef int (WSAAPI *pfn_send)(SOCKET s, const char *buf, int len, int flags); typedef int (WSAAPI *pfn_recv)(SOCKET s, char *buf, int len, int flags); typedef int (WSAAPI *pfn_WSASend)(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); typedef int (WSAAPI *pfn_WSARecv)(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesRecvd, LPDWORD lpFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); extern pfn_send real_send; extern pfn_recv real_recv; extern pfn_WSASend real_WSASend; extern pfn_WSARecv real_WSARecv;send的缓冲区是const char *recv的是char *这两个 const 不能互换——虽然编译期只是警告但一旦签名不匹配函数指针转换在严格模式下会拒绝编译而在宽松模式下编过了就是运行期崩溃。WSARecv的lpFlags是LPDWORD而非DWORD这个参数在部分实现里会被原地修改写成值传递会把标志位丢掉。3.2 钩子函数体先转发再记录别反过来关于「先调原函数还是先落盘」我的做法是先调原函数拿到返回值后再记录。原因在于send的语义它可能只发出一部分返回值小于len也可能返回SOCKET_ERROR而缓冲区一个字节都没出去。先记录会把没发出去的内容也写进日志后面拿.ob和服务端抓包对账时永远差一截。// hook.cpp —— 转发、拷贝、投递三件事分开做 #include hook_api.h #include ob_format.h #include ob_writer.h #include MinHook.h pfn_send real_send nullptr; pfn_recv real_recv nullptr; pfn_WSASend real_WSASend nullptr; pfn_WSARecv real_WSARecv nullptr; // 线程级重入标志同一线程再次进钩子就直接透传防止把自己递归死 static __declspec(thread) int t_depth 0; struct Guard { Guard() { t_depth; } ~Guard() { --t_depth; } bool nested() const { return t_depth 1; } }; static int WSAAPI Hook_send(SOCKET s, const char *buf, int len, int flags) { Guard g; if (g.nested() || !real_send) return real_send(s, buf, len, flags); int ret real_send(s, buf, len, flags); // 先转发业务时延不受影响 // ret 0 才是真正出去的字节数ret len 说明只发了一部分 if (ret 0 buf ! nullptr) { ObEmit(DIR_SEND, API_SEND, (uint64_t)s, (uint16_t)flags, ret, buf, (uint32_t)ret); } return ret; } static int WSAAPI Hook_recv(SOCKET s, char *buf, int len, int flags) { Guard g; if (g.nested() || !real_recv) return real_recv(s, buf, len, flags); int ret real_recv(s, buf, len, flags); if (ret 0 buf ! nullptr) { ObEmit(DIR_RECV, API_RECV, (uint64_t)s, (uint16_t)flags, ret, buf, (uint32_t)ret); } return ret; }Guard这个结构看起来多余实际是保命的。钩子内部只要有一处间接调用了会走send的 API比如某些日志库会往网络上报就会无限递归直到栈溢出用线程局部变量做深度计数比全局布尔量靠谱因为多线程环境下全局标志会把别的线程的正常调用也一起挡掉。WSASend的情况复杂一层它支持分散聚集一次调用可能带多个WSABUF每个指向不同内存块总长度是所有len之和。static int WSAAPI Hook_WSASend(SOCKET s, LPWSABUF bufs, DWORD cnt, LPDWORD sent, DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cb) { Guard g; if (g.nested() || !real_WSASend) return real_WSASend(s, bufs, cnt, sent, flags, ov, cb); int ret real_WSASend(s, bufs, cnt, sent, flags, ov, cb); // 重叠 I/Oret SOCKET_ERROR 且 GetLastError() WSA_IO_PENDING 时 // 真正发出去多少要等完成例程这里只记「已提交」别硬编造成实际发送量 if (ov nullptr ret 0 sent ! nullptr *sent 0) { ObEmitScatter(DIR_SEND, API_WSASEND, (uint64_t)s, (uint16_t)flags, ret, bufs, cnt, *sent); } return ret; }ObEmitScatter的核心是按*sent这个总长度在多个WSABUF之间逐块拷贝最后一块只取需要的部分void ObEmitScatter(uint8_t dir, uint8_t api, uint64_t sock, uint16_t flags, int32_t ret, LPWSABUF bufs, DWORD cnt, DWORD total) { std::vectoruint8_t flat; flat.reserve(total); DWORD left total; for (DWORD i 0; i cnt left 0; i) { DWORD take bufs[i].len left ? bufs[i].len : left; flat.insert(flat.end(), bufs[i].buf, bufs[i].buf take); left - take; } ObEmitVector(dir, api, sock, flags, ret, std::move(flat)); }漏掉这个拼装日志里就只会出现第一个WSABUF的内容长度还写着总长度读的时候要么越界要么截断——这是最常见的「数据对不上」来源之一。3.3 .ob 帧格式定长头加变长体全部小端格式设计的原则是「读的时候不需要猜」。文件头只写一次记录魔数、版本、进程 ID、指针宽度和起始时间戳每帧一个固定 32 字节的头后面跟负载。字段类型字节说明magicchar[8]8文件头魔数OBLOG1\0\0versionuint324格式版本改结构就加一piduint324产出该文件的进程ptrSizeuint3244 或 8防止 32/64 位记录混读startTickuint648GetTickCount64基准帧里存相对值帧头单独一张表字段类型说明magicuint320x4F424642即 ASCII 的 OBFBdiruint80 发送1 接收apiuint80send 1sendto 2WSASend 3recv 4WSARecvflagsuint16原样保留的 flags 参数sockuint64句柄用 64 位存兼容 x64tickuint32相对 startTick 的毫秒lenuint32负载字节数不含本头retint32原函数返回值crc32uint32负载校验可选// ob_format.h —— 结构的唯一定义改动必须同步改 parse_ob.py #pragma once #include cstdint #pragma pack(push, 1) struct ObFileHeader { char magic[8]; // OBLOG1\0\0 uint32_t version; // 1 uint32_t pid; uint32_t ptrSize; // 4 / 8 uint64_t startTick; }; struct ObFrameHeader { uint32_t magic; // 0x4F424642 uint8_t dir; uint8_t api; uint16_t flags; uint64_t sock; uint32_t tick; uint32_t len; int32_t ret; uint32_t crc32; }; #pragma pack(pop) // 编译期卡死尺寸任何人不小心加个字段立刻报错避免解析脚本静默错位 static_assert(sizeof(ObFileHeader) 28, file header must stay 28 bytes); static_assert(sizeof(ObFrameHeader) 32, frame header must stay 32 bytes);这两个static_assert是整份格式定义里最值钱的两行。曾经有人在帧头中间加了个uint16_t port编译通过、写盘正常解析脚本按旧布局读后面所有帧全部错位第一帧的len被读成了sock的低 16 位解出来一个几百 MB 的负载长度脚本直接卡死。3.4 写盘线程别让 recv 线程等磁盘发送和接收线程是最不能阻塞的地方。在钩子里直接WriteFile加FlushFileBuffers一秒钟几千帧下来业务方会明显感觉到收发变慢甚至超时。正确做法是钩子只负责组帧入队另起一个线程专职落盘。// ob_writer.cpp —— 单写线程 队列钩子侧只做一次 memcpy 和一次入队 #include ob_writer.h #include windows.h #include deque #include mutex #include vector static HANDLE g_file INVALID_HANDLE_VALUE; static std::dequestd::vectoruint8_t g_q; static SRWLOCK g_lock SRWLOCK_INIT; static HANDLE g_evt nullptr; static HANDLE g_th nullptr; static volatile LONG g_stop 0; static volatile LONG g_drop 0; static uint64_t g_t0 0; static DWORD WINAPI WriterProc(LPVOID) { std::vectoruint8_t buf; for (;;) { WaitForSingleObject(g_evt, 200); for (;;) { AcquireSRWLockExclusive(g_lock); if (g_q.empty()) { ReleaseSRWLockExclusive(g_lock); break; } buf.swap(g_q.front()); g_q.pop_front(); ReleaseSRWLockExclusive(g_lock); DWORD wrote 0; // 不做 FlushFileBuffers交给系统缓存只在退出前刷一次 WriteFile(g_file, buf.data(), (DWORD)buf.size(), wrote, nullptr); } if (InterlockedCompareExchange(g_stop, 1, 1) 1) break; // 停止时先排空再退 } return 0; }WaitForSingleObject的超时设 200 毫秒而不是INFINITE是为了让停止标志能在队列空闲时也能被及时感知。buf.swap复用一个vector避免每帧都释放内存给堆管理器增加压力。入队侧的丢弃策略也要写清楚void ObEmit(uint8_t dir, uint8_t api, uint64_t sock, uint16_t flags, int32_t ret, const void *data, uint32_t len) { if (g_file INVALID_HANDLE_VALUE || len 65536) return; ObFrameHeader h{}; h.magic 0x4F424642; h.dir dir; h.api api; h.flags flags; h.sock sock; h.tick (uint32_t)(GetTickCount64() - g_t0); h.len len; h.ret ret; h.crc32 Crc32(data, len); std::vectoruint8_t frame(sizeof(h) len); memcpy(frame.data(), h, sizeof(h)); if (len) memcpy(frame.data() sizeof(h), data, len); AcquireSRWLockExclusive(g_lock); if (g_q.size() 8192) g_q.emplace_back(std::move(frame)); else InterlockedIncrement(g_drop); // 宁可缺帧也不拖慢业务线程 ReleaseSRWLockExclusive(g_lock); SetEvent(g_evt); }队列上限 8192 帧、单帧上限 64 KB这两个数是经验值。上限存在的意义在于目标程序如果突然疯狂发包队列无限增长会先把内存吃光然后整个进程被系统干掉那时候连「到底发生了什么」都查不到。丢弃时记一个计数器退出前写进一个旁路文件事后能知道「这段日志是完整的还是缺了一段」。4. 送进目标进程注入器写法、位宽校验与代理 DLL 的导出转发钩子 DLL 编好了得让它进到目标进程的地址空间里。最短的路径是CreateRemoteThread加LoadLibraryW在目标进程里分配一块内存把 DLL 全路径写进去然后创建一个远程线程起点设成LoadLibraryW参数就是那块内存的地址。4.1 最短路径的完整写法// inject.cpp —— inject.exe pid dll 全路径 #include windows.h #include cstdio int wmain(int argc, wchar_t **argv) { if (argc 3) return 2; DWORD pid wcstoul(argv[1], nullptr, 10); const wchar_t *dll argv[2]; HANDLE hp OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, pid); if (!hp) { wprintf(LOpenProcess 失败: %lu\n, GetLastError()); return 3; } SIZE_T bytes (wcslen(dll) 1) * sizeof(wchar_t); void *remote VirtualAllocEx(hp, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!remote) { CloseHandle(hp); return 4; } WriteProcessMemory(hp, remote, dll, bytes, nullptr); HMODULE k32 GetModuleHandleW(Lkernel32.dll); auto load (LPTHREAD_START_ROUTINE)GetProcAddress(k32, LoadLibraryW); HANDLE th CreateRemoteThread(hp, nullptr, 0, load, remote, 0, nullptr); if (!th) { VirtualFreeEx(hp, remote, 0, MEM_RELEASE); CloseHandle(hp); return 5; } WaitForSingleObject(th, 10000); DWORD ec 0; GetExitCodeThread(th, ec); // 注意这里拿到的是被截断成 32 位的 HMODULEx64 下高位可能丢失 wprintf(L远程线程退出码(模块句柄低 32 位) %p\n, (void *)(ULONG_PTR)ec); CloseHandle(th); VirtualFreeEx(hp, remote, 0, MEM_RELEASE); CloseHandle(hp); return ec ? 0 : 6; }这段代码有一处必须知道的局限GetExitCodeThread返回的是 32 位值而 x64 下LoadLibraryW返回的HMODULE通常是 64 位。真要把模块句柄拿到手去调导出函数稳妥做法是让远程线程执行一段自己写的桩代码把句柄写到一块事先分配好的远程内存里主进程再ReadProcessMemory读回来。OpenProcess的权限组合也要对齐。少PROCESS_VM_OPERATION就VirtualAllocEx失败少PROCESS_CREATE_THREAD就CreateRemoteThread失败而这两种失败的错误码都是 5拒绝访问很容易被误判成「权限不够要不要提权」实际是权限位没给全。4.2 三个必须先确认的前提注入失败的表象高度相似——「远程线程起来了但什么也没发生」。动手前先把下面三件事确认掉能省掉大半天。位宽是否匹配。用IsWow64Process判断目标是不是 32 位对 64 位系统上的 32 位进程返回 TRUE自己的 DLL 必须与之同宽。更严格的判断用IsWow64Process2它能区分「x64 系统上的 x86 进程」和「ARM64 系统上的 x64 进程」。会话是否一致。目标进程在另一个登录会话里比如服务进程在会话 0普通权限的OpenProcess直接拒绝。用ProcessIdToSessionId对比一下当前会话号不一致就得换执行环境别在权限上死磕。路径是否可达。LoadLibraryW是在目标进程的上下文里解析路径的相对路径按目标进程的当前目录算还受它的重定向和搜索顺序影响。一律传绝对路径并且确保目标进程有读权限——把 DLL 放在一个只有当前用户能访问的目录注入到以另一个账户运行的进程里就是加载失败。注意钩子类 DLL 会被部分安全软件的行为规则重点关注。所有实验都在自己可控的测试机、测试进程上做别往生产环境或者别人的机器上扔。4.3 代理 DLL 的导出转发与序号坑注入窗口太短、或者在受控环境里不方便起远程线程时代理 DLL 是备选。核心是一个.def文件把原 DLL 的所有导出原样转发。; ws2_32.def —— 只列了常用的几个实际要导全 LIBRARY ws2_32 EXPORTS sendws2_32_orig.send 19 recvws2_32_orig.recv 16 sendtows2_32_orig.sendto 20 recvfromws2_32_orig.recvfrom 17 WSASendws2_32_orig.WSASend 74 WSARecvws2_32_orig.WSARecv 69 WSAStartupws2_32_orig.WSAStartup 115 WSACleanupws2_32_orig.WSACleanup 116这里有个必须踩过才知道的点部分程序是按序号而非名字导入的dumpbin /exports ws2_32.dll能看到原始序号表.def里如果只写名字不写序号转发出去的序号会和原 DLL 不一致程序启动时报「无法定位程序输入点于动态链接库 ws2_32.dll 上」。序号必须逐个核对不能凭猜。还有一个更现实的问题在较新的 Windows 上ws2_32.dll有可能被列入 KnownDLLs系统会优先从已知 DLL 区加载放在 exe 目录下的同名文件根本不生效。验证方法很简单先放一个只弹MessageBoxW的空壳同名 DLL 进去程序启动时弹了框说明代理生效没弹就说明这条路在当前环境走不通老老实实回去做 Inline Hook。5. 五条血泪记录钩子不生效、进程崩溃与字节错位的排查下面这五条每一条都是真实卡过半天以上的。现象、原因、解决按顺序写照着对号入座基本能覆盖大部分翻车现场。5.1 挂载阶段日志一条都没有现象注入返回成功模块也确实在目标进程的模块列表里用 Process Explorer 能看到但.ob文件只有 28 字节的文件头一帧都没有。原因目标程序走的是WSASend而不是send。很多封装良好的网络库在内部一律用WSASendsend那个导出根本没被调用过。另一种情况是程序用GetProcAddress动态取了地址存在自己的函数表里IAT Hook 完全抓不到。解决四个函数一起挂——send、sendto、WSASend、WSASendTo接收侧对应recv、recvfrom、WSARecv、WSARecvFrom。挂完先跑一个自己写的测试程序里面同时调send和WSASend各发一条两个都在日志里出现才算挂载成功。现象.ob有帧但全是接收方向发送方向一帧没有。原因recv的钩子写对了send的钩子写错了但错误被吞掉了——最常见的是send的原型漏了WSAAPI在 x86 下栈帧错乱函数返回时调用方崩溃被上层__try/__except兜住表现成「发不出去但程序不崩」。解决把typedef里的WSAAPI补上编译时开/W4把函数指针不匹配的警告当错误处理。加一句static_assert(sizeof(SOCKET) sizeof(void *), SOCKET 宽度必须和指针一致)防止有人用int接句柄。5.2 运行阶段崩溃与变慢现象注入后目标进程随机崩溃崩溃地址在ntdll.dll里调用栈看不出和自己的代码有关系。原因多个线程并发首次调用send而此时 trampoline 还没写完。Inline Hook 挂载过程分两步——创建钩子改内存和启用钩子真正写跳转如果启用之前已经有线程进到钩子函数里real_send还是空指针解引用就是访问违例。解决HookInit里先MH_CreateHook全部建好再统一MH_EnableHook(MH_ALL_HOOKS)并且在钩子函数入口加if (!real_send) return 原函数。更稳的做法是注入完成后暂停所有线程SuspendThread遍历挂完再恢复。现象目标程序收发明显变慢批量传输时出现超时重传。原因在钩子里同步WriteFile并且每帧FlushFileBuffers。前者让每个send都要等一次系统调用后者直接把数据刷到物理磁盘一次几毫秒几千帧下来就是几十秒的累计延迟。解决改成队列加单写线程钩子侧只做一次memcpy和一次入队FlushFileBuffers只在退出或者每写入 N 兆时调一次。判断标准很简单——打开日志和不打开日志业务侧的成功率曲线应该重合。5.3 数据阶段程序不报错但内容不对现象.ob解析出来的字节是一堆高熵乱码长度和 TCP 抓包对得上内容完全对不上。原因拦截点在加密层之上。如果程序自己用 TLS 或者私有加密做了会话保护send拿到的就是密文明文早在加密函数里就被吃掉了。这不是钩子写错了是拦截层次选错了。解决先确认加密是在哪一层做的。如果用的是系统 TLSSchannel明文在EncryptMessage之前需要往上挪一层钩如果用第三方加密库就看它的输出缓冲区传递路径。另一种可能是只改了send没管WSASend而程序实际用的是后者日志里混进了别的进程的残留数据。现象长度字段写着 8192实际读出来的负载只有几百字节后面的帧全部错位。原因WSASend的多个WSABUF只记了第一个。帧头的len按总长度写负载却只拷了第一块剩下的字节被当成了下一帧的帧头。解决用ObEmitScatter按*sent逐块拼装并在解析脚本里加一道保险——读到帧头后先校验magic不等于0x4F424642就说明前面错位了立刻报错退出而不是继续瞎读。校验 CRC 也要做比事后逐字节肉眼比对省事得多。6. 回放与对账本文还有配套的精品资源点击获取