ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

API Hook实战:拦截指定进程网络数据包,从send/recv到共享内存回传

API Hook实战:拦截指定进程网络数据包,从send/recv到共享内存回传 简介基于VC开发的API Hook拦截示例程序面向网络安全研究人员、逆向工程师及系统管理员用于对指定进程发送和接收的网络数据包进行实时监控、分析或修改。程序通过DLL注入与修改API函数入口代码将执行流程跳转到自定义处理函数实现进程级网络通信拦截。压缩包共22个文件包括8个C头文件、4个CPP源文件、工程文件、说明文档及一个DLL库整体仅37KB代码紧凑、结构清晰便于逐行拆解学习。已有875人在线学习。示例提供进程枚举与选择机制并给出借助VirtualQuery、VirtualProtect、WriteProcessMemory修改虚拟内存中API函数前8个字节、使其跳向自定义函数的完整思路同时涵盖网络数据包过滤、日志记录等扩展方向适合作为深入学习Windows Hook机制的入门与进阶资料。压缩包内同时包含进程选择与DLL注入实现可快速部署到实验环境验证Hook效果。1. API HOOK 拦截指定进程网络数据包运行时改代码而不是傻抓包大多数人拦进程流量第一反应是 Wireshark 或 Fiddler但那只能看到“经过”的数据看不到某个进程内部到底调用了哪一次 send、参数是什么、返回值是什么。这个 VC 工程走的是另一条路把目标进程内存里 API 函数入口的前 8 个字节改成跳转指令让 send、recv 这类函数一被调用就直接跳进自己注入的 DLL数据先被过一手再放行这就是 API HOOK 最典型的落地形态。工程里同时提供了进程枚举对话框、DLL 注入、共享内存回传适合做协议调试、自己程序的网络单元测试以及分析某个软件的通信行为。需要一点 C/C 和 Windows 编程基础但代码结构不算复杂半天能跑通。2. 前 8 字节跳转inline hook 的原理、选型与三件套内存操作2.1 为什么选 inline hook 而不是 IAT hook先把 hook 的两种常见做法说清楚。IATImport Address Tablehook 是修改 PE 导入表把目标进程里“send 函数地址”这一项改成自定义函数的地址。它的优点是改动结构简单代码量少缺点也很明显——目标程序如果用了延迟加载或者自己调 LoadLibrary(ws2_32.dll) 再 GetProcAddress(send) 拿函数指针调用路径完全绕开导入表IAT hook 就漏了。更麻烦的是现在不少软件会做导入表完整性校验发现 IAT 被改直接拒绝启动或退出。inline hook 的做法不同它不改表改的是代码。x86 下 jmp 相对跳转指令正好 5 字节0xE9 操作码 4 字节偏移地址再补 3 个 nop 凑满 8 字节这就是为什么这个工程里反复强调“前 8 个字节”——一次写入刚好覆盖一个完整跳转指令。把 send 在 ws2_32.dll 里入口位置的前 8 字节替换掉无论目标进程通过什么方式拿到 send 地址只要最终执行到那个入口就会先跳到我们的 DLL 函数。这种方式天然避开 IAT 校验因为校验程序大多只看导入表区段很少去看代码段是否被改。对比项IAT hookinline hook修改对象PE 导入表项API 函数入口指令代码量少一个表替换多需要处理指令长度和跳转被绕过难度低动态调用即可绕过高只要执行入口就会被拦检测难度低扫描导入表即可发现高需逐函数比对代码段典型适用简单函数拦截网络 API、加密函数监控对网络数据包这个场景来说进程内部到处都可能动态获取 send 地址IAT hook 根本兜不住inline hook 是唯一靠谱的选择。2.2 VirtualQuery、VirtualProtect、WriteProcessMemory 三件套的配合流程要改的代码段是别的进程的内存默认属性是只读加可执行RX不能直接写。所以必须按顺序做四件事VirtualQueryEx 查目标内存页属性确认当前保护状态VirtualProtectEx 把它改成可写WriteProcessMemory 写入跳转代码写完恢复原属性并刷新指令缓存。工程里那个名字很长的 txt 文件其实就是把这个流程用文字讲了一遍。// 安装 inline hook替换目标进程内 API 函数入口的前 8 字节 BOOL InstallHook(DWORD pid, const char* dllName, const char* apiName, LPVOID pMyFunc, BYTE* origBytes) { HMODULE hMod GetModuleHandleA(dllName); if (!hMod) return FALSE; FARPROC pApi GetProcAddress(hMod, apiName); if (!pApi) return FALSE; HANDLE hProc OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProc) return FALSE; BYTE jmpCode[8] { 0xE9, 0x00, 0x00, 0x00, 0x00, 0x90, 0x90, 0x90 }; // 相对偏移 目标地址 - 跳转指令的下一条指令地址 *(DWORD*)(jmpCode 1) (DWORD)pMyFunc - (DWORD)pApi - 5; ReadProcessMemory(hProc, pApi, origBytes, 8, NULL); // 保存原始字节 DWORD oldProtect 0; VirtualProtectEx(hProc, pApi, 8, PAGE_EXECUTE_READWRITE, oldProtect); WriteProcessMemory(hProc, pApi, jmpCode, 8, NULL); VirtualProtectEx(hProc, pApi, 8, oldProtect, oldProtect); FlushInstructionCache(hProc, pApi, 8); CloseHandle(hProc); return TRUE; }这段代码有几个参数值得细说。0xE9 是 jmp rel32 的操作码含义是“相对偏移跳转”后面 4 字节是带符号偏移。偏移的计算规则是目标地址减去跳转指令的下一条指令地址也就是 pApi 5代码里的- 5就是干这个事。三个 0x90 是 nop 填充让 8 字节完整覆盖原指令区域。ReadProcessMemory 先把原 8 字节读到 origBytes是给卸载 hook 时恢复用的不然后期想摘掉 hook 就没法还原。VirtualProtectEx 第四个参数必须保存下来写完后用旧值恢复避免内存页一直敞着可写。FlushInstructionCache 把 CPU 指令缓存刷掉否则旧指令还留在缓存里写入可能不生效。这里有个关键点为什么偏偏是 8 字节而不是 5 字节因为 send 这类 API 入口由编译器生成通常是mov edi, edi2 字节加上push ebp; mov ebp, esp3 字节之类的短指令5 字节的 jmp 只能覆盖第一条完整指令。直接跳走再回来时第二条指令的边界就乱了。用 8 字节覆盖至少能把入口处两三条短指令一起包住回来的位置才不踩雷。这只是最简处理真正严格的做法要反汇编统计指令长度这个放第 4 章说。2.3 进程枚举与 DLL 注入EnumProcessDlg 的完整链路工程里的 EnumProcessDlg.cpp 就是“先获取所有进程然后选择你要操作的进程”那个环节。枚举进程用 CreateToolhelp32Snapshot 或者 EnumProcesses 都行这个工程用的是对话框列表把所有进程名和 PID 列出来让用户手工挑一个。选中之后注入链路是固定套路四步走。第一步用 OpenProcess 打开目标进程权限至少要带 PROCESS_CREATE_THREAD、PROCESS_VM_OPERATION、PROCESS_VM_WRITE图省事直接用 PROCESS_ALL_ACCESS。第二步 VirtualAllocEx 在目标进程里申请一块内存flAllocationType 传 MEM_COMMITflProtect 传 PAGE_READWRITE把要注入的 DLL 完整路径写进去。第三步 CreateRemoteThread 启动远程线程让目标进程调用 LoadLibraryA参数就是刚才写入的路径。第四步 DLL 的 DllMain 收到 DLL_PROCESS_ATTACH 通知后在里面执行 hook 安装。// 远程线程注入 LoadLibrary 的经典写法 typedef HMODULE (WINAPI* LoadLibraryFunc)(LPCSTR); BOOL InjectDll(DWORD pid, const char* dllPath) { HANDLE hProc OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProc) return FALSE; // 在目标进程内部分配一块内存存放 DLL 路径 size_t pathLen strlen(dllPath) 1; LPVOID pRemoteBuf VirtualAllocEx(hProc, NULL, pathLen, MEM_COMMIT, PAGE_READWRITE); if (!pRemoteBuf) { CloseHandle(hProc); return FALSE; } WriteProcessMemory(hProc, pRemoteBuf, dllPath, pathLen, NULL); // 让目标进程执行 LoadLibraryA(pRemoteBuf) HMODULE hKernel GetModuleHandleA(kernel32.dll); LoadLibraryFunc pLoadLib (LoadLibraryFunc) GetProcAddress(hKernel, LoadLibraryA); HANDLE hThread CreateRemoteThread(hProc, NULL, 0, (LPTHREAD_START_ROUTINE)pLoadLib, pRemoteBuf, 0, NULL); if (!hThread) { CloseHandle(hProc); return FALSE; } WaitForSingleObject(hThread, INFINITE); VirtualFreeEx(hProc, pRemoteBuf, 0, MEM_RELEASE); CloseHandle(hThread); CloseHandle(hProc); return TRUE; }这里有一个非常隐蔽的坑CreateRemoteThread 的入口点参数是 LPTHREAD_START_ROUTINE 类型传 LoadLibraryA 没问题但如果你把 32 位 DLL 注入到 64 位进程调用约定和地址位宽全对不上线程入口直接异常。所以要先确认位数再决定注入策略。这个工程是 VC6 编译的 32 位程序只支持操作 32 位目标进程这是它的设计边界不是 bug。提示CreateRemoteThread LoadLibrary 是最容易理解的注入方式也最容易触发杀软提醒。测试时尽量针对自己写的进程做或者临时加白名单。3. 拆解 10IPPack 工程两个模块的分工与数据回传路径3.1 workspace 里两个工程的分工10IPPack.dsw 是 VC6 的 workspace里面并排放着两个 dsp10IPPack主程序MFC 对话框应用和 10IPPackLib注入用 DLL。主程序侧 IPPack.cpp 是 CWinApp 派生类负责程序初始化和主消息循环IPPack.rc 是对话框资源脚本EnumProcessDlg.cpp 和 EnumProcessDlg.h 实现选进程的对话框IPPack.h 是应用类的头文件。DLL 侧 IPPackLib.cpp 和 IPPackLib.h 是核心逻辑ULHook.cpp 和 ULHook.h 是底层 hook 的封装层ShareMemory.h 提供跨进程共享内存的接口IPPackLib.def 是导出定义文件。Release 目录下已经带了编译好的 10IPPackLib.dll 和主程序装好 VC6 环境可以直接打开跑。工程里有个文件叫“79419100APIhook”数字部分我判断是打包时的版本编号或调试批次标记不是功能标识看代码时忽略它就行。另一个 txt 文件“修改虚拟内存中API函数执行代码的前8个字节使它跳向我们的函数VirtualQuery VirtualProtect WriteProcessMemory.txt”其实是这个工程的实现笔记把安装 hook 的流程精简成了文件系统的提示。DLL 侧入口的典型写法是 DllMain 里根据 fdwReason 分支处理。DLL_PROCESS_ATTACH 时安装 hookDLL_PROCESS_DETACH 时恢复原始字节、卸载全部 hook避免目标进程退出时带着被改写的函数入口白屏或崩溃。// 10IPPackLib.dll 的入口负责安装和卸载 hook BOOL APIENTRY DllMain(HMODULE hModule, DWORD fdwReason, LPVOID lpReserved) { switch (fdwReason) { case DLL_PROCESS_ATTACH: DisableThreadLibraryCalls(hModule); HookSend(LoadLibraryA(ws2_32.dll)); HookRecv(LoadLibraryA(ws2_32.dll)); OpenOrCreateShareMemory(Local\\IPPackShareMem); break; case DLL_PROCESS_DETACH: // 恢复原始字节解除 hook UnhookSend(); UnhookRecv(); break; } return TRUE; }注意这个入口没有做错误传播。LoadLibraryA 如果返回 NULLHookSend 会崩我一般会在 HookSend 内部加一层空指针判断返回 FALSE 时在共享内存里记一个错误码方便主程序诊断。这是 demo 工程常见的省略点。3.2 IPPackLib 的 hook 实现以 send/recv 为例的注入侧代码DLL 被注入到目标进程后hook 就是在自己进程里操作自己的内存不再需要 OpenProcess 和 WriteProcessMemory直接用 memcpy 写就行。下面这段是拦截 send 的典型写法和这个工程的核心思路一致。// 10IPPackLib.dll 核心hook send 函数 typedef int (WINAPI* SendFunc)(SOCKET s, const char* buf, int len, int flags); SendFunc RealSend NULL; // 保存真正的 send 地址 BYTE OrigSendBytes[8]; // 保存 send 入口原始字节 int WINAPI MySend(SOCKET s, const char* buf, int len, int flags) { // 先把数据写进共享内存再调用真正的 send 放行 if (g_shareBuf g_shareBuf-hdr.magic SHARE_MAGIC) { g_shareBuf-hdr.count; int copyLen (len SHARE_DATA_LEN) ? SHARE_DATA_LEN : len; memcpy(g_shareBuf-data, buf, copyLen); g_shareBuf-hdr.len copyLen; g_shareBuf-hdr.direction 0; // 0 表示发送方向 } return RealSend(s, buf, len, flags); } BOOL HookSend(HMODULE hWs2) { RealSend (SendFunc)GetProcAddress(hWs2, send); if (!RealSend) return FALSE; // 保存原始入口字节卸载 hook 时用于恢复 memcpy(OrigSendBytes, RealSend, 8); BYTE jmpCode[8] { 0xE9, 0x00, 0x00, 0x00, 0x00, 0x90, 0x90, 0x90 }; DWORD oldProtect 0; VirtualProtect((LPVOID)RealSend, 8, PAGE_EXECUTE_READWRITE, oldProtect); *(DWORD*)(jmpCode 1) (DWORD)MySend - (DWORD)RealSend - 5; memcpy(RealSend, jmpCode, 8); VirtualProtect((LPVOID)RealSend, 8, oldProtect, oldProtect); FlushInstructionCache(GetCurrentProcess(), (LPVOID)RealSend, 8); return TRUE; }这里的逻辑要点是保存原函数指针 RealSend。MySend 在拿到数据后先做记录再调用 RealSend 完成真实发送目标进程感知不到数据被过了一手。recv 方向的写法同理只是参数从 buf 变成 recv 的接收缓冲区方向标注改成 1。如果目标进程走的是 WSASend、WSARecv 这类异步接口参数结构变成 LPWSABUF要分成 iovcnt 条缓冲遍历收集数据只 hook send 会漏掉一批场景。这个工程如果要完整覆盖建议 send、recv、WSASend、WSARecv 四个函数一起装我在自己项目里是把四份代码用宏打成一份模板避免复制粘贴改到手软。3.3 ShareMemory.h 共享内存hook 数据怎么传回主程序主程序和注入的 DLL 是不同进程DLL 里拿到的数据必须通过跨进程通信送回主程序才能显示。工程用共享内存也就是内存映射文件来做这件事。这是进程通信IPC里最直接的方式——不经过 socket、不依赖管道只要一个全局名字两边各开一次 OpenFileMapping 就能访问同一块内存。#define SHARE_MAGIC 0x49504131 // IPA1 struct SharePacketHeader { DWORD magic; // 校验位判断共享内存是否有效 DWORD count; // 已拦截数据包的总计数 DWORD len; // 当前包有效数据长度 DWORD direction; // 0 发送1 接收 }; struct ShareMemoryBlock { SharePacketHeader hdr; char data[4096]; // 单包数据缓冲 }; ShareMemoryBlock* g_shareBuf NULL; HANDLE g_hShareMap NULL; ShareMemoryBlock* OpenOrCreateShareMemory(const char* name) { HANDLE hMap OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, name); if (!hMap) { hMap CreateFileMappingA(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, sizeof(ShareMemoryBlock), name); } g_hShareMap hMap; return (ShareMemoryBlock*)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, 0); }这个结构体本身不难但有一点必须提醒共享内存没有内置锁。如果目标进程是多线程并发调 send两个线程同时往 data 里写后写的会覆盖先写的主程序读到的是错乱内容。我一般会在 header 里加一个标志位写之前用 InterlockedExchange 抢锁写完了再释放或者在 DLL 侧加一个 CRITICAL_SECTION 包住整个写入动作。这个工程是 demo 级别列表展示和单线程测试没问题真要高并发还得自己改造。4. 常见问题与排查hook 失效、崩溃、数据错乱的五个坑4.1 注入成功但 hook 永不触发现象EnumProcessDlg 里能选到进程注入也显示成功但主程序的列表一直是空的一条记录都没有。原因目标进程可能根本没调用 send而是走了 WSASend也可能在 hook 安装时 ws2_32.dll 还没加载进目标进程比如进程刚启动 WSAStartup 还没执行还有一种常见情况是目标进程做了进程伪装进程名显示成 svchost.exe 之类但实际通信逻辑根本不用 Winsock API或者自己实现了协议栈。hook 不触发不等于没装上很多时候是目标函数压根没被调用。解决GetProcAddress 把 send、recv、WSASend、WSARecv 四个地址全部拿到逐个装 hook在 DllMain 里不要急着装等进程跑起来后由主程序发个消息触发延迟安装先用 Process Explorer 确认目标进程确实加载了 ws2_32.dll 再动手。这四个方向全装上之后还漏的概率就很小了。4.2 目标进程一调用 send 就崩溃现象hook 装上后目标进程第一次发包直接崩或者主程序退出时目标进程跟着崩对话框一关就报错。原因最常见是跳转指令覆盖了原函数入口的多条指令而自定义函数返回后又跳回“原地址8”把被覆盖的中间指令弄丢了执行流错位。第二个常见原因是原始字节只做了复制没有在自定义函数里先执行这些原指令再跳回原地址8相当于 trampoline 缺失。这个坑在 ULHook.cpp 里如果只是简单 memcpy 保存 8 字节八成会踩中。多线程进程里更明显——一个线程在跳转中另一个线程刚好也进了 send两个线程同时改写和读取入口字节直接撞车。解决严格做法是先反汇编出函数入口段指令数清楚 8 字节覆盖了几条完整指令把它们搬到 trampoline 函数里跳转回来时先执行 trampoline 再回到原地址8。我一般用 Capstone 或手写一个 x86 指令长度解码器来做这件事。demo 阶段可以把 8 字节原样搬到 trampoline 里再补一条 jmp先缓解崩溃问题。4.3 VC6 编译正常换成 VS2013 以上编译后 hook 不生效现象下载源码用 VC6 打开能编译能跑换成新环境编译后DLL 注入成功但入口地址对不上要么 hook 没生效要么直接崩。原因新编译器默认开启 /GL、/O2 这类优化函数入口可能不是标准的 push ebp; mov ebp,esp 开头跳转偏移算出来和预想不一样另一层是新版 SDK 里 Winsock 函数的符号可能指向 ws2_32.dll 的转发函数或跳板GetProcAddress 拿到的地址不是真正实现代码的入口跳过去位置错了。还有增量链接生成的安全 cookie 和 /hotpatch 选项会在函数前插桩影响偏移计算。解决所有目标地址全部动态 GetProcAddress 获取不要硬编码偏移编译时把优化关掉、增量链接也关掉DLL 和主程序统一用 Release x86 配置。如果只是想快速验证效果直接用 MinHook 库替换手写的 8 字节跳转一条 API 就搞定不建议在这个基础工程上继续造轮子。4.4 位数不一致注入 64 位进程失败现象Win10 x64 系统下主程序能枚举出所有进程但选中某些进程注入后 WriteProcessMemory 一直失败或者注入看着成功但 hook 没生效。原因这个工程是 VC6 时代的 32 位程序32 位进程无法用远程线程方式加载 64 位 DLLPE 头和调用约定都不一样。另外系统自带的一些受保护进程即使拿到 PROCESS_ALL_ACCESS 也无权写入内核保护的内存区域。解决保持主程序、注入 DLL、目标进程三者位数一致。要测 64 位进程就得用 x64 版本的注入器和 x64 版 Hook DLL把上述代码整套重编一份 x64 配置。受保护进程不要硬刚这是系统边界不是代码问题。实际项目里我也只用它分析自己写的工具或明确允许分析的第三方进程。4.5 杀软弹出注入警告抓到的内容和 Wireshark 对不上现象火绒或其他安全软件弹出“某程序正在注入进程”的提示点击允许后主程序记录的数据和 Wireshark 同屏对比对不上数量、长度都不一样。原因杀软对 CreateRemoteThread 加 LoadLibrary 的组合非常敏感这是经典注入组合弹出警告是正常防御行为不是误报。内容对不上是因为你 hook 的是应用层 send 的缓冲区Wireshark 看到的是链路层的帧中间隔了 TCP 分段、重传、LSP 分层同一笔应用数据在链路上可能被拆成好几个包也可能被粘包处理。还有一个因素send 返回的长度不等于实际到达对端的长度只要没发完Wireshark 上看到的就会少一段。解决测试时把主程序和 DLL 加入安全软件白名单或者临时关闭实时防护对比时不要数包个数要比对应用层载荷内容或者全用 localhost 回环流量测试避免经过物理网卡。回环流量绕开驱动层两边载荷能精确对上排错效率高得多。5. 验证与进阶从拦截 send/recv 到完整的网络数据包分析链路5.1 最小验证闭环一个 TCP 客户端就够了启动主程序从进程列表里选中一个自己写的 socket 测试程序注入 10IPPackLib.dll让目标进程发一条固定消息比如“hello hook”。主程序的列表里如果能出现一条 len10 的记录整条注入到 hook 到共享内存回传的链路就算闭环了。如果没记录按第 4 章的排查顺序走一遍先确认位数再确认 ws2_32 加载然后检查 WSASend 方向最后看共享内存有没有初始化成功。5.2 进阶把共享内存换成命名管道或日志文件共享内存高并发会互相覆盖改成命名管道按顺序写日志更稳。DLL 侧在 MySend 里调用 RealSend 之前把数据写到全局文件句柄写入动作用 CRITICAL_SECTION 包住避免多线程日志交错。命名管道的优点是不丢数据缺点是速度慢hook 高频发包时会拖慢目标进程这个取舍要自己权衡。要是只想做离线分析直接把数据追加到本地文件最简单一条 WriteFile 就能搞定不用引入 IPC 机制。5.3 从记录到拦截让目标进程真正发不出去拦截不只是记录。想让目标进程发不出去在 MySend 里做判断——比如命中关键字就 return SOCKET_ERROR同时 WSASetLastError 设置 10013不调用 RealSend。对目标进程来说这就是一次“发送失败”它会按错误处理逻辑去重试或者放弃不会知道是外部干预。这套思路再往前一步就是在自定义函数里改 buf 内容把敏感字段替换掉再放行这就是中间人改包的雏形也是这个工程最有意思的进阶玩法。从那以后我每次 hook 前都强制走一遍检查先确认位数再确认目标进程加载了 ws2_32.dll然后四个网络 API 全部装 hook最后对比载荷而不是对比包数。这套流程帮我至少省了五个小时的排错时间希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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