为会注入凭据的智能体工具设计 SSRF 测试
针对会注入凭据的智能体工具,SSRF 测试需要一份目标矩阵,在认证前捕获回环地址、私有范围、DNS rebinding、重定向和 IPv6 问题。

会注入凭据的智能体工具,会把普通的出站 HTTP 请求变成一道边界判断。如果工具接受智能体提供的目标,验证了一个看起来正常的主机名,然后让 HTTP 客户端跟随 DNS 和重定向前往任何地方,那么它就把凭据交给了攻击者选择的服务。
解决办法不是在 URL 验证器中贴上一份更长的拒绝列表。你需要一份目标矩阵,覆盖整个请求生命周期:解析、DNS 解析、地址选择、建立连接、处理重定向,最后才是注入凭据。测试必须观察实际收到请求的对端,因为秘密最终就是发送到了那里。
必须在批准目标后注入凭据
工具应先判断自己是否可以联系某个目标,再附加任何认证材料。这意味着 bearer token、基本认证凭据、自定义标头、请求签名、客户端证书和 SSH 身份,都应放在目标检查之后。
团队经常把检查放错位置。他们验证原始 URL 字符串,构造带有 Authorization 标头的请求,然后调用一个开启自动跟随重定向的便捷 HTTP helper。接着,helper 解析名称、选择 IPv6 或 IPv4、跟随 Location 标头,还可能以验证代码完全没有观察到的方式复用标头或连接状态。
这种设计有一个优点:很容易编写。问题是,它把危险的决定交给了一个不知道请求是否携带凭据的组件。
把两个决定分开:
- 这个 URL 在语法上是否符合工具要求?
- 这个具体的网络目标是否获准建立经过认证的连接?
第一个决定负责解析 scheme、host、port、path、userinfo 和百分号编码。第二个决定负责对解析出的地址进行分类,并控制实际连接。主机名白名单可以为两个决定提供信息,但不能代替第二个决定。
RFC 3986 清楚说明了解析问题。authority 组件的形式是 [userinfo@]host[:port]。一个字符串在 @ 前面看起来包含受信任的主机名,实际却可能指向 @ 后面的 IP 地址。RFC 3986 在讨论具有误导性的 URI authority 字符串时,甚至使用了这种构造。不要自己拆分 URL,也不要在原始字符串中搜索受信任名称。请使用一个了解标准的解析器,若工具没有正当理由使用 userinfo,就拒绝目标中的 userinfo,然后读取解析后的 host 字段。
这个安全不变量足够简单,也很容易转化为测试:
在批准该请求所连接的对端和请求 authority 之前,工具不得发送任何经过认证的字节。
只有当请求行或 Host 标头本身会泄露秘密时,它们才算“经过认证的字节”。在大多数 HTTP 场景中,关键界限是携带凭据的标头或签名载荷。如果路径、查询字符串或请求体中包含 capability URL、租户标识符或其他敏感值,就应采用更严格的标准。
编写测试用例前先确定决策点
在明确 SSRF 控制到底在哪个位置做出允许或拒绝决定之前,无法真正测试它。“请求之前”这个说法太模糊。一个请求有多个时刻,目标可能在这些时刻发生变化。
把下面的顺序作为注入凭据的 HTTP 工具模型:
- 解析提供的绝对 URL,拒绝不支持的 scheme、格式错误的 authority、userinfo,以及超出工具约定的端口。
- 通过进程实际使用的解析器解析主机名。收集 A 和 AAAA 应答,而不是只收集开发机器碰巧优先使用的应答。
- 对每个候选地址进行分类。如果集合中包含不在获批目标类别内的地址,就拒绝请求,除非连接器能够将连接固定到获批地址。
- 连接到获批地址,然后在写入凭据前验证远端对端地址。
- 只有验证通过后才构造经过认证的请求。明确设置预期的 Host 标头和 TLS server name。
- 将重定向视为一个从解析开始的新请求,而不是一个可以继承信任的延续请求。
这比检查 hostname != "localhost" 要复杂得多,但这是必要的。RFC 8305 描述了客户端如何几乎同时发起 AAAA 和 A 查询,并考虑 IPv6 优先级来尝试地址。只解析 A 记录的测试套件,可能会在出现一个可访问的 AAAA 记录时立即暴露问题。
决策点也决定了测试装置需要记录什么。对每次尝试,记录:
- 工具收到的 URL
- 返回给工具的 DNS 应答
- 连接时选择的地址
- 接收服务器观察到的地址
- 每个请求标头,报告中要对测试凭据进行脱敏
不要只断言状态码或抛出了异常。捕获服务器返回 403,证明它收到了请求。超时可能什么也证明不了。你真正需要的断言是:“被阻止的监听器没有观察到任何携带凭据的请求。”
围绕目标而不是主机名构建矩阵
目标矩阵是这项工作的长期产物。每一行都记录一种输入形式、解析器应答、可选的重定向行为以及预期结果。它能避免常见的测试漂移:一个开发者测试 127.0.0.1,另一个测试 10.0.0.1,却没人测试运行时接受的奇怪形式。
先从一份拥有明确判定依据的小型矩阵开始。下面的例子假定 public.test 指向一个被分类为允许的测试装置监听器,而其他所有地址类别都必须在注入凭据前拒绝。
| 用例 | 提交的 URL | DNS 或重定向行为 | 预期结果 |
|---|---|---|---|
| 公共 IPv4 | https://public.test/echo | A 记录指向测试装置的公共 fixture | 允许并发送测试凭据 |
| IPv4 回环 | http://127.0.0.1:18080/ | 字面地址 | 在连接前拒绝 |
| IPv4 回环变体 | http://127.1:18080/ | 取决于解析器的简写形式 | 拒绝,或分类为回环地址,绝不能允许 |
| RFC 1918 私有地址 | http://10.20.30.40/ | 字面地址 | 在连接前拒绝 |
| RFC 1918 私有地址 | http://172.20.30.40/ | 字面地址 | 在连接前拒绝 |
| RFC 1918 私有地址 | http://192.168.20.40/ | 字面地址 | 在连接前拒绝 |
| IPv6 回环 | http://[::1]:18080/ | 字面地址 | 在连接前拒绝 |
| IPv6 链路本地 | http://[fe80::1]/ | 字面地址 | 在连接前拒绝 |
| IPv4 映射的 IPv6 | http://[::ffff:127.0.0.1]/ | 字面地址 | 在连接前拒绝 |
| 混合 DNS 应答 | https://mixed.test/ | 允许的 A,阻止的 AAAA | 拒绝,或将连接固定到允许的地址 |
| Rebinding 名称 | https://rebind.test/ | 第一次允许,下一次阻止 | 拒绝向被阻止的对端发送经过认证的请求 |
| 重定向到回环地址 | https://public.test/to-local | 302 到 http://127.0.0.1:18080/ | 拒绝第二个请求 |
| 重定向到私有 DNS | https://public.test/to-private | 302 到 https://internal.test/ | 重新解析并拒绝第二个请求 |
| Userinfo 欺骗 | http://[email protected]/ | @ 后面的字面主机 | 在连接前拒绝 |
RFC 1918 只定义了三个 IPv4 私有地址块:10/8、172.16/12 和 192.168/16。测试边界,尤其是 172.15.255.255、172.16.0.0、172.31.255.255 和 172.32.0.0。粗糙的前缀检查经常会阻止整个 172/8,导致合法公共端点无法使用,或者只阻止 172.16/16,从而漏掉大部分已分配的私有范围。
不要把这张表误认为适用于所有生产环境的通用策略。有些内部工具确实应当能够联系特定的私有服务。如果这是产品要求,就为该服务配置明确规则,限制主机名、端口和身份检查。不要把“公司办公室里的私有地址没问题”设为所有智能体选择目标的默认规则。
URL 解析测试会在 DNS 之前发现错误
解析器必须识别连接器实际使用的主机。在某个测试发现一个组件和另一个组件以不同方式规范化输入之前,这一点听起来显而易见。
把下面这些用例放入只测试解析器的套件中。它们不应执行 DNS 查询,也不应打开网络套接字:
http://[email protected]/admin
http://127.0.0.1.nip.example/
http://[::1]/
http://[::ffff:7f00:1]/
http://0177.0.0.1/
http://2130706433/
http://127.0.0.1%2f.example/
http://public.test:[email protected]/
http://public.test./
预期结果不总是“这个精确字符串必须被解析为回环地址”。URI 和 URL 库对旧式数字 IPv4 格式、区域标识符和无效百分号编码的处理各不相同。测试应描述安全属性,而不是强行规定具体解析结果:如果运行时接受该输入,并且会将它连接到被阻止的地址,工具就必须拒绝。如果运行时直接拒绝输入,也完全可以。
这一点很重要,因为许多团队会先编写自定义主机过滤器,再把未修改的 URL 交给另一个库。过滤器可能把 2130706433 看成一个不熟悉的注册名称,而连接器却把它解释为 127.0.0.1。此时你测试了两个解析器,却信任了更安全的那个答案。
验证和连接应使用同一个解析后的表示。如果无法做到,就让最终连接器报告它选择的数字对端,然后在凭据离开进程前做最后一次地址类别检查。
还应测试主机名规范化。在普通 DNS 使用中,public.test. 末尾的点与 public.test 指向同一个 DNS 名称,但简单的白名单经常比较原始字符串。国际化主机名又增加了一个可能产生分歧的地方。只进行一次转换,将名称变成运行时的规范表示,然后按标签边界匹配 DNS 名称。像 endsWith("trusted.example") 这样的后缀检查,会接受 untrusted.example 和 trusted.example.attacker.test,但它们都不是受信任的子域名。
DNS rebinding 测试必须强制发生第二次查询
DNS rebinding 是一种时序问题。初始查询返回一个可接受的地址,之后对同一名称的查询却返回回环地址、私有地址或其他被阻止的目标。如果验证和连接没有使用同一个解析结果,攻击者就能通过第一次检查,再操控第二次结果。
一个有用的测试装置需要一个由你控制的小型权威 DNS 服务器和两个 HTTP 监听器。一个监听器是允许的 fixture,另一个是被阻止的 fixture,它会记录自己是否收到了测试 Authorization 标头。让 DNS 服务器为 rebind.test 按下面的顺序返回应答:
query 1: rebind.test. A 198.51.100.20
query 2: rebind.test. A 127.0.0.1
query 1: rebind.test. AAAA 2001:db8::20
query 2: rebind.test. AAAA ::1
使用只在测试装置中有效的地址,并根据需要在测试网络中映射允许的监听器。这个例子里的公共地址文档只是 fixture 的标签,不是测试应当在互联网中访问的端点。
现在运行两种变体。第一种,让工具自行执行验证查询,然后调用一个会再次解析名称的 HTTP 库。如果实现存在你要查找的间隙,被阻止的监听器应该会收到凭据。第二种,将连接路径配置为使用已经批准的解析地址,只把原始主机名保留给 Host 和 TLS 名称验证,并断言被阻止的监听器什么也没有观察到。
缓存可能会掩盖问题。解析器缓存、连接池或操作系统缓存,可能让第二次应答根本没有发生。测试装置应提供缓存控制,在每次运行之间关闭空闲连接,必要时每次运行使用新的主机名,并记录每一次 DNS 查询。如果 rebinding 测试通过时没有确认发生过两次不同的查询,那它就没有测试 rebinding。
不要通过信任 DNS TTL 来解决问题。TTL 是缓存提示,不代表名称在验证和连接之间仍然安全。代码必须把批准的地址传递给连接过程,或者在套接字打开后验证对端。
重定向是独立的目标决策
重定向会改变目标。它可能改变 scheme、host、port、path,或者同时改变这四者。把重定向当作受信任的延续请求,会让一个表面安全的公共 URL 变成向 127.0.0.1、实例元数据服务、路由器或内部控制平面发出的请求。
在携带凭据的请求所使用的传输层中,关闭自动跟随重定向。读取重定向响应,设置适度的重定向次数上限,根据库的 URL 解析规则,基于当前 URL 解析 Location 值,然后再次执行完整的目标决策。
重定向 fixture 至少应包含以下行为:
- 公共 URL 返回一个指向 IPv4 回环字面地址的 302。
- 公共 URL 返回一个指向解析为私有 IPv4 地址的主机名的 307。
- 公共 URL 返回相对 Location,例如
/next。经过正常解析后,它应继续使用已经批准的 authority。 - 公共 URL 返回 scheme-relative Location,例如
//other.test/path。这会改变 authority,需要进行完整验证。 - 公共 URL 返回一条重定向链,第一跳允许,后续某一跳被阻止。
状态码很重要。301、302 或 303 可能促使客户端把非 GET 方法改成 GET。307 或 308 设计上会保留方法和请求体。当涉及签名 POST 或 bearer 认证的写入操作时,不要依赖客户端库的默认处理。对每个重定向 fixture,记录方法、请求体哈希、Host 标头和 Authorization 标头。
当 authority 改变时删除 Authorization 是很好的防护措施,但这不代表可以跳过目标验证。没有 Authorization 标头的请求仍可能在路径中携带签名 URL、客户端证书、cookie 或敏感请求体。同一主机的重定向也可能在 DNS 发生变化后,从普通路径指向本地服务。
这里的测试应该故意保持简单。让被阻止的重定向监听器返回 200。如果断言只是期待客户端报错,那么一个会跟随重定向的客户端可能成功发起 SSRF 请求,而你的测试却会因为错误的理由把它判为通过。应断言监听器捕获到的请求数量,以及捕获到的凭据状态。
IPv6 是 SSRF 的一等攻击面
遗漏 IPv6 通常是无意造成的。开发者测试了 127.0.0.1,阻止了 RFC 1918 范围,然后发布了把 [::1] 当成奇怪主机名的代码。现代客户端可以同时查询 AAAA 和 A 记录,即使 IPv4 测试结果正常,IPv6 路由仍可能获胜。
RFC 4291 将 ::1/128 定义为 IPv6 回环地址,将 ::/128 定义为未指定地址。它还定义了 fe80::/10 范围内的链路本地单播地址。这些地址类别不应意外成为智能体选择的、经过认证的目标。
明确测试下面这些类别:
| 地址类别 | 示例 | 默认测试预期 |
|---|---|---|
| 未指定 | [::] | 拒绝 |
| 回环 | [::1] | 拒绝 |
| 链路本地 | [fe80::1] | 拒绝 |
| 唯一本地 | [fc00::1]、[fd12:3456::1] | 除非明确批准,否则拒绝 |
| 组播 | [ff02::1] | 拒绝 |
| IPv4 映射的回环地址 | [::ffff:127.0.0.1] | 拒绝 |
| IPv4 映射的私有地址 | [::ffff:192.168.1.10] | 拒绝 |
地址分类必须基于二进制地址,而不是字符串写法。同一个地址可以使用压缩或展开的 IPv6 形式表示。针对 ::1 的字符串比较会漏掉 0:0:0:0:0:0:0:1。先解析成真正的 IP 类型,这一步虽然不起眼,却能防止这一类错误。
也应明确处理作用域标识符。像 [fe80::1%25en0] 这样的输入,在百分号编码之后包含一个接口区域。大多数工具都应拒绝来自智能体输入的带区域字面目标,而不是尝试判断哪个本地接口安全。如果产品确实有合法的本地网络使用场景,就把接口选择作为由用户控制的明确配置,绝不要让它成为智能体提供的 URL 细节。
混合的 A 和 AAAA 应答需要明确决定。如果解析器返回一个允许的 IPv4 地址和一个被阻止的 IPv6 地址,最安全的默认行为是拒绝。如果为了可用性必须使用允许的应答,连接器就必须将套接字固定到该地址,并且之后不能再把主机名交回解析器。
“我们偏好 IPv4”不是控制措施。网络库、操作系统和连接竞争机制都可能做出其他选择。
在封闭测试装置中运行矩阵
不要把 SSRF 测试指向笔记本上真实运行的 localhost 服务,也不要指向云元数据端点。你需要的是证据,而不是一个令人焦虑的下午。将解析器和所有监听器放在隔离的测试网络中,使用一次性凭据,并让监听器记录自己的地址和收到的标头。
一个简单的测试装置包含四个参与者:
- 测试运行器,使用 URL 和一次性凭据引用调用工具。
- 可控的 DNS 服务器,能够按预先编排的顺序返回 A 和 AAAA 应答。
- 允许的 HTTP fixture,记录成功的认证请求。
- 被阻止的 HTTP fixture,记录任何连接或请求,并将其作为测试失败。
让被阻止的 fixture 返回一个能让本地日志明显显示泄露的响应体,但绝不要打印完整 token。例如,它可以返回对端地址、请求方法、Host 标头,以及一个表示是否存在 Authorization 的布尔值。运行器将这些证据与目标矩阵中的预期行进行比较。
结果格式应足够简单,便于在 CI 中检查:
case=redirect_to_loopback
submitted=https://public.test/to-local
resolved=198.51.100.20
redirect=http://127.0.0.1:18080/
decision=deny
allowed_requests=0
blocked_connections=0
blocked_credentials=0
失败时应保留相同字段,并增加实际对端。这样,模糊的回归就会变成可以立即处理的报告:
case=rebind_ipv6
submitted=https://rebind.test/export
validation_answer=2001:db8::20
connected_peer=::1
blocked_credentials=1
result=FAIL
这份报告会立即指出问题:代码批准了一个 DNS 应答,却连接到了另一个地址。它还会告诉你凭据是否跨越了边界,而这正是你需要关心的风险。
如果工具支持代理配置,就把代理相关行加入矩阵。HTTP 代理会改变 TCP 连接的去向,而 CONNECT 仍可能让代理访问攻击者选择的目标。明确代理本身是否属于受信任的传输层,以及工具是否验证最终 authority。只检查代理地址的代理测试,可能会把一个能够访问内部目标的开放中继错误地认证为安全。
在实现中分开 authority 和目标
TLS 很容易让这个问题变得混乱。为了安全地连接到某个主机名,工具可能需要将套接字打开到一个已批准的数字地址,同时使用原始的获批主机名作为 TLS server name 和 HTTP Host 标头。这些是三个不同的字段,各自承担不同职责。
数字对端回答的是:“这个套接字要连接到哪里?”TLS server name 回答的是:“服务器必须证明哪个证书身份?”Host 标头回答的是:“这个 HTTP 请求针对哪个 authority?”实现应明确保留这三个值,不要让 HTTP 便捷 API 从一个可变的 URL 字符串中自行推断。
这种分离也会暴露一个仍然流行的错误建议:解析一次,然后把 URL 中的主机名替换成数字地址。这样可能避免第二次 DNS 查询,却会破坏 TLS 证书验证、虚拟主机和签名请求方案。开发者经常因此关闭证书验证,或削弱主机检查。这比原始漏洞更糟糕。
正确做法是使用支持以下行为的传输层:连接到批准的地址,同时保留对原始主机名的严格 TLS 验证。握手完成后,确认套接字对端就是获批地址。如果运行时无法提供这些控制能力,就不要声称它能防止经过认证的智能体请求遭遇 DNS rebinding。应把这一限制写进产品边界,并避免向智能体选择的目标注入凭据。
SSH 虽然没有 HTTP 重定向,但结构相同。解析并分类请求的主机,将连接固定到批准的地址,并根据预期的主机身份验证主机密钥。主机密钥提示或宽松的 known-hosts 规则都不是目标策略。
Sallyport 将凭据保存在加密保险库中,并自行执行 HTTP 和 SSH 操作,而不是把秘密材料交给智能体。只有当操作层在使用存储的凭据之前应用目标控制,这个边界才真正成立。
让矩阵成为发布门禁,而不是安全文档
目标矩阵只有在针对即将发布的同一条请求路径运行时才有价值。isPrivateIp() 的单元测试无法测试 HTTP 客户端的解析器、重定向代码、代理行为或连接池。保留单元测试,但每当你更新传输库、URL 解析器、解析器配置或凭据注入代码时,都必须运行测试装置套件。
修复漏洞时添加新行。不要把一次痛苦的事故压缩成“改进 SSRF 验证”这样的宽泛表述。保留精确的输入、DNS 顺序、重定向响应以及凭据不应出现的预期结果。未来的维护者需要以可执行的形式看到这些经验。
按类别检查失败结果。解析差异说明存在重复的 URL 处理。被阻止的监听器看到了 TCP 连接,却没有看到请求,可能意味着你验证得太晚,即使没有 token 泄露。被阻止的监听器看到 Authorization 标头,则意味着安全不变量已经失效,发布流程应立即停止。
这项工作的价值在于,它改变了团队提出问题的方式。不要再问主机名看起来是否来自外部。要问哪个对端收到了经过认证的请求,它是如何被选中的,以及测试能否证明答案。如果你无法从测试输出中回答这些问题,工具仍然存在 SSRF 盲区。
常见问题
仅靠主机名白名单就能防止智能体工具中的 SSRF 吗?
不能。主机名只是解析过程的输入。一个看似无害的主机名,在客户端连接时可能解析到本地或私有地址。测试时应检查解析得到的地址集合和实际连接的对端,而不只是智能体提供的原始文本。
HTTP 客户端应该在重定向时转发凭据吗?
将每次重定向都视为一个新的目标请求。重新验证新的 URL,再次解析,并执行相同的目标判断。只有通过检查后才附加凭据。如果重定向改变了 authority,删除 Authorization 标头是合理的防护措施,但仍然不够。
SSRF 测试应该阻止哪些 IPv6 地址?
应该。阻止或明确分类 IPv6 回环地址(::1)、未指定地址(::)、链路本地地址(fe80::/10)、唯一本地地址(fc00::/7)、组播地址,以及 IPv4 映射的 IPv6 形式。测试套件如果只测试点分十进制 IPv4 地址,就会留下很大的盲区。
如何安全地测试 DNS rebinding?
DNS rebinding 发生在这样的情况下:名称在较早的解析中通过了检查,但在之后,通常是在连接前不久,返回了另一个地址。使用可控的 DNS 测试装置,先返回允许的地址,再返回被阻止的地址,然后确认工具不会向被阻止的对端发送带凭据的请求。
可以只验证一次 DNS,然后让 HTTP 库正常连接吗?
不能。检查一次解析结果后,再让库自行解析主机名,会形成检查时与使用时之间的间隙。解析名称、分类所有候选地址,并使用批准的地址建立连接,或者在连接后验证对端地址,再发送凭据。
什么是 SSRF 测试中的目标矩阵?
SSRF 目标矩阵是一份清单,记录 URL 输入、DNS 应答、重定向目标和预期安全决策。它能把“我们会阻止私有 IP”这类模糊说法变成可重复的测试,并捕获解析器错误、其他地址格式以及库行为变化。
只读型智能体工具也需要 SSRF 防护吗?
如果工具会附加秘密、客户端证书、签名请求,或任何权限超出智能体本身的凭据,就需要。仅仅读取公开文档的请求影响范围较小,但仍应避免意外访问本地服务。
DNS 同时返回公共地址和私有地址时应该怎么办?
默认应拒绝。如果解析器返回了允许和被阻止的混合地址,普通 HTTP 客户端可能因为地址排序或 IPv6 优先级而选择被阻止的地址。注入凭据的工具应拒绝混合结果,除非它能控制连接到哪个获批地址。
阻止 RFC 1918 地址范围就足以防御 SSRF 吗?
不能。私有地址范围只是其中一类。回环地址、链路本地地址、运营商级 NAT 地址空间、文档示例地址、未指定地址、组播地址、IPv4 映射的 IPv6 地址、重定向以及 DNS 变化,都需要明确处理。
怎样证明智能体工具从未向被阻止的目标发送凭据?
使用无害的捕获服务器,记录远端对端、请求路径、Host 标头以及是否收到 Authorization 标头。放入一个只在测试装置中有效的一次性测试 token,不要赋予它任何其他访问权限。好的失败报告应说明 token 被哪个目标收到,而不只是说明请求返回了 200。