Write the telegram yourself
The code: teaching/code/dns_telegram.py - ~30 lines, stdlib only.
Before you start. DNS Resolution showed you a DNS lookup happening. DNS on the wire took its 29 bytes apart on the wire. Here you write those same 29 bytes yourself, by hand, and send them.
2008: the reply nobody could prove was real
In July 2008, Dan Kaminsky, a penetration tester at the security firm IOActive, found that almost any computer could be handed a fake DNS answer and believe it. Patches went out July 8, before he said how. The trick leaked online July 21, two weeks early. He gave the full details that August, at Black Hat.
Every patch made the same fix: a harder-to-guess return address. By the end of this lesson you will have hand-typed the exact bytes behind that attack, and checked whether your own machine got the fix.
What your computer actually sends
Type a website name into a browser, and your computer does not place a call. It writes a short, fixed-format note - a packet, a bundle of bytes sent in one go - and drops it through a numbered slot in some machine’s wall. That slot is a port, and DNS notes go to port 53. No ringing, no pickup, no goodbye: the delivery method is UDP, fire-and-forget, no handshake first.
The note has five parts: a random tag, a “please look this up” flag, a count of questions, the name spelled telegram-style (7·example 3·com 0 - a length, then that many letters, repeated to a zero), and the kind of answer wanted.
flowchart LR
A[2 bytes - random ID tag] --> B[2 bytes - flags, please recurse]
B --> C[8 bytes - four counts, one question]
C --> D[Name - length prefixed labels, 7 example 3 com 0]
D --> E[4 bytes - type A, class IN]
Every part has a fixed width except the name, which just runs until it hits that zero.
Type it yourself
Run the code above against your own router, and this comes back:
mailed : 0a 04 01 00 00 01 00 00 00 00 00 00 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01
reply : 61 bytes
house : 104.20.23.154 (valid 68s)
house : 172.66.147.243 (valid 68s)
Twenty-nine pairs of hex digits went out - each pair is one byte, one slot of up to 256 values, written as two hex digits instead of eight ones-and-zeros because it’s shorter to read. Sixty-one bytes came back, holding two addresses. The 68 on each is the TTL: a countdown, in seconds, before this answer expires and has to be asked again. You caught it partway through counting down, which is exactly why it isn’t a round number.
The reply is a stranger’s letter, not a return value
That reply did not come back down a phone line to you. It arrived as its own separate packet, from a machine you don’t control, at a slot on yours that anyone on the internet can write to. Nothing proves who sent it. The only thing tying it to your question is that first random tag: your code checks the reply’s tag against the one it sent, and throws away anything that doesn’t match.
That check is the entire reason the tag is random, and it is also exactly the gap Kaminsky found.
Doormen don’t apologize
Type one byte wrong - turn the 07 before “example” into a 06 - and nothing comes back. Not an error: silence, for three full seconds, until your code gives up.
That silence is deliberate. A DNS server takes packets from every stranger on earth, so replying to garbage with a helpful error would let an attacker fake your address on a small broken packet and have the server mail a much bigger reply to you - a free megaphone for flooding someone else’s line. Silence is the only safe answer to a note it can’t verify.
One consequence follows you from here on: every timeout in a DNS client is ambiguous. Lost packet, slow server, or your own typo look identical. Making your own bytes provably correct, as you just did, is the only fix.
How big a guess is the tag, really
The tag is 16 bits, so a blind guesser has 2 to the 16th tries: 65,536. That’s not much traffic - a few seconds on an ordinary connection - while the real reply lands within tens of milliseconds. A guesser only needs one lucky guess inside that short window, and retrying for a minute gets there. That was Kaminsky’s trick.
The 2008 fix added a second random number: which port slot the question went out from, not just the port 53 it goes to. Guess tag and port together and the space is 2 to the 32nd, over four billion, turning a few seconds into more than eight hours. Nobody wins that race - though it’s still a bet, not proof; the real proof is a signature, DNS Resolution‘s DNSSEC, in the second read.
You now know
- A DNS lookup is 29 bytes: a random tag, a “please recurse” flag, some counts, the telegram-spelled name, and the answer kind wanted.
- The note travels as one fire-and-forget packet - UDP - to port 53; the reply is a separate, unproven packet back.
- The tag it echoes is the only thing tying a reply to your question - why it has to be random.
- Dan Kaminsky, 2008: 65,536 possible tags is a guessable few seconds, not real security.
- The fix randomised the return port too, stretching the guess to over eight hours - still a bet, not proof; DNSSEC is the proof.
- A malformed request gets silence, not an error, because answering strangers’ garbage would arm them against someone else.
Stop here if you want. What follows is the same telegram in bytes, code, and a resolver you build from scratch that follows it all the way to the root.
Second read: the bytes, the sockets, and the walk to the root
Two ideas make those 29 bytes possible, and both generalise to any protocol you hand-build. A socket is a handle on a mailbox. socket(AF_INET, SOCK_DGRAM) gets a UDP endpoint; sendto(bytes, (address, port)) puts bytes in it; recvfrom() takes whatever arrived out - no connection, no session, just the kernel picking your source port and remembering replies to it are yours. struct.pack turns numbers into bytes. struct.pack(">H", 258) produces 01 02: > is big-end-first (“network byte order”), H an unsigned 2-byte int - the whole translation from a count field to bytes on a wire. Every hand-built protocol is a socket plus struct.
Run it again, months later, both directions in full:
query : 29 bytes
0a 04 01 00 00 01 00 00 00 00 00 00 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01
reply : 61 bytes
0a 04 81 80 00 01 00 02 00 00 00 00
07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01
c0 0c 00 01 00 01 00 00 00 59 00 04 68 14 17 9a
c0 0c 00 01 00 01 00 00 00 59 00 04 ac 42 93 f3
header : id=0x0a04 flags=0x8180 QD=1 AN=2 NS=0 AR=0
flags : QR=1 OPCODE=0 AA=0 TC=0 RD=1 RA=1 RCODE=0
answer 1: NAME=c00c (pointer -> offset 12) TYPE=1 CLASS=1 TTL=89s RDLENGTH=4 -> 104.20.23.154
answer 2: NAME=c00c (pointer -> offset 12) TYPE=1 CLASS=1 TTL=89s RDLENGTH=4 -> 172.66.147.243
Same 29 out, same 61 back, months apart on a different resolver. Only the TTL remainder moved - 89 instead of 68 - the same countdown from DNS Resolution, caught at a different moment.
Where 29 and 61 come from, counted by hand:
header 12 bytes
question 1+7 + 1+3 + 1 = 13, + type 2 + class 2 = 17 bytes
──
query 29 bytes
header + question (echoed) 29
answer 1 c0 0c (2) + type 2 + class 2 + TTL 4 + len 2 + IPv4 4 = 16
answer 2 16
──
reply 61 bytes
c0 0c is a 2-byte pointer back to offset 12, where example.com already sits in the echoed question, instead of 13 more bytes spelling it out again. Two answers, 22 bytes saved - why the reply stays under the 512-byte UDP ceiling from DNS on the wire.
The flags byte pair, decoded by hand. Each hex pair is written out as eight bits; QR is “question or reply”, RD “recursion desired”, RA “recursion available”, RCODE the error code:
sent 01 00 = 0000 0001 0000 0000 QR=0 RD=1
received 81 80 = 1000 0001 1000 0000 QR=1 RD=1 RA=1 RCODE=0
Three bits changed. QR flipped to 1 (“this is a response”), RA came on (“recursion available”), RCODE stayed 0 (“no error”). The rest of those sixteen bits are untouched - the entire status of a DNS exchange, in one field.
Breaking it on purpose, next to a correct run:
correct (07) -> 61 bytes back in 98.9 ms, RCODE=0
corrupted (06) -> nothing. timed out after 3 s.
The resolver reads 06, takes exampl as the label, finds a stray e where a length byte should sit, can’t parse the rest, and drops the packet - the same reasoning behind the silent drop in Security Groups vs Network ACLs, a hang rather than a refusal.
The guess space, both ways:
16-bit query id alone : 2^16 = 65,536 guesses
id + a randomised source port : 2^16 * 2^16 = 4,294,967,296 guesses
“A few seconds of packets”, by hand. A forged reply is ~90 bytes (61 above, plus 8 UDP and 20 IP framing). The attacker must land the guess before the real reply - inside one round trip, tens of milliseconds:
id only 65,536 × 90 B ≈ 5.9 MB at 100 Mbit/s (12.5 MB/s) ≈ 0.5 s
id + port 4.29 billion × 90 B ≈ 390 GB at 100 Mbit/s ≈ 8.6 hours
Half a second against a window of tens of milliseconds is a race won by retrying for a minute - Kaminsky’s trick, forcing fresh queries until a guess landed. Eight hours against the same window is a race nobody wins, and it still isn’t authentication: the second number is merely large, where a signature’s odds are astronomically smaller.
Check your own kernel randomises source ports:
12 consecutive UDP source ports this kernel chose:
[62312, 62136, 58669, 57865, 53151, 56231, 53466, 61556, 64116, 62803, 61309, 60751]
range: 53151..64116 spread: 10965 consecutive-increment pairs: 0/11
Scattered, no increments. A kernel handing out 53151, 53152, 53153… would collapse the space back to 65,536, as several systems did before 2008. DNSSEC - the DS/RRSIG records in a dig +trace in DNS Resolution - is the actual proof: a forgery fails verification, not just improbability.
One level up: be the resolver, not the client
The script you ran is a stub: it asks one server and trusts the answer. The real end of this arc is the walk itself, following referrals from the root, as DNS Resolution describes - needing one thing the stub never did: reading a name back out of a reply, including the c0 0c pointer above.
def read_name(msg, i):
parts = []
while True:
n = msg[i]
if n == 0:
return ".".join(parts), i + 1
if n & 0xC0 == 0xC0: # a ditto mark
ptr = ((n & 0x3F) << 8) | msg[i+1] # 14 bits of offset
rest, _ = read_name(msg, ptr) # go read it from there
parts.append(rest)
return ".".join(parts), i + 2 # a pointer always ends a name
parts.append(msg[i+1:i+1+n].decode())
i += 1 + n
A pointer sends you elsewhere, you read a name there, and come back - always the last thing in a name, so the return is unconditional. The walk is then a loop: ask a server; if it answers you’re done, if it refers you, ask a name server with an address instead. Run against the root, no dig, no resolver, ~65 lines total:
$ python3 walk.py example.com
hop 1 asked 198.41.0.4 ANSWER=0 AUTHORITY=13 ADDITIONAL=11
referral to 13 name servers, 6 with glue; taking l.gtld-servers.net
hop 2 asked 192.41.162.30 ANSWER=0 AUTHORITY=2 ADDITIONAL=12
referral to 2 name servers, 2 with glue; taking hera.ns.cloudflare.com
hop 3 asked 173.245.58.162 ANSWER=2 AUTHORITY=0 ADDITIONAL=0
example.com TTL=300s -> 104.20.23.154
example.com TTL=300s -> 172.66.147.243
$ python3 walk.py wikipedia.org
hop 1 asked 198.41.0.4 ANSWER=0 AUTHORITY=6 ADDITIONAL=12
referral to 6 name servers, 6 with glue; taking a2.org.afilias-nst.info
hop 2 asked 199.249.112.1 ANSWER=0 AUTHORITY=3 ADDITIONAL=6
referral to 3 name servers, 3 with glue; taking ns1.wikimedia.org
hop 3 asked 208.80.153.231 ANSWER=1 AUTHORITY=0 ADDITIONAL=0
wikipedia.org TTL=180s -> 103.102.166.224
Three hops each, different paths, nothing hardcoded. The TTLs are round numbers - 300 and 180 - because these came from authoritative servers, not a cache: this program has none, so every number is the configured one, not a remainder like the 89 earlier. “6 with glue” out of 13: the root sent thirteen .com name servers but only eleven usable addresses.
reply 489 bytes QD=1 AN=0 NS=13 AR=11
ADDITIONAL breakdown: {'A (IPv4 glue)': 6, 'AAAA (IPv6 glue)': 5}
Six IPv4 and five IPv6 addresses for thirteen names, because the referral must fit in one postcard and 489 bytes is already near the classic 512-byte ceiling (DNS on the wire). Two of the thirteen had no usable address, needing a second walk just for a name server’s own name - that, plus caching, root retries, TCP fallback, and DNSSEC checking, separates this toy from a real resolver. What it is not missing is the idea: the walk above is DNS Resolution‘s drawing, executed in your own bytes.
Homework (break it on purpose)
- Change
NAMEto a site you use daily. Predict the record count before running. - Change
CONCIERGEto"1.1.1.1"- compare the TTLs you get. - Corrupt one length byte (
07→06) and watch the silence: doormen bin malformed telegrams without a word.
The bigger homework: stop being a stub
- Check your kernel randomises source ports. Incrementing ports collapse the 2^32
guess-space back to 2^16.
python3 -c "import socket; \ print([ (lambda s: (s.connect(('1.1.1.1',53)), s.getsockname()[1])[1])(socket.socket(socket.AF_INET,socket.SOCK_DGRAM)) for _ in range(12)])" - Add
read_name(the nine lines above) todns_telegram.py, and print the question echoed back rather than assuming it. You now handlec0 0c. - Follow one referral by hand. Query
198.41.0.4with the recurse bit off (flags0x0000) and printAN,NSandAR. You’ll getAN=0- the referral from DNS Resolution, in your own bytes. - Close the loop into a resolver. Read the name servers out of AUTHORITY, match them
against the A records in ADDITIONAL, and ask the next one. Repeat until
AN > 0. That’s the whole program, and the run above is what it prints. - Then find where the toy breaks. Ask for a name whose referral has no usable IPv4
glue - you now need a second walk just to resolve a name server’s name. Add
TChandling, a TCP retry, and a cache keyed on (name, type) with the TTL as its expiry. Each is a real resolver’s job, and each is a few lines.
Go deeper
- RFC 1035 section 4.1 - you can now read this and recognize every field
- Julia Evans - Implement DNS in a weekend (the full version: parsing pointers, doing the resolver’s walk yourself)
Related
The 29 bytes you just hand-built are exactly what’s dissected byte-by-byte in DNS on the wire, same query, this time captured off the wire instead of typed by hand. The full Networking list is in CURRICULUM.
Retrieval practice
8 questions. Answer each one before you open it.
Without looking: what five ingredients go into the 29 bytes?
A random tag, the flags word (“please recurse”), the counts (1 question), the telegram-spelled name, and the question type+class (A, IN).
Why does the script check reply[:2] == telegram[:2]?
That’s the tag - proof this reply answers our question, not someone else’s, and why the tag has to be random: so a stranger’s reply can’t pass as the real one by guessing.
The reply was 61 bytes for a 29-byte question. Roughly where do the extra 32 go?
Two answer records: each is a 2-byte pointer + 10 bytes type/class/TTL/length + 4 bytes IP = 16. 29 + 32 = 61.
You sent flags 01 00 and got back 81 80. Which bits changed, and what did each change say?
01 00 is 0000 0001 0000 0000 - only RD is set. 81 80 is 1000 0001 1000 0000. Three differences: QR flipped to 1 (“this is a response”), RA came on (“recursion available”), RCODE stayed 0 (“no error”). RD is echoed back unchanged - the entire status of a DNS exchange, in one field.
A one-byte corruption produced a three-second timeout and no error at all. Why is silence the correct behaviour, and what does it cost you as the author of the client?
Because a DNS server takes unauthenticated UDP from anywhere on earth. Replying to malformed input would hand an attacker a free amplifier: a small forged packet in, a bigger reply out to the victim. Silence is the only safe answer to input it can’t verify. The cost: every timeout in your client is ambiguous - lost packet, slow server, or your own bug look identical, so your own bytes have to be provably correct first.
The tag check is two bytes of comparison. Do the arithmetic on how much security it actually provides, and say what closes the gap.
The id is 16 bits: 2^16 = 65,536 blind guesses, a few seconds of packets, not a margin. Randomising the source port too multiplies that by another 2¹⁶, to 2^32 ≈ 4.3 billion - the post-2008 (Kaminsky) fix, checkable by confirming a dozen UDP sockets get scattered ports, not incrementing ones. Still a guessing game the attacker probably loses, not authentication: DNSSEC’s DS/RRSIG records make a forgery fail verification outright.
To follow referrals from the root yourself, you need one capability the 29-byte stub did not. What is it, and why does the function that provides it end unconditionally after a pointer?
The ability to read a name out of a reply, including compression pointers - a stub only ever wrote names. On a byte with the top two bits set, read_name takes the remaining 14 bits as an offset, reads the name there, and returns. Unconditionally, because a pointer is always the final element of a name: nothing is left to append.
Your hand-written walk returned TTLs of 300 and 180 - round numbers - while everyday lookups return things like 89. Why can this program never show you an 89?
Because it has no cache and never talks to one. Every reply comes from an authoritative server, stating the configured lifetime, not somebody’s countdown remainder. A resolver’s 89 is what’s left of a 300; a walk from the root always sees the 300 itself.