阅读需 8 分钟

为会注入凭据的智能体工具设计 SSRF 测试

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

为会注入凭据的智能体工具设计 SSRF 测试

会注入凭据的智能体工具,会把普通的出站 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 工具模型:

  1. 解析提供的绝对 URL,拒绝不支持的 scheme、格式错误的 authority、userinfo,以及超出工具约定的端口。
  2. 通过进程实际使用的解析器解析主机名。收集 A 和 AAAA 应答,而不是只收集开发机器碰巧优先使用的应答。
  3. 对每个候选地址进行分类。如果集合中包含不在获批目标类别内的地址,就拒绝请求,除非连接器能够将连接固定到获批地址。
  4. 连接到获批地址,然后在写入凭据前验证远端对端地址。
  5. 只有验证通过后才构造经过认证的请求。明确设置预期的 Host 标头和 TLS server name。
  6. 将重定向视为一个从解析开始的新请求,而不是一个可以继承信任的延续请求。

这比检查 hostname != "localhost" 要复杂得多,但这是必要的。RFC 8305 描述了客户端如何几乎同时发起 AAAA 和 A 查询,并考虑 IPv6 优先级来尝试地址。只解析 A 记录的测试套件,可能会在出现一个可访问的 AAAA 记录时立即暴露问题。

决策点也决定了测试装置需要记录什么。对每次尝试,记录:

  • 工具收到的 URL
  • 返回给工具的 DNS 应答
  • 连接时选择的地址
  • 接收服务器观察到的地址
  • 每个请求标头,报告中要对测试凭据进行脱敏

不要只断言状态码或抛出了异常。捕获服务器返回 403,证明它收到了请求。超时可能什么也证明不了。你真正需要的断言是:“被阻止的监听器没有观察到任何携带凭据的请求。”

围绕目标而不是主机名构建矩阵

目标矩阵是这项工作的长期产物。每一行都记录一种输入形式、解析器应答、可选的重定向行为以及预期结果。它能避免常见的测试漂移:一个开发者测试 127.0.0.1,另一个测试 10.0.0.1,却没人测试运行时接受的奇怪形式。

先从一份拥有明确判定依据的小型矩阵开始。下面的例子假定 public.test 指向一个被分类为允许的测试装置监听器,而其他所有地址类别都必须在注入凭据前拒绝。

用例提交的 URLDNS 或重定向行为预期结果
公共 IPv4https://public.test/echoA 记录指向测试装置的公共 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 映射的 IPv6http://[::ffff:127.0.0.1]/字面地址在连接前拒绝
混合 DNS 应答https://mixed.test/允许的 A,阻止的 AAAA拒绝,或将连接固定到允许的地址
Rebinding 名称https://rebind.test/第一次允许,下一次阻止拒绝向被阻止的对端发送经过认证的请求
重定向到回环地址https://public.test/to-local302 到 http://127.0.0.1:18080/拒绝第二个请求
重定向到私有 DNShttps://public.test/to-private302 到 https://internal.test/重新解析并拒绝第二个请求
Userinfo 欺骗http://[email protected]/@ 后面的字面主机在连接前拒绝

RFC 1918 只定义了三个 IPv4 私有地址块:10/8、172.16/12 和 192.168/16。测试边界,尤其是 172.15.255.255172.16.0.0172.31.255.255172.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.exampletrusted.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 的一等攻击面

中止异常运行
当智能体行为发生变化时,可从 Sessions 日志中撤销正在运行的会话。

遗漏 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 服务,也不要指向云元数据端点。你需要的是证据,而不是一个令人焦虑的下午。将解析器和所有监听器放在隔离的测试网络中,使用一次性凭据,并让监听器记录自己的地址和收到的标头。

一个简单的测试装置包含四个参与者:

  1. 测试运行器,使用 URL 和一次性凭据引用调用工具。
  2. 可控的 DNS 服务器,能够按预先编排的顺序返回 A 和 AAAA 应答。
  3. 允许的 HTTP fixture,记录成功的认证请求。
  4. 被阻止的 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 和目标

在操作前锁定凭据
使用 Touch ID 锁定保险库,所有智能体操作都会被拒绝。

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。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov