Out-of-band detection
Some findings never show up in a response. A server that fetches a URL you control, an XML parser that resolves an external entity, a logging library that expands a lookup: when one of those is exploitable, the reply you get back looks exactly like the reply you get from a system that is perfectly safe. The request went somewhere and nothing you can see says so.
Out-of-band detection closes that gap. You put an address you own inside the payload, and VirtuProbe records whoever reaches for it. A suspicion becomes a verdict, with the source address and the full request as evidence.
How it works
Section titled “How it works”The callback catcher listens on three channels and records every interaction that arrives:
| Channel | What it records |
|---|---|
| DNS | The query name and type, so a name lookup alone proves the payload was processed |
| HTTP | The full request: method, path, every header, and the body |
| SMTP | The envelope and the message, for flows that answer by mail |
Each run gets its own token, and the token travels inside the address the payload carries. That is what ties an interaction back to the exact run that caused it, so two people testing at once never read each other’s results.
You do not mint the token yourself. Reference it in a probe and the run supplies it:
{{oob_url}} http://<catcher>:8880/<token>{{oob_host}} <token>.oob.example.com{{oob_token}} the token on its ownA chain then sends the payload and waits:
1 HTTP POST /api/import body {"url": "{{oob_url}}"}2 WAIT_FOR_CALLBACK timeout 30s protocols HTTP minCount 1The wait blocks until something arrives or the timeout expires, and it is the verdict on its own: the step passes when the callback came and fails when it did not, so the chain result already tells you which happened, and a callback that never came is an answer as much as one that did.
The wait finishes as soon as the minimum count has arrived, so a callback delivered after that is not part of its result. To catch a sender that delivers the same webhook twice, set Keep listening (s) on the step to the sender’s retry window: once the minimum is reached, the step keeps listening that many seconds before it finishes, and everything that arrived in that time is part of the result.
Starting the catcher
Section titled “Starting the catcher”Open Out-of-band from the top bar. Nothing is listening until you say so, and the panel is where you say it.
Set where it listens, then press Start:
| Setting | What it is |
|---|---|
| Bind address | The interface the listeners bind. Loopback by default, so opening it wider is something you do on purpose. |
| Address targets call back to | What goes inside the payload. Not the same thing as the bind address. |
| Base domain | The domain delegated to this catcher. Only DNS needs it, and DNS cannot work without it. |
| DNS, HTTP and SMTP ports | Where each channel listens. Set a port to 0 to let the machine pick a free one. |
The two address fields are separate because they answer different questions. 127.0.0.1 is correct to bind on, and it is useless inside a payload aimed at a target running in a container, which has its own loopback and will reach itself rather than you. Put the address the target can actually route to in the second field.
Settings are locked while the catcher is running. Stop it to change them.
What the panel tells you
Section titled “What the panel tells you”Once it is running, each channel gets a card showing the port it actually bound and one of three markings:
- Ready. It is listening, and a silent result from it is a real answer.
- Only under a condition. It can receive, but only if you arranged something, such as pointing a resolver at it directly.
- Cannot hear. Nothing arriving on it could be attributed to your run, so silence from it proves nothing.
A catcher can be running perfectly with a channel that could never have received anything, and from the outside that looks exactly like a catcher working fine.
Below the cards, Recorded callbacks lists everything that arrived, newest first, with the source address and the time. Click one to see the request exactly as it came in, byte for byte. Callbacks that carry no recognizable token are listed too and marked as such: something reached a listener you own without using an address you handed out, which is worth knowing.
Stopping the catcher does not discard what it recorded.
Reaching the catcher
Section titled “Reaching the catcher”The catcher only works if the system you are testing can actually reach it. In a lab, in Docker, across a LAN, on an internal engagement or in CI, it can. A target on the public internet usually cannot reach a laptop behind NAT, and no setting fixes that.
The address a payload advertises is therefore yours to set, and it is not always the address the catcher is bound to. 127.0.0.1 is correct when the target runs on the same machine and useless from inside a container, which needs the host address instead.
HTTP works anywhere. DNS is the interesting one.
Section titled “HTTP works anywhere. DNS is the interesting one.”An HTTP callback carries its own host and port, so http://10.0.0.5:8880/<token> works on any port you like. That covers most server-side request forgery, and it is most of the value in a lab or on an internal network. It fails where the target validates the field first: a form that insists on https://, an allowlist naming only 80 and 443, or a parser that quietly drops the port.
DNS is worth having because it fires when outbound HTTP is blocked entirely. The application only has to resolve a name, and a resolver almost always gets out even when nothing else does. It is also the only channel that carries data out through the labels of the name itself.
DNS is also the one that needs real infrastructure.
A DNS callback needs a domain delegated to the catcher. The token can only ride in the query name, so the catcher has to be the authoritative nameserver for a domain you control, with an NS record pointing at it. Without a delegated domain there is nothing to read the token out of, and a query that arrives cannot be tied to your run at all.
A DNS callback needs port 53. When the target’s application resolves a name, its resolver walks the chain of delegations and asks whoever is authoritative, on UDP port 53. Nothing in that path consults a port you chose. A catcher on 5353 hears a query only from a resolver pointed straight at it, which is something you can arrange in a lab and cannot arrange on a real engagement.
SMTP has the same shape in a smaller way: mail is delivered to port 25, so a catcher on 2525 receives only what you send it deliberately.
Getting a privileged port
Section titled “Getting a privileged port”Ports below 1024 are restricted, and how you lift that restriction depends on the operating system rather than on VirtuProbe. VirtuProbe does not do it for you, because almost nobody needs port 53 and the ones who do would rather choose the method themselves.
Linux. Grant the capability to the Java runtime that VirtuProbe uses:
sudo setcap cap_net_bind_service=+ep "$(readlink -f "$(which java)")"That applies to the binary rather than to VirtuProbe, so it affects anything else run by the same runtime. A redirect avoids that and leaves the catcher on a high port:
sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353macOS. There is no capability equivalent, so either run with elevated privileges or forward the port with pfctl.
Windows. No restriction applies. Port 53 binds like any other.
Docker. If you run the execution server as a container, this is usually the easiest route, because the capability is granted at run time and nothing on the host changes:
docker run --cap-add=NET_BIND_SERVICE -p 53:53/udp ...The container runs as an unprivileged user, so the capability has to be granted explicitly. Adding it is not the same as running as root.
Silence is only an answer sometimes
Section titled “Silence is only an answer sometimes”A callback that never arrives can mean the target is not vulnerable. It can also mean the channel was never able to hear anything, and those two look identical from the outside.
VirtuProbe will not report the second as though it were the first. When a wait finishes empty, it checks whether each channel you asked for could have received anything at all, and says so when one could not:
- A channel with no listener could not have received anything.
- A DNS channel with no delegated domain could not have attributed anything to your run, whatever port it is on.
- A DNS channel on a port other than 53 is reachable only by a resolver aimed directly at it.
Where any of those apply, the step reports the result as inconclusive and names the reason instead of handing you a clean negative. Where none apply, an empty result is a real finding: nothing reached you, and the channels you asked for were listening and able to hear.
A callback that does arrive is a positive result regardless, because something reached you and that was the question.