简介:基于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 的两种常见做法说清楚。IAT(Import Address Table)hook 是修改 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 hook | inline 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, edi(2 字节)加上push ebp; mov ebp, esp(3 字节)之类的短指令,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_COMMIT,flProtect 传 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,里面并排放着两个 dsp:10IPPack(主程序,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 时安装 hook,DLL_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 如果返回 NULL,HookSend 会崩,我一般会在 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 位 DLL,PE 头和调用约定都不一样。另外系统自带的一些受保护进程,即使拿到 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”。主程序的列表里如果能出现一条 len=10 的记录,整条注入到 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,最后对比载荷而不是对比包数。这套流程帮我至少省了五个小时的排错时间,希望帮到你。
本文还有配套的精品资源,点击获取