EDNS0, UDP Buffer Sizes, and Why DNS Scanners Get Truncated
How the 512-byte UDP limit, the EDNS0 OPT record, and Flag Day 2020's 1232-byte default shape DNS scanner behavior — and what to do about TC.
On this page
A DNS scanner running at 4000+ queries/second eventually trips over a different bottleneck: responses that don’t fit in a UDP packet. The query goes out fine. The answer comes back with the TC bit set and an empty answer section, or the upstream silently drops it because the OPT record was wrong, or the resolver retries over TCP and your tail latency triples. None of these are application bugs. They’re a consequence of how DNS handles message sizes — a story that runs from RFC 1035 in 1987, through EDNS0 in 1999, to DNS Flag Day 2020.
This article covers the 512-byte UDP limit, the EDNS0 OPT record, why DNSSEC and large RRSets force the issue, what Flag Day 2020 standardized at 1232 bytes, and how to handle TC truncation in a Go scanner using miekg/dns.
The 512-Byte Wall
Classic DNS over UDP has a hard message size limit of 512 bytes, set by RFC 1035 §2.3.4:
Messages carried by UDP are restricted to 512 bytes (not counting the IP or UDP headers).
The 512-byte choice predates path MTU discovery and was picked because IP-layer reassembly was assumed to handle at least 576 bytes (the IPv4 minimum reassembly buffer). Subtract IP and UDP headers and you get a safe payload size that any host on the early internet could be expected to receive without fragmentation.
The limit shows up two ways in practice:
- Outgoing message construction: if a resolver tries to build a UDP response larger than 512 bytes, it truncates the message and sets the TC (truncation) bit in the header.
- Incoming buffer assumption: a resolver receiving a UDP response only allocates a 512-byte read buffer unless it advertised otherwise.
When the TC bit is set, the standard recovery is to retry the query over TCP. TCP has no size limit beyond the 16-bit length prefix (65535 bytes), so the full response always fits. The cost is a TCP handshake, which for a SOCKS5-tunneled scanner means another round trip through the proxy before the query even starts.
512 bytes was fine for an internet of A records and short NS lists. It is not fine for modern DNS.
What Won’t Fit in 512 Bytes
Several common response types blow past 512 bytes routinely:
- DNSSEC responses: RRSIG records carry signatures (typically 128-256 bytes each for RSA, 64 bytes for ECDSA P-256) plus signer name and metadata. A signed A record is roughly the original record plus an RRSIG that is several times larger. DNSKEY responses for a zone with KSK and ZSK rotation can run over 1KB.
- Large NS RRSets: a TLD or cloud-hosted zone may list 8+ nameservers. Each NS record plus its glue (A and AAAA in the additional section) accumulates quickly.
- MX/TXT-heavy responses: SPF records, DKIM keys, and DMARC policies in TXT records routinely push individual responses past 1KB.
- ANY queries: when not refused outright, return every RRSet at the name.
- CNAME chains with target answers: the additional section can balloon when the resolver helpfully includes the resolved target.
For a scanner doing NS lookups against TLD nameservers, the response size depends on the zone — .com NS responses fit comfortably, but a query for the apex of a DNSSEC-signed domain with multiple nameservers and glue can exceed 512 bytes routinely.
EDNS0: Advertising a Larger Buffer
RFC 6891 (Extension Mechanisms for DNS, EDNS(0)) is the standard mechanism for negotiating larger UDP message sizes. It defines a pseudo-resource-record called OPT that carries metadata in the additional section of a DNS message.
The OPT record looks like a normal RR but reuses fields for protocol metadata:
NAME = . (root, single zero byte)TYPE = OPT (41)CLASS = requestor's UDP payload size (e.g. 1232)TTL = extended RCODE (8) | version (8) | flags (16, including DO bit)RDLENGTH = length of option dataRDATA = list of {option-code, option-length, option-data}The CLASS field, normally used for IN/CH/HS, is repurposed as the UDP payload size the requestor is willing to receive. When a resolver sends a query with an OPT record advertising CLASS=4096, it is telling the authoritative server: “I can accept UDP responses up to 4096 bytes — send me the full message and skip the truncation dance if you can.”
The DO (DNSSEC OK) bit in the TTL field signals that the requestor wants DNSSEC records (RRSIG, NSEC, NSEC3) included in the response. Without DO, DNSSEC-signed zones return only the base RRSet and skip the signatures.
OPT is per-message. Both query and response carry their own OPT record. The server’s OPT in the response advertises its maximum UDP size and may include extended RCODEs (BADVERS, BADCOOKIE, etc.). A query without an OPT record is implicitly limited to 512 bytes — the classic DNS behavior.
DNS Flag Day 2020 and the 1232-Byte Default
For years, “use EDNS0 with 4096 bytes” was the conventional default. Most resolver implementations advertised 4096 because that comfortably fit any DNSSEC response and most large RRSets. Then path-MTU and IP-fragmentation problems started biting.
The issue: a 4096-byte UDP response from an authoritative server gets fragmented at the IP layer if the path MTU is smaller (typically 1500 bytes on Ethernet, often less over tunnels and VPNs). IP fragmentation has well-documented problems:
- Fragments can arrive out of order or be dropped independently
- Middleboxes and some firewalls drop fragments by policy
- Fragment-based attacks (cache poisoning via crafted second fragments) are a real threat surface
DNS Flag Day 2020 was a coordinated effort by major DNS software vendors and operators to standardize a smaller default UDP payload size that avoids IP fragmentation on most paths. The agreed value: 1232 bytes.
The number isn’t arbitrary. It’s derived from:
- IPv6 minimum MTU: 1280 bytes
- IPv6 header: 40 bytes
- UDP header: 8 bytes
- Remaining for DNS payload: 1280 − 40 − 8 = 1232 bytes
This guarantees that a 1232-byte DNS response fits in a single IPv6 packet on any path that meets the IPv6 minimum MTU requirement, with no fragmentation. On IPv4 paths the margin is similar in practice — most paths support at least 1280 bytes end-to-end.
Post-Flag-Day, BIND, Unbound, Knot, and PowerDNS all default their EDNS0 advertised size to 1232. Responses that exceed 1232 bytes are expected to set the TC bit and force a TCP retry. The protocol gets slower for large responses, but it stops relying on IP fragmentation as a correctness mechanism.
What Happens When OPT Is Missing or Wrong
Authoritative servers vary in how they handle OPT records, and a scanner that gets the details wrong sees this as flaky behavior:
- Missing OPT: the server falls back to 512 bytes. Responses that would have fit in 1232 get TC’d and force TCP retries.
- OPT with class=0 or class=512: some servers treat this as a malformed OPT and either drop the query or downgrade to 512-byte responses. Setting EDNS0 with an explicit size below 512 is meaningless.
- OPT with unsupported version: extended RCODE BADVERS (16). Most authoritatives implement version 0 only.
- OPT with EDNS options the server doesn’t understand: per RFC 6891, unknown options should be ignored, but some authoritatives drop the entire query. This is one reason cookies, client subnet, and other EDNS options can be touchy.
- DO bit set but server doesn’t sign: response comes back without RRSIG records, which is fine. The DO bit is a request, not a requirement.
A handful of broken authoritatives drop large OPT records or oversized queries entirely. For a scanner, the symptom is timeouts on a small subset of TLDs or providers. The fix is usually to retry without EDNS0 — the so-called “EDNS fallback” path. Modern resolvers like Unbound do this automatically; a raw scanner using miekg/dns does not, by default.
Setting OPT in miekg/dns
The Go miekg/dns library exposes EDNS0 through the OPT RR type. Here is the minimal pattern for a scanner that advertises a 1232-byte buffer:
msg := new(dns.Msg)msg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
// Add OPT record advertising 1232-byte UDP bufferopt := &dns.OPT{ Hdr: dns.RR_Header{ Name: ".", Rrtype: dns.TypeOPT, },}opt.SetUDPSize(1232)msg.Extra = append(msg.Extra, opt)
resp, rtt, err := client.ExchangeWithConn(msg, conn)SetUDPSize writes the value into the CLASS field. dns.Msg also has a convenience method SetEdns0(size, doBit) that builds and attaches the OPT record in one call:
msg.SetEdns0(1232, false) // 1232 bytes, DO bit clearPass true as the second argument to set the DNSSEC OK bit. If the scanner doesn’t care about RRSIG/NSEC records (most don’t, for surface-level scanning), keep it false — DO=true increases response sizes substantially against signed zones and increases the TC rate.
Handling TC: Retry Over TCP
When a response comes back with resp.Truncated == true, the answer in the UDP message is partial or empty. The standard recovery is to retry the same query over TCP. miekg/dns makes this straightforward — keep a parallel TCP client and reissue the query when TC is set:
resp, rtt, err := udpClient.ExchangeWithConn(msg, udpConn)if err == nil && resp.Truncated { resp, rtt, err = tcpClient.Exchange(msg, resolverAddr)}The TCP retry costs a connection setup (one round trip for the SYN/SYN-ACK/ACK if a fresh connection) plus the query/response. For a SOCKS5-tunneled scanner the cost is higher because the proxy itself does TCP setup per upstream connection. A few mitigations:
- Persistent TCP connections: open one TCP connection per worker to the resolver and reuse it across queries.
miekg/dns’sConnsupports this; the protocol multiplexes by message ID. - Larger advertised buffer when path MTU is known: if the scanner runs in a controlled environment where the path to the resolver supports 4096-byte UDP, advertise 4096 to avoid TC on DNSSEC responses. This is appropriate for an on-LAN scanner talking to an on-LAN recursive; it is not appropriate for a scanner querying arbitrary authoritatives across the internet.
- Accept the TC tax: if only a few percent of responses are truncated, the TCP retries don’t meaningfully affect aggregate throughput. Measure first.
Choosing the Right Buffer Size
The buffer size advertised by a scanner depends on what it is talking to and over what network.
Scanner → recursive resolver, controlled network: advertise 4096. The path is short, fragmentation is unlikely, and avoiding TC retries is worth more than the marginal fragmentation risk. This is what older defaults assumed.
Scanner → recursive resolver, internet path: advertise 1232. Flag Day 2020 defaults exist for good reasons. Pay the TCP tax on the small fraction of responses that don’t fit.
Scanner → authoritative servers directly, internet path: advertise 1232. Authoritative responses are more variable in size (DNSSEC, large NS sets, glue) and the internet path to a random TLD nameserver has unpredictable MTU. 1232 is the conservative choice.
Scanner → authoritative servers, DNSSEC validation enabled: advertise 1232, set DO=true, expect a higher TC rate, and ensure TCP fallback works. DNSSEC responses commonly exceed 1232 bytes; without working TCP retry, validation will fail intermittently.
For the 4000 qps Go scanner querying TLD nameservers for NS records, 1232 with DO=false is the right default. NS responses without DNSSEC rarely exceed 1232 bytes, and the small fraction that do can fall back to TCP without affecting aggregate throughput. The full DNS resolution path — recursive resolver to root to TLD to authoritative — never hits the OPT-record edge cases for plain A/AAAA/NS queries; they only emerge under DNSSEC, large TXT records, or DDNS-style update responses.
What tcpdump Shows
When debugging EDNS0 behavior, tcpdump with the -vv flag decodes OPT records. The conceptual shape of a query with EDNS0 is something like:
IP scanner.50000 > resolver.53: 12345+ [1au] NS? example.com. (39) example.com. NS? (29 bytes) OPT pseudo-RR: UDPsize=1232, flags=0x0000 (10 bytes)The [1au] count indicates one additional (the OPT record). A response that fits looks like:
IP resolver.53 > scanner.50000: 12345 8/0/1 NS ns1.example.com., NS ns2.example.com., ... (412)A truncated response:
IP resolver.53 > scanner.50000: 12345 0/0/1 NS [tc] (40)The [tc] flag indicates the TC bit is set. A scanner should then reissue the same query over TCP, which appears as a new TCP connection on port 53 followed by a 2-byte-length-prefixed query and response.
If the scanner sees a query go out with EDNS0 and a response come back without an OPT record, the upstream is either ancient or stripping OPT. The response size is implicitly limited to 512 bytes in that case, regardless of what was advertised.
dig Examples for Manual Testing
dig is the easiest way to verify EDNS0 behavior interactively. The relevant flags:
dig +bufsize=1232 @8.8.8.8 example.com NS # set advertised UDP bufferdig +bufsize=512 @8.8.8.8 example.com NS # disable effective EDNS0dig +noedns @8.8.8.8 example.com NS # send no OPT record at alldig +dnssec @8.8.8.8 example.com DNSKEY # set DO bit, request DNSSEC RRsdig +tcp @8.8.8.8 example.com NS # force TCP regardless of sizeIn dig output, look for the ;; OPT PSEUDOSECTION: block to see the negotiated UDP size from the responding server, and the ;; flags: line for tc in the header. Response size is reported on the ;; MSG SIZE rcvd: line at the bottom.
To force a TC response and verify retry behavior, query a DNSSEC-signed zone with +dnssec +bufsize=600 against an authoritative server. The response will exceed 600 bytes, the server will set TC, and dig will automatically retry over TCP (visible as ;; Truncated, retrying in TCP mode.).
What This Buys You
EDNS0 done right gives a scanner three things:
Fewer TCP retries. Most responses fit in 1232 bytes. A scanner with no OPT record TC’s on anything over 512, which for DNSSEC-signed zones is most responses. The cost difference between a 50ms UDP round trip and a 150ms TCP round-trip (SYN/SYN-ACK/ACK + query/response, doubled if going through a proxy) is significant at 4000 qps.
Correct DNSSEC handling. Without DO=true, signed zones return no signatures. Without 1232+ buffer, signatures often won’t fit. A scanner that wants to do any DNSSEC checking must set both.
Predictable behavior under fragmentation. A 1232-byte ceiling means responses fit in a single IP packet on essentially every internet path. No reassembly, no fragment drops, no fragment-based attack surface.
The cost is two extra lines of code per query and an understanding of why the values are what they are. The 512-byte limit is from 1987. The 1232-byte default is from 2020. The OPT record is from 1999. The history matters because the defaults are still arguments over which packet sizes work where, and a scanner that gets them wrong sees the consequences as flaky tails in the latency histogram.