# DNS 重绑定防护需要在执行时查询

对 `api.example.test` 的批准，并不等于批准这个名称稍后返回的任意地址。如果代理可以发起经过身份验证的 HTTP 请求，DNS 重绑定就能把一次过时的主机名判断变成通往本地服务、云元数据端点或内部控制平面的路径。凭据注入可能完全按照设计工作，出错的是目标判断。

我见过一些团队精心设计批准界面，却在最后一刻让 HTTP 库自行解析名称，而且完全不检查解析结果。这个漏洞很容易被忽略，因为普通 DNS 通常表现得很规矩。攻击者不需要普通 DNS 配合，他们只需要一个自己控制的名称、很短的缓存生命周期，以及一个把主机名误当成永久目标的客户端。

DNS 重绑定防护需要在执行时查询。就在打开连接前解析名称，检查完整结果，选择允许的地址，并连接到该确切地址。保留原始主机名，用于 HTTP 和 TLS 验证。每当客户端打开另一条连接、跟随重定向或切换协议时，都要重新执行这套流程。

## 批准的是一类目标，而不是永久固定的 IP 地址

主机名是一条要求进行解析的指令，不是网络对端的稳定身份。这个区别听起来有些吹毛求疵，直到代理从 issue、构建日志、工具响应或其他服务中收到 URL。代理可能请求批准调用 `reports.partner.test`，批准者看到的是一个看起来合理的公共目标。几分钟后，同一个名称可能返回 `127.0.0.1`、IPv6 回环地址，或只能从机器内部网络访问的地址。

常见的重绑定过程分为两个阶段。第一阶段中，权威 DNS 服务器返回一个公共地址。客户端或审核者接受这个主机名，有时还会先发起类似浏览器的预检请求。第二阶段中，攻击者改变答案，并依靠过期、重新连接、重定向、重试或第二次请求触发另一次解析。目标服务看到的请求来自本地机器，而网络防火墙通常会给予本地机器更多访问权限。

代理网关面对的问题更尖锐。只有在有人批准操作后，它才可能附加 bearer token、基本身份验证凭据或自定义授权标头。如果网关检查了代理进程，却没有检查对端地址，就可能把真实凭据交给错误的目标。对本地管理服务的请求可能根本不需要凭据，攻击者也许只是利用该请求，让机器读取一份代理随后可以收到的响应。

不要把第一次解析出的 IP 地址永久记录下来，以此解决问题。公共服务会使用负载均衡，也会更换地址并使用 IPv6。更准确的安全承诺是：每条连接都必须使用一个在该连接开始时通过验证的地址。此前的批准可以授权主机名和凭据使用，但不能为后来属于被阻止网络的地址开绿灯。

## 在套接字打开前立即解析

操作顺序决定检查是否有意义。解析主机名，验证解析器的答案，选择一个地址，然后把该地址直接传给连接例程。如果你先验证了一个地址，接着又调用会再次解析主机名的便捷 API，那么你检查的是一个目标，连接的却是另一个目标。

在整个请求过程中保持以下几个值彼此分离：

- `origin_host` 是用户批准的主机名，也是证书必须覆盖的名称。
- `peer_ip` 是为这个套接字选定的唯一已验证地址。
- `port` 是客户端应用允许的端口规则处理后的请求端口。
- `scheme` 决定客户端是否需要 TLS。

对于 HTTPS，将 TCP 套接字连接到 `peer_ip`，但把 `origin_host` 作为 HTTP Host 标头发送，并用 `origin_host` 进行 SNI 和证书验证。IP 地址证书不能替代主机名证书。如果证书与主机名不匹配，就让请求失败。为了让地址固定更容易而关闭证书验证，只会把一个路由错误换成严重得多的问题。

一个最小的连接边界可以写成这样：

```text
origin_host = parse_url(request_url).host
answers = resolver.lookup_all(origin_host)
allowed = [ip for ip in answers if public_routable(ip)]
if allowed is empty:
    fail("DNS answer contains no permitted address")

peer_ip = choose_one(allowed)
socket = tcp_connect(peer_ip, request_port)
tls = tls_handshake(socket, server_name=origin_host, verify_name=origin_host)
send_http(tls, host_header=origin_host)
```

