
In 2019 a single SSRF request reached the AWS metadata service on a Capital One server, returned the IAM role credentials attached to that instance, and those keys were used to read roughly 100 million customer records out of S3. No malware, no zero-day, no phishing. One web feature that fetched a URL it should not have. That breach is the reason SSRF was promoted to its own slot, A10:2021, in the OWASP Top 10.
This post walks the full arc: how SSRF works, what a metadata credential-theft exploit looks like byte for byte, how blind SSRF and gopher escalation extend the primitive, the filter bypasses that make blocklists pointless, and the network controls that actually stop it. Every technique maps to WSTG-INPV-19, the OWASP test for SSRF.
SSRF is a flaw where a web application fetches a remote resource using a URL you supply, without properly validating where that URL points. Because the request originates from the server, it inherits the server's network position, so you can reach internal services, cloud metadata endpoints, and admin panels firewalled off from the public internet.
Any feature that takes a URL is a candidate: webhook configuration, PDF or image generators that render a page, link previews, document import-from-URL, and proxy or fetch endpoints. The application thinks it is fetching something benign; you point it at http://169.254.169.254/ or http://127.0.0.1:6379/ instead. This is core WSTG-INPV-19 territory and a fixed line in any thorough web application pentesting checklist.
Find every parameter that causes the server to make an outbound request, point it at a host you control to confirm the fetch, then pivot to internal targets and read the differences. Start with a Burp Collaborator hostname and confirm the inbound hit before you touch anything internal.
# 1. Confirm the server actually fetches what you give it
url=http://YOUR-ID.oastify.com/
# Collaborator log shows: DNS + HTTP GET from 13.59.x.x (the app's egress IP)
# 2. Probe localhost and the metadata endpoint
url=http://127.0.0.1:6379/ -> 200, ~12ms (Redis is up)
url=http://127.0.0.1:8500/ -> connection refused, ~3ms
url=http://169.254.169.254/ -> 200, returns 'latest' <-- cloud metadata reachableThe timing and status deltas between an open and closed internal port are the map: 12ms with a 200 versus a 3ms refused tells you what is listening behind the firewall. That differential, not a single 200, is how you turn a confirmed fetch into a network picture.
On cloud hosts the link-local address 169.254.169.254 serves an instance metadata service that can hand out temporary IAM credentials, which turns a read-only SSRF into a path to the whole cloud account. On AWS with the legacy IMDSv1, a single GET returns role credentials with no token required. Here is an image-proxy that takes ?img=, pointed at the metadata service, returning the credential blob through the public endpoint:
GET /proxy?img=http://169.254.169.254/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: app.target.tld
HTTP/1.1 200 OK
Content-Type: image/jpeg <-- the server thinks it returned an image
s3-backup-role
--- escalate to the credential blob ---
GET /proxy?img=http://169.254.169.254/latest/meta-data/iam/security-credentials/s3-backup-role HTTP/1.1
HTTP/1.1 200 OK
{"AccessKeyId":"ASIA...","SecretAccessKey":"wJal...","Token":"IQoJb3...","Expiration":"2026-06-03T14:22:09Z"}On a recent assessment of a media company's upload service, the tell was exactly that Content-Type: image/jpeg wrapping plain JSON. Those keys went straight into aws sts get-caller-identity to prove the role, then aws s3 ls to enumerate the bucket the role could reach. That is the gap between "the parameter hit my Collaborator host" and a critical, credential-theft finding with reproducible evidence. GCP and Azure expose equivalent endpoints (GCP requires a Metadata-Flavor: Google header, raising the bar slightly), and the same primitive is a key check in any cloud penetration test.
Blind SSRF is when the server makes the request but the response is never reflected back to you, so you confirm it out-of-band instead of by reading the body. Point the vulnerable parameter at a Burp Collaborator hostname and watch for the inbound DNS lookup or HTTP request.
A DNS-only hit, with no HTTP follow-up, still proves the server resolved your name, which is enough to confirm blind SSRF even when egress filtering blocks the actual connection. From there you escalate by probing internal hosts and inferring results from response timing or error differences, the same delta-reading you use in the basic case but without the convenience of a returned body.
Naive defenses block the string "127.0.0.1" or "169.254.169.254", which is trivial to evade because there are many ways to write the same address and many protocols to reach the same service. Treat any blocklist as bypassable. The highest-value escalation is gopher://, which lets you send arbitrary raw bytes to a TCP service: an SSRF that can only do GET requests suddenly speaks Redis.
# Alternate encodings of 127.0.0.1 (defeat string blocklists)
http://2130706433/ (decimal) http://0x7f000001/ (hex)
http://127.1/ (short) http://[::1]/ (IPv6 loopback)
# gopher to Redis: write a key by sending the raw RESP protocol
url=gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A...
# Redis on the other side receives:
*1\r\n$8\r\nflushall\r\n*3\r\n$3\r\nset\r\n$1\r\nx\r\n$23\r\n<cron payload>\r\n <-- write-primitive achievedDNS rebinding handles allowlists that validate a hostname before the request: a domain that resolves to an allowed public IP during the check, then re-resolves to 169.254.169.254 when the server actually connects. A 302 redirect from a host you control to the internal target is the simplest bypass of any check that only inspects the first URL.
Automated scanners catch the obvious reflected case but routinely miss blind SSRF, gopher and dict escalation, and the filter bypasses that need a creative second request. A scanner fires 169.254.169.254, sees a block or a timeout, and reports nothing, even though the same parameter is exploitable through DNS rebinding or a redirect. They also struggle with second-order SSRF, where the URL you submit is fetched later by a backend job outside the response cycle the scanner watches.
The common false positive is a 200 that simply echoes your input, or a timeout that is just a dead host; neither proves the server made the request. Confirm out-of-band every time. The false negative that bites teams most is egress filtering masking exploitability: the metadata fetch is blocked, the tool calls it safe, but a gopher:// payload to 127.0.0.1:6379 still talks to a local Redis the firewall never sees. This multi-step pivoting is exactly what continuous, reasoning-driven agentic pentesting handles better than a one-shot scan.
Stop SSRF with positive validation and network controls, not blocklists. Validate that the user-supplied destination resolves to an explicit allowlist of hosts, resolve the name once, pin that resolved IP, and connect to the pinned address so a DNS-rebinding second lookup cannot swap in 169.254.169.254. Reject anything in link-local (169.254.0.0/16), loopback, or private RFC 1918 ranges after resolution, and follow redirects manually, re-validating every hop against the same allowlist.
Then add defense in depth at the layers below the application. Disable URL schemes you do not need (drop gopher, dict, file). Put outbound egress filtering on the application's subnet so even a successful SSRF has nowhere to go. And enforce IMDSv2 on every cloud instance, which is the single highest-value control: the metadata endpoint then requires a PUT to mint a session token before any GET, and most SSRF primitives cannot issue a PUT with the right headers. SSRF maps to A10:2021, so it should be a fixed item in your penetration testing test cases.