Module 04 — The Wire Protocol: UDP, TCP & EDNS(0)

Updated 20 August 2026

Module 04 · The Wire Protocol: UDP, TCP & EDNS(0)

Every dig response you have read carried an OPT PSEUDOSECTION that was never explained, a flags line you learned four letters of, and a size limit you have never hit. This module opens the packet. By the end, "does DNS use TCP or UDP?" stops being a trick question.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–03. You need the dig header, the five sections, RCODEs, referrals, and why a label is 63 octets.

Run this module's exercises on a real machine, not inside a container behind a DNS proxy. Many sandboxes, corporate networks and container runtimes intercept port 53 and rewrite responses — they normalise EDNS buffer sizes, strip the tc bit, and answer on your behalf. If your output disagrees with what is shown here, that interception is the most likely reason, and discovering it is itself a useful finding about the network you are on.

Part A · The DNS message

A1 · One format, for questions and answers alike

The analogy. Think of a standard order form.

The form the customer fills in and the form the shop sends back are the same printed sheet. The customer fills in the top box and leaves the rest blank. The shop fills in the rest and returns it.

That is why a DNS query and a DNS response have an identical structure — a query is just the same message with three sections still empty.

A DNS message is a 12-byte header followed by four sections. Queries and responses use the same structure — a query is simply a message with a filled-in question and three empty sections.

plain text
+---------------------+
|       HEADER        |  12 bytes, always
+---------------------+
|      QUESTION       |  what was asked
+---------------------+
|       ANSWER        |  records answering it
+---------------------+
|     AUTHORITY       |  NS records (referral) or SOA (negative)
+---------------------+
|     ADDITIONAL      |  glue, and the OPT pseudo-record
+---------------------+

You have been reading exactly this in every dig response. The header is the ->>HEADER<<- line plus the counters; the four sections are the four ;; ... SECTION: blocks.

The header is only 12 bytes, and everything expensive about DNS follows from that. Two bytes of transaction ID, two bytes of flags and codes, and four two-byte counters — one per section. That is the entire control plane.

Because the counters are 16 bits, a message can in principle carry 65,535 records per section. Because the transaction ID is only 16 bits, there are only 65,536 possible values — and that number is the root of the spoofing problem in Part C.


A2 · The flags, one bit at a time

The analogy. Think of the tick boxes down the side of that form.

Some are ticked by you — "please deliver", "do not substitute". Some are ticked by the shop — "in stock", "checked by supervisor".

dig only prints the boxes that are ticked. So an empty box tells you as much as a ticked one: if the supervisor's box is blank, nobody checked it.

dig prints the flags that are set and omits the rest. That is why the flags line is short and why an absent flag is as informative as a present one.

FlagNameMeaning, and who sets it
qrQuery / ResponseSet in every response. Answerer
aaAuthoritative AnswerThe answering server holds the zone. Answerer
tcTrunCatedThe response did not fit and was cut short. Answerer — Part B
rdRecursion DesiredPlease do the work for me. Asker
raRecursion AvailableI am willing to. Answerer
adAuthentic DataThe resolver DNSSEC-validated this. Answerer — Module 07
cdChecking DisabledDo not validate; give me the data unchecked. Asker — Module 07
doDNSSEC OKNot a header flag at all — it lives in the OPT record. Asker — B3
Three flags you have already used as diagnostics, now with their proper names. aa absent means you are reading a cache. ra absent means the server does not do recursion for you — the signature of an authoritative-only server, and of a lame or misdirected query. tc set means the answer is incomplete and you are about to learn why in Part B.

The remaining bit worth knowing is that cd is the DNSSEC escape hatch: dig +cd asks a validating resolver to hand over data it would otherwise refuse. That single option is how you confirm a SERVFAIL is a DNSSEC failure rather than a network one — the technique promised back in Module 01 D3.

🧪 Exercise A2.1 — Make the flags change by changing the question
bash
# normal recursive query
dig example.com +noall +comments | grep flags

# same query, recursion not requested
dig example.com +norecurse +noall +comments | grep flags

# ask for DNSSEC data - watch a NEW indicator appear
dig org SOA +dnssec +noall +comments | grep -E 'flags|EDNS'

# tell the resolver not to validate
dig org SOA +dnssec +cd +noall +comments | grep flags
Expected result — click to reveal
plain text
$ dig example.com +noall +comments | grep flags
;; flags: qr rd ra; QUERY: 1, ANSWER: 6, AUTHORITY: 0, ADDITIONAL: 1

$ dig example.com +norecurse +noall +comments | grep flags
;; flags: qr ra; QUERY: 1, ANSWER: 6, AUTHORITY: 0, ADDITIONAL: 1

$ dig org SOA +dnssec +noall +comments | grep -E 'flags|EDNS'
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
; EDNS: version: 0, flags: do; udp: 1232

$ dig org SOA +dnssec +cd +noall +comments | grep flags
;; flags: qr rd ra cd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

Query 2 — rd disappeared, ra stayed. You stopped asking; the server still advertises that it would. Exactly the distinction from Module 01 C4.1, now with the bit names attached.

Query 3 — two new things appeared, and they are in different places. ad is in the header flags: the resolver validated this answer cryptographically and is vouching for it. do is in the EDNS line, not the header at all — DNSSEC OK, meaning "send me the signatures". Asker sets do; answerer sets ad.

Query 4 — ad vanished and cd appeared. You told the resolver not to validate, so it did not, and therefore has nothing to vouch for. This is the most useful diagnostic pair in DNSSEC: if a name SERVFAILs normally but succeeds with +cd, the data is reachable and validation is what is failing. That is a completely different investigation from a network fault, and one option separates them.

Note udp: 1232 in query 3. That is the EDNS buffer size, and it is the subject of B3 and B4. If your output says 512, something between you and the resolver is rewriting your queries.


A3 · RCODEs, and the ones above 15

The analogy. Think of a rubber stamp with only sixteen possible messages on it.

