攻击手法解析:DNS Rebinding
我用跑通的实验来解释。先看问题本身。
问题:一次检查,两次解析
mediaIngest 的职责是「用户给一个 URL,服务端下载它」。但如果不管,用户可以让服务端去访问内网地址:
{"msgtype":"image","media_url":"http://127.0.0.1:9096/health"}
这就是 SSRF(服务端请求伪造)——拿你的服务器当跳板去打内网。所以要有 guardSSRF 检查。
问题在于:检查用的解析,和连接用的解析,是两次独立的 DNS 查询。
① guardSSRF(u.Hostname()) ← 解析一次,得到公网 IP,检查通过
⋮ 时间窗口(毫秒级)
② http.Client.Do(req) ← Transport 内部又解析一次,这次拿到内网 IP
↓
连到 127.0.0.1 —— 检查形同虚设
刚才的实验就是在演示这两步返回不同 IP:
① 检查阶段 → 93.184.216.34(公网),guardIP 通过 ✅
② 连接阶段 → 127.0.0.1(内网)
若此刻也检查 → 会被拦:拒绝内网/本机地址: 127.0.0.1
攻击手法:DNS Rebinding
攻击者控制一个域名(比如 evil.com)的 DNS,把它设成 TTL=0,然后让它这样回答:
| 第几次查询 | 返回 |
|---|---|
第 1 次(guardSSRF 查的) |
公网 IP,比如 1.2.3.4 → 检查通过 |
| 第 2 次(Transport 查的) | 127.0.0.1 → 连到内网 |
因为 TTL=0,中间那几毫秒 DNS 缓存立即失效,第二次查询会拿到新答案。检查通过了,但连的是内网。
这种「检查时和使用时状态不一致」的漏洞模式就叫 TOCTOU(Time-Of-Check to Time-Of-Use,检查时刻 ≠ 使用时刻)。
修复:把检查搬到「连接的那一瞬间」
关键机制是 http.Transport 提供了一个钩子:
Transport: &http.Transport{
DialContext: dialGuarded, // ← 每次建 TCP 连接前,都会调用它
}
DialContext 的职责本来是「给我主机名和端口,你负责建立 TCP 连接」。**Transport 不再自己解析 DNS,而是把这件事交给你的函数。**于是 dialGuarded 拿到控制权:
func dialGuarded(ctx, network, addr string) (net.Conn, error) {
host, port, _ := net.SplitHostPort(addr) // addr = "evil.com:80"
ips, _ := net.DefaultResolver.LookupIPAddr(ctx, host) // ① 唯一的解析
for _, ip := range ips {
if err := guardIP(ip.IP); err != nil { // ② 检查紧挨着
continue
}
// ③ 直接连这个已经检查过的 IP,而不是再传主机名
conn, err := d.DialContext(ctx, network,
net.JoinHostPort(ip.IP.String(), port))
if err == nil { return conn, nil }
}
}
三个要点:
1. 只剩一次解析。 检查用的 ips 和连接用的 IP 是同一份数据,中间没有第二次查询——窗口消失了。这不是"把窗口缩小",是从结构上不存在了。
2. 连接时传 IP,不传主机名。 注意第 ③ 步:
d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
// ^^^^^^^^^^^^^ 是 IP,不是 host
如果这里传 evil.com:80,Dialer 会再解析一次,窗口又回来了。传 IP 就绕过了任何后续解析。
3. 逐个 IP 检查后再连接。 域名可能解析出多个 IP(CDN 常见)。任何一个 IP 是内网就跳过它;全部是内网则整个请求失败。
一句话对比
| 检查时机 | 结果 | |
|---|---|---|
| 修复前 | 发请求前解析一次 | 检查的是 A,连的是 B → 可绕过 |
| 修复后 | 建连接时解析,同一份数据既检查又连接 | 检查的和连的是同一个 IP → 无法绕过 |
用类比说:原来的做法像看身份证放行,但进门时不再核对——你可以在查验之后换个人进去。修复后是查验和进门是同一个人、同一个瞬间。
为什么这个修复值得单独说
因为「检查了内网 IP」这个动作看起来已经做了防护,很容易认为安全了。但它的正确性依赖于一个隐含假设:解析结果在检查和连接之间不会变。而 DNS 是个外部可变系统,这个假设不成立。
这也是为什么我在 devlog 里把这类问题归为「声称做到 vs 真的做到」——guardSSRF 确实写了、确实在检查内网、确实能拦住直接写 IP 的攻击。只有想到 DNS 可以中途换答案的人,才会发现它还差一步。
顺带一提:这个漏洞是外部评审指出来的,我第一版实现就是那个"只查一次"的版本。