DEV Community

Ece Kaptan
Ece Kaptan

Posted on

Understanding DNS by building dig

Full source: ecekaptan/dig-from-scratch

Everyone says DNS is like a phone book for the internet, but most people never stop to ask how it actually does that. The goal is simple: look at the raw bytes of a DNS packet and see what each one means: the header, the flags, the question, and the answer records that come back. The code in dig.c is the companion here, and every idea below maps to a function you can read and follow.

A DNS lookup is really just two packets: you send a query, and the server sends back a response. Both use the same basic structure, made of five parts:

+-------------------+
|      Header       |  always 12 bytes
+-------------------+
|     Question      |  what you asked
+-------------------+
|      Answer       |  the records you wanted   (response only)
+-------------------+
|     Authority     |  which name servers are in charge   (response only)
+-------------------+
|     Additional    |  extra helpful records   (response only)
+-------------------+
Enter fullscreen mode Exit fullscreen mode

Everything is big-endian (most-significant byte first) and packed tightly: no padding, no separators, no wasted space. You know where one field ends because you already know how long it is.


1. The 12-byte header

The header is six 16-bit numbers, in this exact order:

byte:  0   1   2   3   4   5   6   7   8   9  10  11
     +---+---+---+---+---+---+---+---+---+---+---+---+
     |  ID   | FLAGS |QDCOUNT|ANCOUNT|NSCOUNT|ARCOUNT|
     +---+---+---+---+---+---+---+---+---+---+---+---+
Enter fullscreen mode Exit fullscreen mode
Field Bytes What it's for
ID 0-1 A number you choose. The server copies it back into its reply so you can match your response to the query you sent.
FLAGS 2-3 16 bits of yes/no switches and a couple of small values. We break those down below.
QDCOUNT 4-5 How many questions are in the packet. In our case, this is always 1.
ANCOUNT 6-7 How many answer records are present. It is 0 in a query; the reply fills it in.
NSCOUNT 8-9 How many authority records are there.
ARCOUNT 10-11 How many additional records are there.

In the code, this is written with build_query and read back in check_response_header. Because the fields live at fixed offsets, they are read through the put16 and get16 helpers instead of raw pointer casts (more on that in §6).

The FLAGS field, bit by bit

This is the part that often looks intimidating at first, but it becomes much easier once you see it as packed bits instead of one big number. Those two bytes are not a single value; they are a collection of flags. Numbering the 16 bits from 15 (leftmost) down to 0 gives us this layout:

 bit  15  14 13 12 11  10   9   8   7   6  5  4   3  2  1  0
     +---+-----------+---+---+---+---+---+-------+-----------+
     |QR |  Opcode   |AA |TC |RD |RA |    Z      |   RCODE   |
     +---+-----------+---+---+---+---+---+-------+-----------+
Enter fullscreen mode Exit fullscreen mode
Bits Name Meaning
15 QR 0 = this packet is a query, 1 = a response.
14-11 Opcode The type of query. 0 means a standard lookup, which is what we use here.
10 AA Authoritative Answer: the responder actually owns the zone, rather than just giving a cached copy.
9 TC Truncated: the answer didn’t fit in the packet. This is the flag that triggers our TCP retry.
8 RD Recursion Desired: “please chase the full answer for me.” This is the bit we set.
7 RA Recursion Available: the server tells us whether it is willing to do recursion.
6-4 Z Reserved and must be 0.
3-0 RCODE Response Code: 0 means success, while other values mean an error (see the table below).

Why our query’s flags are 0x0100: we want exactly one bit set: RD (bit 8).

0x0100 = 0000 0001 0000 0000
                   ^
                   bit 8 (RD) = 1, everything else 0
Enter fullscreen mode Exit fullscreen mode

A typical successful reply has flags 0x8180:

0x8180 = 1000 0001 1000 0000
         ^        ^ ^
         |        | RA = 1  (server does recursion)
         |        RD = 1    (echoed back)
         QR = 1             (this is a response)
         ...RCODE = 0000 = NOERROR
Enter fullscreen mode Exit fullscreen mode

The code pulls the individual pieces back out with bit math:

  • get16(response + 2) & 0xF gives the RCODE (the bottom 4 bits).
  • (get16(response + 2) >> 9) & 1 gives the TC bit (shifted down, keeping only the one relevant bit). That one line is what decides whether we fall back to TCP.

RCODE: what went wrong