One day sixteen was not enough. Reprinting every form in the country was impossible — so they added a second, smaller stamp on an attached slip instead.

That is the whole story of how DNS gets extended: never change the original form, always clip something on. Old clerks who do not recognise the slip simply ignore it.

The status: field is only 4 bits — sixteen possible values, of which you met five in Module 01.

Four bits ran out, and the fix explains why EDNS exists. DNS needed more response codes than sixteen. Rather than change the header — which would break every implementation in the world — EDNS(0) added eight more bits of RCODE inside the OPT record, extending the range to 4,095.

That is the pattern to take away: DNS is extended by adding a pseudo-record, never by changing the header. Backwards compatibility is absolute, because a server that does not understand the extension simply ignores a record it does not recognise. Every DNS extension since 1999 works this way.

The extended code you will actually meet is BADVERS (16), meaning "I do not speak the EDNS version you asked for".

🎯 Interview questions — The message format

Q. What is in a DNS message?

A 12-byte header and four sections: question, answer, authority, additional. Queries and responses share the same format — a query just has the last three sections empty.

The header holds a 16-bit transaction ID, the flags, the 4-bit opcode and RCODE, and four 16-bit counters, one per section.

The two details worth volunteering: the RCODE is only 4 bits, which is why EDNS had to extend it rather than the header being changed — DNS is always extended by adding a pseudo-record, never by altering the header, so old implementations can ignore what they do not understand. And the transaction ID is only 16 bits, which is the whole reason source-port randomisation had to be added later.

Q. What do the ad and cd flags do?

ad — Authentic Data — is set by a validating resolver to say it checked the DNSSEC signatures and they were good. cd — Checking Disabled — is set by the client to tell the resolver not to validate and to return the data regardless.

Why they matter operationally rather than academically: +cd is the fastest way to diagnose a DNSSEC failure. A name that returns SERVFAIL normally but resolves with +cd tells you the data is reachable and validation is what is failing — a completely different problem from a network fault, isolated by one flag.

Worth adding that ad is only meaningful from a resolver you trust, since anything can set a bit in a packet; the guarantee comes from the channel to the resolver, not from the bit.


Part B · Transport

B1 · UDP first, and where 512 came from

The analogy. Think of a postcard versus registered post.

A postcard is one trip to the postbox. No queue, no form, no signature. If it goes missing you simply write another one.

Registered post means queueing, filling in a form, getting a signature — about five interactions instead of one. For a question as small as "what is this address", the postcard wins every time, which is why DNS reaches for UDP first.

DNS uses UDP by default because the fit is close to perfect: one small question, one small answer, no state worth keeping, and a client that can simply ask again if nothing comes back.

Compare the cost honestly and the choice is obvious. A UDP lookup is one packet out, one packet back. Over TCP the same lookup is a three-way handshake, then the query, then the response, then a teardown — around five round trips instead of one, plus connection state on a server handling tens of thousands of queries per second. At the front of every web request, that difference is the difference between a fast internet and a slow one.

And the 512-byte limit was not arbitrary. RFC 1035 capped UDP DNS messages at 512 bytes because that was the payload every IPv4 network was guaranteed to carry without fragmenting. It was a reliability decision: a fragmented UDP datagram is lost entirely if any fragment goes missing, and DNS could not afford that.

"DNS uses UDP" is a half-answer, and interviewers ask it precisely because most candidates stop there. The full answer is: UDP by default, TCP when the response does not fit, and TCP always for zone transfers. Modern practice adds that a server must accept TCP on port 53 — RFC 7766 makes it a requirement, not an option — so a firewall that permits UDP/53 and blocks TCP/53 is misconfigured, and has been for a decade.

B2 · Truncation and the fallback to TCP

The analogy. Think of the card the postman leaves when a parcel will not fit through the letterbox.

The card itself contains almost nothing — no part of your parcel, no summary of what is in it. It is one instruction: come and collect it from the depot.

That is exactly what a truncated DNS response is. And if the depot is closed — if TCP is blocked — you are left holding a card and no parcel, while all your small letters keep arriving perfectly normally.

When a response will not fit in the permitted UDP size, the server does not send a partial answer and hope. It sends a nearly empty response with the tc bit set, meaning: too big, ask again over TCP.

Diagram source
flowchart TD
    Q["client sends query over UDP"] --> S{"does the response fit<br>in the advertised buffer?"}
    S -->|"yes"| OK["full response over UDP<br>one round trip"]
    S -->|"no"| TC["response with tc=1<br>almost no records"]
    TC --> RETRY["client OPENS TCP<br>and asks again"]
    RETRY --> FULL["full response over TCP<br>about five round trips"]
    RETRY -.->|"TCP/53 blocked<br>by a firewall"| FAIL["TIMEOUT<br>looks like the domain<br>is broken"]
    style OK fill:#22c55e,color:#fff
    style TC fill:#f59e0b,color:#fff
    style FAIL fill:#ef4444,color:#fff
🧪 Exercise B2.1 — Force truncation on purpose, then watch the fallback
bash
# a big response: DNSSEC-signed NS set, squeezed into a 512-byte buffer.
# +ignore tells dig NOT to retry over TCP, so you can SEE the tc bit.
dig org NS +dnssec +bufsize=512 +ignore +noall +comments

# same query, allowed to behave normally
dig org NS +dnssec +bufsize=512 +noall +comments +stats
Expected result — click to reveal
plain text
$ dig org NS +dnssec +bufsize=512 +ignore +noall +comments
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41022
;; flags: qr rd ra ad tc; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

$ dig org NS +dnssec +bufsize=512 +noall +comments +stats
;; Truncated, retrying in TCP mode.
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41023
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 13, AUTHORITY: 0, ADDITIONAL: 1
;; Query time: 68 msec
;; SERVER: 1.1.1.1#53(1.1.1.1) (TCP)
;; MSG SIZE  rcvd: 1442

First command — tc is set and ANSWER: 0. This is the crucial thing to understand about truncation: the truncated response contains essentially nothing. It is not a partial answer you could use. It is a one-bit message meaning "come back over TCP".

