简介:面向网络安全研究人员、逆向工程师或系统管理员,该压缩包提供一套基于VC++开发的API Hook拦截工具,可对指定进程发送和接收的网络数据包进行监控、分析与自定义处理,适用于调试分析、协议解析、安全测试等场景。包内共22个文件,以8个头文件和4个C++源文件为核心工程,另含2个dsp工程文件、2个dsw工作区文件、2个txt说明文档、1个动态链接库及图标资源等,整体仅37KB,便于快速查看与编译。工程代码覆盖了进程枚举与目标选择、虚拟内存中API函数入口前8字节跳转修改,以及结合VirtualQuery、VirtualProtect、WriteProcessMemory等API的底层Hook过程,可对收发的数据包进行检查或改写。配套源码与说明文档完整展示了DLL注入、系统调用表Hook及共享内存等实现思路,适合希望深入理解Windows API Hook机制并开展网络监控实验的读者参考。该资源已有876人学习下载。
1. 什么是API HOOK拦截指定进程数据包:为什么它是调试网络程序的最后手段
当你怀疑某个客户端在后台偷偷发数据、或者自己写的网络程序在特定机器上连不上服务器,通常第一反应是开Wireshark抓包。可抓包只能看到线路上的字节流,一旦程序用了TLS,抓包工具拿到的是密文;而且你没法直观判断某个包到底是哪个进程、哪次调用发出去的。API HOOK拦截指定进程发送和接收的网络数据包,就是绕开这套黑匣子,直接住进目标进程内部,在它调用send/recv的那一刻把进出缓冲区的内容原样截获下来。这个方案适合Windows客户端开发、协议联调、以及排查“某个进程到底在发什么”这类问题;你不用重新编译目标程序,只要注入一个DLL进去就行。
这份方案的核心动作只有三步:把Hook DLL注入到指定进程、钩住Winsock层的收发函数、把缓冲区和连接信息按可读格式记下来。整个过程不依赖任何内核驱动,运行在应用层,看得见进出该进程的真实Buffer。下面直接拆原理、给实现、列参数,再把这几年实操遇到过的五种翻车情况讲透。
2. HOOK点选型:为什么盯住send/recv而不是直接用抓包库
2.1 应用层HOOK与抓包、WFP的分工
Wireshark、Npcap这类工具位于内核协议栈旁路,通过NDIS或AFD的复制路径拿到线路数据。它的优势是能看到所有进程所有网卡接口的流量,劣势是对不上进程的调用上下文:你得靠端口反查pid,加密流量拿到也只能干瞪眼。Windows的WFP(Windows Filtering Platform)能在内核层按进程ID做过滤,但它要写驱动或服务,配置复杂,而且它拿到的仍然是线路上的数据,对应用层明文同样无能为力。
API HOOK的位置完全不同:它在目标进程的用户态内存里,直接在Winsock的send/recv导出函数入口挂跳转,因此天然具备两个抓包工具拿不到的能力。第一,能看到应用层缓冲区buf里面真正要发出去的内容,包括TLS封装前的明文(前提是你把钩子挂在更上层,比如SSL_write;如果只钩send,那看到的就是TLS密文)。第二,能看到发起调用的线程,结合线程栈和当前进程ID,可以精确知道“这个数据是哪个线程发的”。这正是“指定进程”这个需求最核心的价值——不是拦全网,而是只盯一个目标进程的收发动作。
所以选型结论很直接:如果你要分析的是目标进程应用层的数据内容、调用行为、协议交互规律,API HOOK是最短路径;如果你需要全网流量审计、丢包重传分析、或者抓Wi-Fi驱动层面的帧,那该用抓包工具还是用抓包工具,两者不是替代关系。
2.2 四个候选函数:send/recv/WSASend/WSARecv怎么选
Windows下应用层网络收发的主干调用链是:应用逻辑 → 协议库(TLS/HTTP等)→ ws2_32.dll → AFD.sys → 内核协议栈。对我们做HOOK来说,ws2_32.dll这一层是用户态的最后一道关卡,钩住它就能覆盖目标进程绝大多数网络行为,又不用碰驱动。需要优先考虑的函数列表如下:
| 函数 | 场景 | 备注 |
|---|---|---|
| send | TCP/UDP已连接socket的发送 | 参数直接给buf、len,逻辑最简单 |
| recv | TCP/UDP已连接socket的接收 | 注意必须在调用后根据返回值确定有效长度 |
| WSASend | 异步/多缓冲区发送 | I/O完成端口场景更常用 |
| WSARecv | 异步/多缓冲区接收 | 尊重lpNumberOfBytesRecvd |
| sendto / recvfrom | UDP未连接场景 | 可选的补充钩子 |
| closesocket | 连接关闭日志 | 可选,用于记录socket生命周期 |
最常见的做法是先钩住send和recv这两个基础函数,跑通一个最小日志输出;当发现目标程序走的是投递型异步I/O(比如IOCP),再补上WSASend和WSARecv。需要注意:WSASend的第二个参数是WSABUF数组,一个调用可能同时携带多个缓冲区,日志输出必须遍历数组,否则会丢数据;WSARecv则在调用成功后才通过dwNumberOfBytesRecvd给出真实接收长度,很多时候它比入参的len小得多,别拿入参len去写日志。
除了这4个收发函数,connect也可以一起钩住:在connect返回成功后,调一次getpeername记录对端IP和端口,后续所有send/recv日志里的socket都能映射到具体的远程地址。这个准备工作做得好,第4章解析连接信息时就会轻松很多。如果你要覆盖UDP广播/组播那种没有建立“连接”的socket,那就必须再勾上sendto/recvfrom,因为getpeername对这类socket基本不可用。
2.3 进程过滤的粒度:按PID过滤还是按socket句柄过滤
标题里“指定进程”听起来像在HOOK之前就要选好进程,但工程上有两种实现路径,过滤粒度不一样。
第一种是远程线程注入(CreateRemoteThread + LoadLibrary),DLL只被加载到目标进程内部。这种情况下,DLL里的Hook函数只会被目标进程触达,理论上不需要再做进程ID判断。但我依然建议在日志写入前加一道GetCurrentProcessId() == g_targetPid的检查,当作双保险——防止注入线程自身的网络调用也被记录。第二种是WH_GETMESSAGE全局钩子方式:HookDLL会通过注册表被系统自动加载进所有满足消息条件的进程,此时你必须用PID过滤,否则全系统进程的收发都会被记录下来,日志会瞬间爆炸。
socket句柄级过滤则更精细:如果目标进程里有多个连接,我们只关心其中某几个远端端口,就可以在Hook_connect里把感兴趣的SOCKET句柄加到白名单集合,只有白名单内的socket才写日志。这样做的好处是日志干净,缺点是并发量大时哈希表的查询和锁竞争会引入额外开销。我实际做的时候,通常先用PID级过滤保证数据不脏,再在日志还原阶段把连接信息打全,最后按端口条件筛出需要关注的行,而不是在HOOK热路径上做复杂过滤。进程与线程在这里也有区别:PID过滤是进程级的粗粒度筛选,TID记录是线程级的细粒度追踪——记下发出本次调用的线程ID,排查数据竞争时比单纯看进程好用得多。
3. 最小可运行方案:DLL注入加API HOOK的完整实现
3.1 注入器:CreateRemoteThread + LoadLibrary 的标准姿势
既然要“指定进程”,最常见的注入方案就是让目标进程自己调一次LoadLibrary,把我们的HookDLL加载进去。下面这段注入器代码是Windows平台的老牌做法,网上也能搜到同类实现,核心是四步:打开进程、异地分配内存、写入DLL路径、远程线程执行LoadLibrary。
// injector.cpp #include <windows.h> #include <stdio.h> int wmain(int argc, wchar_t* argv[]) { if (argc < 3) { wprintf(L"usage: injector.exe <pid> <hookdll_path>\n"); return 0; } DWORD pid = (DWORD)_wtoi(argv[1]); const wchar_t* dllPath = argv[2]; // 按需申请最小权限,不要像网上老代码那样直接 PROCESS_ALL_ACCESS HANDLE hProcess = OpenProcess( PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid); if (!hProcess) { wprintf(L"OpenProcess failed, error=%d\n", GetLastError()); return 1; } size_t len = (wcslen(dllPath) + 1) * sizeof(wchar_t); LPVOID pRemotePath = VirtualAllocEx(hProcess, NULL, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!pRemotePath) { wprintf(L"VirtualAllocEx failed\n"); CloseHandle(hProcess); return 1; } if (!WriteProcessMemory(hProcess, pRemotePath, dllPath, len, NULL)) { wprintf(L"WriteProcessMemory failed\n"); VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE); CloseHandle(hProcess); return 1; } HMODULE hKernel32 = GetModuleHandleW(L"kernel32.dll"); LPTHREAD_START_ROUTINE pLoadLibrary = (LPTHREAD_START_ROUTINE)GetProcAddress(hKernel32, "LoadLibraryW"); HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, pLoadLibrary, pRemotePath, 0, NULL); if (!hThread) { wprintf(L"CreateRemoteThread failed, error=%d\n", GetLastError()); } else { // 这就是典型的进程等待wait:等远程线程执行完 LoadLibrary WaitForSingleObject(hThread, 10000); DWORD exitCode = 0; GetExitCodeThread(hThread, &exitCode); wprintf(L"LoadLibraryW returned 0x%x\n", exitCode); CloseHandle(hThread); } VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE); CloseHandle(hProcess); return 0; }逻辑说明:OpenProcess的权限参数不需要给满,PROCESS_ALL_ACCESS在部分安全软件策略下会直接触发拦截,给到能创建线程、能读写内存、能查询信息即可。VirtualAllocEx在目标进程地址空间里分配一块区域存放DLL路径字符串,长度要按宽字符计算,别只算strlen。CreateRemoteThread的线程函数地址取自kernel32导出表,因为每个进程都加载kernel32,LoadLibraryW首地址在所有进程里基本一致(只要ASLR没有跨进程随机化问题,实际中取当前进程的地址是可用的)。WaitForSingleObject就是“进程等待”在Windows上的具体形态,等这个注入线程退出,再用GetExitCodeThread把LoadLibraryW的返回值取出来——这个返回值就是DLL的模块基址,如果为0说明加载失败。
3.2 Hook DLL:用Detours挂住send/recv/WSASend/WSARecv
注入只是把DLL送进目标进程,真正干活的是DLL里的Detour钩子。这里用的是微软Detours库的经典用法:先把原函数指针保存下来,再用DetourAttach替换入口,之后原函数指针指向的是绕过HOOK的“真函数”。下面这段是DLL的核心实现。
// hookdll.cpp #include <windows.h> #include <detours.h> #include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") #pragma comment(lib, "detours.lib") static DWORD g_targetPid = 0; static FILE* g_logFile = nullptr; static CRITICAL_SECTION g_logLock; static int (WINAPI * Real_send)(SOCKET s, const char* buf, int len, int flags) = send; static int (WINAPI * Real_recv)(SOCKET s, char* buf, int len, int flags) = recv; static int (WINAPI * Real_WSASend)(SOCKET s, LPWSABUF bufs, DWORD cnt, LPDWORD sent, DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) = WSASend; static int (WINAPI * Real_WSARecv)(SOCKET s, LPWSABUF bufs, DWORD cnt, LPDWORD recvd, LPDWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) = WSARecv; bool IsTargetProcess() { return g_targetPid != 0 && GetCurrentProcessId() == g_targetPid; } void WriteLog(const char* dir, SOCKET s, const char* data, int len) { if (!IsTargetProcess() || len <= 0) return; EnterCriticalSection(&g_logLock); SYSTEMTIME st; GetLocalTime(&st); fprintf_s(g_logFile, "[%02d:%02d:%02d.%03d][%s][sock=%lld][len=%d] ", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, dir, (long long)s, len); int dump = len < 32 ? len : 32; // 默认只打前32字节,后续按配置扩展 for (int i = 0; i < dump; i++) fprintf_s(g_logFile, "%02x ", (unsigned char)data[i]); fprintf_s(g_logFile, "\n"); fflush(g_logFile); LeaveCriticalSection(&g_logLock); } int WINAPI Hook_send(SOCKET s, const char* buf, int len, int flags) { WriteLog("SEND", s, buf, len); // send 是调用前记录 return Real_send(s, buf, len, flags); } int WINAPI Hook_recv(SOCKET s, char* buf, int len, int flags) { int ret = Real_recv(s, buf, len, flags); if (ret > 0) // recv 必须先调用再记录 WriteLog("RECV", s, buf, ret); return ret; } int WINAPI Hook_WSASend(SOCKET s, LPWSABUF bufs, DWORD cnt, LPDWORD sent, DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) { for (DWORD i = 0; i < cnt; i++) WriteLog("WSASEND", s, bufs[i].buf, (int)bufs[i].len); return Real_WSASend(s, bufs, cnt, sent, flags, ov, cr); } int WINAPI Hook_WSARecv(SOCKET s, LPWSABUF bufs, DWORD cnt, LPDWORD recvd, DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) { int ret = Real_WSARecv(s, bufs, cnt, recvd, flags, ov, cr); if (ret == 0 && recvd) { DWORD total = *recvd; // 实际收到的总字节数 for (DWORD i = 0; i < cnt && total > 0; i++) { DWORD n = (bufs[i].len < total) ? bufs[i].len : total; WriteLog("WSARECV", s, bufs[i].buf, (int)n); total -= n; } } return ret; } void LoadConfig() { // 配置文件里只有一行:目标PID // 常见做法是注入器启动前把PID写进 C:\hook_log\hook.ini FILE* cfg = nullptr; fopen_s(&cfg, "C:\\hook_log\\hook.ini", "r"); if (cfg) { fscanf_s(cfg, "%lu", &g_targetPid); fclose(cfg); } } void InstallHooks() { InitializeCriticalSection(&g_logLock); LoadConfig(); if (!g_targetPid) return; // 强制加载winsock,避免DetourAttach时ws2_32还没进进程 LoadLibraryA("ws2_32.dll"); fopen_s(&g_logFile, "C:\\hook_log\\netpacket.log", "a+"); if (!g_logFile) return; DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); DetourAttach(&(PVOID&)Real_send, Hook_send); DetourAttach(&(PVOID&)Real_recv, Hook_recv); DetourAttach(&(PVOID&)Real_WSASend, Hook_WSASend); DetourAttach(&(PVOID&)Real_WSARecv, Hook_WSARecv); if (DetourTransactionCommit() != NO_ERROR) { fclose(g_logFile); g_logFile = nullptr; } } BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { if (reason == DLL_PROCESS_ATTACH) { InstallHooks(); } else if (reason == DLL_PROCESS_DETACH) { if (g_logFile) { DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); // 卸载顺序必须和安装顺序相反 DetourDetach(&(PVOID&)Real_WSARecv, Hook_WSARecv); DetourDetach(&(PVOID&)Real_WSASend, Hook_WSASend); DetourDetach(&(PVOID&)Real_recv, Hook_recv); DetourDetach(&(PVOID&)Real_send, Hook_send); DetourTransactionCommit(); fclose(g_logFile); g_logFile = nullptr; } DeleteCriticalSection(&g_logLock); } return TRUE; }逻辑说明:所有原函数指针都初始化为ws2_32导出函数的地址,DetourAttach会把这4个指针改写为“跳板函数”地址。之后在Hook_send里调用Real_send,执行的是绕过钩子的原始代码路径,不会递归。recv方向必须先调用原始函数,再用返回值ret作为实际长度写日志,因为入参len只是缓冲区容量,数据是否真正填充要等调用返回才知道。WSASend的多缓冲遍历要注意完负数——cnt一般不会为0,但防御性判断不可省。
参数说明:DetourTransactionBegin/Commit是一组事务,多个Attach之间原子性生效;DetourUpdateThread(GetCurrentThread())是让当前线程在事务提交时调整指令指针,避免正在执行被HOOK函数前几条指令的线程崩溃。Installer里LoadLibraryA("ws2_32.dll")是为了确保winsock已经映射进进程地址空间——如果目标进程还从没调过任何网络API,ws2_32.dll可能尚未加载,DetourAttach就会因为找不到模块而失败。
3.3 配置与启动:PID从哪来、日志写到哪里
上面的DLL从C:\hook_log\hook.ini读取目标PID,这是最简单直接的传参方式。注入器只需要在启动前把PID写进这个文件,或者启动时先用命令行参数生成这个文件,不需要在目标进程里做复杂的内存共享。备选方案还有写注册表项、或把PID作为环境变量——但环境变量在CreateRemoteThread的线程里不会天然继承,所以不建议用。
整个方案的启动流程是这样的:以管理员身份打开命令行;启动需要监控的目标进程,从任务管理器或tasklist拿到PID;把HookDLL.dll放到目标机器磁盘上;执行injector.exe <pid> C:\hook_log\HookDLL.dll;观察C:\hook_log\netpacket.log。日志按追加模式打开,进程不退出就一直写。一个容易忽略的点是日志目录必须先创建,否则fopen_s会失败,整个Hook静默退出。
编译时注意平台位数:注入器和HookDLL必须和目标进程保持一致。调试阶段建议先用一个自己写的测试进程(比如一个循环发送UDP数据的小程序)来验证关键步骤,确定日志能正常输出再上真实目标。这样能把“注入失败”和“HOOK不生效”两类问题分开排查,而不是混在一起瞎猜。
4. 把原始buffer还原成能分析的流量:连接信息解析与过滤
4.1 从SOCKET句柄还原对端地址
HOOK到了send/recv的缓冲区和长度,但日志里只有socket整数值(SOCKET本质是unsigned int),不直观。每次记录时对端IP和端口是多少?最常见做法是直接在Hook函数里调一次getpeername,把socket转成可读的地址。这个API本身就是Ws2_32导出的,不需要额外钩子,直接调用真实系统函数即可。
void GetPeerInfo(SOCKET s, char out[64]) { sockaddr_in addr; int addrLen = sizeof(addr); if (getpeername(s, (sockaddr*)&addr, &addrLen) == 0) { sprintf_s(out, 64, "%s:%u", inet_ntoa(addr.sin_addr), // 仅IPv4,IPv6请换inet_ntop ntohs(addr.sin_port)); } else { strcpy_s(out, 64, "unknown"); } }在WriteLog里增加一个字段,把GetPeerInfo的输出放进日志行首。这样每条SEND/RECV记录都自带远程地址,后续在大量日志里grep某个IP非常方便。需要注意:对UDP非连接socket,getpeername在没有调用connect时会失败,返回“unknown”;这就是为什么第2章里说UDP场景要额外挂sendto/recvfrom,那组函数会直接返回目标地址。对TCP的CLOSE_WAIT等状态,getpeername一般仍然可用,只是拿到的可能是一个已经不存在的对端,端口依然有参考价值。
4.2 TCP与UDP:两套函数族,两种日志形态
先澄清一个概念:标题里说的“网络数据包”,在应用层HOOK这个视角下,其实看到的是“进程写入socket的字节流”和“socket读出的字节流”,不包含IP头、TCP头,也不是按网络包边界划分的。TCP是流协议,你调用send一次,内核可能拆成多个报文发出;远端发来的一个报文,recv可能分两次读出来。所以日志里的SEND记录表示“这一次调用写入了多少字节”,不代表线路上真实的一个IP包。
UDP就不一样:sendto/recvfrom天然对应数据报文边界,一条记录就是一个完整的数据报。如果你要做的分析对报文边界敏感(比如TURN、QUIC、DNS),那么在实现时要把sendto/recvfrom也挂上。钩子签名类似send/recv,只是多了目标地址参数sockaddr*;在Hook_sendto里,地址信息由参数直接给出,不需要再调用getpeername。将TCP的字节流日志和UDP的报文日志混合记录时,建议在日志类型字段上区分流(TCP)和数据报(UDP),否则后面处理时很容易误用报文边界。
日志文件本身建议用“时间戳 + 方向 + socket + 对端地址 + 长度 + hex转储”这种一行一记录的布局。hex转储默认截断到前32字节,对大多数协议识别已经够用;要分析完整协议内容时再通过配置项把它放大到64、128甚至全量。对明显是文本协议的数据,可以在hex后面顺带补一行ASCII,方便肉眼快速扫。
4.3 过滤规则:白名单端口、长度阈值与hexdump开关
日志增长非常快,几秒钟就能刷出几十MB,所以过滤规则不是可选项而是必选项。我在实际项目中维护过一组配置项,同样放在hook.ini里:
| 配置项 | 含义 | 示例 |
|---|---|---|
| target_pid | 目标进程PID | 8899 |
| port_whitelist | 只记录这些远端端口,逗号分隔 | 443, 8080, 53 |
| min_len | 小于该长度不记录 | 4 |
| dump_len | hexdump截断字节数 | 64 |
| enable_wsabuf | WSASend/WSARecv是否启用 | 1 |
过滤逻辑放在WriteLog入口,比放在Hook函数里更统一:先判断方向、socket、长度,全部通过才进临界区写日志。例如端口白名单,可以在GetPeerInfo之后取出端口,用strchr句或字符串查找判断;也可以维护一个std::set,编译成Release时查一次。请注意“少记日志”和“丢失关键包”的权衡:端口白名单通常只放感兴趣的端口,一旦目标程序改成随机高端口连服务器,白名单外的流量就被错过了。我建议保留全量漏斗式记录到文件,再写一个独立的小脚本按月归档压缩,而不是在热路径上做太多过滤。
5. API HOOK拦截网络包的五条避坑记录:从注入失败到日志死锁
5.1 注入成功但日志里一条包都没有:Winsock模块时序
现象:任务管理器里已经能看到HookDLL被加载进目标进程,目标进程也在正常收发数据,但netpacket.log里一条记录都没有。
原因:目标进程可能在启动早期就被注入,那个时候ws2_32.dll还没有被加载进地址空间。DetourAttach需要替换ws2_32.dll里导出函数的入口地址,如果模块不存在,DetourAttach会返回失败;可是代码里没有检查DetourAttach的返回值,事务提交时全部一起提交,失败被掩盖了。
解决:在InstallHooks里先显式调用LoadLibraryA("ws2_32.dll"),确保winsock映射进进程再执行DetourAttach。同时要检查DetourTransactionCommit的返回值,提交不成功就打印错误字符串到日志文件,别让它静默失败。另外,如果注入发生在进程初始化极早期,还可以用DLL_PROCESS_ATTACH里启动一个延迟初始化线程,等2秒后再装钩子。这个方案有一定风险,但实测中用CreateThread等待目标进程稳定后再安装,比在Loader Lock里硬干要稳。
5.2 32位注入器打64位进程:直接翻车
现象:注入器对64位目标进程执行CreateRemoteThread返回NULL,错误码是ERROR_INVALID_HANDLE或者ERROR_ACCESS_DENIED;偶尔成功注入,但LoadLibrary的返回值是0。
原因:32位进程无法加载64位DLL,64位进程的模块句柄空间也和32位不同。如果你的注入器和HookDLL都是32位编译,而目标进程是64位,那CreateRemoteThread可能创建成功,但LoadLibraryW尝试加载32位DLL时直接失败。
解决:先确认目标进程位数。可用Process Explorer查看镜像架构,或者代码里用IsWow64Process检测:如果目标进程是64位而当前注入器是32位,则必须切换编译配置。我在实际项目里统一维护Win32和x64两套构建,注入时根据目标PID判断位数再选对应批处理启动。另一种隐蔽情况是HookDLL编译为64位,但注入器是32位,OpenProcess同名进程时打开的是另一个同名32位进程,这也是常见的“注入错进程”误判。确认打开的句柄和日志里的进程ID完全一致,再动手分析。
5.3 recv的日志全是乱码:记录时机不对
现象:RECV记录的内容明显是垃圾数据,长度有时超过实际网络返回值,有时和发送方对不上。
原因:在调用Real_recv之前就用入参里的buf指针写日志,此时缓冲区是空的,里面是上一次recv留下的旧内容。常见于把send的“调用前记录”逻辑复制到recv里,忘了接收方向的区别。SEND是“你手里的数据确定有效”,RECV是“系统还没往这块buf里填数据”,两者时机完全相反。
解决:先把Real_recv调用返回,ret > 0才用buf做记录,长度用ret而不是len。WSARecv同理,必须等调用完成后读lpNumberOfBytesRecvd。这个坑属于血泪经验,每次新写一个hook函数,我都会先在代码注释里写明“调用前可读/调用后可读”,避免惯性写错。
5.4 目标进程卡死:同步日志IO堵住了热路径
现象:日志文件越来越大,目标进程的网络请求变慢,界面卡顿;严重时整个进程假死,结束后日志尾部出现长达几秒的时间间隔。
原因:日志里每个包都执行fprintf_s + fflush,而fflush是同步磁盘I/O。当网络吞吐高时,HOOK函数在调用链上被磁盘写阻塞,相当于每次send/recv都要等一次硬盘写完成。多线程时多路网络线程同时写日志,还会争用临界区,形成锁竞争。
解决:把写盘操作移出HOOK热路径。更好的办法是进程通信(IPC)的共享内存方案:Hook函数只负责把记录塞进一块环形缓冲区,另一个独立观察进程负责从共享内存读出并落到磁盘。目标进程内部只做内存拷贝,IO压力完全转移。这样既能实时观察目标进程的收发数据,又不会把它卡死。如果你只想快速验证,退一步把fflush从每次写都调用改成定时刷新,能缓解但不能根治高并发场景。
5.5 进程退出时崩溃:Hook卸载顺序反了
现象:目标进程正常退出时弹异常;或者日志最后几条记录丢失,文件截断。
原因:DLL_PROCESS_DETACH里先关了g_logFile,而后面DetourDetach还没执行完,此时其他线程正好还停在某次WriteLog里,往一个已经fclose的FILE*写入,触发访问违例。还有一种原因是DetourDetach的恢复顺序和Attach顺序不一致,导致原函数指针在恢复期间被写乱。
解决:严格按“先Detach恢复所有钩子,再关日志文件”的顺序。如果担心进程退出瞬间其他线程还在执行被钩函数,可以先在WriteLog入口加一个g_shutdown标记位,detach前设置标记并等待20~50毫秒,让正在执行中的WriteLog先退出临界区,再做DetourDetach和fclose。注意如果目标进程被TerminateProcess强杀,系统不会给DLL_PROCESS_DETACH机会,日志尾部缺失属于可接受现象,不用在这上面浪费过多时间。
6. 验证收尾:不用抓包工具也能确认钩子生效的三个方法
第一个验证方法是看日志文件本身。启动目标进程做几次网络操作后,打开C:\hook_log\netpacket.log,看到结构化的SEND和RECV记录,就说明钩子链路已经通了。这里有个细节:日志行里的对端地址字段必须和你的实际业务服务地址一致,如果地址是unknown或者端口不对,往往说明Winsock时序或者UDP连接状态有问题,需要回到第5章排查。
第二个验证方法是确认DLL确实被加载进目标进程。执行tasklist /m HookDLL.dll,输出里能看到目标进程的映像名称和PID,说明注入成功。也可以把HookDLL.dll文件路径临时改成一个带版本信息长度的名称,日志里能顺手验证版本。这一步配合第一个验证方法,能从“DLL在没在”和“钩子有没有生效”两个维度把问题切分干净。
第三个验证方法是用netstat交叉核对。执行netstat -ano | findstr <pid>,能找到目标进程当前活动的TCP连接与本地/远端端口。把这两个端口和日志里记录的对端地址比对,应该能对上。如果netstat显示有连接但日志里没有对应记录,大概率是UDP连接或异步I/O场景——这种高吞吐场景下HOOK的日志丢失多半是线程和缓冲问题,而不是钩子没生效。
进阶用法是把日志落盘改成共享内存加独立观察进程,这也是进程通信(IPC)在HOOK场景下的常见演进。Hook函数写入预分配共享内存,观察进程用消息循环实时刷新,目标进程几乎不承担任何磁盘开销。我最初做这个方案时,在recv的“调用前记录”这个简单环节上栽过跟头,日志全是垃圾,排查半天才发现是时机问题。之后养成了一个习惯:每个hook函数先写清数据何时有效,再动手实现。希望帮到你。
本文还有配套的精品资源,点击获取