DNS usually disappears behind a function call. Give the resolver a name, get an address, carry on. This experiment stops before the function returns and reads the answer as a binary.

So I built a small decoder in Elixir. It does not resolve anything or send packets. Its input is one DNS message, its output is a list of fields next to the offsets and octets they came from. The fixtures are synthetic, using example.test and documentation addresses, so there is no live service whose answer has to stay the same for the examples to work.

The companion source is public. The version used here is 233ac23.

The first response is forty-six octets long. It answers one question with one IPv4 address. The bit worth looking at is that the answer’s name is only two octets long.

Twelve octets before the first name

The header is where Elixir makes this feel almost unfair. Once twelve octets have been checked and taken from the input, the decoder matches them like this:

<<id::16, qr::1, opcode::4, aa::1, tc::1, rd::1,
  ra::1, z::1, ad::1, cd::1, rcode::4,
  qd::16, an::16, ns::16, ar::16>> = bytes

Each size is in bits. The integers use the default big-endian order, which is what the message needs. The first sixteen bits become an ID, the next sixteen are split into flags, and four counts say how many entries belong to each section. No shifting and masking is needed to make those fields visible in the code. Elixir’s bitstring syntax lets the pattern describe their widths directly.

The split between z, ad and cd matters when reading old protocol diagrams: the three positions originally shown together as reserved are not three interchangeable spare bits in modern DNS. The decoder exposes the authentication-related flags separately, as described in RFC 4035. It reports the bits; it does not perform DNSSEC validation.

In this fixture the ID is 0x1234, and the question and answer counts are both one. The counts are instructions for how much structure to read, not permission to assume those structures are present. A header claiming sixty-five thousand questions in a twelve-octet message gets an error at the first missing name.

A name is not a dotted string

At hexadecimal offset 000c, immediately after the header, the question contains:

07 65 78 61 6d 70 6c 65 04 74 65 73 74 00

Read that as a length, seven label octets, another length, four more label octets, then the zero-length root. It displays as example.test.. The dots are presentation; they are not separators in those wire bytes.

That distinction also means a decoder should not quietly assume every label is a UTF-8 hostname. This one preserves binary labels and escapes special or non-printable octets in its output. A dot inside a label has to look different from the dot between labels, and a control byte should not be allowed to act on the terminal printing the trace.

After the name come two sixteen-bit fields: the requested type and class. Both are 1 here: an A question in the Internet class. The question ends at decimal offset thirty, where the first answer starts.

Two octets that mean fourteen

Here is the relevant part of the decoder’s actual output:

000c  question.name       07 65 78 61 6d 70 6c 65 04 74 65 73 74 00
      example.test.
001e  answer.name         c0 0c
      example.test.
      pointer @001e -> @000c (cached suffix)

c0 0c starts with the two bits 11. The remaining fourteen bits are an offset from the beginning of the DNS message. In Elixir the split is:

<<3::2, target::14>> = pointer_bytes

The target is twelve, or 000c in the trace: the beginning of the name already present in the question. The answer reuses it. This is the message compression scheme in RFC 1035, section 4.1.4.

The easy mistake is to lose track of which position is moving. Looking up the target tells us what name the pointer represents. Reading the next field of the answer must continue immediately after the two pointer octets, at 0020, rather than somewhere after the question name.

There are two lengths to keep: the encoded bytes consumed here, and the length of the expanded name. Both are real, and they answer different questions.

A suffix, not just a whole name

The second fixture makes that distinction harder to miss. Its CNAME answer contains these six RDATA octets:

03 77 77 77 c0 0c

The literal label is www; the pointer supplies example.test.. Together they display as www.example.test., which would occupy eighteen octets if written out in full.

The record’s RDLENGTH is six. It describes the encoded RDATA in this record, not the expanded name. Checking the eighteen-octet expansion against that six-octet boundary would reject a perfectly ordinary piece of compression. Letting the literal prefix run past the six-octet boundary would make a different mistake: reading the next record as part of this one.

The next answer then uses c0 2a, pointing at the CNAME target at 002a. The trace keeps both references visible:

002a  answer.rdata        03 77 77 77 c0 0c
      www.example.test.
      pointer @002e -> @000c (cached suffix)
0030  answer.name         c0 2a
      www.example.test.
      pointer @0030 -> @002a (cached suffix)

That is enough compression for a small experiment to become interesting. It is also enough for a naive recursive decoder to get itself into trouble.

The pointer I refuse to follow

One deliberately invalid fixture puts c0 0c at 000c. This time there is no earlier question name to reuse. The name points to itself.

A decoder that treats every pointer as an invitation to call itself at a new offset has to prove that it will eventually stop. Bounds checking the target against the packet length does not establish that. The target can be inside the packet and still lead nowhere useful. These are the kinds of mistakes collected in RFC 9267.

I chose a stricter rule for this companion: a pointer must refer backwards to a label boundary already decoded. Each such boundary is cached with the suffix it represents. The pointer branch looks up that suffix instead of walking through pointers again. A prefix followed by a pointer still works, and the expanded length is checked with the prefix included.

There is a cost worth stating. The decoder interprets Internet-class A, AAAA and CNAME records, leaving other RDATA opaque. If an unsupported record contains a name and a later pointer refers into it, the decoder will reject that pointer because the boundary was never registered. That can reject a valid message. It is a deliberate limit of this small decoder, not a new rule for DNS servers.

Where the address finally is

The A record’s last four octets are much less mysterious:

002a  answer.rdata        c0 00 02 0a
      192.0.2.10

By this point the decoder has consumed the owner name, type, class, TTL and RDLENGTH. Four address octets become four decimal numbers. The IPv6 fixture uses sixteen octets; unknown types retain their bytes and are shown as hex rather than guessed at.

With Elixir 1.20.2 and Erlang/OTP 29.0.2 available, clone the companion and run the experiment. There are no external dependencies:

git clone https://git.sr.ht/~bounga/dns-packet
cd dns-packet
git checkout 233ac2319f3552372bfd58d966ab5ec6e4728c75
mix test
mix escript.build
./dns_packet --hex fixtures/compressed-a.hex
./dns_packet --hex fixtures/cname-chain.hex

The tests reject every truncated prefix of the first response, along with self-pointers, forward pointers, targets inside labels and names whose expanded form exceeds the wire limit. There are also CLI tests for offsets and error exit status, and decoder tests for escaped binary labels. None of that makes it a complete DNS validator, but it makes the small set of behaviours shown here checkable.

The result puts the structure next to its bytes: a name, a reference to it, the fields that follow, and an address. DNS compression saves bytes by making position meaningful, and the decoder only became clear once it kept the position being read separate from the position being referred to.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.