ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

API HOOK拦截指定进程网络数据包:DLL注入与MinHook实战

API HOOK拦截指定进程网络数据包:DLL注入与MinHook实战 简介面向网络安全研究人员、逆向工程师及系统管理员的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_PENDINGWSARecvLPWSABUF缓冲数组支持异步异步数据到达时机不确定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(Lkernel32.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; }逻辑说明先按进程名快照找PIDOpenProcess拿目标进程句柄。如果目标进程权限较高PROCESS_ALL_ACCESS会失败这时要么用管理员权限跑注入器要么先OpenProcess调整权限。VirtualAllocEx在远程进程分配一块内存把DLL路径写进去。CreateRemoteThread以LoadLibraryW为入口参数启动远程线程。LoadLibraryW会因为导入DLL而触发DLL_PROCESS_ATTACH到这里注入就算完成了。参数要注意进程名必须带.exe全名且工任务管理器里完全一致DLL路径建议用绝对路径因为目标进程的工作目录和注入器不一定相同。编译器位号必须统一32位注入器只能注入32位进程64位注入器只能注入64位进程这在后面避坑章会展开讲。3.2 Hook DLL里如何挂钩send与recvMinHook的实际配置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_castconst 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_castconst 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(Lws2_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; // 目标进程PID0表示跟随本进程 BYTE filterIp[16]; // 要拦截的目标IP0.0.0.0表示不限制 USHORT filterPort; // 要拦截的目标端口0表示不限制 BOOL enableSend; // 是否记录发送 BOOL enableRecv; // 是否记录接收 };注入器端用CreateFileMappingW(LGlobal\HookConfig_工程名, ...)创建DLL端用OpenFileMappingW映射。注意命名空间有个坑如果注入器是以管理员跑的而目标进程是普通权限那么Global\前缀会引发DACL问题OpenFileMapping可能失败。要么把对象建在Session命名空间要么给FileMapping加上允许Everyone读写的安全描述符。这个坑我帮别人排过不下十次非常经典。传参这块一旦做好后续可以扩展的东西就多了你可以动态改PID把Hook从一个进程转移到另一个进程也可以只记录发给某个IP的流量抓重点。甚至能把filterIp设计成前缀匹配拦整个IP段这对调试一堆内网服务很有用。4. 数据包过滤与回传从拿到明文到攒成日志4.1 过滤规则怎么写按IP、端口、方向还是协议前面结构体里的filterIp和filterPort只是最简单的过滤。实际做拦截时你会发现IP和端口不能覆盖一切。比如一个进程既当客户端又当服务端它的HTTP请求连80端口日志里混着接收和发送你很难分清方向。所以规则里至少要带direction字段以及协议类型。我习惯把规则做成链式匹配每一条规则是一个FilterItemstruct FilterItem { int protocol; // 0ANY, 1TCP, 2UDP int direction; // 0ANY, 1send, 2recv 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(Lws2_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和启动时间。看起来多此一举但一旦目标进程是多开排错时你会庆幸每个日志文件能准确对应到具体进程实例。大部分项目经理手上没有完美的工具把不完美的改到可用来回调试才是日常。希望帮到你。本文还有配套的精品资源点击获取
返回列表