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.