DNSSEC in a Container Lab: Sign a Zone, Tamper With It, and Watch the Resolver Say No

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.

Labs 05 and 06 trusted whatever the authoritative servers said. This lab adds cryptographic proof: the zone is signed, and a validating resolver checks the signatures before it believes an answer.

You will watch three things:

  • A validated answer whose response carries the AD (Authenticated Data) flag, alongside the RRSIG signature record.
  • The zone's public keys — a KSK (key signing key, DNSKEY flag 257) and a ZSK (zone signing key, 256).
  • A tampered answer that the resolver refuses to return, replying SERVFAIL because the signature no longer matches — and how +cd (checking disabled) bypasses that check.

Reading guide: rfc-notes/dns-dnssec-validation.md

Prerequisite: DNS Lab 05: Recursive Resolution You Can Trace

Expected time: 55–70 minutes.

The Goal

By the end, you should be able to fill in this table for www.example.lab:

Query Resolver behavior Status AD flag
signed, untouched validates the RRSIG against the trust anchor NOERROR set
tampered A record signature no longer covers the data → reject SERVFAIL
tampered, with +cd validation disabled, returns data as-is NOERROR

What You Will Learn

  • What a signed zone contains: RRSIG, DNSKEY, and NSEC records next to the normal data.
  • The split between the KSK and the ZSK, and why a resolver needs a trust anchor.
  • How a validating resolver sets the AD flag only when the chain of signatures checks out.
  • Why a single altered byte turns a good answer into SERVFAIL (a fail-closed design).
  • What the +cd (Checking Disabled) flag does, and why it is a debugging tool, not a fix.

This lab does not cover:

  • The full chain of trust from the real root (.lab.example.lab.); here we configure the example.lab key as a direct trust anchor (an "island of trust").
  • NSEC3, DNSSEC key rollovers (RFC 5011), or algorithm rollovers.
  • DANE / TLSA or other records that build on DNSSEC.

Where to Read in the RFCs

DNSSEC is defined by the trio RFC 4033 / 4034 / 4035 — the essential reading for this lab:

