← Blog
October 21, 20269 min readAppSecVulnerability ResearchSSRFSecurity Engineering

From “possible SSRF” to proof: how to verify server-side request forgery

A scanner can flag a possible SSRF in seconds; proving the server actually fetched an attacker-controlled destination — and reached something that matters — is the hard part. A practitioner's guide to confirming SSRF with out-of-band detection, why blocklists and DNS rebinding mislead, and the allowlist and egress controls that actually stop it.

By The Xalgorix Team

Finding a server-side request forgery is usually the easy part. A code path that takes a URL from the user and hands it to an HTTP client is a candidate in seconds — a webhook field, a “fetch preview” box, a PDF-from-URL export, an SSO metadata import, or one of the many AI features that now go and read a link on your behalf. The work is proving the candidate is a real vulnerability: that the server actually makes the request to where you point it, and that where you can point it reaches something that matters. That gap — between a plausible sink and a confirmed, impactful SSRF — is where most scanner output quietly goes to die.

Why SSRF is easy to find and hard to confirm

The shape is always the same: attacker-influenced input flows into an outbound request. A static match can see that flow, but it cannot tell you whether a validation step downstream neutralises it, whether the host is effectively fixed by the time the request is built, or whether the network the server sits on has anything worth reaching. A finding that reads “possible SSRF at /preview” is a question, not an answer.

Confirming one means establishing two separate things, in order. First, that the server makes a request you control — that an attacker-chosen destination is actually fetched. Second, that this capability reaches a sensitive target — an internal service, a cloud metadata endpoint, a credential. Treating these as one claim is the most common reporting error, because they carry very different severities and very different burdens of proof.

Step one: make the server call you

Most SSRF in the wild is blind: the response never comes back to you, so you cannot simply read what the server fetched. The reliable confirmation is out-of-band. Point the application at a listener you control, using a unique, single-use token for each injection point, and watch for the callback. A DNS lookup followed by an HTTP request arriving from the target's own egress address is hard evidence that the server fetched your destination — not a redirect in your browser, and not a guess from a response time.

Injected value (one per sink, so hits are attributable):
  https://a1b2c3.oast.example/

What counts as confirmation on your listener:
  DNS   A?  a1b2c3.oast.example         <- a resolver reached out
  HTTP  GET /  from <target egress IP>  <- the server itself fetched it

A hit from your own IP, or DNS with no HTTP, is not the same thing.
Injected URL
unique token
Server fetches
out of band
Callback
DNS + HTTP
Confirmed
server-side
A unique token ties the callback to one injection point; the source IP ties it to the target.

If you can read the response or influence the path, you have a fuller primitive and can start enumerating. If it stays blind, you confirm reachability out-of-band and then reason about what the server can reach from differences in timing, status codes and error strings rather than from direct reads. Either way, be explicit in the write-up about which one you actually have.

A callback proves the server reached your server. It does not, on its own, prove it can reach an internal target. Those are different claims with different severities — keep them separate, and never upgrade the first into the second without evidence.

Step two: find out what it can reach

Impact is about the network behind the sink: internal-only services, admin planes and, above all, the cloud metadata endpoint. On AWS, Azure and GCP that lives at 169.254.169.254 (and metadata.google.internal on GCP), and it is the classic prize because it can hand out instance role credentials to anything that can reach it.

A concrete 2026 case shows the other direction of impact. In CVE-2026-89111, disclosed on October 13, 2026 and rated CVSS 10.0, the SAP Cloud SDK for Python built its OAuth2 token URL from application configuration and substituted a tenant-subdomain value into it. Without validation it used the value as given — so an attacker who controlled the tenant subdomain could redirect the token request to a server they owned and capture the application's client ID and secret. No in-the-wild exploitation was reported, and the fix (in version 0.45.2) accepts only a single label of letters, digits and hyphens, up to 63 characters. It is a useful reminder that where a server can be made to send its secrets is as dangerous as what it can be made to read.

