FlawOpen

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

● Capital One · CWE-918 · July 2019 · Critical
FlawOpen Security Research

Capital One SSRF: AWS IMDSv1 Credential Theft

The DOJ complaint alleged a misconfigured WAF let SSRF reach AWS IMDSv1 and expose temporary role credentials and S3 data affecting about 106M. Require tokens and scope roles.[10], [2]

💡 Plain English Explainer (ELI5)

The DOJ criminal complaint alleged a chain like this: Imagine a receptionist who fetches documents from addresses you provide. A request relay lets someone point that receptionist toward an internal office. There, the receptionist can obtain a temporary access badge for the workload; the badge opens only the rooms allowed by that workload's role. The mistake was trusting that only staff could reach the office, even though the receptionist could be directed there.[10], [8]

Core Concepts & Subsystem Terms

SSRF
Server-side request forgery (CWE-918): making a server send a request of the attacker's choosing, so the request carries the server's network position and trust.[9]
Web application firewall
A filter in front of a web application. The criminal complaint alleged Capital One's misconfigured WAF could relay requests to internal addresses.[10]
IMDSv1
When IMDSv1 is enabled, metadata requests do not require a token. The IAM security-credentials path for a role returns temporary credentials associated with that role.[7], [8]
IMDSv2
IMDSv2 uses a session token obtained with a PUT request. Set HttpTokens=required to reject tokenless IMDSv1 requests; this blocks many SSRF relays that can issue only a plain GET, but it is not a complete SSRF defense.[7], [6]
IAM role credentials
Temporary AWS keys issued to an instance. Whatever the role may do, anyone holding the keys may also do until they expire.[8]

Framework mappings

Incident Timeline

2019-03-22

According to the criminal complaint, over 22–23 March the intruder used SSRF to obtain the firewall role credentials and copy Capital One S3 data.[10], [2]

2019-07-17

An external researcher reports the exposed data through Capital One's responsible disclosure programme.[2]

2019-07-29

Capital One announces the incident and the FBI arrests a suspect in Seattle.[1]

Step-by-Step Attack Flow

Step 1

The firewall relays a crafted request

The criminal complaint alleges that a misconfigured web application firewall could be made to send requests to attacker-chosen addresses, including internal ones.[10]

Step 2

The metadata service hands over credentials

According to the complaint, the relay reached the instance metadata service's IAM role credential path. Because IMDSv1 did not require a session token, that request could return temporary credentials for the attached role.[10], [8]

Step 3

The role can read far too much

The complaint alleges the credentials carried the role's permissions, including listing and reading S3 buckets that held customer data.[10], [8]

Step 4

Data leaves in bulk

Credit card application data from 2005 to early 2019 was copied: about 100 million people in the US and 6 million in Canada, including about 140,000 US Social Security numbers and 80,000 linked bank account numbers.[2]

Source Code: Flaw vs. Secure Implementation

Illustrative Flask code, not Capital One's configuration. The hostname allowlist is the primary application control. The required egress proxy must enforce destination policy against the resolved address at connection time, deny private and link-local ranges including instance metadata, and apply the same checks to every request and retry; verify that behavior in the proxy configuration. Redirects are not followed. A separate resolve-then-connect check in the application would leave a DNS-rebinding race.[9], [5], [6]

UNPATCHED FLAW
import requests
from flask import Flask, request

app = Flask(__name__)


@app.get("/fetch")
def fetch():
    # The server fetches whatever URL the caller supplies.
    url = request.args["target"]
    resp = requests.get(url, timeout=5)
    return resp.text
HARDENED SECURE PATCH
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"
# Configure this proxy to enforce destination policy after DNS resolution.

@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

Engineering & System Hardening Checklist

Sources