第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 了多少函数,而是你漏了哪一个。
四、连接建立:最关键的一刀,也是最容易搞砸的
Hook connect() 是你改变流量走向的地方。应用本来要去 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_sendto
recvfrom --- 偷偷记下 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"的函数其实是漏网之鱼?那种"我以为全覆盖了但用户的反馈打了我的脸"的经历,来评论区分享一下。