The same signed A record arrived over UDP, TCP, DNS over TLS, and DNS over HTTPS in this lab. The visible packets were different, and so were the failure checks: an incorrect certificate hostname stopped DoT, while an incorrect DoH path reached HTTPS and returned HTTP 404.
Then the parent DS was deliberately broken. Both encrypted transports still delivered 203.0.113.42 over verified TLS connections. Local DNSSEC validation rejected the chain. That distinction is easy to lose when a tool prints a TLS session and a DNS answer in the same output.
This walkthrough reuses the private DNSSEC lab. Complete its namespace, key, zone, and baseline steps first. For basic query output, keep the dig guide nearby; for certificate identity and trust-chain details, use the OpenSSL TLS investigation.
Hold the endpoint and answer constant
The client is 192.0.2.10; one authoritative BIND process on 192.0.2.20 serves the same signed zones through these listeners:
- UDP and TCP
1053: plain DNS. - TCP
1853: DoT with TLS 1.3. - TCP
18443: DoH with TLS 1.3, HTTP/2, and/dns-query.
The nonstandard ports make the lab explicit. DoT normally uses port 853; DoH uses an HTTPS endpoint URI. The protocol definitions are RFC 7858 and RFC 8484.
Tested with: Ubuntu 26.04.1 LTS, Knot DNS kdig 3.5.3, BIND 9.20.24, OpenSSL 3.5.5, tcpdump 4.99.6, TShark 4.6.4. BIND was built with OpenSSL and libnghttp2 support. Syntax was checked with the installed tools and Knot's kdig reference.
This is an authoritative endpoint comparison. Every query names an explicit IP, so it does not depend on the host resolving the endpoint's hostname first.
Create a local certificate and TLS listeners
Stop the foreground server from the prerequisite lab before starting its replacement. In the ordinary-user shell, enter the same lab directory and create a disposable CA, an unrelated CA for a negative test, and a server certificate:
DNS_LAB_ROOT=${DNS_LAB_ROOT:-"$HOME/dns-lab"} DNS_CLIENT=${DNS_CLIENT:-pldns-client} DNS_SERVER=${DNS_SERVER:-pldns-server}
cd "$DNS_LAB_ROOT"
umask 077
for ca in ca wrong-ca; do
openssl req -x509 -newkey rsa:2048 -noenc -days 14 \
-subj "/CN=DNS Lab $ca" \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,keyCertSign,cRLSign' \
-keyout "pki/$ca.key" -out "pki/$ca.crt"
done
openssl req -new -newkey rsa:2048 -noenc \
-subj '/CN=dns.lab.test' \
-keyout pki/server.key -out pki/server.csr
cat > pki/server.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:dns.lab.test
EOF
openssl x509 -req -in pki/server.csr \
-CA pki/ca.crt -CAkey pki/ca.key -CAcreateserial \
-days 5 -extfile pki/server.ext -out pki/server.crt
openssl verify -CAfile pki/ca.crt -verify_hostname dns.lab.test pki/server.crt
openssl x509 -in pki/server.crt -noout -dates -ext subjectAltNameThe certificate has dns.lab.test in its DNS subject alternative name. Clients will connect to the IP while checking that identity. pki/ca.crt is the public trust certificate, not the CA's private key. The leaf lasts five days; inspect dates and regenerate it for later reruns rather than bypassing an expiration failure.
Create transport versions of the healthy and bogus server configurations. The original zone files remain intact:
python3 - <<'PYTLS'
from pathlib import Path
root = Path.cwd()
listeners = '''listen-on port 1853 tls lab_tls { 192.0.2.20; };
listen-on port 18443 tls lab_tls http default { 192.0.2.20; };
listen-on-v6'''
tls = f'''\ntls lab_tls {{
key-file "{root}/pki/server.key";
cert-file "{root}/pki/server.crt";
protocols {{ TLSv1.3; }};
}};\n'''
for mode in ('good', 'bogus'):
original = Path(f'named-{mode}.conf').read_text()
Path(f'named-{mode}-transport.conf').write_text(
original.replace('listen-on-v6', listeners) + tls)
PYTLS
named-checkconf -z named-good-transport.conf
named-checkconf -z named-bogus-transport.confThe resulting listeners and TLS block use BIND's listen-on and TLS configuration. No server-side ca-file is set here: the server presents its certificate but does not require a client certificate.
Start the healthy transport server in a separate VM terminal:
DNS_LAB_ROOT=${DNS_LAB_ROOT:-"$HOME/dns-lab"} DNS_CLIENT=${DNS_CLIENT:-pldns-client} DNS_SERVER=${DNS_SERVER:-pldns-server}
cd "$DNS_LAB_ROOT"
sudo ip netns exec "$DNS_SERVER" \
"$PWD/vendor/extracted/usr/sbin/named" \
-g -n 1 -u "$USER" -c "$PWD/named-good-transport.conf"Wait for running. In the query terminal, the first block below sets the same lab variables and directory. All four queries ask for the same FQDN and type, disable recursion, and request DNSSEC records.
Compare four successful exchanges
Plain UDP and TCP:
DNS_LAB_ROOT=${DNS_LAB_ROOT:-"$HOME/dns-lab"} DNS_CLIENT=${DNS_CLIENT:-pldns-client} DNS_SERVER=${DNS_SERVER:-pldns-server}
cd "$DNS_LAB_ROOT"
sudo ip netns exec "$DNS_CLIENT" kdig \
@192.0.2.20 -p 1053 www.secure.lab.test. A \
+norecurse +dnssec +timeout=2 +retry=0
sudo ip netns exec "$DNS_CLIENT" kdig \
@192.0.2.20 -p 1053 www.secure.lab.test. A \
+tcp +norecurse +dnssec +timeout=2 +retry=0For DoT, configure the CA and the expected hostname explicitly:
sudo ip netns exec "$DNS_CLIENT" kdig \
@192.0.2.20 -p 1853 www.secure.lab.test. A \
+tls +tls-ca="$PWD/pki/ca.crt" \
+tls-hostname=dns.lab.test +tls-sni=dns.lab.test \
+norecurse +dnssec +timeout=2 +retry=0+tls alone selects opportunistic TLS. It is not the strict authentication check demonstrated here. +tls-ca supplies the trust certificate, +tls-hostname supplies the identity to verify, and +tls-sni supplies the name sent in ClientHello. Keeping all three explicit makes failures easier to interpret.
For DoH, retain the same identity checks and select the endpoint path:
sudo ip netns exec "$DNS_CLIENT" kdig \
@192.0.2.20 -p 18443 www.secure.lab.test. A \
+https=/dns-query +tls-ca="$PWD/pki/ca.crt" \
+tls-hostname=dns.lab.test +tls-sni=dns.lab.test \
+norecurse +dnssec +timeout=2 +retry=0Selected output from the measured DoH baseline:
;; TLS session (TLS1.3)-(ECDHE-SECP256R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
;; HTTP session (HTTP/2-POST)-(dns.lab.test/dns-query)-(status: 200)
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 0
;; Flags: qr aa; QUERY: 1; ANSWER: 2; AUTHORITY: 2; ADDITIONAL: 1
www.secure.lab.test. 60 IN A 203.0.113.42
;; From 192.0.2.20@18443(HTTPS) in 13.9 msAll four baseline commands returned NOERROR, the same A record and its RRSIG, and the same signed NS data in the authority section. The output identified the transports as (UDP), (TCP), (TLS), and (HTTPS). DoH used HTTP/2 POST with HTTP status 200.
Each command opened a new connection where applicable. The server was authoritative with a 60-second record TTL, not a recursive cache.
What the capture can actually show
Capture one exchange at a time from client eth0. Use a different filename for each case, start before the query, wait for the listening message, and press Ctrl-C afterward:
sudo ip netns exec "$DNS_CLIENT" tcpdump \
--immediate-mode -U -i eth0 -nn -s 0 \
-w "$PWD/evidence/dot.pcap" "host 192.0.2.20"For example, this offline command reads the DoT capture and decodes the lab port as TLS. The shell opens the file and passes it to TShark on stdin:
tshark -r - -d tcp.port==1853,tls \
-Y tls -T fields -e frame.number \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extensions_alpn_str \
-e tls.record.content_type -e tls.record.opaque_type \
< evidence/dot.pcapFor DoH, change the decode port to 18443 and use its capture filename. In the retained tests:
- The UDP and TCP captures exposed the DNS question, response code, A record, and signatures. Both contained two DNS messages.
- DoT ClientHello exposed SNI
dns.lab.testand offered ALPNdot. - DoH ClientHello exposed the same SNI and offered ALPN
h2. - Neither encrypted capture exposed a decoded DNS message without TLS secrets. Encrypted TLS 1.3 records used opaque outer type 23.
A type-23 record is not a count of DNS queries. TLS 1.3 also encrypts handshake details and alerts. The capture alone does not reveal the certificate verification result or the HTTP response status; the endpoint output provides those facts. No key log or session secrets were supplied to the analyzer.
IP addresses, ports, packet sizes, timing, and the measured ClientHello names remained visible. This setup did not use Encrypted ClientHello. If you want to compare readable DNS logs from an unencrypted PCAP, continue with the Zeek walkthrough.
Fail identity and endpoint checks separately
Repeat the working DoT command with only +tls-hostname changed to wrong.lab.test. Keep the connection IP, CA file, and SNI unchanged:
sudo ip netns exec "$DNS_CLIENT" kdig \
@192.0.2.20 -p 1853 www.secure.lab.test. A \
+tls +tls-ca="$PWD/pki/ca.crt" \
+tls-hostname=wrong.lab.test +tls-sni=dns.lab.test \
+norecurse +dnssec +timeout=2 +retry=0Measured stderr:
;; WARNING: TLS, handshake failed (Error in the certificate.)
;; ERROR: failed to query server 192.0.2.20@1853(TCP)The command returned exit code 1 and no DNS answer. The message is generic; the controlled hostname change, the certificate SAN, and the successful matching-host baseline identify this case.
For the second test, restore dns.lab.test and change only +tls-ca to "$PWD/pki/wrong-ca.crt". The unrelated CA also produced the certificate error, exit code 1, and no answer. Restore both the proper CA and hostname; the same DoT query succeeds again. Do not make the repair by removing the authentication checks.
For DoH, keep its valid CA, hostname, and SNI but replace +https=/dns-query with +https=/wrong. Selected measured stdout:
;; HTTP session (HTTP/2-POST)-(dns.lab.test/wrong)-(status: 404)This query returned exit code 1 without a DNS answer, after reaching an HTTP/2 endpoint that returned 404. TLS identity was accepted; the HTTP path was wrong. Restoring /dns-query returned HTTP 200 and the original A record. A wrong certificate name and a wrong URI path fail at different stages even when the tool ultimately reports a failed query for both.
Carry a DNSSEC-bogus answer over verified TLS
Stop the healthy transport server and restart the foreground command using named-bogus-transport.conf. This selects the signed parent with the wrong child DS from the prerequisite lab. The child A record and TLS certificate remain unchanged.
Repeat the authenticated DoT and DoH commands. Both returned the same signed A answer with NOERROR; the DoH exchange again reported HTTP status 200. The response flags were qr aa, without AD. This authoritative server is not acting as a validating recursive resolver.
Now run the local validator against the plain listener:
sudo ip netns exec "$DNS_CLIENT" delv \
@192.0.2.20 -p 1053 -a "$PWD/anchor.conf" +root=lab.test \
www.secure.lab.test. A +rtrace +vtrace +trustIt rejected the chain with no DNSKEY matching DS and resolution failed: broken trust chain. This is a separate local DNSSEC check against the same server state.
kdig +dnssec requested the DNSSEC records; it did not establish their chain of trust. TLS authenticated the configured endpoint and protected that transport exchange. Those successful checks can coexist with a bogus DNSSEC delegation, as the DNSSEC fault investigation shows in detail. A validating recursive endpoint could instead refuse the bogus result; that is a different server configuration from this experiment.
For the final recovery, restart named-good-transport.conf. The measured delv result returned to fully validated, while the strictly authenticated DoT and DoH queries continued to return the expected answer. The recovery asserts both layers rather than treating the appearance of an encrypted DNS answer as proof of DNSSEC health.