第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/A | Windows 原生 DNS,绕过 Winsock 直接查询。不 Hook 它,前面的全白干 |
GetAddrInfoExW | 异步 DNS,Chrome 就爱用这个 |
插一句: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 Handshake:Chrome 差点杀了我
我最初的实现是:在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 里的 DNS
sendto是无连接 UDP 发送。大多数时候它和我们没关系——但有一种情况是例外:标准 DNS 查询走的是 UDP 53 端口。
如果你的应用使用底层sendto而不是高层getaddrinfo来发 DNS 查询(Python 的某些网络库就这么干),前面的 DNS Hook 就全绕过了。所以我们在sendto里增加一个判断:
sendto(socket, data, len, port=53) │ ├─ port == 53 ? → 这是 DNS 查询! │ └─→ 重定向到本地 DNS Proxy → 假装发送成功 │ └─ port != 53 → 正常调用 real_sendtorecvfrom — 偷偷记下 IP
DNS 响应从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"的函数其实是漏网之鱼?那种"我以为全覆盖了但用户的反馈打了我的脸"的经历,来评论区分享一下。