Second in a series on building out-of-band application security testing (OAST) infrastructure from scratch. The first post hand-rolled a JNDI Reference byte by byte. This one is gentler but just as load-bearing: to catch a DNS callback, you have to be the nameserver for a zone.
I’ve leaned on Burp Collaborator and interactsh for years without once thinking about what’s under them. Building my own OAST platform was the thing that finally made me look — and the piece everyone treats as magic, the DNS side, turned out to be the smallest part of it. Every OAST tool — those two, this one — rests on one capability: you are the authoritative nameserver for a domain, and you answer every query under it, no matter how weird. When a vulnerable app exfiltrates data as whoami-output.<your-host>.example.net, that DNS query travels the resolver chain to your server, and the query itself is the signal. No HTTP needed; DNS alone is the side channel.
So the core of an OAST platform is a catch-all authoritative DNS server. You don’t need BIND. You need a UDP/TCP socket, a DNS message parser, and about 200 lines of logic. Here’s the whole thing, built on dnslib (which does wire parsing so you can think in records, not bytes).
What “authoritative” actually means on the wire
Three things distinguish an authoritative answer from a recursive resolver’s:
- The
aa(authoritative answer) bit is set in the response header. You’re saying “I am the source of truth for this zone,” not “I looked it up for you.” - You answer for names in your zone and refuse everything else. A query for a name you’re not authoritative for gets
REFUSED, not a recursive lookup. - You serve the zone’s
SOAandNSrecords so the rest of the DNS system believes you.
With dnslib, the skeleton is a BaseResolver subclass with a resolve(request, handler) that returns a DNSRecord. Everything below lives in that method.
Step 1: is this query even mine?
The first decision is zone matching. You’re authoritative for one or more zones (say example.net); a query is yours if its name equals a zone or ends in .<zone>. Longest-match if you serve several:
def _match_zone(self, qname: str) -> str | None:
best = None
for z in self.zones:
if qname == z or qname.endswith("." + z):
if best is None or len(z) > len(best):
best = z
return best
Out of zone → set REFUSED and return. In zone → set the aa bit and start answering:
matched_zone = self._match_zone(qname)
if matched_zone is None:
reply.header.rcode = RCODE.REFUSED
else:
reply.header.aa = 1
...
That aa = 1 is the line I forget every time I come back to this code. Without it, some resolvers treat your answer as non-authoritative and behave oddly — and I’ve burned more than one debugging session before remembering it’s one bit that changes everything.
Step 2: the apex (the zone’s own name)
A query for the bare zone name (example.net itself) needs the records that make you a real nameserver:
SOA— start of authority: primary NS, admin email, and the five timers (serial, refresh, retry, expire, minimum). The serial just needs to monotonically increase; Unix time is a fine, lazy choice that fits in a uint32 until 2106:def _soa_serial(self): return int(time.time())NS— your nameserver hostnames. These have to match the glue records at your registrar.A— pointing the apex at your box, sohttp://example.netresolves to you too.MX— so mail is deliverable (more on this below).
And the subtlety that trips everyone: NODATA. If the name exists but the queried type doesn’t (e.g. someone asks for AAAA at your apex and you have no v6), you do not return NXDOMAIN and you do not return empty-and-silent. You return NOERROR with the SOA in the authority section:
if not reply.rr:
reply.add_auth(self._build_soa_rr(matched_zone))
That SOA-in-authority is how a resolver learns “this name is real, that record type just isn’t, and here’s how long to cache the negative answer.” Skip it and you get inconsistent caching and slow retries.
Step 3: the wildcard — answer anything under the zone
This is the OAST heart. For any name under your zone — whatever.user-host.example.net, a.b.c.d.user-host.example.net, names you’ve never seen — you answer an A record pointing at your listener IPs:
def _build_wildcard_a_rrs(self, qname):
return [RR(qname, QTYPE.A, ttl=DNS_DEFAULT_TTL, rdata=A(ip))
for ip in LISTENER_IPS]
You’re not looking anything up in a database to decide whether to answer — you answer everything, because the whole point is to catch callbacks to names the attacker invented on the fly. (Whose host it belongs to — attribution — happens separately, by matching the rightmost meaningful label against your claimed-host table, and it’s fine for that to find nothing: an unattributed hit is still a captured hit.)
The same wildcard logic answers MX for every name, so anything@user-host.example.net is deliverable — the sending mail server looks up MX, gets your mail host (covered by the same wildcard A), and connects to your SMTP sink on port 25:
def _build_mx_rrs(self, qname, zone):
target = f"mail.{zone}."
return [RR(qname, QTYPE.MX, ttl=DNS_DEFAULT_TTL,
rdata=MX(target, self.mx_priority))]
One wildcard, three callback channels (DNS, HTTP, email) all reachable at the same host label.
Step 4: answer your own ACME challenges (the neat trick)
This is my favorite part of the whole platform — a genuinely satisfying bit of bootstrapping. You want a wildcard TLS cert for *.example.net so the HTTPS listener works for every host. Let’s Encrypt issues wildcard certs only via the DNS-01 challenge: prove you control the zone by publishing a specific TXT record at _acme-challenge.example.net.
But you are the zone’s DNS server. So you answer the challenge straight out of a table that your cert-renewal hook writes to:
if qtype in ("TXT", "ANY") and qname.startswith("_acme-challenge."):
for value in self._lookup_acme_challenges(qname):
reply.add_answer(RR(qname_dot, QTYPE.TXT, ttl=0, rdata=TXT(value)))
certbot’s hook drops the challenge value into a row; your DNS server serves it; Let’s Encrypt checks it; you get a wildcard cert — all against infrastructure you already run. No external DNS provider API, no plugin. The server validates its own identity to the CA.
Step 5: don’t get used as a DDoS amplifier
A public UDP DNS server is a reflection/amplification weapon if you’re careless: an attacker spoofs a victim’s source IP, sends you a tiny query, and you blast a big answer at the victim. Two cheap defenses, both before any database work:
A per-source-IP token-bucket floor. Over the limit →
REFUSEDwith no answers (a refusal is the same size as the query, so there’s no amplification to exploit). DNS limits should be generous — recursive resolvers hide millions of clients behind one IP, and a single legit lookup can fire 3–4 packets (A + AAAA + HTTPS-RR + CAA) in milliseconds.Response Rate Limiting (RRL), UDP-only, keyed by client prefix (/24 or /64). Over the limit, answer truncated —
TC=1, no records:if rrl_over_limit and transport == "udp": reply.header.tc = 1 # "retry over TCP" reply.header.rcode = RCODE.NOERROR return replyTC=1is query-sized (no amplification) and forces a legitimate resolver to retry over TCP — which a spoofer can’t do, because TCP needs a real three-way handshake the spoofed source will never complete. Prefix keying (not exact IP) is deliberate: a reflection flood concentrates on one victim prefix even as exact source IPs vary.One more subtlety with real teeth: do not auto-ban the source IP that trips UDP RRL. In a reflection attack that “source” is the victim, not the attacker — banning it punishes the target and is weaponizable against you. TCP-validated surfaces (HTTP) are safe to ban; spoofed UDP sources are not.
The shape of the whole thing
Strip out capture and persistence and the resolver is essentially:
resolve(request):
rate-limit floor -> REFUSED if over
RRL (udp) -> truncated if over
match zone -> REFUSED if out of zone, else aa=1
if name == apex: SOA / NS / A / MX, else NODATA+SOA
else (subdomain): ACME TXT? else wildcard A + MX, else NODATA+SOA
(capture + attribute + optionally run a response modifier)
return reply
That’s an authoritative server. ~200 lines on top of a wire-parsing library, and it’s enough to delegate a real zone to and catch real callbacks from real exploits. The DNS RFCs are large; the authoritative-for-a-wildcard-zone subset you need for OAST is small, and now you’ve seen all of it.
Takeaways that outlast this post
- Authoritative ≠ recursive. Set
aa, refuse out-of-zone, serve your own SOA/NS. Three rules and you’re a real nameserver. - NODATA is
NOERROR+ SOA-in-authority, neverNXDOMAIN, never silence. This single detail separates a server that caches correctly from one that mysteriously doesn’t. - A wildcard zone is a multi-channel callback sink — the same label catches DNS, HTTP, and email, and you can validate your own TLS certs against it.
- A public UDP service is an amplifier until you rate-limit it — and the right response is truncation, not refusal-by-ban, because the source you see may be the victim.
Next in the series: the part where catching a callback isn’t enough — you want to respond with attacker-influenced logic, which means running untrusted Python safely. Six layers of sandbox, and the escape I missed.