Second command — read the three lines in order and the whole mechanism is there.

  • ;; Truncated, retrying in TCP mode.dig saw tc, gave up on UDP and opened a connection
  • ANSWER: 13 — the full response, which never could have fitted
  • SERVER: 1.1.1.1#53(1.1.1.1) (TCP) — the transport, printed explicitly. This line is the one to look for whenever you suspect a size problem
  • MSG SIZE rcvd: 1442 — nearly three times the 512-byte limit

Now the failure this sets up, and it is a genuinely common outage. If TCP/53 is blocked between the client and the server, the first query still succeeds — the truncated response arrives over UDP just fine. The retry is what fails, silently, as a timeout.

The symptom is diabolical: small answers work, large answers hang. dig example.com is fine. dig org NS +dnssec times out. A domain resolves until it adds DNSSEC or a fifth mail server, and then it half-breaks. Nobody suspects the firewall, because DNS "is working".

Now imagine this at 500 hosts behind a security group that allows UDP/53 and not TCP/53. Every DNSSEC-signed domain becomes unreliable, and the failures look random because they depend on response size, which depends on which records happen to be returned.


B3 · EDNS(0) and the OPT pseudo-section

The analogy. Think of an extra sheet stapled to the back of the form.

The form was printed in 1987 and cannot be reprinted, so everything invented since gets clipped on the back: a bigger delivery limit, a request for signatures, extra stamp codes.

The clever part is what happens with an old clerk who has never seen the extra sheet: they ignore it and process the form normally. Nothing breaks. The trouble is the rare clerk who rejects the whole form because of the staple — and that clerk is invisible until you meet them.

512 bytes stopped being enough long ago — DNSSEC signatures alone blow past it. But the header could not be changed. EDNS(0) is the answer, and it has appeared in every dig output you have ever run:

plain text
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags: do; udp: 1232
The OPT record is a fake record that carries protocol options. It is placed in the ADDITIONAL section, which is why every response you have seen says ADDITIONAL: 1 even when there is nothing extra to send. It is a record by construction and a header extension by purpose.

It carries three things worth knowing:

  1. udp: NNNN — the largest UDP response the sender is willing to receive. This is what replaces the 512-byte limit
  2. flags: do — DNSSEC OK. "Send me signatures." Module 07
  3. The extended RCODE bits from A3

And the compatibility trick is the elegant part: a server that has never heard of EDNS sees an unknown record in the ADDITIONAL section and ignores it, then replies without an OPT record of its own. The client sees no OPT in the response, concludes the server does not speak EDNS, and falls back to 512 bytes. Nothing breaks; capability is negotiated per query.

Except that some middleboxes break it, and that is what RFC 8906 exists to name. Certain firewalls and old DNS appliances drop queries containing an OPT record instead of ignoring it, or reply with FORMERR. The result is a server that answers plain queries and silently discards EDNS ones.

Because virtually every modern resolver sends EDNS by default, such a server appears completely dead to the modern internet while working perfectly when you test it with an old tool. This was widespread enough that the major resolver operators coordinated a "DNS flag day" in 2019 and stopped working around it — after which non-compliant domains simply stopped resolving.

The diagnostic is one option: if dig +noedns name succeeds where dig name times out, you have found an EDNS-hostile middlebox or server.

🧪 Exercise B3.1 — Turn EDNS off and watch the size limit reappear
bash
dig org SOA +dnssec +noall +comments | grep -E 'EDNS|flags'

# no OPT record at all - back to 1987
dig org SOA +dnssec +noedns +noall +comments +stats | grep -E 'EDNS|flags|MSG SIZE|SERVER'
Expected result — click to reveal
plain text
$ dig org SOA +dnssec +noall +comments | grep -E 'EDNS|flags'
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
; EDNS: version: 0, flags: do; udp: 1232

$ dig org SOA +dnssec +noedns +noall +comments +stats | grep -E 'EDNS|flags|MSG SIZE|SERVER'
;; flags: qr rd ra tc; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

With EDNS: ADDITIONAL: 1, buffer 1232, do set, and the signed answer arrives.

Without EDNS: ADDITIONAL: 0, no OPT line, and tc is set. Three consequences arrived together, and all three follow from removing one pseudo-record:

  • There is nowhere to advertise a buffer size, so the limit is 512 bytes
  • There is nowhere to put the do bit, so DNSSEC cannot even be requested — the whole of Module 07 depends on EDNS existing
  • The signed response does not fit, so it is truncated

This is the single most useful thing to take from Part B. EDNS is not an optional optimisation bolted onto DNS. Modern DNS does not function without it. DNSSEC needs it, client-subnet needs it, cookies need it, and every response larger than 512 bytes needs it.

Which is exactly why an EDNS-hostile middlebox is so destructive — it does not degrade DNS, it removes everything added since 1999.


B4 · Buffer sizes, fragmentation, and why 1232

The analogy. Think of an order split across three boxes.

If one box goes missing, the whole order is useless — you cannot use two-thirds of a delivery. Worse, the courier only reads the label on the first box, so the other two travel almost anonymously.

That is why the size limit was lowered rather than raised: a single box that always arrives beats three boxes that usually do. And the anonymous boxes are a security problem too — anyone can slip a fake one into the pile.

EDNS lets you advertise any buffer size you like. For years the common default was 4096, and that turned out to be a mistake.

Advertising a large buffer does not make large packets work — it makes them fragment. A 3,000-byte UDP response exceeds the typical 1,500-byte Ethernet MTU, so IP splits it into fragments. Three problems follow, and they get progressively worse:
  1. All-or-nothing loss. Lose one fragment and the entire response is unusable. There is no partial delivery and no retransmission at the IP layer
  2. Firewalls drop fragments. Many drop non-initial fragments by policy, because only the first carries the port numbers they filter on
  3. Fragments are a security problem. An off-path attacker only has to forge a later fragment, which carries no transaction ID and no port numbers — so the usual defences in Part C do not apply to it at all