RFC Sections What to focus on
RFC 4033 2, 5 The purpose of DNSSEC; the trust anchor and authentication chain concepts
RFC 4034 2, 3 The DNSKEY and RRSIG formats (KSK/ZSK, what gets signed)
RFC 4034 4 NSEC (authenticated denial of existence for missing names)
RFC 4034 5 DS (the record that links a child zone's key from its parent)
RFC 4035 3.2.3, 4.3 Validating-resolver behavior; the AD and CD flags
RFC 6605 all ECDSA (this lab's algorithm 13, ECDSAP256SHA256)

The Big Picture

One client, one validating resolver, and one authoritative server hosting the signed zone.

client (10.0.0.2) --- resolver (10.0.0.1 / 10.0.1.1) --- auth (10.0.1.2)
    dig +dnssec        validating recursive              serves signed example.lab
                       trust-anchor: example.lab KSK      (RRSIG / DNSKEY / NSEC)

The resolver has the example.lab KSK statically configured as a trust anchor. Any answer about example.lab that does not come with an RRSIG chaining up to this key is treated as bogus, and the resolver returns SERVFAIL. In real operation, trust chains down from the root via DS records; here we simplify observation by making a single-zone island of trust.

203.0.113.0/24 is an RFC 5737 documentation prefix, used only inside the lab and never leaked outside.

sequenceDiagram
  participant C as client (dig +dnssec)
  participant R as resolver (validating)
  participant A as auth (signed example.lab)

  Note over R: trust anchor = example.lab KSK (257)
  C->>R: www.example.lab A? (DO=1)
  R->>A: www.example.lab A? (DO=1)
  A-->>R: A 203.0.113.10 + RRSIG
  Note over R: RRSIG verifies against ZSK,<br/>ZSK verifies against KSK (trust anchor)
  R-->>C: A 203.0.113.10  (AD flag set)

  Note over A: an attacker changes the A record<br/>but cannot forge the RRSIG
  C->>R: www.example.lab A? (DO=1)
  R->>A: www.example.lab A? (DO=1)
  A-->>R: A 203.0.113.66 + old RRSIG
  Note over R: signature does not cover 203.0.113.66
  R-->>C: SERVFAIL  (fail closed)

Note: Everything here uses documentation address space (RFC 5737), so nothing in this lab touches the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab
  • A BIND9 container image
  • A netshoot container image (client tools)

Images used:

  • protocol-lab/bind9:9.20 — built locally from examples/dns-13/Dockerfile; a thin wrapper that adds iproute2 on top of internetsystemsconsortium/bind9:9.20.
  • nicolaka/netshoot:latest

The zone is pre-signed (examples/dns-13/auth/db.example.lab.signed). The signatures were produced with dnssec-keygen and dnssec-signzone, and the resolver's trust anchor is taken from that KSK. run.sh builds the BIND image before deploying.

Running the Lab

These steps run inside the Linux environment where containerlab is installed.

If you have the repo checked out, the quick path deploys, loads the signed zone, confirms the validated (AD) answer, confirms the tamper-induced SERVFAIL, and tears everything down for you:

./scripts/labctl.sh run dns-13

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/dns-13

2. Read the signed zone

Before running anything, look at how signing changed the zone.

cat auth/db.example.lab          # before signing (the plain, human-readable zone)
cat auth/db.example.lab.signed   # after signing (RRSIG / DNSKEY / NSEC added)

How to read it:

  • The plain zone contains only ordinary records like www.example.lab. A 203.0.113.10.
  • After signing, each RRset gains a neighboring RRSIG (the signature), the apex gains DNSKEY records (the public KSK/ZSK), and NSEC records chain the names together.
  • The resolver's trust anchor lives in resolver/named.conf as trust-anchors { "example.lab." static-key 257 3 13 "..."; }; — the public half of the KSK used to sign the zone.

3. Deploy

Build the BIND image (with iproute2) first, then deploy.

docker build -t protocol-lab/bind9:9.20 .
sudo containerlab deploy -t dns-13.clab.yml

Confirm that clab-dns-13-{client,resolver,auth} are up:

docker ps --format "table {{.Names}}\t{{.Status}}"

4. See a validated answer (the AD flag)

docker exec -it clab-dns-13-client dig +dnssec @10.0.0.1 www.example.lab A

What to look for:

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, ...
;; flags: qr rd ra ad; ...

;; ANSWER SECTION:
www.example.lab.  300  IN  A      203.0.113.10
www.example.lab.  300  IN  RRSIG  A 13 3 300 (...) example.lab. ...
  • flags: ... ad: AD (Authenticated Data) — the resolver's mark that it verified the signatures.
  • RRSIG A 13 ...: the signature over the A record. 13 is ECDSAP256SHA256.

5. See the zone's public keys (KSK / ZSK)

docker exec -it clab-dns-13-client dig +dnssec @10.0.0.1 example.lab DNSKEY
  • DNSKEY 257 3 13 ...: the KSK (flag 257) — the key that serves as the trust anchor.
  • DNSKEY 256 3 13 ...: the ZSK (flag 256) — the key that signs the day-to-day records.

The KSK signs the ZSK, and the ZSK signs the individual records. The resolver walks the chain from the trust anchor (KSK) to the ZSK to each RRSIG.

6. Make the resolver detect tampering (SERVFAIL)

run.sh rewrites the www A record in the zone copy that auth is serving from 203.0.113.10 to 203.0.113.66, leaves the signatures untouched, and reloads — recreating the situation where an attacker can change the data but cannot forge a valid signature. It then flushes the resolver's cache and asks again.

To reproduce it by hand:

# rewrite auth's live copy (/var/cache/bind/…) and reload
docker exec clab-dns-13-auth sh -c \
  "sed 's/203.0.113.10/203.0.113.66/' /var/cache/bind/db.example.lab.signed > /var/cache/bind/db.tmp \
     && mv /var/cache/bind/db.tmp /var/cache/bind/db.example.lab.signed \
     && chown bind /var/cache/bind/db.example.lab.signed; kill -HUP \$(pidof named)"
docker exec clab-dns-13-resolver rndc flush
docker exec -it clab-dns-13-client dig +dnssec @10.0.0.1 www.example.lab A

What to look for:

;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, ...
;; flags: qr rd ra; ...   (no ad flag)

The resolver's log tells you why it failed:

docker logs clab-dns-13-resolver 2>&1 | grep -i "no valid signature"

7. Disable validation and peek at the data (+cd)

With +cd (Checking Disabled), the resolver skips validation and returns the raw data.

docker exec -it clab-dns-13-client dig +cd @10.0.0.1 www.example.lab A +short

After the tampering, this returns 203.0.113.66. In other words, the data was arriving all along — DNSSEC had simply judged it untrustworthy. +cd is handy for root-causing, but using it routinely throws away DNSSEC's protection.

Expected Output

Exact matches matter less than confirming these:

  • Signed www: status: NOERROR, flags: ... ad, an RRSIG present, and 203.0.113.10.
  • DNSKEY: one 257 (KSK) and one 256 (ZSK).
  • Tampered www: status: SERVFAIL, no ad, and no valid signature in the resolver log.
  • With +cd: 203.0.113.66 — turn off validation and the tampered data becomes visible.

Why It Works

DNSSEC adds origin authentication and integrity to DNS answers. It does not encrypt (the content stays visible). What it protects is the claim "this answer really came from that zone and was not altered in transit."

  • Signatures (RRSIG). Each RRset in the zone is signed with the private key; the resolver verifies with the corresponding public key (DNSKEY). Change even one byte of the data and the signature no longer matches.
  • KSK/ZSK separation. The ZSK is used constantly (it signs every record), so it's built to be short-lived and easy to replace. The KSK signs only the DNSKEY RRset and serves as the trust anchor. Keeping the KSK long-lived means you can rotate the ZSK without touching the trust registered upstream (the DS record or trust anchor).
  • Trust anchors and the chain. The resolver starts with a key it trusts a priori — the trust anchor. In production that's the root KSK: the root signs the TLDs' DS records, TLDs sign their children's DS records, and so on down the chain. This lab skips the chain and installs the example.lab KSK directly as the trust anchor.
  • The AD flag. The resolver sets AD in a response only when it could validate the chain all the way to a trust anchor. A stub (the client's dig) learns "this was validated" from that single bit — which is only meaningful if the path between client and resolver is itself trustworthy.
  • Fail closed. If a signature cannot be verified, the resolver refuses with SERVFAIL rather than returning stale or suspicious data. The design errs on the side of safety.
  • The CD flag. +cd is the client saying "I'll do the validation myself — give me the raw data without checking." It's for debugging and for the special case of clients that validate on their own.

The key insight: DNSSEC uses keys to guarantee where an answer came from and that it's intact — and if it's broken, it silently discards it.

Common Pitfalls

  • DNSSEC is not encryption. The content is visible in plaintext (unlike TLS in Lab 09). What it protects is authenticity and integrity.
  • Confusing AD with RRSIG. RRSIG is signature data attached by the server. AD is the flag the resolver sets to say "I verified this." They are different things.
  • Reading SERVFAIL as "server down." A DNSSEC validation failure also produces SERVFAIL. Distinguish the cause via log lines like no valid signature.
  • Thinking +cd "fixed it." +cd merely disables validation. Getting an answer back says nothing about its correctness.
  • Signature lifetimes. RRSIGs carry inception/expiration times. This lab's zone is signed with a long window (through ~2036), but production zones need periodic re-signing. Expired signatures are another cause of SERVFAIL.
  • Mismatched trust anchors. If the resolver's trust anchor doesn't match the zone's actual KSK, even correct signatures fail to validate and you get SERVFAIL.

Cleanup

If you deployed manually, destroy the topology:

sudo containerlab destroy -t dns-13.clab.yml --cleanup

If you used labctl.sh run dns-13, the script runs destroy for you at the end.

Check Your Understanding

  1. Beyond the ordinary records, what kinds of records does a signed zone gain? (Name three.)
  2. What's the difference between the KSK and the ZSK? Why split them into two keys?
  3. When does a resolver set the AD flag on a response? How does a stub use it?
  4. Why does changing a single byte of the A record turn the answer into SERVFAIL?
  5. What does +cd (Checking Disabled) do? Why shouldn't it be used routinely?
  6. This lab made the example.lab KSK a direct trust anchor. In production DNS, where does that trust come from, and how does it chain down?

References

Verified Run Log (2026-07-05)

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
  • resolver / auth: protocol-lab/bind9:9.20, a thin wrapper around internetsystemsconsortium/bind9:9.20 (BIND 9.20.24)
  • client: nicolaka/netshoot:latest (dig 9.20.23)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dns-13 performed deploy → verify → destroy, and verification.json returned "status": "verified". The zone was pre-signed with ECDSAP256SHA256 (algorithm 13), with RRSIG validity set about ten years out (through ~2036).

The validated answer (AD flag + RRSIG)

$ docker exec clab-dns-13-client dig +dnssec @10.0.0.1 www.example.lab A

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 29151
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; ANSWER SECTION:
www.example.lab.	300	IN	A	203.0.113.10
www.example.lab.	300	IN	RRSIG	A 13 3 300 20360702202237 20260705202237 11788 example.lab. vlD55Qq025WbrlJYwdCrzP0EszjfbGZUBl37DPL9Bd0E43tHyLWwNq0z NwZCptsHI2FoCPkb/o9P9qHDZc6+qQ==

flags: ... ad is set, and the A record is accompanied by RRSIG A 13 (ECDSAP256SHA256, key tag 11788 = the ZSK). This shows the resolver validated the chain up to the trust anchor.

The zone's public keys (KSK 257 / ZSK 256)

$ docker exec clab-dns-13-client dig +dnssec @10.0.0.1 example.lab DNSKEY

;; ANSWER SECTION:
example.lab.	300	IN	DNSKEY	257 3 13 <KSK public key>
example.lab.	300	IN	DNSKEY	256 3 13 <ZSK public key>

257 is the KSK (the trust anchor's source key); 256 is the ZSK.

Tampering → SERVFAIL (fail closed)

After rewriting the www A record from 203.0.113.10 to 203.0.113.66 (signatures untouched) and flushing the cache, the same query gets:

$ docker exec clab-dns-13-client dig +dnssec @10.0.0.1 www.example.lab A

;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 20532
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

status: SERVFAIL, no ad, ANSWER 0. The resolver's log:

$ docker logs clab-dns-13-resolver | grep "no valid signature"
validating www.example.lab/A: no valid signature found

Disable validation and the tampered data appears (+cd)

$ docker exec clab-dns-13-client dig +cd @10.0.0.1 www.example.lab A +short
203.0.113.66

+cd (Checking Disabled) skips validation, so the tampered 203.0.113.66 comes straight through. It's a neat demonstration that DNSSEC doesn't hide data — it rejects data that's broken.

Cleanup

containerlab destroy -t dns-13.clab.yml --cleanup

That's DNSSEC in miniature: sign the zone, anchor the trust, and let the resolver do the checking — a good answer gets the AD flag, and a tampered one gets thrown away.

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 follow the chain of trust further — how DS records link a parent zone to its child, turning an island of trust into a full path from the root.

Read more