FlawOpen

flawopen.com/Incidents/capital-one-ssrf-imds-july-2019

● Capital One · CWE-918 · July 2019 · 严重
安全研究 · FlawOpen

Capital One SSRF:窃取 AWS IMDSv1 凭据

联邦刑事诉状称,配置错误的 WAF 使 SSRF 能访问 AWS IMDSv1,暴露了临时角色凭证及约 1.06 亿人的 S3 数据。应强制令牌并限制角色权限。[10], [2]

💡 通俗易懂的原理解析 (ELI5)

DOJ 刑事诉状指称的链路可以这样理解:想象一位前台接待员,你告诉她地址后,她就会去取文件。请求转发可能让别人把她引到内部办公室。她在那里可以取得工作负载的临时通行证;通行证只能进入关联角色获准访问的房间。问题在于设计者以为只有员工能到办公室,却忽略了接待员可以被引导过去。[10], [8]

核心概念与专有名词

SSRF
服务端请求伪造(CWE-918):让服务器发出攻击者选定的请求,使该请求带上服务器的网络位置和信任。[9]
Web 应用防火墙
部署在 Web 应用前的过滤器。刑事诉状称,Capital One 配置错误的 WAF 可被诱导将请求转发至内部地址。[10]
IMDSv1
启用 IMDSv1 时,元数据请求不需要令牌。IAM 角色的安全凭证路径会返回与该角色关联的临时凭证。[7], [8]
IMDSv2
IMDSv2 使用通过 PUT 请求获取的会话令牌。设置 HttpTokens=required 可拒绝不带令牌的 IMDSv1 请求;这能阻止许多只能发送普通 GET 的 SSRF 转发,但不能彻底防止 SSRF。[7], [6]
IAM 角色凭据
签发给实例的临时 AWS 密钥。角色能做的事,持有密钥的人在其过期前都能做。[8]

框架映射

事件演进时间线

2019-03-22

根据刑事诉状,3 月 22 至 23 日,嫌疑人利用 SSRF 获取防火墙角色凭证并复制 Capital One 的 S3 数据。[10], [2]

2019-07-17

一名外部研究人员通过 Capital One 的负责任披露计划报告了外泄的数据。[2]

2019-07-29

Capital One 公布事件,FBI 在西雅图逮捕一名嫌疑人。[1]

攻击执行流程分解

步骤 1

防火墙转发伪造的请求

刑事诉状称,配置错误的 WAF 可被诱导向攻击者选择的地址发送请求,包括内部地址。[10]

步骤 2

元数据服务交出凭据

根据诉状,转发请求到达了实例元数据服务中的 IAM 角色凭证路径。由于 IMDSv1 不要求会话令牌,该请求可能返回关联角色的临时凭证。[10], [8]

步骤 3

角色能读的东西太多

诉状称,这些凭证具有该角色的权限,包括列出和读取存有客户数据的 S3 存储桶。[10], [8]

步骤 4

数据被大批带走

被复制的是 2005 年至 2019 年初的信用卡申请数据:美国约 1 亿人、加拿大约 600 万人,其中包括约 14 万个美国社会安全号码和 8 万个关联银行账号。[2]

源代码对比:漏洞与安全实现

这是 Flask 示例代码,并非 Capital One 的配置。主机名允许列表是应用层的主要控制。必需的出口代理必须在连接时针对解析后的地址执行策略,阻止私有和链路本地网段(包括实例元数据服务),并对每个请求及重试均重复检查;请核实代理配置确实如此。代码不会跟随重定向。若应用先单独解析 DNS 再连接,仍会留下 DNS 重绑定竞态。[9], [5], [6]

存在漏洞的实现
import requests
from flask import Flask, request

app = Flask(__name__)


@app.get("/fetch")
def fetch():
    # 服务器会获取调用方提供的任意 URL。
    url = request.args["target"]
    resp = requests.get(url, timeout=5)
    return resp.text
加固后的安全修复
from urllib.parse import urlsplit

import requests
from flask import Flask, abort, request

app = Flask(__name__)
ALLOWED_HOSTS = {"images.example-cdn.com", "api.partner.example"}



EGRESS_PROXY = "http://egress-proxy.internal:3128"
# 配置此代理,使其在 DNS 解析后执行目标策略。

@app.get("/fetch")
def fetch():
    parts = urlsplit(request.args.get("target", ""))
    if parts.scheme != "https" or parts.hostname not in ALLOWED_HOSTS:
        abort(400, "destination not allowed")
    if parts.port not in (None, 443) or parts.username or parts.password:
        abort(400, "destination not allowed")
    resp = requests.get(parts.geturl(), timeout=(2, 5), allow_redirects=False, proxies={"https": EGRESS_PROXY}, trust_env=False)
    if resp.is_redirect:
        abort(502, "upstream redirect refused")
    return resp.text

工程与系统安全加固清单

参考来源