The industry response was to advertise a smaller buffer, and the value that settled out is 1232 — chosen to fit inside the smallest IPv6 MTU of 1280 bytes with headers to spare, so a DNS response never fragments on any conforming path. It is the default in modern BIND, Unbound and Knot, and it is why your dig says udp: 1232.

The trade-off, stated plainly: more responses now exceed the buffer and fall back to TCP. That is a deliberate choice — TCP is slower but reliable; fragmented UDP is fast and unreliable. Which is also why B2's blocked-TCP failure has become much more damaging than it was ten years ago.

🧪 Exercise B4.1 — Find the size at which a real response stops fitting
bash
for size in 512 768 1024 1232 1500 4096; do
  printf 'bufsize %-5s ' "$size"
  dig org NS +dnssec +bufsize=$size +ignore +noall +comments 2>/dev/null \
    | grep -q ' tc;' && echo "TRUNCATED" || echo "fits"
done

# and the actual size of the response
dig org NS +dnssec +noall +stats | grep 'MSG SIZE'
Expected result — click to reveal
plain text
bufsize 512   TRUNCATED
bufsize 768   TRUNCATED
bufsize 1024  TRUNCATED
bufsize 1232  TRUNCATED
bufsize 1500  fits
bufsize 4096  fits
;; MSG SIZE  rcvd: 1442

The crossover sits between 1232 and 1500, and MSG SIZE rcvd: 1442 tells you exactly where.

Read what that means for a real deployment. With the modern 1232 default, this perfectly ordinary query — the NS set of a signed TLD — requires TCP. Not an edge case, not a pathological zone: a signed delegation with a normal number of nameservers.

So TCP/53 is not a rare fallback you can afford to leave blocked. For any DNSSEC-signed zone it is part of the normal path. Firewall rules written in 2010 that allow UDP/53 only are actively breaking DNSSEC today.

The other lesson is about response size as a design input. Every record you add to a zone apex — another nameserver, another MX, a longer SPF string, DNSSEC signatures — pushes responses toward the boundary. A zone that answers in 1200 bytes today can cross into mandatory-TCP territory because somebody added a fifth mail server. MSG SIZE rcvd is worth watching on your own apex, and D2 turns that into a check.

🎯 Interview questions — Transport

Q. Does DNS use TCP or UDP?

Both, and the interviewer is checking whether you stop at "UDP". UDP is the default because a lookup is one small question and one small answer — a single round trip, no connection state on a server handling tens of thousands of queries per second.

TCP is used in three situations: when a response is too large for the advertised UDP buffer and comes back with the tc bit set; always for zone transfers, which are far too big for a datagram; and increasingly for DNSSEC-signed responses, which routinely exceed the modern buffer size.

The point that shows current knowledge: since the buffer default dropped from 4096 to 1232 to avoid IP fragmentation, TCP fallback happens far more often than it used to. RFC 7766 makes TCP support mandatory, so a firewall allowing UDP/53 but not TCP/53 is misconfigured — and the symptom is the nastiest kind: small answers work, large ones hang.

Q. What is EDNS(0) and why does it matter?

An extension mechanism that adds an OPT pseudo-record to the additional section, carrying options the 12-byte header has no room for: a larger UDP buffer size, extra RCODE bits, and the DNSSEC OK flag.

It is negotiated per query and degrades cleanly — a server that does not understand OPT ignores it and answers without one, so the client falls back to 512 bytes.

What makes it more than trivia: modern DNS does not work without it. DNSSEC cannot even be requested without the do bit, which lives in the OPT record — so no EDNS means no DNSSEC, no responses over 512 bytes, no client-subnet, no cookies. That is why an EDNS-hostile middlebox that drops OPT queries is so destructive, and why the 2019 flag day removed the workarounds and let non-compliant domains fail. The one-line diagnosis is dig +noedns: if that works where a normal query times out, you have found one.

Q. Why did the recommended EDNS buffer size drop from 4096 to 1232?

To avoid IP fragmentation. A 4096-byte advertisement invites responses larger than the path MTU, and a fragmented UDP datagram is lost entirely if any one fragment is dropped — which firewalls do routinely, since non-initial fragments carry no port numbers.

1232 is chosen to fit inside the 1280-byte minimum IPv6 MTU with headers to spare, so responses never fragment on a conforming path.

The security half, which is the part people miss: fragmentation is an attack surface. An off-path attacker forging a later fragment does not have to guess the transaction ID or the source port, because those only appear in the first fragment — so the standard anti-spoofing defences simply do not apply. Choosing reliable TCP fallback over fast fragmented UDP was a security decision as much as a reliability one.


Part C · Forging an answer, and the defences

C1 · The off-path attacker's problem

The analogy. Think of someone trying to answer your phone call before your bank does.

To pull it off they must guess which number you dialled, the exact second you dialled it, and the reference number you quoted — and get their voice in first.

Originally only the reference number was hard to guess, and they could keep trying forever at no cost. Everything in this section is about making that guess more expensive — and about the one measure that stops it being a guessing game at all.

UDP has no handshake, so a response is accepted on the strength of what is written in it. An attacker who can guess those fields can answer instead of the real server.

To have a forged response accepted, an off-path attacker must get five things right, and all of them at once:
  1. The right source address — the real nameserver's (trivially forged)
  2. The right destination port — the resolver's source port
  3. The right transaction ID — 16 bits
  4. The right question, echoed exactly
  5. And it must arrive before the genuine response

In the original design, only item 3 was actually unknown. The source port was frequently a fixed value, the question is whatever the attacker triggered, and arriving first is achievable if you make the real server slow or simply flood. So the attack reduced to guessing one 16-bit number — 65,536 possibilities, which a flood of forged packets exhausts quickly.

Kaminsky's 2008 contribution was making that guess cheap to repeat. By querying random1.bank.com, random2.bank.com and so on, an attacker gets an unlimited supply of fresh races — and each forged reply can include a poisoned NS record for bank.com in the AUTHORITY section, hijacking the whole zone rather than one name. Failed attempts cost nothing, so the attacker simply keeps trying.