这段示意代码特意使用了 `lookup_all`。只查询一个地址会掩盖一个常见故障：解析器同时返回一个允许的公共 IPv4 地址和一个 IPv6 回环地址。即使应用只检查了 IPv4，库也可能优先使用 IPv6。要么在存在任何被阻止答案时拒绝该主机名，要么定义严格的选择规则，只把经过验证的地址传给连接器。拒绝混合答案更容易审计，也能减少攻击者利用连接竞速行为的空间。

“立即”这个词很重要。要在套接字打开前解析，而不是在代理起草请求时、批准卡片出现时，或用户存储凭据时解析。查询与连接之间仍会有很短的间隔，但客户端已经为该套接字选定了地址，不需要再次向 DNS 发起查询。

## 私有 IPv4 过滤只是第一道防线

RFC 1918 为私有网络保留了 IPv4 网段，包含三个常见区块：`10.0.0.0/8`、`172.16.0.0/12` 和 `192.168.0.0/16`。对于面向互联网的客户端，阻止这些网段是必要的，但把这份列表当成全部防护，是一个常见且代价高昂的错误。

目标过滤器需要拒绝那些不能安全视为公共地址的类别。至少要拒绝回环地址、未指定地址、链路本地地址、多播地址、私有地址和 IPv6 唯一本地地址。规范化地址后，还要拒绝 IPv4 映射的 IPv6 形式，因为 `::ffff:127.0.0.1` 在实际效果上仍是回环地址。将 IPv6 区域标识符视为远程 URL 中的无效内容。不要让文本写法决定安全性，应将地址解析为二进制形式后再分类。

有些网段需要明确的产品决策，而不是依赖偶然的默认值。`100.64.0.0/10` 中的运营商级 NAT 地址并不具备全球可达性。文档和基准测试网段不应作为正常的生产目标出现。在某些云环境中，IPv4 链路本地网段可以访问元数据服务。IPv6 链路本地地址需要接口范围，也不应从公共主机名请求中出现。对于外部操作网关，最安全的默认设置是只允许普通的全球单播地址，并在单独的内部部署模式中配置明确的例外。

不要使用字符串前缀检查。`127.1`、`127.0.0.1`、宽松解析器接受的整数格式、IPv6 压缩形式和映射形式都会让这种做法失效。将 URL 主机解析为 DNS 名称或 IP 字面量。如果是 IP 字面量，直接分类。如果是 DNS 名称，就解析它并分类返回的每个地址。在解析前拒绝格式错误的名称。

团队也经常在这里混淆公共 DNS 与公共可达性。解析器可能返回一个公共地址，但流量会经过企业代理、VPN 或分流网络路径。DNS 答案只是一个输入，并不保证整条路由。地址过滤可以阻止直接重绑定到明显的本地网段，但网络出口规则仍需阻止主机能够访问、而产品不应使用的路径。

## CNAME 和混合答案需要统一决策

CNAME 不会让名称变得安全。它会把最终答案委托给另一个名称，而后者本身也可能变化或返回混合答案。解析器通常会在向应用提供 A 和 AAAA 记录前跟随这条链，因此应用应判断收到的最终地址。如果解析器 API 暴露了这条链，可以记录下来用于诊断，但不要根据第一个名称看起来是否熟悉来执行策略。

一个强而简单的默认规则是：如果请求主机名的任何最终答案属于被阻止类别，就拒绝请求。这样可能会拒绝一个在公共地址旁发布了不可达私有地址的服务，但这种配置本身已经会让客户端行为不可预测。发送凭据的网关应该要求服务所有者修复 DNS 记录，而不是悄悄选择一个方便的答案。