Impact testing against metadata or credentials is legitimate only on systems you own or are explicitly authorised to test. Never pull live cloud credentials from a target you do not control, even to “prove” impact. On an authorised engagement, a single controlled callback from the metadata address or a benign internal host is usually proof enough without ever touching a secret.

Why blocklists — and naive allowlists — mislead

The reflexive defence is a blocklist of private ranges. OWASP is blunt about it: “Deny-lists are bypass-prone. Prefer allow-lists.” Blocklists fall to alternate IP encodings, IPv6 forms, open redirects that bounce an allowed host to an internal one, and parser disagreement — for example a backslash before an @ that one URL parser reads as userinfo and another reads as the host.

The subtler trap defeats even a hostname allowlist when it is implemented naively: DNS rebinding. You validate api.example.com, it resolves to a public address and passes, and then the HTTP client re-resolves the name at connection time and gets an internal address you now control. Validation and connection disagree — a time-of-check/time-of-use gap. OWASP's answer is to resolve the name yourself, check every A and AAAA record against the permitted networks, and connect only to a validated IP — while keeping the original hostname for the Host header, SNI and certificate checks — and to apply the same rules to redirects and retries.

Validate host
resolves public
Re-resolve
at connect time
Internal IP
attacker-set
SSRF
allowlist bypassed
Validation sees a public IP; the connection re-resolves to an internal one.

The controls that actually hold

No single layer is enough; the ones that survive a bug in the layer above are what matter.

  • Application layer — don't accept a whole URL. Take a validated host or IP, then build the request yourself with a fixed scheme, port and path. Allowlist destinations, disable redirect following, permit only http and https (schemes like file://, gopher:// and dict:// are classic abuse vectors), reject any host two parsers read differently, and resolve-and-pin to the validated IP to close the rebinding window.
  • Network layer — an egress firewall and segmentation are the backstop that holds even when application validation is bypassed. OWASP calls network-level restriction highly recommended, and for good reason: if the app server simply cannot route to the metadata address or an internal management plane, a validation bug degrades from a breach to a non-event.
  • Cloud layer — on AWS, enforce IMDSv2 and disable IMDSv1. IMDSv2 requires a session token fetched with a PUT request and sent on later calls, refuses to issue tokens to requests carrying an X-Forwarded-For header (so open reverse proxies can't reach it), and returns the token with an IP TTL of 1 so an instance misconfigured as a router drops it. AWS states the combination “protects against the vast majority of SSRF vulnerabilities.”
Minimal safe outbound fetch (shape, not a library):

  host = validateHostFormat(input)      // no full URLs, no schemes
  ips  = resolve(host)                  // every A / AAAA record
  for ip in ips:
      assert ip in ALLOWED_NETWORKS     // fail closed on any miss
  connect(pinnedIp = ips[0],            // pin the checked IP
          hostHeader = host,            // keep name for Host/SNI/cert
          followRedirects = false,      // re-validate on each redirect
          scheme in {http, https})
Application validation stops the obvious cases, resolve-and-pin closes DNS rebinding, and the egress firewall and IMDSv2 are what save you when the first two have a bug — which, eventually, they will.

Proof, not a maybe

A queue of “possible SSRF” tickets is effectively unactionable: teams triage them to the bottom, and the occasional real one rots there with the false positives. A finding that carries its evidence — the server fetched an attacker-controlled host, here is the out-of-band callback, and on an authorised target here is the controlled request to an internal address — is something an engineer fixes this sprint. That is the whole premise of exploit-verified testing: a result should be evidence, not a guess. Xalgorix works its 22-phase methodology, attempts the request, and reports SSRF only when it can demonstrate it with an out-of-band callback it controls.

It is worth being honest about the limits of that approach. Automated confirmation is only as good as its out-of-band infrastructure — a reachable listener is one of the standing costs of running an agentic scanner yourself. And the highest-impact proof — reaching a specific internal service, or demonstrating credential theft — is exactly the testing you must not fully carry out on systems you don't own. A tool worth trusting proves reachability and stops short of harm; it does not invent an internal reach it could not safely show.

References

Ready to see it prove a bug?

Start scanning on Cloud →