Internet Protocols · DNS

DNS Message Format and Name Compression on the Wire

How the twelve-byte header, length-prefixed labels, and 0xC0 compression pointers carry a query and its answer, and which encodings a parser must reject.

Everything DNS does is expressed in one packet format, and that format has not changed in any way that matters since 1987. A query and a response are the same structure with different flag values; a zone transfer is a sequence of the same structure; DNSSEC adds record types, not new framing. Reading a hex dump of a single response therefore teaches most of the protocol’s vocabulary at once.

The layout is five sections in a fixed order: header, question, answer, authority, additional. The header is exactly twelve octets and states how many records each of the following sections holds. Nothing delimits a section on the wire; the parser walks records until the declared count is satisfied, then treats the next byte as the start of the next section. A count that disagrees with the payload is not a recoverable error, it is a malformed message, and this is the first place where a naive parser and a correct one diverge.

Twelve octets of header and what each flag buys

OffsetSizeField
02Transaction ID
22Flags
42QDCOUNT
62ANCOUNT
82NSCOUNT
102ARCOUNT

The transaction ID is chosen by the client and echoed by the server. It exists to match responses to queries on a connectionless transport, and it is only sixteen bits, which is why source-port randomisation carried most of the spoofing resistance for decades.

The flag word is bit-packed from the top: QR (query or response), a four-bit opcode, AA (authoritative answer), TC (truncated), RD (recursion desired), RA (recursion available), three reserved bits, and a four-bit response code. RFC 1035 required all three reserved bits to be zero; DNSSEC later assigned two of them as AD (authenticated data) and CD (checking disabled), which is why a parser written against RFC 1035 treats them as must-be-zero padding and why one bit remains unassigned. The four-bit response code space holds sixteen values, of which the original specification assigned six; extended codes live in the OPT pseudo-record described in EDNS(0) and the Truncation Boundary.

AA means the answering server holds the zone containing the name, not that the answer is correct. RA means the server is willing to recurse, not that it did. RD is a request from the client, and a response copies it unchanged, so the bit in a reply says nothing about what the responder actually did. These distinctions matter because a resolver’s cache and an authoritative server’s data are governed by different rules, and the AA bit is the only in-band signal of which one produced the response.

Labels, the empty label, and the 255-octet ceiling

A domain name in a message is a sequence of length-prefixed labels terminated by a zero octet. The label www is encoded as the octet 3 followed by w, w, w. The terminating zero is the root label, and a name that consists only of that zero octet is the root itself.

Two limits follow from the encoding. A label carries at most 63 octets, because its length prefix is six bits with the top two bits reserved for the compression marker. A complete name carries at most 255 octets including all length octets and the final zero. The presentation form without a trailing dot is two octets shorter than the wire form: each label’s length octet becomes the dot that separates it from the next, and the root’s zero octet disappears, which is how the wire limit of 255 octets becomes the familiar 253-character limit for a textual name. An implementation that stores names in wire form needs a 255-byte buffer and no length arithmetic; one that stores presentation form needs to re-derive the wire form before every comparison, which is why most servers keep both.

Labels are compared case-insensitively, but the case of the octets is preserved on the wire. RFC 4343 makes the case rule explicit and records that case is preserved on output because some data stored in the DNS, such as the local part of an email address in an RP record, can be case-sensitive. Some resolvers exploit the preserved case by randomising the case of query names and checking that the response echoes the same pattern, which detects off-path forgery at the cost of making responses harder to compare in logs.

The root’s zero octet also serves as the “end of name” marker inside compression, and the two uses are indistinguishable, which is exactly the point: a pointer terminates a name in the same way the zero label does.

Record bodies and the types that cannot be compressed

Each record after the question repeats the owner name, then carries a two-octet type, a two-octet class, a four-octet TTL, a two-octet RDLENGTH, and finally RDATA of exactly RDLENGTH octets. The length field is what lets a parser skip a record whose type it does not understand. Unknown types are not an error; the record is retained or discarded, but the message still parses. This forward compatibility is the reason new record types can be deployed without changing the framing.

The TTL field is nominally signed but is treated as unsigned with one exception: a value whose most significant bit is set is treated as zero rather than as a negative number, a rule RFC 2181 added because implementations disagreed. Values above 2^31-1 seconds are therefore not usable, which no real zone approaches.

RDATA is type-specific and opaque to the generic parser. That opacity is why name compression is legal only in a known set of record types. The compression scheme replaces a suffix of a name with a two-octet pointer whose top two bits are set: 0xC0 in the first octet, with the remaining fourteen bits holding an offset from the start of the message. Fourteen bits cap the reachable offset at 16383 octets, which is not a practical constraint on a 512-byte datagram but does constrain very large messages.