flags & 0x000F  ->  RCODE
Enter fullscreen mode Exit fullscreen mode
RCODE Name Meaning
0 NOERROR Success.
1 FORMERR The server couldn’t parse the query.
2 SERVFAIL The server failed while trying to answer.
3 NXDOMAIN That name does not exist.
4 NOTIMP The server does not support this kind of query.
5 REFUSED The server refuses to answer (policy).

check_response_header prints these names and stops instead of trying to parse an answer section that is not there.


2. The question section

Right after the header comes the question. One question has three pieces:

QNAME (variable)      the hostname, label-encoded
QTYPE (2 bytes)       what record you want (A = 1, MX = 15, ...)
QCLASS (2 bytes)      almost always 1 = IN (Internet)
Enter fullscreen mode Exit fullscreen mode

How a name is encoded (QNAME)

DNS does not store www.example.com as plain text with dots. Instead, each dot-separated label becomes a length byte followed by the characters in that label, and the whole name ends with a 0 byte:

example.com
  ->  07 'e''x''a''m''p''l''e'   03 'c''o''m'   00
      ^^                          ^^            ^^
      7-char label                3-char label  root (end of name)
Enter fullscreen mode Exit fullscreen mode

A label’s length byte can only be 0-63, because the top two bits are reserved for compression pointers (§5). encode_name builds this format and rejects malformed input like a..b (an empty label), while still accepting a trailing dot like example.com.: the root that dot represents is just the final 00.


3. Resource records, the shape every answer shares

The Answer, Authority, and Additional sections are all made of resource records (RRs), and every RR has the same six-field layout:

NAME      (variable)   which name this record is about
TYPE      (2 bytes)    A, AAAA, CNAME, ...
CLASS     (2 bytes)    1 = IN
TTL       (4 bytes)    how many seconds it may be cached
RDLENGTH  (2 bytes)    how many bytes of RDATA follow
RDATA     (RDLENGTH)   the payload, whose shape depends on TYPE
Enter fullscreen mode Exit fullscreen mode

The frame stays the same; only the RDATA changes from one record type to another. That is the key idea: parse the frame the same way every time, then switch on the TYPE to interpret the payload. print_answers does exactly this: read the frame, then check if (type == ...).

RDLENGTH is what allows a parser to skip record types it does not understand. Even if you cannot decode the value, you still know it occupies exactly RDLENGTH bytes, so you can jump to the next record safely.


4. Response types: what RDATA looks like for each one

This is the other half of the story that is easy to gloss over. Here is the actual on-the-wire RDATA for every type this tool decodes.

A, IPv4 address (TYPE 1)

RDATA is exactly 4 raw bytes, one per octet. No text at all.

RDATA = 5D B8 D8 22   ->   93.184.216.34
        93 184 216 34
Enter fullscreen mode Exit fullscreen mode

The code checks rdlength == 4 and hands the 4 bytes to inet_ntop.

AAAA, IPv6 address (TYPE 28)

Same idea, 16 raw bytes. inet_ntop turns them into the 2a00:1450:...
colon form.

CNAME / NS / PTR, a name (TYPES 5, 2, 12)

RDATA is a single encoded name (same label format as QNAME, and it may use
compression). They differ only in meaning:

  • CNAME: "this name is an alias; the real records live under that name."
  • NS: "that server is authoritative for this zone."
  • PTR: a reverse lookup, where an IP's x.x.x.x.in-addr.arpa name points back to a hostname.

All three decode by just calling read_name on the RDATA.

$ dig 8.8.8.8.in-addr.arpa PTR
8.8.8.8.in-addr.arpa  18634  PTR  dns.google
Enter fullscreen mode Exit fullscreen mode

MX, mail exchanger (TYPE 15)

RDATA = a 2-byte preference (lower = higher priority) followed by a name.

RDATA = 00 0A  <name: alt1.gmail-smtp-in.l.google.com>
        ^^^^^
        preference 10
Enter fullscreen mode Exit fullscreen mode
$ dig gmail.com MX
gmail.com  1873  MX  5 gmail-smtp-in.l.google.com
gmail.com  1873  MX  10 alt1.gmail-smtp-in.l.google.com
Enter fullscreen mode Exit fullscreen mode

SOA, start of authority (TYPE 6)

The zone's "control record." RDATA packs two names then five 32-bit numbers:

