1Station 1 of 6: DNS
The answer to a question nobody asked
A DNS client that accepts the first datagram on its socket, from anyone, about anything, lets anyone on the network choose where the service connects.
$ uv run python -m wire run 1$ uv run python -m wire check 1Goal#
Fix a DNS stub resolver that accepts forged answers, without checking who sent them, which query they answer, or which name they claim to resolve.
The idea#
What DNS over UDP is
DNS translates human-readable domain names like api.example.test into IP addresses like 10.20.0.15. Without DNS, every web request, API call, and email would need a numeric address. DNS runs over UDP port 53: one datagram asks a question, one datagram answers it. No connection setup, no handshake, no stream. A client sends a query packet to a DNS server and waits for a response packet back.
Why stateless protocols need validation
UDP is stateless. The server does not remember your query, and your socket does not remember which server you asked. When a datagram arrives, nothing in the network stack proves it is the answer to your query. It could be:
- The real answer from the server you asked.
- A late answer to an old query from five minutes ago.
- A forged answer from anyone on the network who can send you a UDP packet.
- A blind guess from an attacker who floods your port with answers for every possible query id.
Without validation, your resolver accepts the first datagram that arrives, regardless of who sent it or what it claims.
How a DNS query works
A DNS client sends a query with these parts:
- Query ID: a 16-bit number (0 to 65,535) that pairs questions with answers.
- Source port: the UDP port the client listens on for the response.
- Question section: the domain name and record type (usually A for IPv4 addresses).
The server sends back a response with the same query ID and the answer. The client must check:
| What to check | Why it matters |
|---|---|
| Sender address and port | The datagram came from the server we asked, not someone else |
| Query ID | This answers the query we just sent, not a stale or forged one |
| Question name | The answer is about the domain we asked for, not a different one |
If all three match, the answer is trustworthy. If any do not match, discard it and keep waiting.
What people get wrong
Reusing the same source port. Many stub resolvers bind one socket at startup and use it for every query. Once an attacker sees one query, they know the port and can send forged answers at any time. The fix is a fresh socket with an ephemeral port (bind to port 0) for each query.
Predictable query IDs. Using random.randrange() for query IDs makes them predictable from process timing. An attacker can guess the next ID and flood the port with answers. The fix is secrets.randbelow(), which uses the OS entropy pool.
Trusting the first datagram. A resolver that calls recvfrom() once and uses whatever arrives is trivial to poison. The attacker sends 20 forged answers before the real server replies, and the resolver caches the wrong address for the TTL (time to live) in the forged answer, which could be 86,400 seconds (one day). The fix is a loop: keep reading until a datagram matches all three checks.
How production systems handle DNS
Caching resolvers. Most services delegate to systemd-resolved, dnsmasq, Unbound, or a cloud provider DNS (like AWS Route 53 resolver, Google Cloud DNS, or Azure DNS). These validate sender, query ID, and question name, retry on timeout, and respect the TTL from the answer. They also handle query pipelining, EDNS0 extensions, and DNSSEC where supported.
DNSSEC. DNS Security Extensions (RFC 4033, 4034, 4035) add cryptographic signatures to DNS records. A validating resolver can prove the answer came from the authoritative server and was not modified in transit. DNSSEC adoption is growing but not universal. Many large domains (like google.com, cloudflare.com) are signed; many others are not. Even with DNSSEC, you still need to validate sender and query ID to prevent response flooding.
RFC 5452 (DNS forgery resilience). Recommends source port randomization (ephemeral ports per query) and unpredictable query IDs. A resolver that follows these measures forces an attacker to guess both the port (16 bits) and the query ID (16 bits), making blind poisoning attacks require hundreds of thousands of attempts.
Public resolvers. Services like 1.1.1.1 (Cloudflare), 8.8.8.8 (Google Public DNS), and 9.9.9.9 (Quad9) implement all of these protections, handle millions of queries per second, and provide DNSSEC validation. For most applications, using a public resolver or your cloud provider's DNS is safer than running your own stub resolver.
The case#
Run the case to see the weakness:
uv run python -m wire run 1
case 01: The answer to a question nobody asked
the DNS server says: api.example.test is 10.20.0.15, for 300 s
the service connects to: 203.0.113.66
and caches that for: 86,400 s
The DNS server answered 10.20.0.15, with a 300-second TTL. The service connected to 203.0.113.66, and cached that answer for a full day. Someone sent 20 forged answers before the real one arrived, and the resolver took the first datagram without checking anything.
Reasoning towards the fix#
Start by running the case and reading lab/cases/case01_dns.py. The Resolver class creates one UDP socket at init time, binds it to a fixed source port, and reuses it for every query. Anyone who observes one query knows where to send answers.
The lookup method sends a query, then immediately calls recvfrom. It takes the first datagram that arrives, parses it, and caches the result. It never checks who sent it, whether the query id matches, or whether the name is the one it asked for.
Print what arrives. Before you parse the datagram, print the sender address and the query id from the response. Compare them to the server address and the query id you sent.
To fix it:
- Use
secrets.randbelow(65536)for the query id instead ofrandom.randrange.randomis predictable from process timing;secretsis not. - Bind a fresh socket per query with
socket.socket(...)in awithblock. Bind to port 0, which picks a random ephemeral port. - Loop on
recvfromuntil you get a datagram where the sender matchesself.server, the query id matches the one you sent, and the name in the answer matches the name you asked for. Only then break and cache the result.
The checks use the after_send hook to inject forged datagrams onto the wire after your query goes out. With all three checks in place, they are ignored.
Check your fix#
Once you have made the changes, check them:
uv run python -m wire check 1
As shipped, the check output is:
PASS api.example.test resolves to the real address (10.20.0.15)
FAIL 20 forged answers sent ahead of time are ignored (got 203.0.113.66)
FAIL the right query id from the wrong address is ignored (got 203.0.113.66)
FAIL a late answer to an earlier query is ignored (got 10.20.0.99)
PASS the answer is cached for its own 300 s TTL, then asked again
not yet. Try: uv run python -m wire hint 1
When fixed, all lines show PASS. The resolver ignores forged datagrams, answers from the wrong source, answers with the right query id but wrong sender, and late answers to stale queries.
Try it on your own machine#
Run the experiments to see DNS, TLS and HTTP in numbers:
uv run python -m wire try trace
uv run python -m wire try handshake
The trace experiment shows a full sign-in flow, request by request. Every Set-Cookie and Cookie header is annotated. The handshake experiment measures the cost of TCP, TLS, and a full HTTPS request on 127.0.0.1. On a real network, add one round trip for TCP and one for TLS 1.3 before the first byte of the request. That is why connection reuse matters.
Capture your own app's traffic in your browser's network tab, Wireshark, or Burp Suite, and explain every line.
In production#
DNS stub resolvers are rare in production. Most services delegate to a caching resolver like systemd-resolved, dnsmasq, or a cloud provider's DNS. Those resolvers already validate query ids, source addresses, and names, and they retry on timeout.
If you do run your own resolver:
- Use ephemeral source ports. Bind a fresh socket per query, or rotate through a pool of sockets with random ports.
- Generate query ids with
secrets, notrandom. The query id must not be predictable from timing or process state. - Validate every answer. Check the sender address, the query id, and the QNAME before you cache anything.
- Set a timeout and retry on silence. If the server never answers, the attacker can keep you waiting forever.
- Respect the TTL in the answer, not your own default. A 300-second answer should not live in your cache for a day.
- Consider DNSSEC if your resolver and the zones you query support it. DNSSEC provides cryptographic proof of the answer's authenticity, but it is not universal yet.
Public resolvers like 1.1.1.1 and 8.8.8.8 already do all of this. Use them unless you have a specific need for a local resolver.
Key points#
- DNS over UDP is stateless. Without validation, any datagram looks like an answer.
- Check the sender, the query id, and the name before you trust an answer.
- Use ephemeral source ports and unpredictable query ids to make blind attacks hard.
- Respect the TTL in the answer, not your own policy.
- In production, use a battle-tested caching resolver. Do not write your own unless you have a reason.