C2 · What was added, and what it actually buys

The analogy. Think of a longer PIN versus a verified signature.

Adding digits to a PIN makes guessing slower. Add enough and it becomes impractical — but it is still, in principle, guessable.

A signature checked against a specimen on file is a different kind of thing entirely: a forgery is not unlikely, it is detectable. Everything in the table below except DNSSEC is a longer PIN. DNSSEC is the signature.

DefenceBits addedWhat it does
Transaction ID16The original, and alone it is not enough
Source port randomisation~16The 2008 emergency fix. Raises the guess to roughly 2³² combinations
0x20 encoding~1 per letterRandomise the case of the queried name and require it echoed exactly. Free, thanks to case-insensitive matching
DNS cookies64An EDNS option carrying a shared secret between client and server
Fragment avoidanceCloses the hole where later fragments carry no ID or port at all
DNSSECcryptographicThe only one that actually solves it rather than raising the cost. Module 07
Every defence except DNSSEC is a probability argument, and it is important to be honest about that. They make guessing expensive — not impossible. 0x20 is the nicest of them precisely because it is free: DNS matching has always been case-insensitive while case is preserved on output, so a resolver can ask for ExAMpLe.cOM and discard any reply that does not echo the capitalisation back. Module 01 A4 taught the property; this is what it was for.

DNSSEC is categorically different. It does not make forgery unlikely, it makes forged data detectably invalid — the attacker would need the zone's private key. That is why the other measures are described as raising the cost, and DNSSEC as closing the hole.

And it explains why fragmentation is treated so seriously in B4. A later fragment carries neither the transaction ID nor the port numbers, so source-port randomisation, 0x20 and the transaction ID all provide exactly zero protection against a forged non-initial fragment. Removing fragmentation removes the bypass.

🧪 Exercise C2.1 — Confirm your resolver randomises its source ports
bash
# each query should leave from a DIFFERENT source port.
# run this on a machine where you can see your own traffic.
sudo timeout 8 tcpdump -n -i any 'udp port 53 and outbound' 2>/dev/null &
sleep 1
for i in 1 2 3 4 5; do dig +short probe$i.example.com > /dev/null; done
wait
Expected result — click to reveal
plain text
13:20:41.101 IP 10.0.0.5.54219 > 8.8.8.8.53: 12043+ A? probe1.example.com. (36)
13:20:41.140 IP 10.0.0.5.38871 > 8.8.8.8.53: 41255+ A? probe2.example.com. (36)
13:20:41.178 IP 10.0.0.5.61004 > 8.8.8.8.53: 8891+  A? probe3.example.com. (36)
13:20:41.215 IP 10.0.0.5.49330 > 8.8.8.8.53: 55710+ A? probe4.example.com. (36)
13:20:41.252 IP 10.0.0.5.17662 > 8.8.8.8.53: 30402+ A? probe5.example.com. (36)

Read the two numbers that change on every line. The source port — 54219, 38871, 61004 … — and the transaction ID before the +12043, 41255, 8891 … Both are random per query, and both must be guessed correctly for a forgery to land.

That is the ~2³² combinations from the table, made visible. With only the transaction ID randomised it would be 65,536 — a flood of forged packets gets there in seconds. With both, an off-path attacker needs on the order of four billion attempts per race, and the genuine answer arrives long before that.

What a broken system looks like: the same source port on every line. That is a NAT device or an old resolver de-randomising ports — NAT is the common culprit, because a device that rewrites source ports to a predictable range silently undoes the 2008 fix for every client behind it. Worth checking on any network where you do not control the middleboxes.

If you are inside a container behind a DNS proxy, you will see the proxy's ports rather than the resolver's, which tells you about the proxy and not much else.

🎯 Interview questions — Spoofing

Q. What is DNS cache poisoning and what prevents it?

An attacker gets a resolver to cache a forged answer by racing the real nameserver with a spoofed UDP response. To be accepted, the forgery must match the source address, destination port, transaction ID and question, and arrive first — and originally the transaction ID was the only genuinely unknown part, just 16 bits.

Kaminsky's refinement made the race repeatable: query random subdomains for an unlimited supply of attempts, and put a poisoned NS record in the authority section so a single win hijacks the entire zone rather than one name.

Defences: source-port randomisation, which roughly doubles the entropy to 2³²; 0x20 case randomisation, which is free because DNS matching is case-insensitive but case is preserved; DNS cookies; and avoiding fragmentation.

The distinction worth drawing explicitly: all of those raise the cost of guessing — none of them makes forgery impossible. DNSSEC is the only one that solves the problem, because it makes forged data cryptographically detectable rather than merely improbable.

Q. What is 0x20 encoding?

A resolver randomises the capitalisation of the name it queries — ExAMpLe.cOM — and requires the response to echo it back exactly. Any reply with different capitalisation is discarded.

It works because DNS matching is case-insensitive while case is preserved on output, so it adds roughly one bit of entropy per letter at zero protocol cost and with no compatibility flag day.

Where it earns credit: it is a genuinely elegant defence that exploits an accident of the 1987 specification, and it is the practical payoff of the case-insensitivity rule from Module 01 A4. It is also worth knowing it can trip up badly written authoritative servers and middleboxes that normalise case, which is one of the rarer causes of intermittent resolution failure.


Part D · Field recipes

D1 · Diagnose a size or transport failure

The analogy. Think of a street where letters arrive but parcels never do.

Nobody would call that "the post is broken" — the post is clearly working. Something specific to large items is failing: a locked gate, a narrow doorway, a depot that never sends its van.

Small DNS answers working while big ones hang is the same shape of clue, and it points at the same kind of cause.

The symptom is always the same: small queries work, large ones hang. Four commands identify which layer.

bash
NAME=org
S=1.1.1.1        # or the server you suspect

echo "1. plain small query - baseline"
dig @$S $NAME SOA +noall +comments +stats | grep -E 'status|SERVER'