有些团队更喜欢“使用任意允许的答案”。这个规则很受欢迎，因为它能让更多集成继续工作，但也会产生难以复现的行为：一个解析器顺序可以正常工作，另一个却连接到被阻止的地方，Happy Eyeballs 实现还可能在不同时间发起 IPv6 和 IPv4 尝试。如果选择这条路线，连接层必须只接收选定的允许地址，绝不能回退到原始主机名。通用 HTTP 库通常无法给出这样的保证。

记录足够的证据，以便解释拒绝原因。请求日志应包含原始主机名、端口、解析出的地址集合、选定的对端地址（如果有）、策略结果和请求关联标识符。不要为了方便调试而记录凭据或响应正文。DNS 安全故障通常仅凭地址列表就能看清。

## 只有保留对端地址的连接池才安全

复用的 TLS 连接不会重新执行 DNS 查询，也不需要这样做。套接字打开时已经选定并验证了对端地址。如果正常的 HTTP 来源规则和证书检查允许，客户端可以为同一个来源复用这条确切的连接来发送另一个请求。

新连接则不同。连接池通常会隐藏因空闲超时、传输故障、HTTP/2 流数量限制或代理状态变化而发生的重新连接。如果连接池再次让操作系统通过主机名连接，就重新打开了重绑定窗口。应将解析器和连接器放在连接池之下，而不是只放在第一个请求旁边。

连接合并需要谨慎。只要证书覆盖多个主机名且对端合适，HTTP/2 就可以复用一条 TLS 连接来服务多个主机名。普通浏览器可能接受这种取舍，但操作网关应为每个来源明确做出目标决策。不要因为证书列出了两个名称，就认定为一个已批准主机名打开的套接字可以承载另一个主机名的带凭据请求。批准范围、HTTP authority 和连接复用是彼此独立的决策。

重试也是同样的情况。连接失败后的重试是一次新的出站尝试。重新解析、重新分类、选择地址，并打开新的套接字。永远不要让允许结果的缓存时间超过它所授权的连接。可以短暂缓存被阻止的结果来减少重复工作，但不能把这个缓存变成未经审核的路由权威。

调用过程中发生的变化不会让攻击者把已建立的 TCP 连接移到新的 IP 地址。TCP 已经选定了对端。真正危险的是创建替代连接、跟随重定向、升级协议，或在第一次响应后发起另一个请求的代码路径。只测试一次成功请求，会漏掉生产客户端在负载下使用的路径。

## 重定向属于独立的出站请求

重定向会改变目标 URI。即使 HTTP 库把它作为便捷功能，也要将其视为一次新的操作。解析 `Location` 值，拒绝不支持的协议，在执行时解析其主机名，验证返回的地址，并在连接前检查端口。跨来源自动携带授权标头本身就是另一个凭据泄露错误，因此除非新来源有明确的独立授权决定，否则应删除这些标头。

将重定向次数限制在一个很小的固定值内。重绑定测试服务器可以在每一跳轮换名称和答案，而无限重定向会把简单的出站操作变成不透明的链条。记录每一跳的来源、目标来源、地址集合、选定的对端、状态码，以及任何拒绝的原因。

也要在容易忽略的协议功能周围执行 DNS 检查。HTTP 代理会改变最初接收 TCP 连接的一方，因此要验证代理地址，然后明确代理可以解析什么。否则 CONNECT 隧道可能把主机名解析转移到不执行同样检查的组件中。只要代理可以影响目标，webhook、回调 URL、对象存储端点和软件包仓库都应接受同样的处理。

阻止私有 DNS 答案，并不能让任意 URL 变得安全。它无法阻止公共服务器发出危险命令、返回巨大响应，或将请求重定向到一个已获批准但具有恶意的服务。DNS 重绑定防御只是其中一道边界，规则应足够精确，避免人们把它误认为通用策略语言。

## 将解析器和连接器作为一个整体测试

将 `127.0.0.1` 传给地址分类器的单元测试是必要的，但还不够。故障发生在客户端让主机名经过多层组件，而其中某一层执行了未受保护的解析时。测试必须观察实际的套接字目标。

