Networking in C · intermediate · ~12 min
## What you will learn - Read the on-the-wire **label encoding** of a DNS name and explain why DNS does not store dotted strings like `www.example.com`. - Walk a length-prefixed label chain byte by byte and join the labels into a dotted name. - **Bounds-check every read** against the packet length and **every write** against the output buffer, so a malformed packet can never push the parser past either edge. - Recognize a **compression-pointer** byte (top two bits set, `byte & 0xC0`) and refuse it, keeping a single-name decoder loop-proof. - Design a clear function contract that returns a length on success and `-1` on any error, so callers can react instead of trusting garbage. - Connect pointer arithmetic and bounded copying (your prereqs **pointers** and **bounded-copy**) to a real binary protocol.
When your program looks up www.example.com, that text never travels across the network as a dotted string. DNS packs the name into a compact binary shape called label encoding, and the very first job of any DNS tool — dig, a stub resolver, a passive-DNS sniffer — is to turn those bytes back into a readable name. This lesson teaches that one step: decoding a DNS question name (the QNAME) safely.
A DNS name is a chain of labels. Each piece of the name (www, example, com) is one label. Every label is written as a single length byte followed by exactly that many character bytes, and the whole chain ends with a single zero byte (0x00). So www.example.com becomes 03 w w w 07 e x a m p l e 03 c o m 00. There are no dots on the wire; the dots are something you add back while decoding.
Length-prefixing means the parser never has to scan for a delimiter — it always knows exactly how many bytes the next label spans before it reads them. That is faster and, more importantly, it makes bounds-checking exact: you can compare the claimed length against the bytes you actually have before copying anything.
You will walk the packet with a moving pointer and read one byte at a time — that is the pointers lesson made concrete. Every time you copy a label into the output, you must check it fits — that is bounded-copy applied to untrusted input. The new idea here is that the data is attacker-controlled: the length bytes come from the network, so you must treat each one as a claim to verify, never a fact to trust.
0x00 byte that ends the name.0xC0 family) that re-uses a name written earlier in the packet. We deliberately refuse it in this lesson.Decoding the name is step one of every DNS tool. If you can't turn the label bytes into a string, you can't filter by domain, log a query, build a resolver, or detect suspicious lookups. Everything downstream depends on this parse being correct.
It also matters because the input is hostile by nature. DNS messages arrive from the network, which means the length bytes are chosen by whoever sent the packet — possibly an attacker. A parser that trusts those bytes is exactly how real software gets a buffer overflow or an infinite loop. The difference between a toy parser and a production-grade one is not the happy path (both decode www.example.com fine); it is what happens on a malformed or malicious packet.
The specific trap is the compression pointer. A crafted packet can chain pointers into a cycle so a naive "just follow the pointer" loop never terminates — a classic parser denial-of-service. Because a single-name decoder never needs to follow a pointer, the safe and simple choice is to refuse pointer bytes outright. Learning to make that call — do I even need this feature, and what does refusing it buy me? — is a core defensive-programming habit you will reuse far beyond DNS.
Definition. A DNS name is a sequence of labels, each written as one length byte (0–63) followed by that many raw bytes, terminated by a 0x00 length byte.
How it works internally. The decoder keeps a cursor into the packet. It reads one length byte, then reads that many label bytes, then expects another length byte, and so on, until it hits 0x00.
offset: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
bytes: 03 w w w 07 e x a m p l e 03 c o m 00
^len ^^^^^ ^len ^^^^^^^^^^^^^^^^ ^len ^^^^^^^ ^terminator
=3 "www" =7 "example" =3 "com" end of name
When to use vs not. This encoding is mandatory for every DNS name field. You never invent it; you only read and write it. You do not use it for IP addresses (those are raw bytes in answer records) — only for names.
Common pitfall. Forgetting that the dots are not in the data. If you copy the length byte itself into your output, you get a control character in your string.
Knowledge check: How many bytes does the name
a.bcoccupy on the wire, including the terminator? (Count:01 'a' 02 'b' 'c' 00.)
Definition. A bounded walk: read length → bounds-check → copy → add dot → repeat → stop at 0x00.
Structure. Two cursors are involved: an input position i into the packet, and an output position o into your string buffer. Both must stay inside their buffers at every step.
INPUT pkt[n]: [03][w][w][w][07][e]...[00]
i ───────────────────►
OUTPUT out[cap]: [ w][ w][ w][ .][ e]... [\0]
o ───────────────────►
Rule: before copying L label bytes, require i + 1 + L <= n (read fits)
before writing, require o + needed <= cap (write fits)
When NOT to do something clever. Do not optimise by skipping the per-label bounds check "because the packet is probably fine." Untrusted input is never probably fine.
Common pitfall. Off-by-one on the trailing \0: you must reserve one byte of cap for the string terminator, or your output is not a valid C string.
Knowledge check (predict the output): Given
outof capacity 4 and the namewww(03 w w w 00), what should the function do — succeed with"www", or fail? (It needs 3 chars + a\0= 4 bytes; that exactly fits, so it succeeds.)
Definition. A pointer is a 2-byte form where the top two bits of the first byte are set (byte & 0xC0 == 0xC0). The remaining 14 bits are an offset to a name written earlier in the same packet.
Why it exists. Real DNS responses repeat names a lot; pointers let the second occurrence say "the name is over there" instead of repeating bytes.
Why it is dangerous to follow naively.
Malicious packet:
offset 12: C0 0C <- pointer says "jump to offset 12"
└ points back to itself!
Naive follower: read pointer -> jump to 12 -> read pointer -> jump to 12 -> ...forever
This is a parser denial-of-service: one tiny packet hangs your program.
The defensive rule for a single-name decoder. We only need to read one QNAME, and a QNAME in a question is not required to use compression. So we treat any pointer byte as an error and return -1. No following, no cycles, no DoS.
When you would support pointers. A full message parser that reads answer records does need them — but it must add a guard (cap on jumps, or require every jump to move strictly backward) to stay terminating. That is out of scope here.
Knowledge check (find the bug): A learner writes
if (len > 63) follow_pointer();. Why is that the wrong test for a pointer, and what is the correct one? (Pointers are identified by the top two bitslen & 0xC0, not by being numerically large; a value like0xC0is 192 but a value like0x40also has a high bit set yet is reserved, so the precise mask is(len & 0xC0) == 0xC0.)
#include <stdint.h> /* uint8_t fixed-width byte type */
#include <stddef.h> /* size_t for lengths/capacities */
/* Decode the QNAME at the start of pkt into a dotted C string.
* Returns the string length on success, or -1 on any error
* (pointer byte, label/packet overrun, or output too small). */
int parse_dns_qname(const uint8_t *pkt, size_t n, char *out, size_t cap);
Key points in the signature:
const uint8_t *pkt — read-only view of the packet bytes; const documents that we never modify the input.size_t n — how many bytes are actually available. Every read is checked against this.char *out, size_t cap — the destination string and its capacity (including room for the \0).int lets us signal failure with -1; a successful return is the non-negative string length.The pointer test, written once and clearly:
if ((len & 0xC0) == 0xC0) return -1; /* top two bits set => compression pointer; refuse */
Every DNS tool starts the same way: by decoding the queried name (the QNAME). Examples include dig, a resolver, or a passive-DNS monitor.
DNS does not store names as dotted strings like www.example.com. Instead, it stores them as a chain of labels. Each label is one piece of the name (such as www or example). Every label is prefixed by a length byte, and the whole chain ends in a zero byte.
03 'w' 'w' 'w' 07 'e' 'x' 'a' 'm' 'p' 'l' 'e' 03 'c' 'o' 'm' 00
└len┘ └label┘ └len┘ └────label────┘ └len┘└label┘ └end
The rule is simple:
0x00 terminator.DNS supports compression pointers to save space. A pointer reuses a name that appears earlier in the same packet.
You can recognize a pointer by its length byte: if the top two bits are set (byte & 0xC0), the byte is a pointer, not a normal label length.
Following pointers without care is dangerous. A crafted packet can make pointers reference each other in a cycle, and a naive parser will loop forever. This is a classic parser denial-of-service.
A defensive decoder that only needs to read a single name takes the safe path: it refuses pointer bytes outright.
Implement:
int parse_dns_qname(const uint8_t *pkt, size_t n, char *out, size_t cap);
It must:
out.n.cap.-1 on a pointer byte or any overrun.#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
/* Decode a DNS QNAME (label-encoded name) into a dotted C string.
* pkt/n: the packet bytes and how many are available.
* out/cap: destination buffer and its capacity (room for trailing '\0').
* Returns the length of the decoded string, or -1 on any error. */
int parse_dns_qname(const uint8_t *pkt, size_t n, char *out, size_t cap)
{
if (!pkt || !out || cap == 0) return -1; /* defensive: reject bad args */
size_t i = 0; /* read cursor into pkt */
size_t o = 0; /* write cursor into out */
for (;;) {
if (i >= n) return -1; /* need a length byte but ran out */
uint8_t len = pkt[i];
if (len == 0x00) break; /* terminator: name is complete */
if ((len & 0xC0) == 0xC0) /* compression pointer: refuse */
return -1;
if (len > 63) return -1; /* labels are 1..63 bytes; anything else is invalid */
/* The label occupies pkt[i+1 .. i+1+len-1]; verify it fits. */
if (i + 1 + len > n) return -1; /* label would read past packet end */
/* If we already wrote at least one label, we need a separating dot. */
if (o != 0) {
if (o + 1 >= cap) return -1; /* keep one byte for the final '\0' */
out[o++] = '.';
}
/* Copy the label, checking the output bound on each byte. */
if (o + len >= cap) return -1; /* +len chars, still need room for '\0' */
memcpy(out + o, pkt + i + 1, len);
o += len;
i += 1 + len; /* advance past length byte + label */
}
out[o] = '\0'; /* always NUL-terminate the result */
return (int)o;
}
int main(void)
{
/* www.example.com encoded as labels, then the 0x00 terminator. */
const uint8_t pkt[] = {
3, 'w','w','w',
7, 'e','x','a','m','p','l','e',
3, 'c','o','m',
0
};
char name[256];
int len = parse_dns_qname(pkt, sizeof pkt, name, sizeof name);
if (len < 0) {
fprintf(stderr, "parse failed\n");
return 1;
}
printf("name = %s (len %d)\n", name, len);
/* A crafted pointer byte (0xC0) must be refused, not followed. */
const uint8_t evil[] = { 0xC0, 0x0C };
if (parse_dns_qname(evil, sizeof evil, name, sizeof name) == -1)
printf("pointer byte correctly refused\n");
return 0;
}
The program decodes a hard-coded label-encoded name and prints it, then proves that a compression-pointer byte is rejected instead of followed.
name = www.example.com (len 15)
pointer byte correctly refused
pkt = {0x00}): the loop breaks immediately, out becomes "", and the return is 0.o + len >= cap, so a tight buffer yields -1 rather than an overflow.i + 1 + len > n catches it.(len & 0xC0) == 0xC0 and len > 63 both reject malformed lengths.Decoding 03 w w w 07 e x a m p l e 03 c o m 00 (n = 17) into a 256-byte buffer.
| Step | i |
pkt[i] (len) |
Action | o after |
out so far |
|---|---|---|---|---|---|
| 1 | 0 | 3 | not 0, not pointer, fits; o==0 so no dot; copy www |
3 | www |
| 2 | 4 | 7 | copy a dot, then example |
11 | www.example |
| 3 | 12 | 3 | copy a dot, then com |
15 | www.example.com |
| 4 | 16 | 0 | terminator → break |
15 | www.example.com |
| 5 | — | — | write out[15] = '\0', return 15 |
— | www.example.com\0 |
Key transitions:
i += 1 + len is what jumps the cursor over the length byte and the label. After step 1, i goes from 0 to 1 + 3 = 4, landing exactly on the next length byte.if (o != 0)), which is why www has no leading dot but .example and .com do.out[o] = '\0' runs only after a clean break, so a failure path never leaves a half-written "valid" string — it returns -1 first.For evil = { 0xC0, 0x0C }, n = 2:
i = 0, i < n OK, len = 0xC0.len is not 0x00.(0xC0 & 0xC0) == 0xC0 is true → return -1 immediately.The parser never jumps to offset 0x0C, so the self-referential cycle the packet was trying to create can never start.
Wrong:
memcpy(out + o, pkt + i + 1, len); /* len came straight from the packet */
o += len;
This copies len bytes with no check that they exist in the packet or fit in out. A packet ending in FF (claiming a 255-byte label that isn't there) reads past the buffer — undefined behaviour, often a crash or info leak.
Corrected: bounds-check both sides first:
if (i + 1 + len > n) return -1; /* read fits in packet */
if (o + len >= cap) return -1; /* write fits in out, room for '\0' */
memcpy(out + o, pkt + i + 1, len);
Prevent it by treating every length-from-the-wire as a claim you verify, never a fact.
Wrong: if (len >= 192) ... or if (len > 63 && len != 0) follow();
These stumble on the real definition. The pointer marker is the top two bits: (len & 0xC0) == 0xC0. Values with only the 0x40 bit set are reserved, not pointers, and treating them as labels is also a bug. Using the exact mask keeps both classes correct.
Corrected:
if ((len & 0xC0) == 0xC0) return -1; /* pointer */
if (len > 63) return -1; /* reserved / invalid length */
capWrong: if (o + len > cap) return -1; then later out[o] = '\0';
With > (not >=) the copy can fill the buffer to the brim, and then out[o] = '\0' writes one byte past the end. Corrected: use >= so one byte is always reserved for the \0. Recognise this class of bug by always asking "where does my \0 go, and did I leave room?"
Wrong: writing a . before every label gives .www.example.com. Corrected: only add the separator when o != 0 (i.e. not before the first label).
unknown type name 'uint8_t' → you forgot #include <stdint.h>. size_t needs <stddef.h> (or <stdio.h>/<string.h>).implicit declaration of function 'memcpy' → add #include <string.h>.comparison of integer expressions of different signedness → mixing signed int and unsigned size_t. Keep cursors as size_t and only cast at the return.cc -g -fsanitize=address,undefined parse.c -o parse && ./parse
ASan will point at the exact line that read or wrote out of bounds.o != 0 guard around the separator is missing.pkt + i + 1, not pkt + i.i += 1 + len advances past both the length byte and the label; i += len alone is off by one.n before the read?cap, leaving room for \0?1 + len?-1 (test with {0xC0, 0x0C})?This function reads attacker-controlled bytes into a fixed buffer, so it is exactly the kind of code where memory-safety bugs become security bugs.
pkt[i] and every label copy must be proven inside [0, n) first. The checks i >= n (before reading a length) and i + 1 + len > n (before reading a label) cover this. Skipping either is an out-of-bounds read — undefined behaviour, and a potential info leak.o (+len) >= cap, reserving one byte for the terminator. This is the bounded-copy discipline applied to untrusted input: never write without first proving it fits.out is only treated as a valid C string after out[o] = '\0'. On any error path we return -1 before claiming the buffer is a string, so the caller never reads uninitialized tail bytes as content.i + 1 + len uses size_t; with len <= 63 and realistic packet sizes this cannot wrap. If you ever widen len's source, re-check the arithmetic — a wrap could turn a bounds check into a bypass.pkt and out for the call only and stores no pointers, so there are no dangling-pointer concerns here.A decoder over hostile input should have exactly one success path and many early -1 returns. If you can reach the final return o with any invariant unchecked, that is the bug.
dig, getaddrinfo internals) decode QNAMEs from every response.Beginner rules
\0; return a clear error instead of overflowing.-1 on any error).Advanced rules
-1.-fsanitize=address,undefined.1. Count the labels.
Write int dns_label_count(const uint8_t *pkt, size_t n) that returns how many labels a QNAME has (e.g. www.example.com → 3), or -1 on a pointer byte or overrun.
{3,'w','w','w',3,'c','o','m',0} → 2.2. Total wire length.
Write int qname_wire_len(const uint8_t *pkt, size_t n) returning the number of bytes the QNAME occupies including the terminating 0x00, or -1 on error.
i ends up plus one.3. Decode into a caller buffer with exact capacity.
Adapt parse_dns_qname so a caller can pass a tight cap. Add a test that passes cap = decoded_len (no room for \0) and assert it returns -1.
4. Lowercase normalisation.
Decode the name and store it lowercased (DNS names are case-insensitive). Only transform A–Z.
if (c >= 'A' && c <= 'Z') c += 32;5. Survive a fuzzer.
Write a small driver that feeds parse_dns_qname thousands of random byte buffers of random lengths (including length 0). The function must never crash — only return a valid string length or -1.
-fsanitize=address,undefined; the run must finish clean.[len][bytes]...[0x00]. The dots are added by you during decoding.0/pointer/oversize → bounds-check the label against n → copy (bounds-checked against cap) → add a dot before all but the first label → advance i by 1 + len → repeat until 0x00.(len & 0xC0) == 0xC0, the read check i + 1 + len > n, and the write check o + len >= cap (reserving the \0).-1 exits, and never reads or writes out of bounds.