☰
API HOOK拦截指定进程网络数据包:DLL注入与MinHook实战
2026/10/1 17:58:07 网站建设 项目流程

简介:面向网络安全研究人员、逆向工程师及系统管理员的API Hook实践资源,提供一套基于VC的完整工程,用于拦截指定进程发送和接收的网络数据包,适用于网络监控、接口调试、协议分析及安全测试场景。压缩包共22个文件,以8个C/C++头文件与4个源文件为核心,配合工程配置、DLL导出库、图标与说明文档,整体仅37KB,结构紧凑。已有875人学习。资源完整演示了从获取进程列表、用户选择目标进程,到通过VirtualQuery、VirtualProtect、WriteProcessMemory等API修改目标函数内存前8字节、实现跳转Hook的全过程,并封装了Hook库与进程选择对话框,便于二次开发。读者既可借此理解API Hook的底层原理,也可直接编译运行,用于观察与干预特定进程的收发数据包,是一份小巧但完整的实战参考。

1. API HOOK拦截指定进程发送和接收的网络数据包:从下载到跑通先想清楚这三件事

看到这个以.zip结尾的标题,别急着解压运行,更别拿它当黑匣子双击。它的本质是用API HOOK挂钩指定进程的网络收发函数,在明文还没进入内核协议栈之前,就把发送和接收的数据拿住。这和抓包工具有本质差别:抓包看到的是网卡上经过的包,而这个方案能准确回答"某个进程发出去的字节流是什么、收到的字节流又是什么"。它能解决的实际问题很直接:调试私域协议、排查连接泄漏、做安全审计,以及只想看一个应用、不想看全厂流量的定向拦截。适合的读者是Windows下的C++开发者和终端安全工程师。新手最好先有DLL注入和socket编程的基础,否则后面几个坑会让你怀疑人生。

2. 拦截指定进程的数据流:先搞清Hook在哪个层级生效

2.1 为什么抓包工具做不到"指定进程":TCP/IP栈与用户态API的差异

先还原一次调用链路。一个普通进程要发数据,典型流程是调用send()或者WSASend(),这两个函数属于ws2_32.dll导出的用户态API。数据从应用程序缓冲区拷贝到winsock内部,再通过内核AFD.sys进入TCP/IP协议栈,最后交给网卡驱动。抓包工具挂的是NDIS或数据链路层,能看到完整IP报文,但报文里早就没有进程PID了——一个连接可能是多个进程共享的,也可能是内核直接发出的。所以真想"指定进程",只能在进程上下文里拦入口。

API HOOK的位置就在进程自己的地址空间里,把send、recv这类导出函数的调用劫持掉。每当目标进程发起一次网络调用,控制流先进到你的DLL,你把缓冲区和长度记下来,再调用原函数放行。这个过程能拿到当前进程ID、线程ID、调用栈、缓冲区内容,天然满足"指定进程"这个需求。不过这里有个关键分支,决定你的方案是走IAT Hook还是Inline Hook。

IAT Hook比较老实,把进程导入表里的send函数地址替换成我们的函数指针,但坑在只对"启动时静态导入ws2_32.dll"的进程有效。而且如果API被间接调用,比如通过GetProcAddress取地址之后调用,就拦不到。Inline Hook直接修改ws2_32.dll中send函数入口处的机器码,让它跳到我们的处理函数,改的是代码段本身,不依赖导入表。市面上大多数能用的API HOOK网络包拦截方案,底层用的都是Inline Hook,比如MinHook和Detours。我建议优先MinHook,部署简单,对被挂钩进程的性能影响也小。

这里有一个很容易理解错的地方:API HOOK抓到的是"应用层字节流",不是"网络数据包"。send的缓冲区不一定是完整的一条应用层消息,TCP会分段;recv返回的也是一截一截的流。下层抓包工具抓到的是IP分片,API HOOK抓到的是write和read的字节流。如果标题里的"网络数据包"指代的是wireshark那种格式,那你就得在API HOOK基础上再做TCP流重组。我见过太多人把这两层混在一起,最后拿着字节流和wireshark对不上,开始怀疑人生。

