Networking Fundamentals

Ch.25: Anatomy of a DNS Message

By Ayush Arora8 min read

Inspired by: YouTube

Ch.23 covered what DNS is and how a resolver walks the hierarchy to answer a query. Ch.24 covered what the records themselves store. Neither looked at the actual bytes that travel between resolver and server to make any of that happen, the DNS message itself, sitting inside the UDP datagram's data field. This post opens that up, field by field, the same way earlier posts did for UDP and TCP, and ends with a real capture showing a query that outgrows UDP entirely.


The DNS header (12 bytes, every message)

Every DNS message, query or response, starts with the same fixed 12-byte header, followed by a variable number of the sections it describes:

1st Byte (0-7)2nd Byte (8-15)3rd Byte (16-23)4th Byte (24-31)
012345678910111213141516171819202122232425262728293031
Identification (Transaction ID)QROpcodeAATCRDRAZADCDRCODE
QDCOUNT (Question Count)ANCOUNT (Answer Count)
NSCOUNT (Authority RR Count)ARCOUNT (Additional RR Count)
Question Section (name, type, class, one per QDCOUNT)
Answer / Authority / Additional Resource Records (variable length, one per each COUNT field)

Click any field to highlight the exact bits it occupies in the header.


Transaction ID: matching a response to its query

The first 16 bits are the Transaction ID. A resolver can have hundreds of DNS queries in flight at once, so when responses start arriving, out of order, from different servers, it needs a way to know which query each one answers. The response simply echoes back the same Transaction ID the query carried. Wireshark uses this directly: it's what lets it draw the little "this is a query" / "this is the response to that query" arrows between two packets. This is the same transaction ID concept introduced back in the UDP post as one of the things standing in for the reliability UDP itself doesn't provide.


The flags: ten sub-fields packed into 16 bits

Right after the Transaction ID comes a 16-bit block that's really ten separate flags packed together, each answering a narrow yes/no or small-enum question about the message.


The four counts, and what each section actually holds

The next four 16-bit fields, QDCOUNT, ANCOUNT, NSCOUNT, and ARCOUNT, don't carry data themselves. They're a table of contents, telling the receiver exactly how many entries to expect in each of the four sections that follow the header:


Seeing it happen: a query that outgrows UDP

All of this becomes concrete in a real capture. Querying a root server directly for github.com's NS records (the same technique from Ch.23's nslookup walkthrough) produces a response like this:

The root server doesn't know github.com's name servers, only who manages .com, so it answers with NSCOUNT = 13, the .com TLD's own thirteen letter-named authoritative servers (a.gtld-servers.net through m.gtld-servers.net, mirroring the root's own A-through-M naming). Each of those 13 servers gets both an A and an AAAA glue record included automatically, 13 × 2 = 26 Additional records. That's more than fits in a single UDP datagram: the first response comes back with TC = 1 and only 11 of those 26 glue records present, a visibly partial answer. Seeing that flag, DNS retries the exact same query over TCP, a full three-way handshake, the identical NS query sent again, and this time the complete response comes back: TC = 0, all 13 Authority records, all 26 Additional records, followed by TCP's own connection teardown.

This is the TC flag and the UDP-to-TCP fallback described above, no longer abstract: a real 512-byte-and-up UDP ceiling, a real truncated response, and DNS transparently retrying over TCP to get the complete answer.


Summary