建立一个受控的权威测试区域，使用类似 `flip.test` 的名称。第一次查询时返回一个会记录无害请求的公共测试端点，下一次查询时返回一个被阻止的地址。然后在第一个端点响应后关闭它，以迫使客户端建立第二条连接。预期结果不应是第二次请求成功，而日志中出现警告。连接器必须在打开通往被阻止地址的套接字前拒绝请求。

使用一次只改变一个条件的测试矩阵：

1. 先返回公共 IPv4 答案，低 TTL 后再返回回环 IPv4 答案。
2. 在同一个答案集合中返回一个允许的 IPv4 地址和 `::1`。
3. 返回一个 CNAME，其最终答案从公共地址变为 IPv6 唯一本地地址。
4. 重定向到第二个主机名，而该主机名解析到被阻止的地址。
5. 关闭连接池中的连接，并验证重试会执行全新的验证。

在网络边界添加断言。假的连接器应记录它收到的数字 IP 和端口。如果它收到主机名，测试就失败，因为这意味着验证之后仍可能有另一个解析器运行。在集成测试中，在被阻止的回环端口上放置监听器，并断言它记录到零次连接。一个被拒绝的请求如果已经到达监听器，就说明它已经越过了你要保护的边界。

短 TTL 测试很重要，因为它能暴露隐藏在意外位置的缓存：应用解析器、操作系统、代理、语言运行时或 HTTP 库。既要测试遵守 TTL 的解析器，也要测试会立即返回变化答案的解析器。安全性不能依赖某个特定缓存的速度。

## 批准和地址验证回答的是不同问题

每会话批准回答的是“本次运行期间，哪个代理进程可以执行操作？”每次调用批准回答的是“现在使用这个凭据是否值得人工决策？”DNS 验证回答的是“哪个网络对端可以接收这条连接？”将这些问题合并到一个批准提示中，会让界面看起来更简单，却掩盖了用户批准内容的重要变化。

在批准界面上显示主机名和端口，但不要假装主机名是不可变的对端地址。无论用户几秒前是否看到过提示，实现都必须在执行时验证地址。如果结果被阻止，就拒绝操作，并说明主机名和被分类的地址类别。用户可以修正错误的 DNS 记录，却无法有效批准一场不可见的重绑定竞速。

Sallyport 的 vault gate、会话授权和每次调用凭据批准，控制谁可以调用操作，以及何时必须由用户确认。但它的 HTTP 操作路径仍需要同样的执行时目标纪律，因为一个从不向代理提供秘密的保险库，也不应把秘密发送给用户无意信任的 DNS 答案。

不要为了解决这个狭窄的问题而添加自由格式规则引擎。核心行为可以保持确定性：拒绝外部 HTTP 操作中的非公共答案，在连接时解析，将套接字绑定到选定的地址，并在每次新连接时重新评估。带有可测试边界的小规则，比一页没人说得清的例外更容易保持正确。

## 日志必须显示实际使用的目标

只记录主机名的审计记录，无法回答事故后真正重要的问题：套接字到底去了哪里？为每条允许的连接同时保存请求来源和选定的对端地址。对于被拒绝的尝试，保存返回的答案集合和导致拒绝的分类。让时间戳尽量接近连接创建时间，以便调查人员将其与解析器行为对应起来。

不要声称日志无法证明的确定性。如果 HTTP 库只收到一个主机名，审计记录可以说明应用请求了某个主机名，却不能证明数字对端。先修复连接器，再完善审计条目。证据必须来自控制套接字的那一层。

对于防篡改审计轨迹，应将 DNS 决策记录与授权、连接、重定向和响应事件放在同一序列中。Sallyport 从一个写入盲加密哈希链审计日志中生成 Sessions 和 Activity 日志，而 `sp audit verify` 可以离线验证密文上的链条。只有当操作记录包含解析出的目标，而不是一个令人安心却不完整的主机名时，这些机制才有用。

从连接工厂开始。找出所有接受主机名的路径，让它们返回经过验证的数字对端，并让套接字 API 拒绝原始主机名。一旦这个不变量成立，短 TTL 和答案变化就会成为普通测试用例，而不是隐藏在下一次重新连接之后、等待爆发的安全意外。
