Encrypted DNS in Practice: Resolve the Same Name over Do53, DoT, and DoH — Then Sniff the Wire
This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.
Lab #13 made DNS answers tamper-evident (DNSSEC). This lab makes the DNS query itself private on the wire. We resolve the same name three ways and watch what an on-path observer can actually read:
- Do53 — classic DNS on port 53. The query name is cleartext.
- DoT — DNS over TLS (RFC 7858), port 853. The query rides inside TLS.
- DoH — DNS over HTTPS (RFC 8484), port 443,
/dns-query. Also inside TLS, and it looks like ordinary HTTPS.
Reading guide: rfc-notes/dns-encrypted-dot-doh.md
Prerequisites: DNS Lab 05: Recursive Resolution You Can Trace, TLS Lab 09: What Is Visible Before Encryption
Expected time: 45–60 minutes.
The Goal
By the end, you should be able to fill in this table for a query name on the wire:
| Transport | Port | Query name visible to an observer? | What the observer sees instead |
|---|---|---|---|
| Do53 | 53 | yes (cleartext) | the full question, e.g. A? leak-probe.example.lab |
| DoT | 853 | no | a TLS handshake, then encrypted records |
| DoH | 443 | no | a TLS handshake that looks like HTTPS |
What You Will Learn
- That DNSSEC (Lab 13) and encrypted DNS solve different problems: integrity vs privacy.
- What DoT and DoH are, which ports they use, and how they relate to the TLS from Lab 09.
- Why Do53 leaks the query name to anyone on the path, and DoT/DoH do not.
- What encrypted DNS still does not hide (the server you talk to, and — via SNI — often the destination name).
This lab does not cover:
- Encrypted Client Hello (ECH), which also hides the SNI.
- Oblivious DoH (ODoH) or DNS over QUIC (DoQ).
- Picking or configuring a public resolver; here we run our own server.
Where to Read in the RFCs
| RFC | Section | What to focus on |
|---|---|---|
| RFC 7858 | 3 | DNS over TLS (DoT), port 853, how the TLS session is established |
| RFC 8484 | 4, 5 | DNS over HTTPS (DoH), /dns-query, GET/POST and application/dns-message |
| RFC 9499 | 6 | The terminology for Do53 / DoT / DoH |
| RFC 8446 | 2 | The underlying TLS 1.3 handshake (a refresher on Lab 09) |
| RFC 5737 | 3 | Confirming 203.0.113.0/24 is a documentation prefix |
The Big Picture
One client, one server. The server runs BIND and serves the same example.lab zone over all three transports: Do53 (53), DoT (853), and DoH (443).
client (10.0.0.1) ------ eth1 ------ server (10.0.0.2)
dig @server :53 BIND
dig +tls @server :853 Do53 / DoT / DoH
dig +https @server :443 cert: dns.example.lab
The client resolves the same name with dig (Do53), dig +tls (DoT), and dig +https (DoH). Meanwhile it captures packets on its own interface, and we compare whether the query name shows up in cleartext. The TLS certificate for DoT/DoH is a self-signed one for dns.example.lab, generated by run.sh.
flowchart LR
C[client]
S[server<br/>BIND]
C -- "Do53 :53<br/>A? leak-probe.example.lab (cleartext)" --> S
C -- "DoT :853<br/>TLS { A? ... }" --> S
C -- "DoH :443<br/>HTTPS POST /dns-query { A? ... }" --> S
note["What the observer sees:<br/>53 → the question itself<br/>853/443 → only a TLS handshake"]
Note:
203.0.113.0/24is an RFC 5737 documentation prefix. It never leaves the lab, so nothing here touches the real internet.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
- A BIND9 container image
- A netshoot container image (dig 9.18+ for
+tls/+https, plus tshark and openssl)
Images used:
protocol-lab/bind9:9.20— built locally from examples/dns-14/Dockerfile.nicolaka/netshoot:latest
To observe DoT and DoH, the client's dig must support +tls (DoT) and +https (DoH). netshoot's dig 9.20 supports both. The certificate is generated self-signed by run.sh before deploy (and never committed).
Running the Lab
The quick path, which generates the certificate, deploys, confirms all three transports return the same answer, captures each transport to compare whether the query name is readable, and tears down for you:
./scripts/labctl.sh run dns-14
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/dns-14
2. Deploy
run.sh creates the certificate before deploying. To follow along by hand:
# self-signed cert for dns.example.lab -> ./server/tls/
mkdir -p server/tls
docker run --rm -v "$PWD/server/tls:/w" -w /w --entrypoint sh nicolaka/netshoot:latest -c \
'openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt \
-subj "/CN=dns.example.lab" -addext "subjectAltName=DNS:dns.example.lab" -days 3650; \
chmod 644 server.key server.crt'
docker build -t protocol-lab/bind9:9.20 .
sudo containerlab deploy -t dns-14.clab.yml
3. Resolve the same name three ways
docker exec clab-dns-14-client dig +short @10.0.0.2 www.example.lab A # Do53
docker exec clab-dns-14-client dig +tls +short @10.0.0.2 www.example.lab A # DoT (853)
docker exec clab-dns-14-client dig +https +short @10.0.0.2 www.example.lab A # DoH (443)
All three return 203.0.113.10. The answer is identical — what differs is how it travels.
(Because the certificate is self-signed, dig +tls does not verify it by default. In production you would verify the resolver's certificate, and check the name too with dig +tls-hostname=....)
4. Compare cleartext vs encrypted with a capture
On the client, capture each transport while resolving a marker name (leak-probe.example.lab).
# Do53: capture port 53 and check whether the query name is readable
docker exec -d clab-dns-14-client sh -c "tcpdump -i eth1 -A -s0 -w /tmp/do53.pcap 'port 53'"
docker exec clab-dns-14-client dig @10.0.0.2 leak-probe.example.lab A >/dev/null
docker exec clab-dns-14-client pkill -INT tcpdump
docker exec clab-dns-14-client sh -c "tcpdump -A -r /tmp/do53.pcap | grep leak-probe"
Over Do53, A? leak-probe.example.lab. is readable as-is. Repeat the same exercise on port 853 (+tls) and 443 (+https), and leak-probe never appears — it's inside TLS.
5. DoT/DoH still show a TLS handshake
docker exec clab-dns-14-client tshark -r /tmp/dot.pcap -Y "tls.handshake" -T fields -e tls.handshake.type
You'll see the TLS handshake sequence — 1 (ClientHello), 2 (ServerHello), and so on. In other words, the observer can tell that a TLS session was established, but cannot read the question inside it.
Expected Output
- All three transports — Do53, DoT, and DoH — resolve
www.example.labto203.0.113.10. - The Do53 capture contains
leak-probe.example.labin cleartext. - The DoT (853) and DoH (443) captures contain no trace of
leak-probe. - The DoT/DoH captures show a TLS handshake (ClientHello=1, ServerHello=2).
Why It Works
DNS was born cleartext (Do53): anyone on the path could read "who asked for what." DoT and DoH make the query private by wrapping the DNS message in TLS (Lab 09).
- DoT (RFC 7858). Establish TLS on the dedicated port 853, then exchange ordinary DNS messages inside it. Being DNS-specific makes it easy to identify — and easy to break if port 853 is blocked.
- DoH (RFC 8484). Ride on top of HTTPS on port 443, POSTing (or GETting)
application/dns-messageto/dns-query. Because it's hard to distinguish from ordinary web traffic, it's much harder to block. - Both encrypt the payload. The query name and the answer are unreadable from the path. But the TLS handshake is still visible, so an observer can tell you're using encrypted DNS and can see the server's IP.
- What doesn't get hidden. The resolver you talk to (its IP). And TLS's SNI is cleartext by default, so when DoH targets a specific virtual host on a public resolver, the destination name can still leak — hiding that is what ECH is for.
The key insight: DNSSEC (Lab 13) is about the authenticity of answers, while DoT/DoH are about the privacy of queries — separate goals served by separate mechanisms. Only by combining both do you get DNS that is "correct and unobserved."
Common Pitfalls
- Confusing DNSSEC with encrypted DNS. DNSSEC detects tampering (signatures); DoT/DoH prevent eavesdropping (encryption). They protect different things.
- Assuming encrypted DNS hides everything. The server's IP, the TLS handshake, and often the SNI remain visible.
- Assuming
dig +tlsverifies the certificate. By default it usually doesn't. In production, verify against a CA/anchor. - Dismissing DoH as "just HTTPS." The payload is DNS, but riding on port 443 makes it operationally hard to block — that property is the whole point.
- Mixing up the ports. DoT = 853, DoH = 443. Port 53 is cleartext.
- Getting the capture filter wrong. 853 and 443 are TCP; 53 can be both UDP and TCP. Match your filter accordingly.
Cleanup
sudo containerlab destroy -t dns-14.clab.yml --cleanup
If you used labctl.sh run dns-14, the script runs destroy for you at the end.
Check Your Understanding
- Which ports do Do53, DoT, and DoH each use?
- What's the difference between DoT and DoH? What is the advantage of DoH riding on port 443?
- With encrypted DNS, what is hidden from an on-path observer — and what is not?
- What does DNSSEC (Lab 13) protect, and what do DoT/DoH protect?
- Why did
dig +tlssucceed even with a self-signed certificate? What should you do in production? - Looking at a capture, why is it hard to say for certain that a DoT connection is "DNS"?
References
- RFC 7858: Specification for DNS over Transport Layer Security (TLS)
- RFC 8484: DNS Queries over HTTPS (DoH)
- RFC 9499: DNS Terminology
- RFC 8446: TLS 1.3
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- BIND 9 Administrator Reference Manual
Verified Run Log (2026-07-07)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
- Docker 29.1.3
- containerlab 0.77.0
- server:
protocol-lab/bind9:9.20, a thin wrapper aroundinternetsystemsconsortium/bind9:9.20(BIND 9.20.24) - client:
nicolaka/netshoot:latest(dig 9.20.23, tshark, openssl)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dns-14 performed deploy → verify → destroy, and verification.json returned "status": "verified".
The same answer over all three transports
$ dig +short @10.0.0.2 www.example.lab A # Do53
203.0.113.10
$ dig +tls +short @10.0.0.2 www.example.lab A # DoT (853)
203.0.113.10
$ dig +https +short @10.0.0.2 www.example.lab A # DoH (443)
203.0.113.10
Do53 exposes the query name in cleartext (port 53 capture)
$ tcpdump -A -r do53.pcap | grep leak-probe
09:28:21.443917 IP 10.0.0.1.40349 > 10.0.0.2.53: 10833+ [1au] A? leak-probe.example.lab. (63)
leak-probe.example.lab appears three times, in cleartext — completely exposed to an on-path observer.
DoT / DoH never reveal the query name
$ tcpdump -A -r dot.pcap | grep -c leak-probe
0
$ tcpdump -A -r doh.pcap | grep -c leak-probe
0
The DoT (853) and DoH (443) captures contain leak-probe exactly zero times — it stays inside TLS.
DoT / DoH show only the TLS handshake
$ tshark -r dot.pcap -Y "tls.handshake" -T fields -e tls.handshake.type
1 2 # 1=ClientHello, 2=ServerHello
$ tshark -r doh.pcap -Y "tls.handshake" -T fields -e tls.handshake.type
1 2
The observer can tell a TLS session was set up, but cannot read the question inside. Where DNSSEC (Lab 13) guarantees the authenticity of the answer, DoT/DoH guard the privacy of the query — separate goals met at separate layers.
Cleanup
containerlab destroy -t dns-14.clab.yml --cleanup
That's encrypted DNS: same question, same answer, but with DoT and DoH the wire shows nothing more than a TLS handshake — the query name itself never leaks.
Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.
Next up, we'll pull on the thread encrypted DNS leaves dangling — what the SNI still reveals, and the mechanisms (like ECH) designed to close that last gap.