| 失效的假设 | “这个请求是我的服务器发的,所以它去的地方是我说了算” |
| 所属组 | W8 · 服务端出站 |
| 前置 | 第 1 篇:注入的一般形式 的"黑名单必输";第 12 篇:纵向越权 的"默认拒绝" |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、一个再普通不过的功能
产品要做个"贴链接自动显示标题"的功能。用户贴一个 URL,你的服务器去把那个页面取回来,抓出 <title>。
上线,好用。
现在有人贴了这个:
http://127.0.0.1:8080/
对用户的浏览器来说,这个地址指向他自己的电脑,取不到任何东西。但取这个页面的不是他的浏览器,是你的服务器——在你的服务器上,127.0.0.1 是你自己。
这就是 SSRF(Server-Side Request Forgery,服务端请求伪造)的全部:用户提供地址,你的服务器去访问。于是攻击者借到了你服务器的网络位置。
为什么"网络位置"这么值钱
因为内网里有一大堆因为"反正外面进不来"而没做认证的东西:
云厂商的实例元数据服务 一个固定的内网地址,会直接返回这台机器的临时凭据
内部管理后台 "只在内网开放",所以没加登录
数据库 / 缓存 / 消息队列 绑在内网网卡上,没设密码
服务间的内部 API 默认信任来自集群内部的调用
这些东西的安全,全部建立在一条网络边界上——外面的人到不了这里。SSRF 干的事情就一件:在这条边界上凿一个洞,而且是你自己的服务器亲手凿的。
⭐ 换个说法,SSRF 是一次权限借用:攻击者没有拿到你服务器的控制权,只是让它替自己发了一个请求。可"发请求"这个动作会自动带上服务器的网络位置——就像第 13 篇里浏览器自动带上 cookie 一样。能力是自动附加的,攻击者不需要偷任何东西。
二、复现:凭据是怎么被取走的
下面这段起两个真实的服务器:一个演云厂商的元数据服务(只绑在 127.0.0.1,公网根本路由不到),一个演你那个抓标题的功能。
import threading, urllib.request, urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
# ── 内网服务:只绑 127.0.0.1,"外面进不来",所以它不需要认证 ──
class Metadata(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200); self.end_headers()
self.wfile.write(b"AccessKeyId=ASIA-DEMO\nSecretAccessKey=wJalr-DEMO")
def log_message(self, *a): pass
meta = HTTPServer(("127.0.0.1", 0), Metadata)
threading.Thread(target=meta.serve_forever, daemon=True).start()
MPORT = meta.server_address[1]
# ── 你那个"抓网页标题"的功能:用户给 URL,服务器去取 ──
def fetch(url):
try:
return urllib.request.urlopen(url, timeout=2).read().decode().replace("\n", " | ")
except Exception as e:
return f"(取不到:{type(e).__name__})"
print("用户贴的地址在【他自己的浏览器】里是打不开的,")
print("但去取它的是【你的服务器】。于是:")
print()
print(" fetch('http://127.0.0.1:<内网端口>/') ->", fetch(f"http://127.0.0.1:{MPORT}/"))
meta.shutdown()
用户贴的地址在【他自己的浏览器】里是打不开的,
但去取它的是【你的服务器】。于是:
fetch('http://127.0.0.1:<内网端口>/') -> AccessKeyId=ASIA-DEMO | SecretAccessKey=wJalr-DEMO
那台元数据服务从来没被攻破,它的"只绑 127.0.0.1"也没有失效——它确实只接受来自本机的连接,而这个连接确实来自本机。 是你的服务器替攻击者走了这一趟。
⚠️ 注意这里的收获是云上的临时凭据。拿到它之后,攻击者就不再需要 SSRF 了——他可以直接拿着这份凭据去调云 API,读对象存储、列实例、翻数据库快照。SSRF 是入口,凭据才是战果,而这两步之间没有任何额外的门。
三、为什么"把内网地址加进黑名单"必输
几乎所有人的第一反应是:那我把 127.0.0.1、localhost、169.254.169.254 拉黑不就行了。
这是第 1 篇那条结论的又一个现场,而这一次判它输的是操作系统:
import socket
BLOCK = {"127.0.0.1", "localhost", "169.254.169.254"} # 一份看着很合理的黑名单
def blacklist_says(host):
return "拒绝" if host in BLOCK else "放行"
print(f"{'用户填的主机名':<20}{'黑名单判定':<12}{'操作系统真正解析到':<20}")
for h in ["127.0.0.1", "localhost", "127.1", "2130706433", "0x7f000001", "0"]:
ip = sorted({x[4][0] for x in socket.getaddrinfo(h, 80, type=socket.SOCK_STREAM)})[0]
print(f"{h:<22}{blacklist_says(h):<14}{ip:<20}")
用户填的主机名 黑名单判定 操作系统真正解析到
127.0.0.1 拒绝 127.0.0.1
localhost 拒绝 127.0.0.1
127.1 放行 127.0.0.1
2130706433 放行 127.0.0.1
0x7f000001 放行 127.0.0.1
0 放行 0.0.0.0
看后四行。 黑名单说"放行",操作系统把它们全都解析到了本机。
127.1—— IPv4 点分记法允许省略中间的 02130706433—— 就是127.0.0.1那 32 位的十进制写法0x7f000001—— 同一个数的十六进制写法0—— 解析成0.0.0.0,在多数系统上同样指向本机
⭐ 这不是"黑名单列得不够全"。是"主机名"这个字符串和"最终连到哪台机器"之间,隔着一整个解析器——而这个解析器(操作系统的 getaddrinfo)接受的写法比你想列举的多得多。你在比较字符串,它在解析地址。你们比的根本不是同一样东西。
这句话应该很眼熟:第 1 篇 §五 的第 2 条说过,“只要过滤和使用之间还隔着任何一步变换,过滤的结论就失效”。这里那一步变换就是 DNS 解析。
⚠️ 而且这还只是写法层面。攻击者还可以直接控制 DNS:注册一个域名,把它解析到 127.0.0.1。这时候连"看着像内网地址"这个线索都没有了——你的黑名单看到的是一个平平无奇的域名。
四、正确的修复:先解析,判断解析出来的 IP
既然字符串比不了,那就别比字符串。让操作系统先把它解析成 IP,然后判断那个 IP:
import socket, ipaddress
def resolve_and_check(host):
"""先解析,再对【解析出来的 IP】做判断 —— 而不是对用户给的那串字判断"""
ips = sorted({x[4][0] for x in socket.getaddrinfo(host, 80, type=socket.SOCK_STREAM)})
for ip in ips:
a = ipaddress.ip_address(ip)
if a.is_private or a.is_loopback or a.is_link_local or a.is_reserved:
return f"拒绝({host} 解析到 {ip})"
return f"放行({host} -> {ips[0]})"
for h in ["127.1", "2130706433", "0x7f000001", "0", "10.0.0.5", "169.254.169.254",
"93.184.216.34"]:
print(" ", resolve_and_check(h))
拒绝(127.1 解析到 127.0.0.1)
拒绝(2130706433 解析到 127.0.0.1)
拒绝(0x7f000001 解析到 127.0.0.1)
拒绝(0 解析到 0.0.0.0)
拒绝(10.0.0.5 解析到 10.0.0.5)
拒绝(169.254.169.254 解析到 169.254.169.254)
放行(93.184.216.34 -> 93.184.216.34)
上一节那四种写法,现在全部被挡住了——而且我一条规则都没有为它们单独写。因为判断的对象换了:不再是"用户写了什么",而是"这最终指向哪台机器",而后者由 ipaddress 按 IP 地址的分类规则来判定,不由拼写决定。
⭐ 这就是第 1 篇 ③ 白名单那一条在网络层的样子:别去判断输入长什么样,去判断它解析后是什么。 同一句话在第 2 篇是"防护贴在拼接点上",在第 17 篇是"一次解析、全程复用"。
但还差一步:DNS 重绑定
上面的代码有一个真实存在的洞,而且它是这类修复的经典翻车点:
第 1 步 你调 getaddrinfo("evil.com") -> 93.184.216.34 判定:公网,放行 ✓
第 2 步 你调 urlopen("http://evil.com/")
urlopen 会【再解析一次】 -> 127.0.0.1 然后连过去 ✗
攻击者控制着 evil.com 的 DNS,把 TTL 设成 0,让两次解析返回不同的结果。你检查的那个 IP,和你最终连上的那个 IP,不是同一个。
这叫 DNS 重绑定(DNS rebinding),本质是 TOCTOU——检查的时刻和使用的时刻之间,被检查的东西变了。
⚠️ 这一段第一次读会觉得像在抬杠:“我明明检查过了。“检查过了,没错。问题是你检查的是那一次解析的结果,而 urlopen 后来自己又解析了一次——你们俩查的不是同一个东西。这类 bug 在并发和文件系统里也反复出现,见到"先检查、后使用"这个形状就该警惕:中间那段时间里,世界可以变。
修复是把"检查"和"使用”绑在同一次解析上:解析一次,拿到 IP,检查这个 IP,然后直接连到这个 IP(把 Host 头单独带上),不给第二次解析任何机会。
⚠️ 这条比听起来难落地——很多 HTTP 客户端库不让你"连这个 IP、但用那个 Host”。这也是为什么下一节的 ④(出站代理)在工程上往往比在应用里做校验更可靠。
五、它出现过的地方
展开在事件分析里:
2019.07 Capital One 一个 SSRF 打到云实例元数据服务,换出该实例的 IAM 临时凭据,
再用这份凭据读走对象存储里的数据,约 1 亿人受影响
⭐ 这起事件值得记住的地方不是"有人写了个 SSRF",而是后面那条链:SSRF → 元数据服务 → 临时凭据 → 对象存储。链上每一环单独看都"符合设计"——元数据服务本来就该把凭据给本机,凭据本来就该能读存储。是这条链的总长度决定了后果,而没有任何一个团队负责审这条链。
这也是这一篇被排在最后一组的原因:它是把前面几组连起来的那种漏洞。
六、防御
① 能不让用户提供 URL 就不提供 改成让用户从你给的列表里选;或只接受你签发过的地址
② 出站白名单,而不是黑名单 明确列出允许访问的域名/IP 段,其余一律拒绝(默认拒绝)
③ 解析后判断 IP,并连到那个 IP 见 §四:判断解析结果,且把检查和连接绑在同一次解析上
④ 用专门的出站代理 所有出站流量走一个只允许公网目的地的代理,
在网络层而不是应用层执行策略 —— 一处生效,不怕漏
⑤ 给内网服务加认证 撤掉"反正外面进不来"这个前提(元数据服务改用需签名的版本)
⑥ 出站身份最小权限 就算凭据被换走,它能读的东西也要尽量少
判断标准:你的服务端有没有一处,是拿用户给的字符串去发网络请求的? 有,就按 ②③④ 处理;答不上来"有几处",说明这件事还没被盘过。
七、这些防御的边界
① 重定向会把你送回内网。 你校验了初始 URL 指向公网,可对方返回一个 302 Location: http://169.254.169.254/,而你的 HTTP 客户端默认会跟随重定向。必须关掉自动跟随,或对每一跳重新做 §四 的检查。这是 SSRF 修复里最常见的漏点。
② 挡住"读回来"挡不住"发出去"。 就算你不把响应体返回给用户(所谓盲 SSRF),攻击者仍然能用它触发副作用(打内部的写接口)、或用响应时间/错误类型的差别探测内网——这和第 2 篇的盲注是同一个信道,不回显只是减速带。
③ 协议不止 HTTP。 如果你用的客户端支持 file://、gopher://、ftp://,攻击面立刻扩大到读本地文件和向任意 TCP 端口发构造数据。显式白名单允许的协议,别只想着主机名。
④ IPv6 和内网的边界不总是清楚。 is_private 这类判断覆盖标准私有段,但你自己的 VPC 网段、容器网络、服务网格地址可能落在公网段里却仍属内网。白名单(②④)比"排除已知私有段"可靠。
关于时效:SSRF 的机制不随时间变。会变的是云厂商元数据服务的默认防护——业界已经在往"必须带令牌才能取凭据"的版本迁移,这直接削弱了最经典的那条利用链。你用的是哪一版、默认开没开,要按当前文档核实;本篇不给"某云默认安全"的结论。
八、本篇小结
SSRF:用户提供地址,你的服务器去访问 —— 攻击者借走了你服务器的【网络位置】。
和 CSRF 对照:CSRF 借的是浏览器自动带的 cookie,SSRF 借的是服务器自动带的网络位置。
两者都是"能力自动附加",攻击者不需要偷任何东西。
⭐ 值钱的是网络位置,因为内网里全是"反正外面进不来"所以没做认证的东西
云元数据服务是其中最值钱的一个:它直接发临时凭据
⭐ 拿主机名做黑名单必输 —— 判它输的是操作系统:
127.1 / 2130706433 / 0x7f000001 全部解析到 127.0.0.1
字符串和"连到哪台机器"之间隔着一个解析器(呼应第 1 篇"过滤跑在变换之前")
⭐ 修复:先解析 -> 判断解析出来的 IP -> 直接连那个 IP(绑住检查与使用,防 DNS 重绑定)
工程上更可靠的是出站代理:在网络层一处执行,不怕应用里漏一处
边界:重定向会把你送回内网(必须逐跳检查) · 不回显只是减速带 ·
file:// gopher:// 要显式白名单 · 自建内网段未必落在 is_private 里
思考题
- §二 里元数据服务"只绑 127.0.0.1"这条防护从头到尾都生效了,攻击却成功了。用一句话说清:这条防护保护的到底是什么,它假设了什么?
- §三 的四种写法都被操作系统解析到了同一个地址。为什么"再多列几种写法"仍然解决不了?把它和第 1 篇 §二 那个
1 OR 1=1放一起——两处"黑名单必输"的原因是同一个吗? - §四 的 DNS 重绑定是一个 TOCTOU。在这一整个系列里再找出两个"检查时和使用时不是同一个东西"的例子(提示:第 1 篇的解码、第 17 篇的两个解析器)。
- 为什么说"出站代理"比"在应用里做校验"更可靠?用第 2 篇"防护贴在拼接点上、而点很多"的论证来回答。
- §七① 说重定向是最常见的漏点。设计一个测试用例,能在 CI 里稳定地发现"忘了关自动跟随重定向"这个 bug。
- 盲 SSRF(响应不回显)还剩下哪些可用信道?把它和第 2 篇 §三 的盲注对照,两者利用的是不是同一类"可观测差异"?
- Capital One 那条链是 SSRF → 元数据 → 凭据 → 存储。如果只能加固其中一环,你会选哪一环,为什么?(提示:考虑每一环加固后,攻击者还剩什么。)
相关:第 1 篇:注入的一般形式 · 第 13 篇:CSRF · 第 17 篇:解析器差异 · 第 20 篇:配置与暴露 · Web 安全机制篇 · 事件分析