2.2 Hook send/recv还是WSASend/WSARecv:函数选型对照

很多人卡在同一个问题:明明hook了send,为什么拦不到数据?因为现代程序几乎不用send。WSASend支持异步I/O、散射/聚集缓冲区,性能更好,是网络服务的主流选择。给你一张参数对照表,照着这个选型不会漏:

API缓冲区类型同步/异步需要额外处理的场景
sendchar* + 长度同步阻塞简单协议、老代码
recvchar* + 长度同步阻塞简单客户端
WSASendLPWSABUF缓冲数组支持异步,IOCP常用非阻塞返回WSA_IO_PENDING
WSARecvLPWSABUF缓冲数组支持异步异步数据到达时机不确定
connectsockaddr*同步记录对端IP和端口,关联连接
accept / WSAAccept新socket同步/异步服务端需要关联连接

实际落地时,我一般会同时挂send、recv、WSASend、WSARecv四个,外加connect做连接记账。只挂单独一个函数必然漏数据。如果你要拦截的服务端程序用了AcceptEx,还得挂AcceptEx和GetAcceptExSockaddrs,否则连进来的连接你都不知道对端是谁。

这里不得不提一个淘汰品:LSP,也就是分层服务提供者。LSP是winsock的插件机制,拦截层级比API HOOK更低,直接挂在WSP接口上。早期很多上网行为管理做过LSP,但LSP在64位系统上兼容性越来越差,杀软也爱把它当风险模块,排错特别痛苦。今天再做指定进程拦截,API HOOK加DLL注入是更可控的方案。碰到有人说"用LSP挂一下就好了",你先看看他的工程是不是还停留在WinXP时代。

还有一个层级细节需要注意:Hook函数里如果用GetCurrentProcessId拿PID,这是进程粒度,能区分是哪个进程。但如果你需要更细,比如"只拦主线程连接1",就要在send的参数里拿socket句柄,再维护一张socket到进程内业务ID的映射表。这层设计会直接影响过滤规则的复杂程度。

3. 落地一套进程网络拦截器:注入器与Hook DLL的分工

3.1 注入器怎么写:CreateRemoteThread + LoadLibrary最小的可运行代码

一个能跑的拦截方案,必须有两个进程侧组件。一个注入器exe,负责把Hook DLL塞进目标进程;另一个是Hook DLL,负责真正做API HOOK。为什么不能直接让目标进程加载DLL?因为目标程序大概率没有插件机制,就算有,你也不想给它改业务代码。所以注入器是通用解。

注入器的流程四步:拿到目标进程PID、在目标进程里申请内存、把DLL绝对路径写进去、创建远程线程去调用LoadLibraryW。这段代码是经典DLL注入模板,我直接贴能用的版本:

#include <windows.h> #include <tlhelp32.h> #include <iostream> DWORD FindProcessId(const wchar_t* name) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); PROCESSENTRY32W entry{ sizeof(entry) }; DWORD pid = 0; if (Process32FirstW(snapshot, &entry)) { do { if (_wcsicmp(entry.szExeFile, name) == 0) { pid = entry.th32ProcessID; break; } } while (Process32NextW(snapshot, &entry)); } CloseHandle(snapshot); return pid; } bool Inject(const wchar_t* processName, const wchar_t* dllPath) { DWORD pid = FindProcessId(processName); if (pid == 0) return false; HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProc) return false; size_t pathLen = (wcslen(dllPath) + 1) * sizeof(wchar_t); void* remoteBuf = VirtualAllocEx(hProc, nullptr, pathLen, MEM_COMMIT, PAGE_READWRITE); if (!remoteBuf) { CloseHandle(hProc); return false; } WriteProcessMemory(hProc, remoteBuf, dllPath, pathLen, nullptr); HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); FARPROC loadLib = GetProcAddress(kernel32, "LoadLibraryW"); HANDLE hThread = CreateRemoteThread(hProc, nullptr, 0, (LPTHREAD_START_ROUTINE)loadLib, remoteBuf, 0, nullptr); if (!hThread) { VirtualFreeEx(hProc, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProc); return false; } WaitForSingleObject(hThread, 5000); CloseHandle(hThread); VirtualFreeEx(hProc, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProc); return true; } int wmain(int argc, wchar_t** argv) { if (argc < 3) return 1; return Inject(argv[1], argv[2]) ? 0 : 1; }