MNAME    name   primary name server for the zone
RNAME    name   admin email (first dot = @: dns-admin.google.com -> dns-admin@google.com)
SERIAL   u32    version number, bumped on every change
REFRESH  u32    how often secondaries check for updates (s)
RETRY    u32    how long to wait after a failed refresh (s)
EXPIRE   u32    give up serving the zone after this long without contact (s)
MINIMUM  u32    default negative-cache TTL (s)
Enter fullscreen mode Exit fullscreen mode
$ dig google.com SOA
google.com  5  SOA  ns1.google.com dns-admin.google.com 967105881 900 900 1800 60
Enter fullscreen mode Exit fullscreen mode

The parser reads MNAME, advances by however many bytes it consumed, reads
RNAME, then reads five get32s.

TXT, arbitrary text (TYPE 16)

RDATA is one or more length-prefixed strings: a length byte, then that many
characters, repeated until RDLENGTH runs out. (One TXT record can hold several
chunks, which is why the parser loops.)

RDATA = 22 'v''=''s''p''f''1'' '...   1B ...
        ^^                            ^^
        0x22 = next 34 chars          next string starts here
Enter fullscreen mode Exit fullscreen mode

TXT records are big, which is exactly what triggers truncation:

$ dig google.com TXT
(UDP reply truncated, retrying over TCP)
answers: 16
google.com  26  TXT  "v=spf1 include:_spf.google.com ~all"
...
Enter fullscreen mode Exit fullscreen mode

5. Name compression

DNS responses often contain the same names multiple times. Instead of storing
the same name again, DNS can use a pointer to a name that already exists
earlier in the packet.

5.1 Normal name

A name like:

example.com
Enter fullscreen mode Exit fullscreen mode

is encoded as:

07 example 03 com 00
Enter fullscreen mode Exit fullscreen mode

Each label starts with its length, and 00 means the name is finished.

5.2 Compression pointer

Suppose example.com already starts at byte 16:

offset 16:
07 example 03 com 00
Enter fullscreen mode Exit fullscreen mode

Instead of writing it again, another record can contain:

C0 10
Enter fullscreen mode Exit fullscreen mode

This means:

Go to byte 16 and continue reading the name there.

So:

C0 10
  ↓
byte 16
  ↓
07 example 03 com 00
  ↓
example.com
Enter fullscreen mode Exit fullscreen mode

The pointer is therefore a reference to another location, not the name itself.

5.3 How do we recognize a pointer?

The first two bits tell us what the byte means:

00xxxxxx → normal label
11xxxxxx → pointer
Enter fullscreen mode Exit fullscreen mode

For example:

03 = 00000011
     ^^
     00 → normal label
Enter fullscreen mode Exit fullscreen mode

while:

C0 = 11000000
     ^^
     11 → pointer
Enter fullscreen mode Exit fullscreen mode

A pointer uses two bytes. The remaining 14 bits contain the offset.

C0 10
^^
11 = pointer

remaining bits → offset 16
Enter fullscreen mode Exit fullscreen mode

5.4 Partial compression

A pointer can also represent only the rest of a name.

For:

ftp.example.com
Enter fullscreen mode Exit fullscreen mode

we could write:

03 ftp C0 10
Enter fullscreen mode Exit fullscreen mode

where C0 10 points to:

example.com
Enter fullscreen mode Exit fullscreen mode

So:

03 ftp + pointer
        ↓
        example.com

= ftp.example.com
Enter fullscreen mode Exit fullscreen mode

5.5 The important part: cursor vs consumed

This is the tricky part.

Suppose an answer contains:

C0 10 | 00 01 | ...
^^^^^   ^^^^^
 NAME    TYPE
Enter fullscreen mode Exit fullscreen mode

The NAME physically occupies only:

C0 10
Enter fullscreen mode Exit fullscreen mode

so it occupies 2 bytes.

But the parser follows the pointer:

C0 10
  ↓
byte 16
  ↓
07 example 03 com 00
Enter fullscreen mode Exit fullscreen mode

The parser reads those bytes to discover the name, but those bytes are not physically part of the NAME field in this record.

That's why we track two things:

cursor

Where are we currently reading?

C0 10
  ↓
jump to byte 16
  ↓
read example.com
Enter fullscreen mode Exit fullscreen mode

The cursor can jump.

consumed

How many bytes did the name occupy in the current record?

For:

C0 10
Enter fullscreen mode Exit fullscreen mode

the answer is:

consumed = 2
Enter fullscreen mode Exit fullscreen mode