echo "2. large query over UDP - does it truncate cleanly?"
dig @$S $NAME NS +dnssec +ignore +noall +comments | grep -E 'flags'

echo "3. forced TCP - is TCP/53 reachable at all?"
dig @$S $NAME NS +dnssec +tcp +noall +comments +stats | grep -E 'status|SERVER|MSG SIZE'

echo "4. EDNS disabled - does the server hate OPT records?"
dig @$S $NAME SOA +noedns +noall +comments | grep -E 'status|flags'
Result patternDiagnosis
1 ok · 2 shows tc · 3 times outTCP/53 is blocked. Firewall or security group. The most common cause by far
1 times out · 4 okEDNS-hostile server or middlebox dropping OPT records. RFC 8906
1 ok · 2 does not truncate but 3 shows a huge MSG SIZEBuffer advertised too large — responses are fragmenting. Lower it toward 1232
All four okTransport is healthy. The fault is elsewhere — go back to Module 03 D1

D2 · Watch your own response sizes

The analogy. Think of hand-luggage weight at the airport.

Nobody weighs your bag while you are packing. You find out at the gate, and it is always one last item that tips you over — an item that on its own seemed harmless.

Zones gain weight the same way: another nameserver, another mail server, a longer text record. Weigh the bag at home.

Response size is a property of your zone that nobody monitors until it breaks something.

bash
#!/usr/bin/env bash
# response-size.sh <domain> - how close is each RRset to needing TCP?
D="${1:?usage: response-size.sh <domain>}"
printf '%-10s %-8s %s\n' "TYPE" "BYTES" "STATUS (limit 1232)"
for t in SOA NS A AAAA MX TXT CAA DNSKEY; do
  for extra in "" "+dnssec"; do
    sz=$(dig "$D" "$t" $extra +noall +stats 2>/dev/null \
         | awk '/MSG SIZE/{print $NF}')
    [ -z "$sz" ] && continue
    label="$t${extra:+ +dnssec}"
    if   [ "$sz" -gt 1232 ]; then st="OVER - needs TCP"
    elif [ "$sz" -gt 900 ];  then st="close - watch it"
    else                          st="fine"; fi
    printf '%-10s %-8s %s\n' "$label" "$sz" "$st"
  done
done
🧪 Exercise D2.1 — Measure a real zone and find what would break it
bash
chmod +x response-size.sh
./response-size.sh seamless.se
./response-size.sh org        # a signed TLD, for contrast
Expected result — click to reveal
plain text
$ ./response-size.sh seamless.se
TYPE       BYTES    STATUS (limit 1232)
SOA        105      fine
NS         180      fine
A          72       fine
MX         200      fine
TXT        299      fine
$ ./response-size.sh org
NS         1442     OVER - needs TCP

seamless.se is comfortable everywhere — largest response under 300 bytes. It is an unsigned zone with four nameservers and one SPF record.

org needs TCP for its NS set, because it is DNSSEC-signed and every RRset carries RRSIG records alongside it. That single contrast is the whole lesson: signing a zone roughly triples its response sizes.

So use this before you turn DNSSEC on, not after. A zone sitting at 400 bytes today will be near 1200 once signed, and a zone at 900 will require TCP for ordinary queries. Neither is a reason not to sign — it is a reason to confirm TCP/53 works end to end first, which is exactly the check in D1.

The other things that quietly grow responses: adding nameservers, adding mail servers, long SPF or DKIM records, and TXT records accumulating vendor verification tokens that nobody ever removes. The apex is where they all land, which is why the apex is what to measure.


Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart TD
    C["client builds a query<br>12-byte header<br>random txid + random source port<br>0x20 case randomised"] --> E{"EDNS?"}
    E -->|"yes - OPT record<br>udp 1232, do bit"| U["send over UDP"]
    E -->|"no - 1987 mode"| U512["send over UDP<br>hard 512-byte limit"]
    U --> F{"response fits<br>in 1232?"}
    U512 --> F512{"response fits<br>in 512?"}
    F -->|"yes"| DONE["full answer<br>ONE round trip"]
    F -->|"no"| TC["tc=1, empty response"]
    F512 -->|"no"| TC
    TC --> TCP["retry over TCP<br>~5 round trips"]
    TCP --> DONE2["full answer"]
    TCP -.->|"TCP/53 blocked"| DEAD["TIMEOUT<br>small answers work<br>large ones hang"]
    U -.->|"buffer set too large"| FRAG["IP fragmentation<br>all-or-nothing loss<br>+ spoofing bypass"]
    style DONE fill:#22c55e,color:#fff
    style DONE2 fill:#22c55e,color:#fff
    style TC fill:#f59e0b,color:#fff
    style DEAD fill:#ef4444,color:#fff
    style FRAG fill:#ef4444,color:#fff

Four ideas, and everything in this module follows from them.

  1. DNS is extended by adding a pseudo-record, never by changing the header. EDNS, DNSSEC, cookies and client-subnet all ride in OPT, which old servers safely ignore.
  2. 512 was a fragmentation-avoidance decision, and so is 1232. The number changed; the reasoning never did.
  3. TCP is not an exception any more. With 1232 buffers and signed zones, TCP fallback is part of the normal path — so blocking TCP/53 breaks DNSSEC while leaving simple lookups working.
  4. Every anti-spoofing measure except DNSSEC is a probability argument. They raise the cost of guessing; only cryptography makes forged data detectable.

E2 · Production practice