逻辑说明:先按进程名快照找PID,OpenProcess拿目标进程句柄。如果目标进程权限较高,PROCESS_ALL_ACCESS会失败,这时要么用管理员权限跑注入器,要么先OpenProcess调整权限。VirtualAllocEx在远程进程分配一块内存,把DLL路径写进去。CreateRemoteThread以LoadLibraryW为入口参数,启动远程线程。LoadLibraryW会因为导入DLL而触发DLL_PROCESS_ATTACH,到这里注入就算完成了。

参数要注意:进程名必须带.exe全名,且工任务管理器里完全一致;DLL路径建议用绝对路径,因为目标进程的工作目录和注入器不一定相同。编译器位号必须统一,32位注入器只能注入32位进程,64位注入器只能注入64位进程,这在后面避坑章会展开讲。

3.2 Hook DLL里如何挂钩send与recv:MinHook的实际配置

DLL被加载后,不要在DllMain里面直接初始化Hook,因为此时系统还持着Loader Lock,任何等待GDI、等待其他DLL或调用GetModuleHandle都是死锁重灾区。正确做法是在DLL_PROCESS_ATTACH分支里创建一个新线程,把MinHook初始化和挂钩都扔到线程函数里。

#include <windows.h> #include <MinHook.h> #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") #pragma comment(lib, "libMinHook-x64-vs.lib") using Send_t = int(WSAAPI*)(SOCKET, const char*, int, int); using Recv_t = int(WSAAPI*)(SOCKET, char*, int, int); Send_t OriginalSend = nullptr; Recv_t OriginalRecv = nullptr; int WSAAPI HookedSend(SOCKET s, const char* buf, int len, int flags) { // 在调用原函数之前记录,防止缓冲区被上层改写 DWORD pid = GetCurrentProcessId(); if (IsTargetProcess(pid)) { RecordData("send", s, reinterpret_cast<const unsigned char*>(buf), len); } return OriginalSend(s, buf, len, flags); } int WSAAPI HookedRecv(SOCKET s, char* buf, int len, int flags) { // recv是同步的,先等原函数返回再记录,这样才能拿到实际数据 int ret = OriginalRecv(s, buf, len, flags); if (ret > 0) { DWORD pid = GetCurrentProcessId(); if (IsTargetProcess(pid)) { RecordData("recv", s, reinterpret_cast<const unsigned char*>(buf), ret); } } return ret; } DWORD WINAPI HookWorker(LPVOID) { MH_Initialize(); MH_CreateHook(&send, &HookedSend, (void**)&OriginalSend); MH_CreateHook(&recv, &HookedRecv, (void**)&OriginalRecv); MH_EnableHook(MH_ALL_HOOKS); return 0; } BOOL APIENTRY DllMain(HMODULE mod, DWORD reason, LPVOID) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(mod); HANDLE hThread = CreateThread(nullptr, 0, HookWorker, nullptr, 0, nullptr); CloseHandle(hThread); } return TRUE; }

这段代码的要点都在注释里。对recv这种同步API,先调用原函数再记录,拿到的才是真正读到的数据。对send这种写缓冲区,必须在调用原函数之前记录,因为send一旦返回,应用层可能立刻覆写这块内存。MH_CreateHook的第一个参数传函数符号地址,MinHook会在内部做模块定位,所以你不需要自己GetProcAddress,但如果遇到转发函数,可以改成:

HMODULE ws2 = GetModuleHandleW(L"ws2_32.dll"); void* pSend = (void*)GetProcAddress(ws2, "send");