Even though we read more bytes after following the pointer.

5.6 Why the code freezes consumed

if (!jumped) {
    consumed = cursor + 2 - offset;
    jumped = 1;
}
Enter fullscreen mode Exit fullscreen mode

When the first pointer is encountered, we calculate how many bytes the name occupied before the jump.

For:

03 ftp C0 10
Enter fullscreen mode Exit fullscreen mode

the name occupies:

1 + 3 + 2 = 6 bytes
Enter fullscreen mode Exit fullscreen mode

So:

consumed = 6
Enter fullscreen mode Exit fullscreen mode

After jumping to example.com, we do not increase consumed.

This is essential because the next field (TYPE) comes immediately after the pointer:

03 ftp C0 10 | 00 01
^^^^^^^^^^^^   ^^^^^
 NAME           TYPE
  6 bytes       2 bytes
Enter fullscreen mode Exit fullscreen mode

5.7 Mental model

Think of a pointer as a book reference:

Current page:

NAME: "See page 16"
TYPE: A
Enter fullscreen mode Exit fullscreen mode

You go to page 16 and read:

example.com
Enter fullscreen mode Exit fullscreen mode

But the NAME on the original page still occupies only:

"See page 16"
Enter fullscreen mode Exit fullscreen mode

Similarly:

C0 10
Enter fullscreen mode Exit fullscreen mode

occupies 2 bytes, but tells us to go somewhere else to discover the actual name.

cursor = where I'm reading.
consumed = how many bytes this field occupies here.

That's the key idea behind DNS compression.

5.8 The safety guard

A corrupt or hostile packet could point in a circle (offset A holds a pointer to B, and B points back to A), or even point to itself. A naive parser would follow those forever and hang. read_name counts those jumps and gives up after 20, and it refuses any offset that lands outside the packet. The result is bounded work and no infinite loops, even on a malicious response.


6. Reading a whole packet at once

Putting it all together, here is a complete example.com A exchange, annotated.

The query we send (dig example.com):

1A 2B                                ID = 0x1A2B (random)
01 00                                FLAGS: RD=1
00 01                                QDCOUNT = 1
00 00  00 00  00 00                  AN/NS/AR = 0
07 65 78 61 6D 70 6C 65              label "example"
03 63 6F 6D                          label "com"
00                                   end of name
00 01                                QTYPE = A
00 01                                QCLASS = IN
Enter fullscreen mode Exit fullscreen mode

The response (same header offsets, followed by the answer):

1A 2B                                ID echoed back
81 80                                FLAGS: QR=1 RD=1 RA=1, RCODE=0 (NOERROR)
00 01                                QDCOUNT = 1
00 01                                ANCOUNT = 1   (one answer)
00 00  00 00                         NS/AR = 0
07 65 ... 03 63 6F 6D 00  00 01 00 01   the question, echoed verbatim
=== answer starts here ===
C0 0C                                NAME = pointer to offset 12 (the question name)
00 01                                TYPE = A
00 01                                CLASS = IN
00 00 00 3C                          TTL = 60 seconds
00 04                                RDLENGTH = 4
5D B8 D8 22                          RDATA = 93.184.216.34
Enter fullscreen mode Exit fullscreen mode

Read from top to bottom, and that is the full round trip: your ID comes back, the flags say “response, no error,” the count says there is one answer, and the answer’s 4 RDATA bytes are the address. Nothing magical is hiding in the bytes; it is just a packet with a known layout.

One small footnote: why the code never does *(uint16_t *)p

It is tempting to read a 2-byte field as ntohs(*(uint16_t *)(a + 8)). But a + 8 can land on an unaligned address, and reading a uint16_t from an unaligned address is undefined behavior in C. It may work on x86 or ARM, but it can crash on stricter CPUs, and the compiler is allowed to assume it never happens. That is why every field goes through get16 and get32, which copy the bytes with memcpy and then fix the endianness. A small helper, but it prevents a whole class of “works on my machine” bugs.


Where to go next

  • Watch a real packet: run dig example.com in one terminal while sudo tcpdump -X -n port 53 runs in another, and match the hex to §6.
  • Add a record type: SRV (priority, weight, port, then a name) and CAA are great next examples once the frame-parsing foundation is in place.
  • The sections we ignore: this tool only prints the Answer section. If you print the Authority and Additional sections too, you will see the NS records and glue records that make recursion work.

Top comments (0)