HabitWhy
Allow both UDP/53 and TCP/53 in every firewall and security groupRFC 7766 makes TCP mandatory. Blocking it produces the worst symptom there is: small answers work, large ones hang
Leave the EDNS buffer at the modern default of 1232Larger values invite IP fragmentation, which is both unreliable and a spoofing bypass
Measure your apex response sizes before enabling DNSSECSigning roughly triples response size. Confirm TCP works end to end first
Reach for +noedns when a server times out on normal queriesOne option separates an EDNS-hostile middlebox from a network fault
Reach for +cd when you see SERVFAILIf it resolves with validation disabled, the fault is DNSSEC, not the network
Read the SERVER: line for (TCP) or (UDP)It tells you a fallback happened, which is the clue that a size limit was hit
Watch MSG SIZE rcvd on the apex as records accumulateA fifth mail server or a longer SPF record can push a zone across the TCP threshold silently
Verify source-port randomisation is not undone by NATA NAT device rewriting to predictable ports silently reverses the 2008 anti-poisoning fix for everyone behind it
Never treat tc as an error in monitoringIt is a correct, healthy response meaning "ask again over TCP". Alerting on it produces noise

E3 · Capstone exercise

Everything here is from Parts A–D. Attempt it without scrolling back.

Brief. A colleague reports: "our new DNSSEC-signed domain resolves from my laptop but fails from the production subnet." Produce a diagnosis, with evidence, answering all seven:

  1. Which single dig option proves the response is too large for UDP, without letting dig silently fix it for you?
  2. Which dig output line proves a fallback to TCP actually happened?
  3. How do you distinguish "TCP/53 blocked" from "the server hates EDNS"? Give the command for each.
  4. Why does this fail only for the signed domain and not for the others on the same subnet?
  5. The buffer is advertised as 1232. Explain where 1232 comes from and what breaks at 4096.
  6. Name the four fields an off-path attacker must match to forge a response, and which two are randomised today.
  7. Why does IP fragmentation defeat both of those randomisations?
Model answer — attempt it first, then click

1. +ignore. It tells dig not to retry over TCP, so the truncated response is shown as received — flags: ... tc with ANSWER: 0. Without it, dig transparently retries and you never see the truncation that is the whole problem.

bash
dig @$SERVER example.com NS +dnssec +bufsize=512 +ignore +noall +comments

2. The SERVER: line.

plain text
;; SERVER: 1.1.1.1#53(1.1.1.1) (TCP)

dig also prints ;; Truncated, retrying in TCP mode. above it. The (TCP) marker is the durable evidence — the retry message is easy to lose in a pipeline.

3. Two commands, one each.

bash
dig @$SERVER name NS +dnssec +tcp    # blocked TCP -> times out here
dig @$SERVER name SOA +noedns        # EDNS-hostile -> SUCCEEDS here while plain query fails

The patterns are opposite and cannot be confused. TCP blocked: the small query works, the truncated response arrives fine, and only the +tcp attempt times out. EDNS-hostile: even the small query times out, and +noedns is what makes it work — because the server or middlebox is discarding the OPT record, not reacting to size.

4. Because signing roughly triples response size. Every RRset in a signed zone is accompanied by RRSIG records, so an NS set that was 300 bytes becomes 1400. Unsigned domains on the same subnet stay under 1232, never truncate, never fall back to TCP, and therefore never touch the blocked port. The firewall rule has been wrong for years and only became visible when this zone was signed.

5. 1232 fits inside the minimum IPv6 MTU of 1280 with headers to spare, so a DNS response never fragments on any conforming path. At 4096 the server happily emits a 3,000-byte datagram, IP splits it, and three things go wrong: losing any one fragment loses the whole response with no retransmission; firewalls drop non-initial fragments because they carry no port numbers to filter on; and — see 7 — fragments are a spoofing bypass.

6. Four fields: source address, destination port, transaction ID, and the echoed question — plus the requirement to arrive before the real answer. Randomised today: the transaction ID (16 bits, always was) and the source port (~16 bits, added in 2008). Together roughly 2³² combinations. 0x20 case randomisation adds about one bit per letter of the name on top.

7. Because a non-initial fragment contains neither of them. The transaction ID and the UDP port numbers appear only in the first fragment. An attacker forging a later fragment has nothing to guess — source-port randomisation, the transaction ID and 0x20 all provide exactly zero protection against it. Reassembly then produces a response the resolver accepts as genuine. That is why fragmentation avoidance is a security control and not merely a reliability tweak.

The five things most people miss:

  1. +ignore in requirement 1. Without it dig hides the exact symptom you are trying to demonstrate, and you conclude everything is fine
  2. Reading (TCP) on the SERVER: line. It is the one durable artefact that a fallback occurred
  3. Requirement 4 — that the firewall was already broken. The signing did not cause the fault, it revealed it. Saying so changes who owns the fix
  4. Requirement 5 — that 1232 comes from the IPv6 minimum MTU, not from Ethernet's 1500. That is why the number looks arbitrary and is not
  5. Requirement 7 — that fragmentation bypasses the defences entirely rather than merely weakening them. This is the point that turns "avoid fragmentation" from a performance tip into a security requirement

E4 · Official documentation

LinkCovers
RFC 1035 §4 — MessagesThe header, the four sections, the flags, RCODEs, name compression. All of Part A
RFC 6891 — EDNS(0)The OPT pseudo-record, buffer size, extended RCODEs, and the compatibility rules
RFC 7766 — DNS over TCP, Implementation Requirements · RFC 9210 — Operational RequirementsWhy TCP support is mandatory, connection reuse, and timeouts
RFC 9715 — IP Fragmentation Avoidance in DNS over UDPWhere 1232 comes from, and the security argument behind it
RFC 8906 — Failure to CommunicateThe standard test procedure for EDNS-hostile servers. Short and directly useful
RFC 5452 — Resilience against Forged AnswersThe spoofing analysis in Part C, including the entropy arithmetic
IANA DNS ParametersThe live registries: header flags, RCODEs, EDNS option codes
dig manual · BIND 9 Security Configurations+bufsize, +ignore, +tcp, +noedns, +cd — every option used in this module
How to read these efficiently.

RFC 8906 is the one to read end to end. It is a test procedure, not a specification — a numbered list of queries to send and what a compliant server must answer. It is effectively Part D written by the IETF.

RFC 1035 §4.1.1 is one page and contains the entire header layout. Print it once.

Skim RFC 6891 §6.1 and §6.2 only. The rest is registry mechanics.