这个"玄学"在调试时出现过很多次,有时Visual Studio的调用栈指向的地址并不是真正的模块入口,MinHook虽然能识别,但不如我们自己显式解析来得踏实。

3.3 如何让DLL拿到"指定进程"和"过滤规则":三种传参方式

DLL要决定自己动不动手,必须先知道"指定进程是谁"。最笨的方法是把比较逻辑放在DLL里,拿GetCurrentProcessId去和目标PID比较,但PID从哪来?常见做法有三种:

第一种,环境变量传入。注入器调用CreateRemoteThread之前,先通过远程进程环境块设置一个自定义变量,DLL里GetEnvironmentVariable读回来。代码量最少,但会污染目标进程环境,而且部分壳会清掉环境块,不稳。

第二种,写注册表或者文件。启动前注入器把配置写到固定路径,DLL加载时读取。特点是简单直接,但不适合高频更新过滤规则,而且写注册表会引来信安产品警告。

第三种,共享内存。注入器用CreateFileMapping创建一个命名对象,把配置结构体丢进去,DLL加载后OpenFileMapping映射到自己的地址空间。这个最像正经工具包的做法,修改规则不需要重新注入,改共享内存内容就能实时生效。

我一般用第三种。这里定义一个配置结构体:

struct HookConfig { DWORD targetPid; // 目标进程PID,0表示跟随本进程 BYTE filterIp[16]; // 要拦截的目标IP,0.0.0.0表示不限制 USHORT filterPort; // 要拦截的目标端口,0表示不限制 BOOL enableSend; // 是否记录发送 BOOL enableRecv; // 是否记录接收 };

注入器端用CreateFileMappingW(L"Global\HookConfig_工程名", ...)创建,DLL端用OpenFileMappingW映射。注意命名空间有个坑:如果注入器是以管理员跑的,而目标进程是普通权限,那么Global\前缀会引发DACL问题,OpenFileMapping可能失败。要么把对象建在Session命名空间,要么给FileMapping加上允许Everyone读写的安全描述符。这个坑我帮别人排过不下十次,非常经典。

传参这块一旦做好,后续可以扩展的东西就多了:你可以动态改PID,把Hook从一个进程转移到另一个进程;也可以只记录发给某个IP的流量,抓重点。甚至能把filterIp设计成前缀匹配,拦整个IP段,这对调试一堆内网服务很有用。

4. 数据包过滤与回传:从拿到明文到攒成日志

4.1 过滤规则怎么写:按IP、端口、方向还是协议

前面结构体里的filterIp和filterPort只是最简单的过滤。实际做拦截时,你会发现IP和端口不能覆盖一切。比如一个进程既当客户端又当服务端,它的HTTP请求连80端口,日志里混着接收和发送,你很难分清方向。所以规则里至少要带direction字段,以及协议类型。

我习惯把规则做成链式匹配,每一条规则是一个FilterItem:

struct FilterItem { int protocol; // 0=ANY, 1=TCP, 2=UDP int direction; // 0=ANY, 1=send, 2=recv BYTE remoteIp[16]; BYTE localIp[16]; USHORT remotePort; USHORT localPort; BOOL enable; };

匹配顺序按数组顺序执行,命中后立即决定记还是不记。为什么不直接用一个位掩码?因为真实的网络行为里,一个进程可能同时和多个远端通信,你要抓的是"和歹徒通信的那条连接",不能眉毛胡子一把抓。规则链是妥协后最直观的方案,也方便做规则可视化。

在Hook函数里做匹配时,注意一点:不要直接在send函数里做复杂的规则解析,因为每个包都会经过这里,性能压力很大。对于高性能拦截,建议用hash表按socket句柄先建立连接上下文,连接建立时做一次规则匹配,之后每条socket只做上下文判断,不用重复匹配远端IP。

如果你想在API HOOK层判断协议,不要依赖端口号,因为HTTP可以跑在8080,也可以跑在8443。正确做法是对TCP流做首包特征,比如判断前几个字节是否匹配"GET "、"POST "、TLS ClientHello的模式。这个可以在回传通道里做,不要在Hook的临界区做。

4.2 数据回传的两种通道:共享内存与命名管道

接到数据之后,DLL不能直接打开文件写日志。Hook函数现在运行在目标进程的业务线程里,做磁盘IO会阻塞业务,还可能因为文件锁导致死锁。我用过两种通道,共享内存和命名管道,各有边界。

共享内存适合高频小包:你维护一块环形缓冲区,DLL把数据写进去,外部一个监控进程去读。吞吐高,但实现环形缓冲区的无锁读写有点复杂,一旦指针错位,整个日志偏移,排查成本非常高。

命名管道适合中低频和调试场景:DLL里用CreateFile创建管道客户端,把数据序列化后写入,外部服务端读取并落盘。管道自带阻塞背压,如果外部读取慢,DLL里的写管道会拖慢目标进程。不过大多数调试场景可以接受。

我实际项目里用的是共享内存加事件通知:

// 共享内存尾插一条记录 BOOL AppendLog(SharedLog* log, const char* dir, const char* data, int len) { int headerSize = sizeof(LogHeader); if (log->writePos + headerSize + len > log->capacity) return FALSE; LogHeader* h = (LogHeader*)(log->buffer + log->writePos); h->direction = (strcmp(dir, "send") == 0) ? 1 : 2; h->length = len; h->timestamp = GetTickCount64(); memcpy(h + 1, data, len); log->writePos += headerSize + len; return TRUE; }

外部监控进程只需要在共享内存上再加一个ReaderLock,轮询读取。为了不丢数据,DLL每写完一条记录都会SetEvent通知,外部WaitForSingleObject再触发读取。这个方式丢包率很低,并且不会逆断目标进程。

但无论选哪条通道,都要考虑一个事情:如果目标进程崩了,共享内存映射还在,但里面的数据可能写到一半。所以你最好在头结构里放magic和长度自校验,外部读取时先检查完整性,不完整的记录直接跳过。这不是洁癖,是真会遇到的翻车现场。

5. 避坑:从注入失败到数据漏抓的5个常见问题

5.1 32位注入器注入64位进程:OpenProcess失败,错误码5

现象:注入器显示OpenProcess返回失败,GetLastError是5,也就是拒绝访问。或者注入器是32位,目标进程是64位,CreateRemoteThread能创建但LoadLibraryW失败。

原因:位数不匹配。32位进程不能创建64位远程线程,64位进程也不能加载32位DLL。这是Windows内核跨越Wow64的限制。

解决:注入器平台必须跟目标进程一致。在x64系统上,如果你的工具链默认编Win32,换成x64配置重新编译。同时把OpenProcess的权限换成PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,不要一上来就贪PROCESS_ALL_ACCESS,部分安全产品会对这个高权限调用产生敏感。

5.2 注入成功但Hook不生效:目标进程在注入前已经静态加载ws2_32.dll

现象:注入器返回成功,DLL也打印了加载日志,但send/recv的日志就是不增长。

原因:目标进程是通过静态导入方式绑定ws2_32.dll的,而且ws2_32.dll在进程启动时就被系统加载。我们的Hook DLL虽然插进去了,但MinHook初始化的线程可能还没有启动完成,业务线程已经在并发调用send了。更隐蔽的是,如果目标进程在WSAStartup之前就启动,ws2_32.dll可能被延迟加载,但我们的Hook时机不对。

解决:在Inject成功之前,不要直接等待。正确做法是让HookWorker里先调用LoadLibraryW(L"ws2_32.dll"),手动把winsock拉进进程地址空间,再MH_CreateHook。这样能保证ws2_32的地址是有效且统一的。如果目标程序是UWP或者其他沙箱进程,还需要考虑模块重定向问题,但这超过zip包的范畴了。

5.3 WSARecv是异步的,返回WSA_IO_PENDING后你读到的是脏数据

现象:记录recv的日志全是乱码,或者长度和实际内容对不上。

原因:WSARecv是非阻塞的,调用后马上返回WSA_IO_PENDING,真正的数据要等内核完成之后写入用户缓冲区。如果不处理完成通知,只靠返回值和缓冲区数组,你抓到的是"还没写入的旧内存"。

解决:不能只挂WSARecv函数的入口,要等IOCP完成包或者事件对象有信号后再记录。简单做法是把WSARecv的lpCompletionRoutine包一层,或者直接放弃记录异步数据,改为记录投递请求本身。在调试阶段,建议先在目标进程里把WSARecv临时改成同步版,强制用这个zip包先跑通,后面再慢慢切异步方案。血泪经验是:别一开始就想搞定所有异步场景,先把一条链路跑通再扩展。

5.4 杀软或Windows安全中心拦截了注入动作

现象:注入器一跑,目标进程直接退出,或者DLL被隔离到隔离区。甚至在部分系统上,CreateRemoteThread被拦截,错误码是87或5。

原因:DLL注入这个技术本身是安全产品重点关注的。很多杀软会检测远程线程创建,尤其是加载未知DLL。

解决:测试环境先把目标进程加入Windows安全中心排除目录,再把注入器和DLL都加白。若要给外部用,必须对DLL做商用代码签名,否则到任何一台装了防护软件的机器上都会被拦。这不是Hacking,而是工程分发的基本门槛。别问我为什么知道,我见过有人把改了几个字节的DLL发给客户,客户那边直接蓝屏加隔离。

5.5 进程退出后Hook和日志全丢:配置中心不够独立

现象:目标进程被重启,新进程的流量不再被拦截,因为注入是对旧进程做的。

原因:API HOOK是针对进程现场做的,进程没了就没了。目标进程往往会自动重启,结果你的日志缺了一大段。

解决:把注入器的逻辑从"手动注入一次"改成"监听进程创建事件"。用WMI的Win32_ProcessStartTrace事件,目标进程一出现,自动往新进程注入一次。这个能力在一些正规工具包里是标配,否则就不能叫"指定进程"的完整方案,只能叫"一次性调试专用"。

6. 验证与进阶:如何确认你拦到的数据是完整且可信的

6.1 先做本地回环验证:一个HTTP请求一个HTTP响应

拿到这个zip之后,不要一头扎进复杂场景。第一步应该启动一个最简单的HTTP服务,然后用目标进程主动发起请求,在日志里比对这个过程。先用浏览器或者curl请求http://127.0.0.1/test,若你的过滤器能拦截指定进程的send和recv,你会看到请求头一行一行写入日志,响应体也完整落在recv记录里。这个验证能同时确认两件事:API HOOK挂到了函数,且数据回传通道没丢包。

这里我给个小建议:用'send'和'recv'字段分别标记方向,再把时间戳、socket句柄、长度写到每一行的头部。后续对数据做排序和筛选时可以不用再解析整个报文,只扫一遍头部。

6.2 进阶:挂钩WSARecv并处理分段,而不是只记录一段缓冲

一旦基础链路通了,你要面对的下一个阶段是"流重组"。API HOOK拿到的是字节流,不是消息边界。最典型的例子是HTTP响应分多次recv返回,而你只记了最后一段。所以验证完基础功能后,我建议你做一个"按连接拼接"的小模块:每个socket句柄维护一个环形缓冲,收到recv数据后追加到自己的区域内,再在应用层按业务协议拆包。如果这些不做,你这个zip工具包只能看原始字节,没法回答"这个请求发了几次、是不是完整"。

最后说一个我这几年形成的习惯:无论这个工具包改到什么程度,我都会在日志文件名里强制加上PID和启动时间。看起来多此一举,但一旦目标进程是多开,排错时你会庆幸每个日志文件能准确对应到具体进程实例。大部分项目经理手上没有完美的工具,把不完美的改到可用来回调试,才是日常。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询