Triaging SSRF Reports: Proving the Server Made the Request
How to triage SSRF reports: confirming the server actually fetched the URL, blind vs full read, cloud metadata and IMDSv2, internal port scans, and severity.
The first thing to establish in any SSRF report is whether a server made the request at all. A large share of submissions labeled SSRF are a URL parameter that gets echoed back in a response, a link rendered in HTML, or a fetch the reporter's own browser performed. The out-of-band callback in the screenshot looks identical in all four cases if you only glance at the hit count. What separates them is where the request came from and what the requester could reach.
SSRF is also the class where severity spans the widest range for identical-looking mechanics. The same vulnerable URL parameter is a Critical when it returns AWS credentials and an Informative when the fetcher lives in an isolated egress-only subnet with a deny-by-default proxy in front of it. Nothing in the report title tells you which one you have.
Prove the server made the request
Set up an out-of-band listener (Collaborator, interactsh, or a box you control) and inspect the callback properties, not just the fact that a callback arrived.
- Source IP. If the hit comes from the reporter's residential ISP or a VPN exit node, the reporter's browser made the request. If it comes from a cloud provider range that matches the target's egress, a server made it.
- User-Agent.
Go-http-client/2.0,python-requests/2.31.0,Apache-HttpClient,curl/8.x,okhttp, or a headless Chrome build string all point server-side. A normal desktop Chrome UA withSec-Fetch-SiteandAccept-Languageheaders points at a real browser. - DNS hit with no HTTP hit. This is the trap. Many URL validators resolve the hostname to check it against an internal-IP denylist, then reject the URL. That resolution produces a DNS lookup on your listener and no request at all. A DNS-only callback is weak evidence, and on its own it does not prove a fetch occurred.
POST /api/v1/webhooks/test HTTP/1.1
Host: app.example.com
Content-Type: application/json
{"url":"http://abc123.oast.site/probe"}
If abc123.oast.site logs an HTTP GET from 52.x.x.x with User-Agent: Go-http-client/1.1, the server fetched it. If it logs only a DNS A-record query, keep digging: the validator may be resolving and rejecting.
Timing is a secondary signal. If a routable-but-blackholed IP produces a consistent 10 second response and your listener returns in 200ms, something is opening sockets server-side.
Blind, partial, and full read
The value of an SSRF is mostly determined by how much of the internal response comes back to the attacker.
Full read means the response body is returned. The attacker gets to browse internal HTTP as if they were on the network. This is the version that reads metadata credentials, dumps internal admin panels, queries an unauthenticated Elasticsearch, and pulls files with file:///etc/passwd if the fetcher accepts the scheme.
Partial read covers everything in between, and it is worth more than most reporters realize. Distinct error strings for connection-refused vs connection-timeout vs HTTP-200 give you a port scanner. A parser error that only fires on valid JSON tells you the internal service speaks JSON. Response length, status code, and content-type leakage are all partial reads. An SSRF that returns 502 upstream returned 401 has told you an authenticated service exists at that address.
Blind means no response differential at all, only the out-of-band callback. Blind SSRF is still a bug, but the impact case has to be built from what the attacker can do without seeing responses: hitting internal endpoints that perform actions on GET, reaching an internal service that accepts unauthenticated writes, or spraying a known-vulnerable internal appliance.
Always test the redirect path, because it converts a lot of restricted SSRF into unrestricted SSRF. Point the parameter at a host you control that responds 302 Location: http://169.254.169.254/latest/meta-data/. Many fetchers validate the submitted URL and then follow redirects with no revalidation.
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
If the callback logs show the fetcher following that, the hostname allowlist is decorative.
Cloud metadata: IMDSv1 vs IMDSv2
The metadata endpoint at 169.254.169.254 is the standard escalation target, and whether it works now depends almost entirely on the instance metadata service version.
IMDSv1 answers a plain GET with no headers. A basic GET-only SSRF is enough to walk /latest/meta-data/iam/security-credentials/<role-name> and retrieve an AccessKeyId, SecretAccessKey, and Token. If the reporter pastes those (redacted) and you can confirm they belong to a real instance role, the finding is Critical and you should be escalating to the program's on-call, not queueing it behind three XSS reports.
IMDSv2 requires a session token obtained with a PUT that carries a custom header:
PUT /latest/api/token HTTP/1.1
Host: 169.254.169.254
X-aws-ec2-metadata-token-ttl-seconds: 21600
A GET-only SSRF cannot do this. It cannot set the method, and it cannot set an arbitrary request header. IMDSv2 also defaults the response hop limit to 1, which kills the container-escape variant where the SSRF originates inside a pod on the host. So when a reporter claims metadata access on an IMDSv2 instance, ask what primitive gave them arbitrary methods and headers. Legitimate answers exist: a fetcher that proxies the client's method and headers verbatim, a gopher:// capable client, request smuggling into an internal proxy, or a full HTTP proxy misconfiguration. Absent one of those, the metadata claim usually does not survive verification.
The other providers have their own shapes and are worth testing before you conclude no metadata exposure exists:
- GCP:
http://metadata.google.internal/computeMetadata/v1/requiresMetadata-Flavor: Google. - Azure IMDS:
http://169.254.169.254/metadata/instance?api-version=2021-02-01requiresMetadata: true. - ECS task roles:
http://169.254.170.2/v2/credentials/<uuid>, no headers required, and the UUID often sits in theAWS_CONTAINER_CREDENTIALS_RELATIVE_URIenvironment variable. - Kubernetes: the API server, and kubelet read-only ports if they survive on the cluster.
Check the encodings before accepting a "metadata is blocked" response. http://2852039166/, http://0xA9FEA9FE/, http://[::ffff:169.254.169.254]/, and attacker-controlled DNS records pointing at the link-local address all bypass naive string denylists.
Internal port scanning and what it is worth
Reporters frequently include a port scan of 127.0.0.1 or the RFC1918 range as the proof of impact. Treat that as a starting point rather than the conclusion. A list of open ports is reconnaissance; the impact is what runs on them.
Rate the scan by what it found:
- Open port with no identified service and no response differential: low, and mostly an information disclosure about network topology.
- Identified unauthenticated internal service (Redis, Memcached, Elasticsearch, Consul, an internal Jenkins, a Kubernetes dashboard): now you have a real target and the severity is driven by what that service does.
- Reachable internal admin interface that accepts state-changing GETs: high, even blind.
The follow-up question is protocol reach. A fetcher restricted to http and https can only speak HTTP to those ports. A fetcher that accepts gopher:// can construct arbitrary TCP payloads, which turns "Redis is open on 6379" into remote code execution via a crafted SLAVEOF or cron write. Ask which schemes the client permits before you assume a port scan is harmless.
DNS rebinding and other TOCTOU cases
DNS rebinding is the answer to a validator that resolves the hostname, checks the resulting IP against a denylist, and then hands the original hostname to the HTTP client, which resolves it again. Two resolutions, two answers: the first returns a public IP and passes, the second returns 169.254.169.254 and gets fetched.
This is a real bug class and it is legitimately flaky to reproduce. Resolver caching, minimum TTL enforcement, and connection reuse all interfere. Reasonable triage behavior:
- Accept a single successful demonstration if the mechanism is explained and the callback data supports it. Do not demand determinism from a race against a DNS cache.
- Confirm the code path really does resolve twice. If the validator resolves once and connects to the resolved IP directly (pinning), rebinding does not apply and the report is theoretical.
- Note the precondition in the writeup. Rebinding needs attacker-controlled DNS, which is trivial, and needs the resolver to honor short TTLs, which is not always true.
The same TOCTOU shape shows up without DNS: a validator checks the URL string, then a client re-parses it differently. Backslashes, embedded userinfo (http://[email protected]/), and unicode normalization all make validator and client disagree about the host.
Common false positives
| Reported as SSRF | Usually is |
|---|---|
| Callback from the reporter's own IP or a desktop browser UA | Client-side fetch, no server involvement |
| DNS lookup only, never an HTTP request | Validator resolving the host before rejecting it |
| URL reflected in the response body or rendered as a link | Content injection at most |
| Image proxy, link unfurler, or webhook sender fetching an arbitrary external URL | Working as designed, unless it reaches internal ranges |
| Fetch that resolves but is refused by an egress proxy on every internal target | Bug is mitigated; document the control, rate low |
| Headless renderer loading attacker HTML | Server-side, but rate on what the renderer's origin and network can reach |
The image-proxy case causes the most friction. Products build fetchers on purpose: avatar imports, link previews, RSS readers, webhook testers, PDF generators, SAML metadata loaders. "The server fetched my URL" describes the feature. The finding only exists if the fetcher reaches something it should not, so the burden is on demonstrating internal reach, not external reach. When you close one of these, say exactly that, and invite a follow-up if the reporter can show an internal target responding. Clear reasoning here saves the argument later, which is the same principle behind our general triage workflow.
Severity reasoning
Score SSRF on reach and readability, in that order:
| Situation | Rough impact |
|---|---|
| Full read, valid cloud credentials retrieved | Critical |
| Full read, internal service with sensitive data or RCE primitive reached | Critical to High |
| Full read of internal HTTP, no valuable service identified yet | High to Medium |
| Partial read enabling reliable internal port and service enumeration | Medium |
| Blind, reaches internal ranges, no confirmed target | Medium to Low |
| Blind, external only, egress proxy blocks all internal ranges | Low to Informative |
Two adjustments belong in the writeup. First, "no valuable internal service found" is a statement about how much time the reporter spent, not about the network. Programs that treat unexplored internal reach as low severity teach researchers to stop enumerating. Second, preauth SSRF outranks the same bug behind a paid account, and an SSRF in a component running with a broad IAM role outranks the same bug in a locked-down worker. Both are context the reporter cannot see, so the program has to supply them. That is the same data-and-scale reasoning our severity guide applies to authorization bugs like IDOR.
The SSRF triage checklist
- Confirm a server, not a browser, made the request: source IP, User-Agent, and an actual HTTP hit rather than a lone DNS query.
- Classify the read: full, partial (errors, timing, lengths), or blind.
- Test redirect following, alternate encodings, and non-HTTP schemes before accepting an allowlist as effective.
- Attempt metadata for the actual cloud provider, and establish whether IMDSv2 is enforced before accepting or rejecting a credential claim.
- Map internal reach: which ranges, which ports, which identified services.
- For rebinding claims, verify the code resolves twice, and accept a single clean demonstration.
- Rate on reach and readability, and record the mitigating controls (egress proxy, hop limit, IMDSv2) in the verdict.
SSRF is where a fast close and a slow read produce opposite outcomes, because the difference between an intentional image proxy and a credential-stealing full read is twenty minutes of testing that busy queues skip. TRIAGERS™ runs on-demand triage teams who do that testing before the verdict goes out. If your fetcher findings deserve a closer look, let's talk.