Compression applies to the owner name of every record and to the names inside the RDATA of types whose format the specification defines as containing domain names: NS, CNAME, SOA, PTR, MX, and a handful of others. It does not apply to names inside a TXT record, an opaque blob, or the newer types defined after the scheme was frozen. A parser that decompresses names in unknown RDATA corrupts data; a parser that fails to decompress names in NS RDATA produces a name field containing raw pointer bytes.

What a decompressor must refuse

Compression pointers may only point backwards, to a position earlier in the message than the pointer itself. Forward pointers would allow a message to describe a name by reference to bytes not yet read, and a chain of them would let a small message expand without bound. Backward-only is the property that makes decompression terminate, and it is enforced by checking each pointer’s offset against the current position rather than by trusting the sender.

Even with that check, a pointer can point into the middle of a label rather than at a length octet. The resulting byte stream is arbitrary, and a decompressor that follows it may loop between two offsets or grow a name past 255 octets. The defensive rules are: cap total output at 255 octets, require every pointer target to be strictly less than the position of the pointer that referenced it, and cap the number of pointers followed. The backward check alone is not enough, because the bytes at the target can end in another pointer aimed at an earlier offset, so the offsets visited form a cycle that only a follow counter stops.

def read_name(msg: bytes, off: int) -> tuple[str, int]:
    labels, end, total, jumps = [], None, 0, 0
    while True:
        if off >= len(msg):
            raise ValueError("name runs past end of message")
        n = msg[off]
        if n == 0:
            off += 1
            break
        if n & 0xC0 == 0xC0:
            if off + 1 >= len(msg):
                raise ValueError("truncated pointer")
            target = ((n & 0x3F) << 8) | msg[off + 1]
            if target >= off:                  # no forward or self pointers
                raise ValueError("forward or self pointer")
            if end is None:
                end = off + 2                  # caller resumes just past the pointer
            jumps += 1
            if jumps > 32:                     # backward pointers can still form a cycle
                raise ValueError("too many compression pointers")
            off = target
            continue
        if n > 63:
            raise ValueError("label too long")
        off += 1
        labels.append(msg[off:off + n].decode("ascii", "replace"))
        off += n
        total += n + 1                         # label octets plus its length octet
        if total > 254:                        # 255 including the root label
            raise ValueError("name exceeds 255 octets")
    return ".".join(labels) + ".", (end if end is not None else off)

The return value carries two positions because a caller that jumped needs the offset just past the pointer, not the offset where decompression finished. Conflating the two is the classic bug in hand-written DNS parsers, and it produces a record length that silently desynchronises every following record.

Why the scheme exists and what it cost

The original transport limit was 512 octets for a UDP payload. A response carrying an NS RRset, its glue, and an SOA would not fit if every name were spelled out, so compression was a way to buy space with computation. It worked: a referral repeats the zone’s name at several points, and pointers reduce each repetition to two octets.

The cost is that the message is no longer self-describing at fixed offsets. A record’s position depends on everything before it, so a parser cannot validate a message by checksum alone, and a middlebox that rewrites a name must also rewrite every pointer that targets it or the message decodes into different names. That is the mechanism behind a class of DNS bugs where a device that rewrites query names in transit produces responses whose answer owner names no longer match the question.

DNSSEC sidesteps the problem by defining a canonical form for signing in which no compression is permitted and names are lowercased. Signatures are computed over that canonical form, not over the bytes on the wire, so a resolver that validates must re-encode the RRset before verifying it. That work is the subject of DNSSEC Validation and the Chain of Trust, and it exists because compression made the wire form unstable.

Truncation is the other escape hatch. When a response does not fit, the server sets TC and the client retries over TCP, where messages carry a two-octet length prefix instead of relying on the datagram boundary. A truncated response may contain a partial RRset, so it must not be cached or used to answer anything; the TC bit means “discard this and ask again”, not “use what fits”. The interaction between that rule and the larger payload sizes negotiated through OPT is where most modern DNS size problems live.

The format’s endurance comes from a property that is easy to overlook: every field that might need to grow was given a length or an escape. Types and classes are sixteen bits, RDATA carries its own length, and the header’s reserved bits were repurposed rather than redefined. The one place the design did not anticipate growth is the response code, whose four bits hold only sixteen values and had to be extended out of band.

References

  1. RFC 1035: Domain Names — Implementation and Specification — IETF
  2. RFC 2181: Clarifications to the DNS Specification — IETF
  3. RFC 4343: Domain Name System (DNS) Case Insensitivity Clarification — IETF