第2篇:Winsock API Hook — 在应用层精确动刀

发布时间:2026/6/24 9:16:51
第2篇:Winsock API Hook — 在应用层精确动刀 第2篇Winsock API Hook — 在应用层精确动刀系列《从0到1搭建一个自己的Proxifier》上一篇第1篇《网络五层模型 — Proxifier 的战场地图》下一篇预告第3篇《注入的艺术 — Ghost Proxifier 核心架构拆解》一、一百个函数你到底 Hook 谁选定 API Hook 这个方向之后我打开了ws2_32.dll的导出表。一百多个函数。名字从accept到WSASendMsg横跨二十年的 Windows 网络编程史。全部 Hook每多一个 Hook就多一份性能开销也多一个潜在的 bug。做技术的都有一种冲动叫我要覆盖所有边界情况。这种冲动在大多数时候是好事——它让你写出健壮的代码。但在 Hook 这件事上它会导致你的代码像一块瑞士奶酪到处都是洞每个洞都可能被触发。我的策略是反过来的只 Hook 那些你不 Hook 就会出事的函数。二、一个 TCP 连接走过的路任何 TCP 客户端——不管是 Chrome 还是 curl——发起一个网络请求时走过的路大致是这样你的应用 │ ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ DNS 查询 TCP 连接 TLS 握手 │ │ │ ▼ ▼ ▼ getaddrinfo connect() (应用层不管) gethostbyname WSAConnect DnsQuery ConnectEx │ │ ▼ ▼ 返回 IP 三次握手完成 │ │ └─────┬──────┘ │ ▼ send()/recv() WSASend/WSARecv │ ▼ closesocket()沿着这条路看有三个地方你必须出手┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ ① DNS 查询 │ │ ② TCP 连接 │ │ ③ 数据收发 │ │ │ │ │ │ │ │ 不拦截 → ISP │ │ 不拦截 → IP直连 │ │ 不拦截 → 裸发 │ │ 知道你在访问什么 │ │ 代理形同虚设 │ │ 前面全白干 │ │ │ │ │ │ │ │ 拦截 → 自建DNS │ │ 拦截 → 重定向到 │ │ 拦截 → 先握手 │ │ 加密隧道查询 │ │ 代理地址 │ │ 再转发数据 │ └──────────────────┘ └──────────────────┘ └──────────────────┘另外还有一个隐藏关卡——进程创建。它不在网络调用链上但没有它子进程追踪就无从谈起第四篇详聊。三、DNS 查询流量泄露的第一道口子你可能会觉得奇怪我的流量本身已经走代理了ISP 又看不到内容——DNS 泄露有什么关系关系很大。你的 ISP 看不到你访问www.google.com之后传输了什么内容但它能看到你查询了www.google.com这个域名。在某些网络环境下DNS 本身就可以触发封锁——GFW 的 DNS 投毒就是这样工作的它在你查询www.google.com时返回一个假的 IP你的浏览器连上了假 IP自然什么都访问不了。所以 DNS Hook 的使命很简单劫持目标进程发出的所有 DNS 查询不让一个字节的 DNS 流量离开你的控制。原始路径 (泄露): 应用 → getaddrinfo(google.com) → 系统DNS服务器(ISP) → ISP记录日志 ✗ Hook 后: 应用 → getaddrinfo(google.com) │ └──→ hook_getaddrinfo() │ ├─ 交给本地 DNS Proxy (127.0.0.1:随机端口) │ │ │ └─ UDP转TCP → 通过代理隧道 → 8.8.8.8:53 │ │ │ └─ 加密隧道中传输 │ ISP 看不到 ✗ │ ├─ 拿到真实 IP 后存入 IP→域名 映射表 │ (这个表等会儿 connect 阶段要用) │ └─ 返回结果给应用需要 Hook 的 DNS 函数有五个函数理由getaddrinfo最常用的必须GetAddrInfoW同上宽字符版gethostbyname老 API但有些老程序还在用DnsQuery_W/AWindows 原生 DNS绕过 Winsock 直接查询。不 Hook 它前面的全白干GetAddrInfoExW异步 DNSChrome 就爱用这个插一句DnsQuery_W这个函数给了我一个教训——做 Hook 不能只看 Winsock。Windows 提供了多条 DNS 查询路径覆盖不全就等于没覆盖。这也是一种 “安全是木桶最短的那块板” 的变体Hook 不是你 Hook 了多少函数而是你漏了哪一个。四、连接建立最关键的一刀也是最容易搞砸的Hookconnect()是你改变流量走向的地方。应用本来要去142.250.80.4:443你要让它变成去127.0.0.1:2080你的代理地址。逻辑上很简单但实际上有一个致命陷阱。先说逻辑应用调用 connect(google.com:443) │ ▼ hook_connect() 接手 │ ├─ 查出 google.com (从刚才存的 IP→域名 映射表反查) │ ├─ 把真实目标 google.com:443 存到 PendingMap[socket] │ ├─ 把目标地址改成 127.0.0.1:2080 (代理地址) │ └─ 调用 real_connect(127.0.0.1:2080) │ └─ TCP 三次握手完成 → 返回给应用看着很简单对吧那陷阱在哪里Lazy HandshakeChrome 差点杀了我我最初的实现是在hook_connect()里connect 到代理后马上同步发送 HTTP CONNECT 请求等待代理返回200 Connection Established然后才返回。这是一个完整的、直觉上正确的做法。Chrome 的反应是进程卡死杀进程重启。原因在于Chrome 使用非阻塞 IO 事件循环connect()在事件循环的主逻辑中被调用。如果你在connect()阶段阻塞几百毫秒做 HTTP CONNECT 握手事件循环就整体停滞了——Chrome 的心跳检测以为进程挂了。这就好比你去餐厅点菜服务员不是把菜单给你就离开而是站在你旁边等你点完、再去厨房等厨师做完、再端回来——期间你不能做任何其他事情。Chrome 就是那个等不及的客人。解决方案把握手推迟到第一次send()的时候。connect() 阶段: → 只做地址重定向非阻塞返回 → 把真实目标(google.com:443)记在 PendingMap[socket] 里 → 应用以为连接已建立继续事件循环 send() 首次调用: → 检查 PendingMap[socket]发现有待握手 → 发送 HTTP CONNECT google.com:443 → 等待 200 Established (通常 5ms本地代理) → 然后转发应用原本要发的数据 后续 send(): → PendingMap 里没记录了直接转发这样一来connect()调用几乎零延迟Chrome 的事件循环不受影响。真正需要等待的那几十毫秒被分摊到了send()的首次调用——这个调用本来就在事件循环的某个异步任务里等几毫秒完全无感。这个设计让我想起一句话好的解决方案不是解决了问题而是让问题发生在对的地方。五、数据收发DNS 劫持 握手触发sendto — 藏在 UDP 里的 DNSsendto是无连接 UDP 发送。大多数时候它和我们没关系——但有一种情况是例外标准 DNS 查询走的是 UDP 53 端口。如果你的应用使用底层sendto而不是高层getaddrinfo来发 DNS 查询Python 的某些网络库就这么干前面的 DNS Hook 就全绕过了。所以我们在sendto里增加一个判断sendto(socket, data, len, port53) │ ├─ port 53 → 这是 DNS 查询 │ └─→ 重定向到本地 DNS Proxy → 假装发送成功 │ └─ port ! 53 → 正常调用 real_sendtorecvfrom — 偷偷记下 IPDNS 响应从recvfrom回来。趁机从里面提取 IP→域名 关系recvfrom(socket, buf) → real_recvfrom → 拿到 DNS 响应包 → 解析 DNS 报文的 Answer Section → 提取 IP 地址 → 写入 IP→域名 映射表 (供 connect 阶段反查) → 返回给应用六、MinHook这块最没有故事选 MinHook 而不是 Detours 的原因没有太多戏剧性。MinHook 开源、BSD 协议、代码量少到几个文件就能搞定、x86 和 x64 都支持。Detours 曾经不开源后来又开了但不稳定。MinHook 的原理叫 Trampoline——在一个函数的开头插入一条无条件跳转跳到你的函数。你的函数做完该做的事之后通过跳板调回原始函数。图示Hook 前: 应用 ──→ ws2_32.connect() ──→ 内核 Hook 后: 应用 ──→ ws2_32.connect() │ ├── JMP hook_connect ← MinHook 改写的 5 字节 │ │ │ ├─ 重定向、记映射... │ │ │ └─ call trampoline ──→ 原始 connect 的剩余部分 │ │ └───────────────────────────────────────┘ ↓ 内核使用流程可以浓缩成一张图MH_Initialize() ← 1. 开机 ↓ MH_CreateHook × 25 ← 2. 逐个注册 Hook ↓ MH_EnableHook(ALL) ← 3. 一键激活 ★ (原子操作避免部分生效的时间窗口)有一个值得说的细节所有 Hook 注册完后用MH_EnableHook(MH_ALL_HOOKS)一次性激活。不能一个一个激活——因为connect的 Hook 和send的 Hook 通过PendingMap协作两者必须同时就位。七、全景图我们到底 Hook 了哪些函数不贴代码了。一张表就够了┌─── getaddrinfo / GetAddrInfoW / gethostbyname DNS 解析 ───────┤ DnsQuery_W/A / GetAddrInfoExW └──→ 作用接管 DNS建 IP→域名 映射 ┌─── connect / WSAConnect / ConnectEx TCP 连接 ───────┤ └──→ 作用重定向到代理地址Lazy Handshake ┌─── send/WSASend → 首次 Send 触发 HTTP CONNECT 数据收发 ───────┤ sendto/WSASendTo → 劫持 UDP DNS │ recvfrom/WSARecvFrom → DNS 响应映射 │ recv/WSARecv → 监控 └─── closesocket → 清理 PendingMap ┌─── CreateProcessW/A 进程追踪 ───────┤ CreateProcessAsUserW (第四篇详聊) │ NtCreateUserProcess └──→ 作用子进程自动注入 ┌─── WSAIoctl → IO 控制兼容 其他 ──────────┤ GetQueuedCompletionStatus/Ex → IOCP 兼容 └──→ 作用不让边缘情况炸掉一共 25 个 Hook。有人可能会说太多了。但做过网络代理的人都知道——漏掉一个就有一条逃逸路径。有一种工程师的洁癖叫我以为全覆盖了有一种用户的反馈叫为什么我这个程序还是直连了。两者之间的差距就是你漏掉的那一个 Hook。八、秘密武器提示上面一直跳过了最根本的问题——Hook 代码是怎么跑进目标进程的传统的做法是用CreateRemoteThread建一个新线程执行LoadLibraryW。这在绝大多数场景下工作正常——除了 Cygwin 程序它们会直接 SIGSEGV 崩溃。我们用的是一种更精巧的方式不创建新线程借用目标进程的主线程完成一切。修改挂起进程的 RCX 寄存器让 Windows 的 DLL 加载器跑完后自动跳转到我们的 shellcode。传统方式 (CreateRemoteThread): 父进程 ─→ 在目标进程中创建新线程 ─→ LoadLibraryW ─→ Cygwin崩溃 ❌ 我们的方式 (SetThreadContext): 父进程 ─→ 修改挂起线程的 RCX ─→ LdrInitializeThunk 自然完成 ─→ jmp RCX 跳转到 shellcode ─→ 主线程执行一切 ─→ Cygwin 完美兼容 ✅这里面的门道——shellcode 的每一个字节、7 步完整时序、Cygwin 为什么对线程敏感——全部留给下一篇。那将是整个系列最硬核的一篇。九、下一步Hook 函数定好了MinHook 上膛了。剩下一个问题把扳机扣进目标进程。下一篇拆枪。讨论你在做 Hook 的时候有没有发现某个看起来不需要 Hook的函数其实是漏网之鱼那种我以为全覆盖了但用户的反馈打了我的脸的经历来评论区分享一下。