资讯动态

IAT Hook 拦截 send:PE 导入表解析与原始数据落盘

发布时间:2026/10/24 1:29:30 来源:尧图企业网站定制
简介围绕 Windows 平台套接字发送函数的拦截与数据落盘需求整理出一套可编译工程及配套源码面向有 C 与网络编程基础、关注接口钩子、网络监控和安全审计的中高级开发者。压缩包共 23 个文件约 898KB涵盖 C 源码、动态库与导入库等工程产物、项目配置以及调试符号和编译中间文件并附说明文档便于还原目录结构、复现构建过程并观察模块间的配合关系。已有 1263 人学习或下载。读者可据此梳理导入地址表替换、发送调用转发、缓冲区内容写入目标文件、原始函数恢复等关键环节并借助工程配置与调试符号排查编译、链接与注入问题。对于学习网络数据捕获、函数调用跟踪与本地日志留存这份材料提供了较完整的实践参照也能帮助理解用户模式网络函数拦截的基本流程与注意事项。1. 抓包工具看不到的 content得自己从 send 里捞有一次帮某团队排查一个客户端发包异常的问题现象很诡异服务端收到的字段偶尔少一截抓包工具里只能看到加密后的字节流业务层到底把什么塞进了 send完全是个黑匣子。试过在业务代码里加日志但发包路径分散在好几个模块改一遍编译一遍复现一次要等半小时。后来换了个思路直接在 ws2_32.dll 的 send 上做 IAT 拦截把每次发送的原始缓冲区原样追加到一个 .ob 文件里问题当天下午就定位了——某个模块把一个没初始化的缓冲区传了进去长度是按 sizeof 算的。这篇讲的就是这条链路怎么搭起来拦截点为什么选 IAT、导入表怎么解析、函数指针怎么换、落盘时哪些细节会让程序当场翻车以及从防守方视角能看到的痕迹。新手可以照着复现熟手可以对着判断依据挑刺。2. 拦截点选在哪一层IAT、inline 与协议层的取舍拦截 send 这件事最容易犯的错是一上来就写代码写完才发现选的位置根本拿不到想要的东西。window 上的网络数据在到达 send 之前会经过业务层、可能存在的 TLS 库、WS2_32 的导出函数、AFD 驱动每一层能拿到的东西和代价完全不同。先把位置定下来后面 80% 的坑都能提前避开。2.1 四个候选位置的能力对比我一般会按「能拿到什么 / 实现成本 / 稳定性风险 / 覆盖面」四个维度横着比一遍先砍掉明显不合适的再在剩下的里面挑。拦截位置能拿到的数据实现成本稳定性风险覆盖面IAT 钩子调用方传给 send 的原始 buf 与 len低纯用户态几百行低只改一张表只覆盖走 IAT 静态导入的调用Inline 钩子所有调用方含 GetProcAddress 动态解析中要算指令长度、处理并发改写高函数头被改多线程下容易崩覆盖几乎全部调用分层服务提供者原始字节流与 API 无关高需要注册、要有管理员权限中全局但要重新部署驱动层过滤全部流量很高有蓝屏风险最全这张表里最关键的其实是最后一列。IAT 钩子的覆盖面是被写死的——它只能拦住那些在 PE 导入表里静态声明了 send 的模块。如果目标程序用 LoadLibrary GetProcAddress 拿函数地址IAT 里压根没有这一项你改无可改。判断方法很土但很有效把目标程序拖进任意 PE 查看器看导入表里有没有 ws2_32.dll 和 send有就稳了没有就得换 inline。提示只做技术验证、只在自己写的 Demo 或内部工具上跑IAT 足够。想在别人的成品程序上做通用拦截先把覆盖面这件事想清楚否则后面会陷入「为什么有的包拦不到」的死循环。2.2 为什么最后落在 IAT 上选 IAT不是因为它是万能的是因为它的成本和收益比最合适。改 IAT 本质上就是改一个指针把导入表里 send 那一项从 ws2_32 的地址换成自己的函数地址改完立刻生效不需要去碰 ws2_32 的代码段不需要处理指令重定位不需要挂起其他线程也不会因为某条指令被拆成两半而让别的线程执行到半截崩溃。对排查问题这种一次性需求来说这些特性比覆盖面重要得多。代价也很明确。第一它污染的是调用方模块的内存如果调用方模块的 .rdata 段是只读的你得先 VirtualProtect 改页属性改完再改回去忘了改回去在某些开了页保护校验的环境里会被直接拦下。第二多线程同时调用 send 时钩子函数本身必须是可重入的这一点跟选哪个位置无关但 IAT 钩子让人容易忽略——因为「只是换了个指针」听起来太无害了。第三也是最容易被忽略的某些模块的导入表会被系统做绑定Bound Import里面存的是绝对地址改的时候要一并处理不然你改的是一个已经被系统预填好的槽位效果和预期不一样。2.3 send 的原型决定了钩子能拿到什么拦截之前必须把函数签名背下来因为这决定了你在钩子里能看到哪些参数、哪些参数能安全地留到后面用// winsock2.h 里的声明WSAAPI 在 x86 下展开为 __stdcallx64 下无区别 int WSAAPI send( SOCKET s, // 已连接的套接字句柄 const char *buf, // 待发送数据的起始地址 int len, // 待发送的字节数 int flags // 通常为 0少数场景是 MSG_DONTROUTE 等 );四个参数里s 和 flags 是值随手存下来没问题buf 是指针len 是长度这两个是整件事的核心也是最容易出事的地方。buf 指向的内存在 send 返回之后就可能被调用方复用或释放所以钩子里绝对不允许把这个指针丢进队列等另一个线程慢慢写盘必须当场拷贝或者当场写完再转发。见过太多翻车案例都是图省事在钩子里 post 一个消息给工作线程工作线程醒来时那块内存已经变成别的内容了日志里全是不相关的字节。还有一个细节len 是 int可以是 0理论上不该是负数但真实环境里传入 -1 的情况不是没有。钩子里对 buf 判空、对 len 做len 0的守卫是必须的否则一次异常调用就能让整个进程挂掉。flags 里如果带 MSG_OOB数据的语义和普通流不一样落盘时最好把 flags 一起记下来事后分析才知道这条记录为什么看起来「不连续」。3. 把钩子落到 send 上从找 IAT 槽位到替换指针位置定了接下来是三步找到那个槽位、保存原始地址、替换成自己的函数。每一步都有细节写错一步就是访问违例或者死循环。3.1 先确认你改的是谁的导入表这是新手最容易混淆的地方。ws2_32.dll 自己是提供 send 的模块它的导入表里没有 send你要改的是调用方模块的导入表。如果钩子是以 DLL 形式注入到目标进程里那么「调用方模块」指的是目标进程的主模块而不是你的 DLL。找模块基址用GetModuleHandle(NULL)拿主模块或者用GetModuleHandleA(目标.exe)。拿到基址之后标准的 PE 解析路径是DOS 头 → e_lfanew → NT 头 → OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT] → 导入描述符数组。下面这段是核心遍历逻辑// 在 hCaller 这个模块的导入表里找 dllName!funcName 对应的 IAT 槽位地址 FARPROC* FindIatSlot(HMODULE hCaller, const char* dllName, const char* funcName) { auto* dos (IMAGE_DOS_HEADER*)hCaller; auto* nt (IMAGE_NT_HEADERS*)((BYTE*)hCaller dos-e_lfanew); auto dir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (dir.VirtualAddress 0) return nullptr; // 该模块没有任何导入 auto* imp (IMAGE_IMPORT_DESCRIPTOR*)((BYTE*)hCaller dir.VirtualAddress); for (; imp-Name; imp) { // Name 为 0 表示数组结束 if (_stricmp((char*)hCaller imp-Name, dllName) ! 0) continue; // OriginalFirstThunk 是「名字数组」FirstThunk 是「地址数组」两者一一对应 auto* oft (IMAGE_THUNK_DATA*)((BYTE*)hCaller imp-OriginalFirstThunk); auto* ft (IMAGE_THUNK_DATA*)((BYTE*)hCaller imp-FirstThunk); for (; oft-u1.AddressOfData; oft, ft) { // 按序号导入的项没有名字直接跳过别去解引用 if (IMAGE_SNAP_BY_ORDINAL(oft-u1.Ordinal)) continue; auto* byName (IMAGE_IMPORT_BY_NAME*)((BYTE*)hCaller oft-u1.AddressOfData); if (strcmp((char*)byName-Name, funcName) 0) return (FARPROC*)ft-u1.Function; // 这就是要改的那个槽位 } } return nullptr; }这段代码里有三个地方值得停一下。OriginalFirstThunk和FirstThunk的区别前者保存的是「导入名称表」的 RVA程序加载后内容不变是可靠的查找依据后者在加载完成后已经被系统填成了真实函数地址是我们真正要改的地方。用前者查名字、用后者改地址这个配对不能反。第二个是序号导入。有些程序为了减小体积导入时只写序号不写名字这时候oft-u1.AddressOfData的最高位是 1直接当指针用会崩必须先用IMAGE_SNAP_BY_ORDINAL判断。第三个是延迟导入Delay Import。如果一个模块是延迟加载 ws2_32 的它的 send 不在标准导入表里而在 Delay Import Descriptor 指向的结构里第一次调用时才被解析。这种情况用上面的代码找不到得额外遍历IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT或者干脆等第一次调用之后再装钩子。3.2 替换指针时要保住原始地址改指针本身只有三行代码但顺序错了就会变成无限递归——你的 HookSend 里调用了一个「原始 send」而这个「原始 send」其实指向你自己。using SendFn int (WSAAPI*)(SOCKET, const char*, int, int); static SendFn g_realSend nullptr; // 必须保存否则没法转发 static FARPROC* g_slot nullptr; int WSAAPI HookSend(SOCKET s, const char* buf, int len, int flags) { if (buf ! nullptr len 0) { LogSend(s, buf, len, flags); // 同步落盘写完再转发 } return g_realSend(s, buf, len, flags); // 一定走原始地址 } bool InstallHook(HMODULE hCaller) { g_slot FindIatSlot(hCaller, ws2_32.dll, send); if (g_slot nullptr) return false; g_realSend (SendFn)*g_slot; // 先读出来存好再动手改 if (g_realSend (SendFn)HookSend) return true; // 已经装过别装第二次 DWORD oldProtect 0; if (!VirtualProtect(g_slot, sizeof(FARPROC), PAGE_READWRITE, oldProtect)) return false; *g_slot (FARPROC)HookSend; VirtualProtect(g_slot, sizeof(FARPROC), oldProtect, oldProtect); // 属性改回去 return true; }g_realSend (SendFn)*g_slot;这一行必须在写指针之前执行反过来的话你读到的就是 HookSend 自己的地址然后 HookSend 调用 HookSend栈原地爆炸。这个 bug 的典型症状是程序在第一次发包时立刻 0xC00000FD栈溢出而不是访问违例看到这个错误码可以先怀疑这里。if (g_realSend (SendFn)HookSend) return true;这行防的是重复安装。有些程序会在多个线程里初始化网络你的安装函数被并发调用两次第二次读到的「原始地址」已经是 HookSend 了后面所有转发都变成自调用。加个幂等判断成本很低。VirtualProtect的第三个参数用PAGE_READWRITE而不是PAGE_EXECUTE_READWRITE是因为我们改的是一个数据槽位不需要执行权限。要执行权限反而更容易触发某些保护机制的警觉。改完记得把 oldProtect 恢复回去虽然很多场景下不恢复也能跑但在开了页属性校验的环境里一段本该只读的 .rdata 变成可写是会留下痕迹的。3.3 装钩子的时机比钩子本身更容易出错同样的代码装在进程启动时能跑装在网络初始化之后也能跑但装早了 ws2_32 还没加载GetModuleHandleA(ws2_32.dll)返回 NULL整条链路直接失效而且失败得很安静——日志文件是空的你会以为是钩子逻辑有问题其实是根本没装上。判断时机的方法是看目标程序什么时候第一次调用WSAStartup。在这之前ws2_32 可能还没被加载进进程虽然实际上大多数程序启动时就被静态导入了。稳妥做法是装钩子失败时返回 false然后等一个延迟重试几次或者干脆在目标程序完成初始化、第一个窗口出现之后再注入。另一个和时机相关的坑是如果你的钩子是放在 DllMain 的DLL_PROCESS_ATTACH里装的要注意 DllMain 里能做的事情非常有限加载顺序也没保证某些情况下 ws2_32 确实还没映射进来。把安装逻辑挪到一个由外部显式调用的导出函数里或者起一个线程延迟几百毫秒再装都比在 DllMain 里硬来靠谱。这条血泪经验不止适用于钩 send任何在 DllMain 里做重活的操作都值得警惕。4. 把发送内容写进 .ob格式、并发与字节流的处理钩子装上了能拿到 buf 和 len 了接下来是落盘。这一步看着最没技术含量实际上翻车率最高因为它牵扯到格式设计、并发控制和数据类型三个容易想当然的地方。4.1 为什么必须用长度前缀最直觉的写法是每次 send 之后往文件里追加一段「时间 内容 换行」人眼一看就懂。这个方案在文本协议下能用遇到二进制协议立刻废掉——数据里本来就可能包含 0x0A你按换行切分切出来的帧数对不上真实调用次数后面统计「发了多少次包」全是错的。更别说数据里可能有 0x00用任何字符串函数处理都会在中途截断。所以帧格式要定长头 变长长度的组合#pragma pack(push, 1) // 关掉对齐填充保证跨编译器字节布局一致 struct ObHeader { DWORD dataLen; // 本帧有效字节数后面紧跟这么多字节 ULONGLONG tick; // GetTickCount64毫秒级够定位先后顺序 DWORD sock; // 这次发送用的 socket 句柄 DWORD flags; // send 的 flags 原样保留 }; // 合计 20 字节 #pragma pack(pop)#pragma pack(1)不能省。默认对齐下ULONGLONG前面会补 4 字节结构体变成 24 字节而如果你的读端是另一套编译器或者脚本很容易按 20 字节去解析从此全盘错位读出来的 dataLen 是个天文数字。定长头的好处就在这儿解析时先读 20 字节取出 dataLen再读 dataLen 字节指针往后推 20 dataLen循环任何二进制内容都不会破坏帧边界。tick用GetTickCount64而不是GetSystemTime是因为前者单调递增、调用开销小、不受系统时间调整影响。排查时序问题只需要相对顺序绝对时间反而是干扰项。如果确实需要墙上时间在文件开头写一次后面全部用 tick 做偏移。4.2 句柄、锁与「崩了会不会丢数据」写入部分有两个决定要做文件句柄是常驻还是每次开关以及要不要加锁。每次 send 都CreateFileWriteFileCloseHandle好处是进程崩了也不丢已经写下去的内容坏处是性能极差——一次 send 的开销从微秒级涨到毫秒级高频发包的程序会肉眼可见地卡住而且句柄的打开关闭本身在文件被别的进程占用时会失败。常驻句柄反过来性能好但进程被强杀时缓冲区里的最后几条记录可能就没了。我一般选常驻句柄 每条记录独立 WriteFile 不缓存理由是排查问题最怕的是「关键的那一条没记下来」而性能损失在大多数场景下可以接受。如果确实要追性能用FILE_FLAG_NO_BUFFERING之外的方式做批量缓冲但那就要额外处理崩溃时的 flush 策略复杂度上一个台阶。并发这块必须加锁。send 本来就会被多个线程同时调用两个线程同时进 LogSend一个写头一个写体文件里立刻出现交错的垃圾static HANDLE g_file INVALID_HANDLE_VALUE; static CRITICAL_SECTION g_cs; void LogSend(SOCKET s, const char* buf, int len, int flags) { ObHeader hdr{}; hdr.dataLen (DWORD)len; hdr.tick GetTickCount64(); hdr.sock (DWORD)(ULONG_PTR)s; hdr.flags (DWORD)flags; DWORD written 0; EnterCriticalSection(g_cs); // 头和体必须在同一把锁里写完 if (g_file ! INVALID_HANDLE_VALUE) { WriteFile(g_file, hdr, sizeof(hdr), written, nullptr); WriteFile(g_file, buf, (DWORD)len, written, nullptr); // 原样写不做任何转换 } LeaveCriticalSection(g_cs); }锁的粒度要覆盖「写头 写体」两次调用只在单次 WriteFile 外面加锁是没有意义的。另外EnterCriticalSection在持有期间不能调用任何可能阻塞或者可能再次触发 send 的函数否则就是自己等自己。这里只做两次 WriteFile是安全的。sock存成DWORD而不是直接把SOCKET塞进去是因为SOCKET在 64 位下是UINT_PTR8 字节而实际句柄值远小于 4G转一下不影响分析还能让头部保持定长。真要严谨可以单独开一个「句柄映射表」把 socket 映射成自增 ID但那是锦上添花。4.3 字节流不是字符串别用字符串的思路处理这是我最想强调的一点。send 的 buf 是const char*但这个 char 跟「字符串」没有半点关系它只是一块内存的地址。里面可能有 0x00可能根本不是文本可能是一段 protobuf 或者二进制结构体。所以落盘时有三条铁律。第一长度一律用传入的 len绝不用strlen(buf)后者遇到第一个 0x00 就停日志里会莫名其妙地短一截。第二不要做任何编码转换不做 UTF-8 转 GBK不做转义原样写字节。想要可读性在解析端做不在采集端做。第三如果确实需要人眼看可以额外生成一份「十六进制 ASCII 并排」的可读副本但原始 .ob 必须保留字节原貌因为它是唯一可信的证据。判断一段数据是不是文本可以在解析工具里做启发式统计可打印字符占比超过 90% 当文本显示否则走 hexdump。这个判断放在采集端做就错了采集端一旦「智能」原始数据就没了。5. 避坑清单五个让程序当场翻车的现场下面这几条都是我在复现过程中真实撞过的按「现象 → 原因 → 解决」记方便对照。现象一装上钩子后程序第一次发包就 0xC0000005异常地址指向一块已释放的内存。原因钩子里没有当场拷贝 buf而是把指针塞进队列交给工作线程异步写盘。send 返回后调用方复用了那块缓冲区工作线程拿到的指针已经悬空。 解决日志写入必须在钩子函数内同步完成或者至少在返回前memcpy到自己的堆内存里。异步方案可以做但拷贝动作一定要在钩子内部完成。现象二.ob 文件里一半记录是乱码长度字段对不上解析到中途直接错位。原因写头的时候用了sizeof(ObHeader)但结构体没加#pragma pack(1)编译器插了 4 字节填充实际写出去 24 字节而读端按 20 字节解析。 解决结构体强制 1 字节对齐并且在读端加一条自检——读出 dataLen 后判断它是否超过一个合理上限比如 16MB超了就说明帧已经错位停止解析而不是继续硬读。现象三只有主程序的发包被拦到了某个第三方网络库发的包完全没记录。原因只改了主 exe 的导入表第三方 DLL 有自己独立的 IAT里面那份 send 还是原始地址。 解决遍历EnumProcessModules或CreateToolhelp32Snapshot拿到的所有模块对每个模块都跑一遍 FindIatSlot能装的全装上。要注意的是遍历过程中模块可能被卸载得先做一次快照再处理别边遍历边用。现象四程序跑十几分钟后越来越卡最后 WriteFile 开始返回失败。原因每条记录都CreateFile/CloseHandle高频发包下句柄没来得及回收或者磁盘句柄数触顶。 解决句柄常驻进程退出时统一关。同时给日志加一个大小上限超过就滚动到新文件避免把磁盘写满导致更难排查的问题。现象五装完钩子杀软或 EDR 直接把进程挂起连个提示都没有。原因改写其他模块的导入表属于典型的高风险行为很多终端防护会校验关键系统 DLL 的导入项是否指向模块自身地址范围。你的 HookSend 在自己的 DLL 里地址明显不在 ws2_32 的范围内一眼就被识别。 解决这道坎没有技术上的「绕过」只有认知上的调整——这类手段适合在自己的测试环境、自己的程序上做验证和自查不适合当成生产环境的常规手段。真要做线上流量审计走分层服务提供者或者程序自身的日志埋点成本和风险都低得多。6. 复现之后怎么验证以及蓝队会看哪里写完不等于通了得有一套验证方法否则你永远不知道日志里的沉默是因为「没发包」还是「钩子没生效」。最小的验证闭环是自己写一个客户端连本机的一个 echo 服务发三条已知内容的数据一条纯 ASCII、一条带 0x00 的二进制、一条长度超过 8KB 的大包。然后按帧格式写个十几行的解析脚本把 .ob 读出来逐条比对长度和内容。三条都对上说明采集端没问题只有第三条对不上多半是缓冲区边界处理错了只有第二条对不上就是把 buf 当字符串了。import struct def read_ob(path): with open(path, rb) as f: data f.read() pos, frames 0, [] # 头部 20 字节dataLen(4) tick(8) sock(4) flags(4) while pos 20 len(data): length, tick, sock, flags struct.unpack_from(IQII, data, pos) if length 16 * 1024 * 1024: # 长度异常说明帧已错位 break pos 20 payload data[pos:pos length] pos length frames.append((tick, sock, flags, payload)) return framesIQII里的表示小端且不填充和 C 端的#pragma pack(1)严格对应。如果不加Python 默认会按平台对齐规则补位读出来的字段全部错位这是脚本端最常见的坑。蓝队视角其实很简单拿GetModuleHandleA(ws2_32.dll)拿到模块基址再遍历进程内所有模块的导入表检查每一项 send 的地址是否落在 ws2_32 的地址区间内。不落在里面的就是被改过。这个检查实现起来不到一百行可以做成定期巡检。再加上编译期的 CFG控制流保护和对 .rdata 页属性的监控IAT 钩子的生存空间其实很小。回过头看这套东西真正的价值不在于「能拦」而在于让你彻底搞明白一个普通的 send 调用背后导入表、地址空间、调用约定、缓冲区生命周期这几件事是怎么咬合在一起的。我后来每次要动别人模块的内存之前都会先强制走一遍流程确认这个槽位属于谁、这个指针的原始值存在哪、改完之后谁负责恢复。少走一步排查时间就从半小时变成一整天。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