The offline route. dig -h lists every option in this module on one screen, and man dig explains +bufsize, +ignore and +noedns precisely. On a machine with no internet access, dig +ignore and +noedns are the two options that isolate transport faults with no external reference needed.


E5 · Self-assessment

1. Why is the DNS header only 12 bytes, and what did that force later?

It was designed for a 512-byte datagram, so every byte counted: 16-bit transaction ID, 16 bits of flags and codes, four 16-bit counters.

It forced two things. The 4-bit RCODE ran out, so EDNS added eight more bits in the OPT record rather than changing the header. And the 16-bit transaction ID proved far too little entropy against spoofing, which is why source-port randomisation had to be bolted on in 2008.

The general principle: DNS is extended by adding a pseudo-record, never by altering the header, because old implementations ignore records they do not recognise.

2. What does the tc bit mean, and what does the truncated response contain?

The response did not fit in the permitted UDP size. It contains essentially nothing — usually zero answer records. It is a one-bit instruction meaning "ask again over TCP", not a partial answer you can use.

The client then opens a TCP connection and repeats the query. If TCP/53 is blocked, that retry times out and the symptom is "small answers work, large ones hang".

3. What is in the OPT pseudo-record?

The advertised UDP buffer size, the extended RCODE bits, the do (DNSSEC OK) flag, and any EDNS options such as cookies or client-subnet.

It sits in the ADDITIONAL section — which is why almost every response shows ADDITIONAL: 1 even with nothing extra to send.

Without it you are limited to 512 bytes and cannot request DNSSEC at all, since the do bit has nowhere else to live.

4. Where does 1232 come from?

The minimum IPv6 MTU is 1280 bytes; 1232 leaves room for IPv6 and UDP headers. A response of that size never fragments on any conforming path.

It replaced the old default of 4096, which invited fragmentation — and fragmented UDP is lost entirely if any fragment is dropped, is filtered by many firewalls, and is a spoofing bypass.

The trade-off accepted deliberately: more TCP fallback, in exchange for never fragmenting.

5. A domain resolves for simple queries but times out for DNSSEC queries. First hypothesis?

TCP/53 is blocked somewhere on the path. Signed responses are roughly three times larger, so they truncate and require a TCP retry that the firewall drops.

Confirm with dig name NS +dnssec +tcp — if that times out while the plain query works, that is the answer.

Second hypothesis if +tcp is fine: an EDNS-hostile middlebox, tested with +noedns.

6. dig name times out but dig name +noedns works. What have you found?

A server or middlebox that drops or rejects queries containing an OPT record instead of ignoring it — the RFC 8906 problem.

It is severe because every modern resolver sends EDNS by default, so such a server looks completely dead to the current internet while responding perfectly to an old tool.

This was common enough that resolver operators coordinated a DNS flag day in 2019 and removed the workarounds, after which non-compliant domains simply stopped resolving.

7. What must an off-path attacker guess, and what stops them?

Source address (trivially forged), destination port, transaction ID, and the echoed question — arriving before the genuine response.

Randomised today: transaction ID (16 bits) and source port (~16 bits), for roughly 2³² combinations, plus about a bit per letter from 0x20 case randomisation.

All of that is probabilistic. Only DNSSEC makes forged data cryptographically detectable rather than merely unlikely.

8. Why is fragmentation a security problem and not just a reliability one?

A non-initial fragment carries neither the transaction ID nor the UDP port numbers — they appear only in the first fragment. So an attacker forging a later fragment has nothing to guess, and source-port randomisation, the transaction ID and 0x20 provide zero protection.

Reassembly then yields a response the resolver treats as genuine. Avoiding fragmentation removes the bypass entirely, which is why RFC 9715 is a security document as much as a performance one.

9. Which dig options isolate a transport fault, and what does each prove?

+ignore — do not retry over TCP, so the tc bit is visible as received. Proves the response was too large.

+tcp — force TCP. A timeout here with UDP working proves TCP/53 is blocked.

+noedns — send no OPT record. Success here where a normal query fails proves an EDNS-hostile server or middlebox.

+bufsize=N — set the advertised buffer, to find the size at which a real response starts truncating.

+cd — disable validation; success here proves the fault is DNSSEC rather than transport.

10. Why should monitoring never alert on the tc bit?

Because truncation is correct, healthy behaviour. It is the protocol working as designed: the response was larger than the advertised buffer, so the server said so and the client retried over TCP.

Alerting on it produces constant noise from every DNSSEC-signed zone. What is worth alerting on is the TCP retry failing, and response sizes trending toward the buffer limit.


Next — Module 05 · Running an Authoritative Server (BIND 9).

Four modules of reading other people's DNS. Now you build one: install named, write and load a zone, run named-checkconf and rndc, set up a primary and a secondary, watch a zone transfer with AXFR and IXFR, authenticate it with TSIG, and break the serial number on purpose to produce the "only some servers agree" outage from Module 03 C1 with your own hands.

📚 Sources for the interview questions

A note on this module's transcripts, in the interest of honesty. Modules 01–03 used output captured live. This module could not: the environment it was written in sits behind a DNS proxy that intercepts port 53, normalises every EDNS buffer to 512, strips the tc bit and blocks direct TCP — precisely the interception warned about at the top of the page. The transcripts here are therefore reconstructed from the specifications and from dig 9.18's documented output format, not captured. Run them on a real machine; the shapes will match, and if they do not, you have found a middlebox.

Specifications verified directly: RFC 1035, RFC 5452, RFC 6891, RFC 7766, RFC 8906, RFC 9210, RFC 9499, RFC 9715, and the IANA DNS Parameters registry.

Question selection cross-referenced against publicly published 2026 DNS and networking interview question sets:

Answers were rewritten and deepened rather than reproduced. "Is DNS TCP or UDP?" is answered "UDP, and TCP for zone transfers" almost everywhere — which has been an incomplete answer since the buffer default dropped to 1232 and made TCP fallback part of the ordinary path for every signed zone.

Spotted a mistake or want something added? Send me a note.