The whole incident in one picture: a 31.8× amplifier becomes a 0.45× de-amplifier, and no legitimate user notices.
A DNS amplification attack does not attack your resolver. It recruits it. That distinction changes everything about how you are supposed to respond.
The anomaly: one name eating the whole resolver
It started as a glance at the query log. A public resolver serves a long tail of names: some Google, some CDN, some ads getting blocked, thousands of different domains. Ours, that morning, looked like this:
$ jq -r .QH /dev/shm/querylog.json | sort | uniq -c | sort -rn | head -3
3455950 cloudflare.com ← 93.38% of all DNS traffic
9124 www.google.com
7733 googlevideo.comThree and a half million queries for cloudflare.com in twelve hours, all of them TXT. Not A, not AAAA, not HTTPS: TXT, the record type that holds text and therefore holds a lot of bytes. No human browses to a TXT record. Nothing on a normal network asks for one three and a half million times.
This is the tell. When one qname and one qtype dominate a resolver's traffic, you are almost never looking at a client bug. You are looking at somebody standing behind your resolver and using it to hit something.
The mathematics of a weapon
Here is why cloudflare.com/TXT and not something else. Run it yourself, right now, against any resolver:
$ dig TXT cloudflare.com +bufsize=10000
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 26
;; MSG SIZE rcvd: 2147
$ dig TXT samsung.com +bufsize=10000
;; flags: qr rd ra; QUERY: 1, ANSWER: 52
;; MSG SIZE rcvd: 4131 ← 58× the query sizeBig companies accumulate TXT records the way garages accumulate boxes: domain-verification tokens for every SaaS product they ever signed up for, SPF policies, DKIM keys, site-ownership proofs. Nobody ever deletes them. cloudflare.com carries 26 of them; samsung.com carries 52. To an attacker these are not corporate records, they are free ammunition: a huge, publicly resolvable, cacheable answer that any resolver on earth will hand out to anyone who asks. This is why the same handful of fat-TXT domains show up in reflection floods again and again. None of these companies did anything wrong, and their infrastructure is not the target - their record size is simply the payload.
Here is the ammunition, re-measured on 31 August 2026 from a second host, with the DNSSEC-OK bit set exactly as the flood set it. The ratio is wire to wire: dig reports the DNS payload, so 28 bytes of IPv4 and UDP header go on top before dividing by the query's own size on the wire - and the query is not one size, because the name is inside it. It is 63 bytes for hp.com and 71 for cloudflare.com.
| Domain | TXT records | Answer | Query | Ceiling |
|---|---|---|---|---|
| sony.com | 107 | 9,019 B | 65 B | 139.2× |
| cisco.com | 88 | 6,814 B | 66 B | 103.7× |
| adobe.com | 74 | 6,004 B | 66 B | 91.4× |
| intel.com | 104 | 5,915 B | 66 B | 90.0× |
| amazon.com | 62 | 5,209 B | 67 B | 78.2× |
| microsoft.com | 61 | 4,890 B | 70 B | 70.3× |
| hp.com | 55 | 4,298 B | 63 B | 68.7× |
| samsung.com | 53 | 4,207 B | 68 B | 62.3× |
| oracle.com | 47 | 3,608 B | 67 B | 54.3× |
| ibm.com | 47 | 3,363 B | 64 B | 53.0× |
| salesforce.com | 39 | 3,356 B | 71 B | 47.7× |
| ebay.com | 37 | 2,712 B | 65 B | 42.2× |
| cloudflare.com | 28 | 2,591 B | 71 B | 36.9× |
| apple.com | 23 | 2,149 B | 66 B | 33.0× |
| aol.com | 16 | 1,228 B | 64 B | 19.6× |
| paypal.com | 12 | 1,237 B | 67 B | 18.9× |
| google.com | 16 | 1,103 B | 67 B | 16.9× |
| yahoo.com | 11 | 796 B | 66 B | 12.5× |
The attacker who hit us picked cloudflare.com, which is not even the loudest name on the list. sony.com carries 107 TXT records and answers with 9,019 bytes: a 139× ceiling, close to four times what they used. Fifteen of these eighteen zones are unsigned, so the DNSSEC-OK bit buys nothing at all on them; it is worth 110 bytes on cloudflare.com, 110 on salesforce.com and 106 on paypal.com, the three that are signed - and on cloudflare.com it is the same 110 bytes the flood collected, still there seven weeks later.
Two corrections to the version of this table published on 13 July 2026, both in the arithmetic rather than the measurement. It divided every answer by 71 bytes, which is cloudflare.com's query and nobody else's, so every shorter name came out too quiet: cisco.com was printed at 96.0× and is 103.7× on its own query size. And it compared the answer's DNS payload with the query's wire size. Both columns are wire to wire here. The 31.8× quoted elsewhere in this article is on the old convention; on this one the same packets are 32.2×. We keep 31.8× where it appears, because that is the figure in the capture and in what we reported upstream, and the gap is 0.4×.
The July table also carried a prediction - that these sets grow, because nobody prunes verification tokens. Seven weeks is a short window and five of its ten rows moved, all in the same direction: cloudflare.com 26 records to 28, google.com 14 to 16, hp.com 52 to 55, samsung.com 52 to 53, ebay.com 36 to 37. None shrank. In bytes, cloudflare.com went from 2,147 to 2,481 and hp.com from 4,001 to 4,298. Those are plain TXT sizes, measured the way the July table was, so they compare directly.
Now do the arithmetic that the attacker did. On our wire, during the flood:
- • The query: 71 bytes on the wire.
- • The answer: 2,257 bytes, too big for one UDP datagram, so it left as two IP fragments.
- • The ratio: 31.8×.
One reconciliation, because a careful reader will notice it: our wire shows 2,257 bytes, and the table above lists cloudflare.com at 2,591. Two differences, not one. The first is 110 bytes of DNSSEC signatures: a plain TXT query returned 2,147 bytes in July, and with the DNSSEC-OK bit set the resolver also attaches the RRSIG records that authenticate it, which is the 2,257 we captured. The attack set that bit - buying an extra 110 bytes of amplification for free, and confirming this was a tuned tool rather than a stray misconfiguration. The second is seven weeks of new verification tokens: the same question today answers with 2,481 bytes plain and 2,591 signed, the same 110-byte gap on a set that has grown by two records.
The attacker forges the source address of each query to be the address of whoever they want to hurt. Our resolver, doing exactly what a resolver is built to do, sends 2,257 bytes to that address. The attacker spends 71 bytes; the victim receives 2,257. At the ~250 queries per second we were sustaining, that is roughly 4.6 Mbps of attack traffic leaving our server, aimed at people we had never heard of.
The uncomfortable sentence. We were not the target of this attack. We were the weapon. Every packet of it was ours, sent from our IP, and it would have shown up in the victim's logs with our name on it.
One more detail worth knowing, because it is how you confirm the diagnosis: every one of those queries carried a DNS transaction ID of 0x0000, advertised an EDNS UDP buffer size of 10000, and set the DNSSEC-OK bit. No real resolver does the first two. A real client randomises its transaction ID (that randomness is what makes cache poisoning hard), and modern software advertises 1232 bytes, not 10000. Every field was set to squeeze the largest possible answer out of us: the huge buffer to avoid early truncation, the DNSSEC-OK bit to pull in the signatures. This is a purpose-built tool, not a misconfigured device.
The census, and the number hiding inside it
The obvious way to build that table is a loop: ask a resolver for each domain's TXT set, read MSG SIZE rcvd, divide. Read carelessly, that loop hands you a number worth nothing to an attacker - and it is the number most people quote. Here is the loop with the two corrections that matter:
#!/bin/bash
# TXT amplification census. Two numbers, not one:
# full - how big the answer is, whatever it takes to deliver it
# udp - how much of it the resolver hands back in a single UDP datagram
# Only the second one is ammunition. A spoofed source cannot complete the TCP
# handshake that dig silently falls back to, so an answer that only arrives over
# TCP amplifies nothing.
#
# usage: ./txt-amp-census.sh [resolver-ip]
R="${1:-8.8.8.8}"
DOMAINS="cisco.com sony.com adobe.com intel.com amazon.com microsoft.com hp.com
samsung.com oracle.com ibm.com salesforce.com ebay.com cloudflare.com
apple.com aol.com paypal.com google.com yahoo.com"
printf '%-16s %4s %6s %7s %7s %8s\n' "domain @$R" TXT query full udp amp
for d in $DOMAINS; do
# Query size on the wire, as a flood sends it: 12 (header) + wire-encoded name
# + 4 (qtype/qclass) + 11 (EDNS OPT) + 28 (IPv4 + UDP headers). dig's own query
# is 12 bytes larger, because it appends a DNS Cookie; an attack tool does not.
q=$(awk -v n="$d" 'BEGIN{for(i=split(n,a,".");i;i--)l+=1+length(a[i]);print l+1+55}')
full=$(dig +dnssec +bufsize=4096 TXT "$d" @"$R" | awk '/MSG SIZE/{print $NF}')
udp=$( dig +dnssec +bufsize=4096 +ignore +notcp TXT "$d" @"$R" | awk '/MSG SIZE/{print $NF}')
n=$(dig TXT "$d" @"$R" +noall +answer | awk '$4=="TXT"' | wc -l)
amp=$(awk -v a="${udp:-0}" -v q="$q" 'BEGIN{printf "%.1f",(a+28)/q}')
printf '%-16s %4d %6d %7s %7s %7sx\n' "$d" "$n" "$q" "${full:-0}" "${udp:-0}" "$amp"
doneTwo columns, not one. full is what the answer weighs. udp is how much of it comes back in a single UDP datagram, asked the way a flood asks: +ignore to keep the truncated reply instead of discarding it, +notcp to refuse the fallback. Leave those two flags off and dig prints ;; Truncated, retrying in TCP mode. and then reports 6,814 bytes for cisco.com - a figure that arrived over a TCP connection, on a channel a forged source address can never open. It is a real measurement of the wrong thing.
The record count has a smaller trap of the same kind. Counting IN TXT lines in dig's default output includes the question section, so every domain comes out one record too high; awk '$4=="TXT"' over +noall +answer counts the answer only.
Run against Google Public DNS, from a second host, on 31 August 2026:
$ ./txt-amp-census.sh 8.8.8.8
domain @8.8.8.8 TXT query full udp amp
cisco.com 88 66 6814 38 1.0x
sony.com 107 65 9019 37 1.0x
adobe.com 74 66 6004 38 1.0x
intel.com 104 66 5915 38 1.0x
amazon.com 62 67 5209 39 1.0x
microsoft.com 61 70 4890 42 1.0x
hp.com 55 63 4298 35 1.0x
samsung.com 53 68 4207 40 1.0x
oracle.com 47 67 3608 39 1.0x
ibm.com 47 64 3363 36 1.0x
salesforce.com 39 71 3356 43 1.0x
ebay.com 37 65 2712 37 1.0x
cloudflare.com 28 71 2591 43 1.0x
apple.com 23 66 2149 38 1.0x
aol.com 16 64 1228 1228 19.6x
paypal.com 12 67 1237 1237 18.9x
google.com 16 67 1103 1103 16.9x
yahoo.com 11 66 796 796 12.5xFourteen of the eighteen rows are 1.0×. Google does not send a shortened answer for those names, it sends a bare header - 35 to 43 bytes, TC bit set, answer section empty. The four that come back whole are the four already small enough to fit, and they are the ceiling: the loudest thing Google Public DNS will hand a stranger over UDP, out of eighteen of the fattest TXT sets on the internet, is aol.com at 19.6×. Cloudflare matches Google row for row. Quad9 does not quite: it truncates paypal.com to 39 bytes where the other two send all 1,237, which is the one disagreement among the three in eighteen names. The 139× and 103× in the table above are real sizes and they are not available over UDP from any of the three.
Not everyone does that. The same script, same host, same minute, against four public resolvers and against ours - the four public rows measured 31 August 2026, the dnsdoh.art row re-measured 1 September 2026 after the change described below it:
| Resolver | Largest whole answer sent over UDP | Above that | Worst case |
|---|---|---|---|
| Google 8.8.8.8, 8.8.4.4 | 1,237 B | header only, 35-43 B | 19.6× |
| Cloudflare 1.1.1.1 | 1,237 B | header only, 35-43 B | 19.6× |
| Quad9 9.9.9.9 | 1,228 B | header only, 35-43 B | 19.6× |
| OpenDNS 208.67.222.222 | 3,608 B | partial answer, 1,209-1,253 B | 54.3× |
| dnsdoh.art 194.180.189.33 | 1,256 B | header + OPT, 63-71 B | 20.1× |
The cut-off in the first three rows is bounded, not guessed: a 1,237-byte answer still comes whole from Google and Cloudflare, a 1,473-byte one (suse.com) does not, and Quad9 already refuses at 1,237 while sending 1,228. The DNS Flag Day 2020 value of 1,232 sits inside that window. OpenDNS is the outlier in the other direction: it advertises udp: 1410 in its own OPT record and then honours whatever the client asks for, returning oracle.com's full 3,608 bytes in one datagram for a 67-byte query. Every one of these behaviours is legal. Only some of them are ammunition.
Which of the two truncation styles is right was settled in 1997, in RFC 2181 section 9: "Where TC is set, the partial RRSet that would not completely fit may be left in the response. When a DNS client receives a reply with TC set, it should ignore that response, and query again, using a mechanism, such as a TCP connection, that will permit larger replies." May, not must - and the client is told to throw those bytes away unread. Filling the datagram therefore serves no legitimate client at all. The only party who gets value out of the 1,200 bytes is the one who never asked for them.
Our own row was the second-best one. Update, 1 September 2026: it is not any more. What this section said until today was that the clamp works - the worst case fell from 31.8× to 20.1× - but that our edge still filled the datagram before truncating, returning 1,023 to 1,256 bytes on the fourteen fat names where Google returns 35 to 43. Same clamp, same RFC, one behaviour behind. That was true when it was written, and it is the thing we changed.
Our resolver now empties the datagram instead of filling it. Once TC is set on a plain-UDP answer, the answer, authority and additional sections are dropped and only the OPT record is kept. The same census, same script, same host, re-run at 17:24 on 1 September 2026: fifteen of the eighteen names come back at 63 to 71 bytes where they were 1,023 to 1,256. The three that still arrive whole - aol.com at 1,256, google.com at 1,215 when the census ran, yahoo.com at 824 - are the ones already small enough to fit, and no resolver truncates what fits, so our worst case is unchanged at 20.1×. Google and Cloudflare sit at 19.6× on the same names for the same reason. The table row above has been updated; the number that moved is the one in the "above that" column.
The residual 1.4× is 28 bytes of DNS Cookie, measured rather than assumed - aol.com is 1,256 with the cookie and 1,228 without it, microsoft.com 70 and 42, which is byte-for-byte what Google returns on the same names. The OPT record is kept deliberately: it carries the cookie the client needs on the TCP retry, and the EDNS echo RFC 6891 requires. TCP, DoT, DoQ and DoH are structurally untouched, because TC is never set on them.
Two honest edges to this. Our aol.com answer is 1,256 bytes, which is 24 more than the 1,232 clamp we advertise: the message is 1,228 before the cookie and fits, and the server cookie is appended after the truncation budget has been decided, so an answer that only just fits can end up over. It is unreachable through the truncation path, which needs an answer that is truncated at all, and the same slack is visible in Google's and Cloudflare's 1,237-byte paypal.com above. It is recorded as an open item, not fixed, because the fix belongs in the cookie path. The same 28 bytes cut the other way on paypal.com: 1,237 plus a cookie is 1,265, over the clamp, so we truncate to 67 bytes a name that Google and Cloudflare send whole.
One mismatch to expect if you run the census yourself against both: our google.com row read 1,215 bytes against their 1,103, and that is not a size behaviour. We returned seventeen TXT records for the name where Google, Cloudflare and Quad9 all returned sixteen, and one extra TXT record is 112 bytes - which looks exactly like a truncation difference until you count records. Compare record counts before comparing byte counts, and note the tense: that name spent the afternoon changing underneath the measurement, and the reason is worth more than the row.
Correction, 1 September 2026. This paragraph has now been wrong twice in one evening, and both wrong versions are worth keeping. It first said the extra record was one Google had added and the other resolvers had not caught up with, and called the difference cache freshness. Checking the authoritative servers overturned that: at 17:36 all four of ns1 through ns4.google.com returned sixteen records and none carried the extra name. So the second version said the opposite - a withdrawn record that we alone were still handing out, re-supplied from somewhere above our caches on every refresh, since the record survived a TTL crossing at two layers. That was better evidenced and still wrong.
At 21:22 the same four servers no longer agreed with each other. Sampled five times each from one host: ns1 and ns2 returned sixteen records, ns3 and ns4 returned seventeen, every time. Sampled from a second host in a different network, all four returned sixteen. The addresses are identical from both vantages - 216.239.32.10, .34.10, .36.10, .38.10 - so this is one anycast address answering with two different versions of the same record set depending on which instance you reach.
That is the whole explanation, and neither earlier one survives it. Nothing was stale in our chain and nothing was being withdrawn: the record set itself was inconsistent across the zone's own authoritative fleet, and our resolver and the large public ones simply reached different halves of it. The practical consequence for anyone re-running this census is that a byte count for a name in that state is a snapshot of which server your resolver happened to ask. Ours read 1,215 while it held the seventeen-record view and 1,131 while it held the sixteen-record one, which is Google's 1,103 plus our 28-byte cookie exactly. The number tracked the record set, not our behaviour.
The same instability shows up inside a resolver chain, which is what made this hard to pin down. Sampling our own three layers together, they do not always agree with each other: at 21:31 the DNSCrypt layer returned seventeen records for three consecutive samples while the caching layer above it and the edge in front of it both returned sixteen. Each cache holds whichever half it reached the last time it refilled, so a reading at any one layer - or one moment when all of them happen to agree - is a snapshot of a single fetch and not a state. An earlier reading of sixteen everywhere looked exactly like the problem resolving itself, and was three caches drawing the same half in the same window.
Closed, 2 September 2026. Twenty-four hours later the fleet agrees with itself. ns1 through ns4 all return seventeen records including the extra name, five samples each from both hosts - forty readings, not one of them sixteen. Google, Cloudflare and Quad9 return seventeen as well, our own three layers return seventeen, and the record set is byte-identical across all of them. It closed onto the seventeen-record side, so nothing was ever being withdrawn. The census row moved with it, and only on their side of the comparison: google.com now reads 1,187 bytes from Google, Cloudflare and Quad9 against our 1,215, which is 1,187 plus our 28-byte cookie exactly. Our figure never moved. Forty samples are what it takes to write that sentence, because as the paragraph above shows, one reading of this name only ever told you which instance answered.
One trap if you run the census yourself. A 3,363-byte UDP answer arrives as three IP fragments, and a great many networks drop fragmented UDP before it reaches the host - so from such a vantage the big rows time out and print 0, which reads exactly like a resolver refusing to answer and is not. Check with a single dig +ignore +notcp TXT ibm.com @208.67.222.222 that anything comes back at all before you trust a table full of zeroes, and run the census from a host that receives fragments. The two vantages we used agreed on every figure wherever both could receive the answer at all; where they disagreed it was the network in between, not the resolver.
The other free ammunition: QTYPE=ANY
A fat TXT set is one way to get a large answer out of a small question. ANY is the other, and it is older: one question, and a cooperative authoritative server hands back every record type it holds for that name, signatures included. It needs no research into which domains carry the most bytes.
The fix for that one is not yours to deploy, it belongs to whoever runs the authoritative server, and it is written down as RFC 8482, Providing Minimal-Sized Responses to DNS Queries That Have QTYPE=ANY. It permits a server to answer with a single record set instead of the lot, or to synthesise one HINFO record and be done. You can see who has adopted it by asking each domain's own nameserver. Measured from a second host on 31 August 2026:
$ NS=$(dig +short NS cloudflare.com | head -1)
$ dig @$NS cloudflare.com ANY +noall +answer
cloudflare.com. 3600 IN HINFO "RFC8482" ""| Domain | Its own nameserver | Records returned for ANY |
|---|---|---|
| cloudflare.com | ns3.cloudflare.com | 1 - HINFO "RFC8482" "" |
| ietf.org | jill.ns.cloudflare.com | 1 - HINFO |
| facebook.com | c.ns.facebook.com | 1 - HINFO "RFC 8482" "" |
| google.com | ns1.google.com | 26 |
| quad9.net | ns3.pch.net | 21 |
| dnsdoh.art | ns-cloud-e2.googledomains.com | 25 |
Two spellings of the same citation are in production: Cloudflare synthesises "RFC8482", Facebook "RFC 8482". Nothing depends on the string - it is a human-readable breadcrumb in a record type nobody uses any more.
Our own zone is in the bottom half of that table, at 25 records, and that changes on a date. Google told Cloud DNS customers on 31 August 2026 that from 5 January 2027 public authoritative zones will return a single record set for ANY rather than the full inventory. When it lands, the last row becomes a 1 without us touching the zone. The rows above it show the destination: this has been normal at the large providers for years.
It would not have stopped the attack in this article. The flood described here asked for TXT cloudflare.com by name. RFC 8482 governs ANY and nothing else, so every packet of that flood would have been served exactly the same way with it in force. It closes one door - the lazy one that needs no target research - and leaves the specific-query door wide open. That is the whole reason the rest of this article is about what a recursive resolver does, not what an authoritative server does.
The two roles are easy to confuse. RFC 8482 is for the authoritative side - the server that owns the zone. A recursive resolver, which is what most readers here run, has a blunter option: refuse the query type outright. Ours does, and you can see it from anywhere:
$ dig @194.180.189.33 dnsdoh.art ANY
;; ->>HEADER<<- opcode: QUERY, status: NOTIMP
;; flags: qr rd ra; QUERY: 1, ANSWER: 0NOTIMP is stricter than RFC 8482 asks for: no record set at all, not even a synthesised one. Check your own resolver with the same command, and your own zone with the one above it - if a zone you are responsible for still hands out its full inventory, the fix is a setting on the DNS provider, not a firewall rule.
The trap: the obvious fix is the worst thing you can do
The reflex is immediate and it is wrong. You have a list of IP addresses hammering your resolver. Ban them. Firewall the /24s. Done.
We ran tcpdump and looked up who those addresses actually belonged to. Roughly 1,792 distinct IPs, packed into just eight /24 blocks:
| Source range | Who it actually is |
|---|---|
| 205.164.x | Subnet Digital LLC, United States |
| 45.181.x / 177.66.x / 177.91.x | Brazilian access ISPs |
| 167.249.x | WIKI TELECOMUNICACOES, São Luís, Brazil |
These are not the attacker. These are the victims. The source addresses are forged; they are the addresses the flood is being aimed at. Somewhere in Brazil there is an ISP whose customers were drowning in DNS answers they never requested, and we were one of the machines drowning them.
So look at what banning them would have accomplished. The attacker's goal is to make those networks unreachable. If we had blocked those ranges at the firewall, our resolver would have stopped answering anyone at those addresses - including the real users behind them - while the attacker simply spun up a new list of forged victims and carried on. We would have completed the denial of service on the attacker's behalf, for free, and called it a fix.
The rule we now operate by: under a reflection attack, never punish the source address. The source is a lie, and behind the lie is somebody being hurt. You cannot ban your way out of an attack whose return address is a victim. Shape the response instead - that is the only thing in the exchange that is actually yours.
This is also why "just block the attacker's IPs" advice you find on forums is actively dangerous for this attack class. It works fine for a direct flood. Applied to reflection, it turns your resolver into an accomplice twice over.
Why our rate limits never fired
We are not an unprotected box. There is a per-IP query cap and a per-subnet cap in nftables, sitting in front of everything:
# per-IP query cap -> auto-ban on excess
udp dport 53 \
meter dns_qps_ip { ip saddr limit rate over 30/second burst 100 packets } \
add @dns_bad_ip { ip saddr timeout 30m } counter drop
# per-/24 cap -> ban the whole subnet on a spray
udp dport 53 \
meter dns_qps_24 { ip saddr & 255.255.255.0 limit rate over 150/second burst 400 packets } \
add @dns_bad_24 { ip saddr & 255.255.255.0 timeout 30m } counter dropBoth counters were at zero through the entire flood. Not "low" - zero. The attack was calibrated to stay underneath them: each spoofed source sent only 1 to 5 queries, spread across ~1,792 addresses. Thirty per second per IP is a generous ceiling and the attack never came close to it, because it never needed to. The volume came from the number of forged sources, not from the rate of any one of them.
There was even a global ceiling of 300 queries/second. The flood sat at ~250. Coincidence is not the word I would use.
This is the structural lesson, and it applies to every rate limiter on every resolver including the one built into AdGuard Home (ratelimit, per client IP): a limiter keyed on the source address cannot see an attack that has no real source address. Spoofing makes the key meaningless. The attacker gets to choose a fresh key for every packet.
The self-inflicted wound
One rule did fire. Months earlier we had added a blunt instrument: drop small UDP/53 packets when they exceed 150 per second, on the theory that amplification queries are small.
udp dport 53 udp length 30-70 \
limit rate over 150/second burst 300 packets \
counter name "drop_short_dns_flood" drop
# counter: 2,930,000+ packets dropped. Some of them were the attack.
# Some of them were our users.Look at what else lives in a 30-70 byte UDP/53 packet. ssl.gstatic.com is 41 bytes on the wire. api.epicgames.dev is 43. Ordinary A queries from ordinary people are exactly the same size as the attack's queries, because a DNS query for a short name is a short packet, whoever sends it.
So the rule had a single 150/second budget, and the flood was spending it at 250/second. Legitimate users' queries arrived, found the budget already exhausted by the attack, and were dropped. Real people's DNS was broken - by our own defence, on behalf of the attacker. We call this the shared fate failure: a filter that cannot distinguish attack from user, given a bucket they must share, will starve the user.
The fix was to stop asking a layer-4 rule to do a layer-7 job. A packet filter can see size and rate. It cannot see the query name, and it cannot see how big the answer is going to be - which are the only two facts that actually separate this attack from a person opening a website. So we demoted the rule to what it is honestly good at - a backstop against raw packet-rate exhaustion - and moved the real defence up to the resolver:
# Pure pps-exhaustion backstop. Fires only on a genuine packet flood,
# far above any legitimate aggregate. Amplification defence now belongs
# to the RRL layer, which can see the qname and the response size.
udp dport 53 udp length 30-70 \
limit rate over 3000/second burst 6000 packets \
counter name "drop_short_dns_flood" drop
# drops since the change: 0If you have a rule like the first one in your firewall right now - and a lot of hardening guides recommend exactly that shape - it is worth checking its counter. It may be protecting you. It may also be quietly eating your own traffic.
Is this happening to you? Three checks.
If you run AdGuard Home or Pi-hole and UDP port 53 is reachable from the internet - whether you meant it to be or not - you can be conscripted exactly the way we were. Here is how to find out in about two minutes.
1. Are you an open resolver at all?
From any machine outside your network (a phone on mobile data, a cheap VPS, a friend's connection), ask your public IP to resolve something. If you get an answer, the entire internet can too.
$ dig @YOUR.PUBLIC.IP TXT cloudflare.com +short
# A list of TXT records = you are an open resolver.
# "connection timed out" or REFUSED = you are not. Good.2. What does your top-queried domain look like?
This is the check that found our incident, and it needs no tooling at all. Open the dashboard.
- • AdGuard Home: Dashboard → "Top queried domains".
- • Pi-hole: Dashboard → "Top Permitted Domains".
A healthy resolver shows a spread: telemetry endpoints, CDNs, whatever your household actually uses, none of it dominant. One single domain at 60, 80, 93 percent is not a statistic, it is a symptom. Click into the query log and check the record type. If a fat-TXT domain like cloudflare.com, samsung.com or google.com is being asked for TXT thousands of times, from addresses that are not yours, you are being used as an amplifier right now.
3. Watch what you are sending out
The definitive proof is on the wire. This shows the answers leaving your box:
$ tcpdump -ni any -v 'udp and src port 53'
IP (length 1500) 194.180.189.33.53 > 45.181.231.105.61694: 27/0/1 cloudflare.com. TXT ...
^^^ your server ^^^ a stranger you have never servedIf large answers are streaming to addresses that are not your clients, and those addresses never sent you anything, you are the weapon. The bytes are yours and so is the abuse complaint.
The 30-second fix: refuse to serve the ammunition
You almost certainly cannot deploy response rate limiting in AdGuard Home or Pi-hole this afternoon, because neither ships it. But you can do something simpler that gets most of the benefit immediately, and it is the thing we would tell a friend to do first: stop answering the one query the attack depends on.
The attack needs a big answer. If the answer is small, the amplification is gone - the queries keep arriving, but there is nothing worth reflecting.
AdGuard Home
Filters → Custom filtering rules. Add one line per abused name, blocking only the TXT type with the $dnstype modifier:
||cloudflare.com^$dnstype=TXT
||samsung.com^$dnstype=TXT
# ...and whatever else your query log shows being abusedThis is surgical. $dnstype=TXT blocks the TXT lookup and nothing else: A, AAAA and HTTPS queries for the same domain keep working normally, so anyone on your network who actually visits cloudflare.com is unaffected. We measured what the blocked answer costs on our own resolver - a NODATA reply with a synthetic SOA, 117 bytes against a 71-byte query. That is 1.6× instead of 31.8×: a 95% cut in the amplification, from one line of config.
Pi-hole
Pi-hole's blocklist has no record-type modifier, so you have two options with an honest trade-off between them:
- • Blacklist the domain (Domains → Add to blacklist). Blunt: it blocks all record types for that name, so if a device on your network genuinely needs cloudflare.com, it breaks. Acceptable as an emergency stop, not as a permanent setting.
- • Filter the record type in dnsmasq, which is what Pi-hole runs underneath. Recent dnsmasq (2.90+, shipped with Pi-hole v6) supports filter-rr, which strips a record type from answers without touching the rest. In Pi-hole v6 put it in Settings → All settings → misc.dnsmasq_lines; on v5, drop a file in /etc/dnsmasq.d/:
filter-rr=TXT
# TXT lookups now return an empty answer, for every domain.
# Almost nothing on a home network legitimately queries TXT.
# Do NOT use this on a mail server, which needs SPF/DKIM/DMARC.This is broader than AdGuard Home's rule - it removes TXT everywhere, not just for the abused domain - but on a home or small-office resolver that is usually a fine trade, and it takes away the whole ammunition class rather than one round of it. Verify with dig @your-pihole TXT cloudflare.com: the answer count should be zero.
Be honest about what this is. Blocking the abused name is whack-a-mole, and we should say so plainly: the attacker is not attached to cloudflare.com. When their queries start returning 117 bytes, they will move to samsung.com, then to some other domain with a fat TXT record, and there are thousands. Type-filtering (filter-rr) is more durable than name-blocking because it removes the whole record class. But neither is a real defence against an attacker who is paying attention. They are a tourniquet: apply immediately, then fix the wound.
The real decision: is your resolver a public service or not?
Nearly every guide ends the same way: close port 53. It is not wrong, but it is really the answer to a question worth asking out loud first - did you mean to run a resolver that strangers can use? An open UDP/53 is not a quiet default that sits there harmlessly. Internet-wide scanners catalogue open resolvers continuously, and a freshly exposed one is typically found and put to work within days. So treat the exposure as a choice with two honest branches, not an accident to be tidied away.
Branch 1: you did not mean to be public (most people)
If your AdGuard Home or Pi-hole exists to serve your household, your office or your own devices, it has no business answering UDP/53 from the whole internet. Close it, and this entire attack class disappears - there is nothing to defend because there is nothing to reach.
- • Bind the resolver to your LAN interface, or drop inbound UDP/53 at the firewall. One rule, done.
- • Need to reach it from the road? Put it behind a VPN (WireGuard, Tailscale) rather than opening 53 to the world. The resolver stays private; you join its network.
- • If a handful of known remote addresses genuinely need it, use AdGuard Home's allowed_clients (IPs, CIDRs, ClientIDs). A resolver that only answers clients it recognises cannot be recruited by a forged source.
Branch 2: you mean to be public
Then closing 53 is not on the table, and "just firewall it" is not advice you can take - which is exactly the position we are in, and the reason this whole article exists. Running a public resolver is a commitment: you will be found, you will be pointed at someone eventually, and the job is to make sure that when it happens, your resolver refuses to do any damage. That is a defence-in-depth problem, and no single switch solves it. Layer these, outermost first:
- • Turn on refuse_any. The classic amplification vector is the ANY query, which asks for every record at once. AdGuard Home has this setting and it should be on. We had it on, which is precisely why the attacker used TXT instead - so understand what it buys you: it closes one door, not the corridor.
- • Clamp the EDNS buffer size you honour. Our attacker advertised a 10000-byte buffer to force the biggest possible UDP answer. DNS Flag Day 2020 settled on 1232 bytes as the sane maximum (1280-byte minimum IPv6 MTU, minus headers); honouring 1232 means large answers truncate early by the protocol itself, capping amplification for free. Standard, RFC 6891-compliant, costs nothing.
- • Steer clients to encrypted transports. DoH, DoT and DoQ cannot be used for reflection at all: they are connection-oriented, so a client must complete a handshake before it receives a single byte, and a forged source address cannot complete a handshake. Every query that arrives over an encrypted transport is structurally incapable of being part of an amplification attack. Plain UDP/53 is the entire attack surface here - the more of your traffic lives on the encrypted transports, the smaller it gets.
- • Keep the tourniquet ready. The $dnstype=TXT filter (AdGuard Home) or filter-rr (Pi-hole) from the section above is your immediate response the moment you spot a flood in progress. It is not a permanent defence, but it stops the bleeding in one line while you deploy the real one.
- • The real fix: response rate limiting with a TC=1 slip. Neither AdGuard Home nor Pi-hole ships it, so this is the one piece you cannot just toggle - you either run resolver software that has it (BIND, Knot, PowerDNS all do) and turn it on, or you build it, as we did. It is the only item on this list that defeats an attacker who adapts, because it keys on the query instead of the source. The next section is how it works.
The honest version of "close port 53". A public UDP/53 resolver that you are not prepared to actively defend will, sooner or later, be used to hurt someone. That is not a reason never to run one - the encrypted DNS most people reading this actually want needs a public resolver behind it. It is a reason to run one on purpose, with the layers above in place, rather than by accident and then wonder why your bandwidth graph exploded.
The real fix: response rate limiting with a TC=1 slip
The proper defence for this attack has existed since 2012, when Paul Vixie and Vernon Schryver described Response Rate Limiting. BIND, Knot and PowerDNS all ship a form of it. Most operators never turn it on, and the DNS software most home users run - AdGuard Home, Pi-hole - does not implement it at all. We built it into our AdGuard Home fork. Here is the whole idea in three gates:
# Runs after the answer is resolved, before it is written to the wire.
GATE 1 - transport. UDP only. DoH / DoT / DoQ / TCP are
connection-verified, cannot be spoofed, never touched.
GATE 2 - size. Is the response bigger than 512 bytes?
If not, send it. This alone exempts 99.7% of real
traffic: an A record is tiny, and a small answer
cannot amplify anything.
GATE 3 - budget. Charge two token buckets, 5 tokens/s, burst 20:
bucket A: (source /24, qname, qtype) <- fairness
bucket B: (qname, qtype) <- the killer
If either is empty -> SLIP.
SLIP = reply with the header only: TC=1 (truncated),
empty answer section, 32 bytes.Bucket B is the one that kills the attack, and it is the piece that most rate limiters get wrong. The attacker's whole strategy was to vary the thing everyone keys on - the source address. So we key on the thing they cannot vary: the query itself. Every one of those 1,792 forged sources was asking for the same name and the same type, so all 1,792 of them converge on one bucket. The carpet-bomb collapses into a single budget of 5 answers per second. Spreading the attack across more IPs does not help them; it changes nothing at all.
And the slip is what makes it safe. We do not drop the query - dropping is indistinguishable from an outage, and a real client behind that /24 would just sit there timing out. We answer, with the truncation bit set, which is a completely standard DNS instruction meaning "too big for UDP, ask me again over TCP." A real client obeys it, opens a TCP connection, and gets its full answer, and we verified this works. A spoofed client cannot: to complete a TCP handshake you must receive the SYN-ACK, which goes to the address you forged, not to you. The very thing that makes the attack possible - lying about your address - is the thing that makes it impossible to follow the retry.
The elegance is that we never decide who is an attacker. We never see the attacker, never identify them, never ban anybody. We simply make the resolver's most expensive answer scarce, and hand out a 32-byte "ask me over TCP" to everyone who exceeds the budget. Honest clients follow the instruction. Liars cannot. The attack sorts itself out.
The result on the wire, measured with dig: the 2,257-byte answer becomes a 32-byte response. Against a 71-byte query that is 0.45× - our resolver now emits fewer bytes than it receives. As an amplifier, it is worse than useless. It is a net absorber.
Proving it before trusting it
A defence that truncates real users' answers is worse than the attack. So we did not flip it on. We ran the engine in shadow mode first - the full decision logic, running live against the real flood, deciding what it would do and writing that to a ledger, while the resolver kept answering normally.
Then the step that actually earns trust: parity. The shadow engine and the enforcement engine are two separate pieces of software - the policy brain lives in our Go WAF, the enforcement lives in the resolver fork - so we made them share one frozen hash of the query name and checked that, over the same 30 seconds of the same live flood, they reached identical verdicts: 98.0% slip, 5.0 answered per second, both of them, to the decimal. Only then did we flip enforcement on.
Across 67,905 shadow decisions the false-positive ledger stayed clean: not one legitimate query was ever slated for truncation. In the live packet capture taken minutes after enforcement went live, a real user's web.api.vk.com lookup was served in full, 190 bytes, while the flood around it was being reduced to 32-byte stubs.
The twist: success made us blind
Minutes after enforcement went live, our monitor reported the flood had stopped. Zero percent slip. Nothing happening. We had won so completely that the attack had apparently given up on the spot.
It had not. The monitor read the resolver's query log, and gated on response size - the same 512-byte gate as the engine. But enforcement truncates the response before it is written to that log. So the log now recorded a stream of harmless 32-byte answers, the monitor's size gate exempted every one of them, and the tool that had found the attack in the first place went completely blind to it. The flood was still running at full strength. The counters inside the enforcement engine were climbing by four thousand per window. On the wire, a six-second capture showed 632 truncated responses against 14 full ones.
A general lesson worth stealing. When your mitigation sits upstream of your telemetry, a working mitigation and a vanished attack look exactly the same. If we had trusted that monitor we would have concluded the attack was over and possibly rolled the defence back. Any time you deploy a fix, ask: could this fix have broken the instrument I am using to check whether it worked? We built a second monitor that reads the enforcement counters directly, and stopped believing the first one.
The verdict
Enforcement went live at 15:46 on 12 July. The attack kept coming for another fifteen and a half hours, getting nothing but 32-byte stubs, and then - at 07:12 the next morning - it stopped, and has not come back. Reflection capacity is rented by the hour, and an amplifier with a 0.45× ratio is not worth the rent.
| Metric | Before | After |
|---|---|---|
| Response to a spoofed query | 2,257 B (2 fragments) | 32 B (TC=1) |
| Amplification factor | 31.8× | 0.45× |
| Attack traffic we emitted | ~4.6 Mbps | ~0.15 Mbps |
| Share of the flood neutralised | 0% | 98.2% |
| Legitimate DNS | dropped as collateral | untouched |
| Cost per query | n/a | 0.061 ms median |
| Responses neutralised, total | n/a | 12,301,925 |
Twelve million reflected responses that never reached a victim, at a measured cost of 0.061 milliseconds each. No scrubbing appliance, no upstream provider, no traffic contract - a few hundred lines of Go, and a firewall rule that we made less aggressive, not more.
And the honest residual, because a defence you cannot state the limits of is a defence you do not understand: the budget of 5 answers per second gets spent on the attack, so roughly 90 kbps of full-size answers still reached those victims for as long as the flood ran. We killed 98.2% of it, not 100%. The remaining 1.8% is the price of not dropping queries - of leaving a door open so that a real client behind a victim's /24 can still resolve a name.
What this incident is actually about
We did not stop the attacker. We never saw them, we still cannot see them, and for all we know their queries will start arriving again tonight. We did not defeat a botnet, and any write-up claiming otherwise about an attack like this is selling something.
A reflection attack is fixed by refusing to emit the bytes, because the traffic is forged and there is nobody to charge. A flood that arrives over HTTP is the opposite case - the connections are real, so the defence can make each one pay. That is a different lever with its own arithmetic, and we measured it: what a memory-hard proof-of-work challenge actually costs an attacker and a real visitor, including why raising the difficulty is the wrong move.
What we did was narrower and, I think, more useful: we refused to be the weapon. The packets still arrive. We simply stopped being willing to turn 71 bytes into 2,257 and post them to a stranger. And we did it without banning a single address - which mattered, because every address we could have banned belonged to somebody who was already being hurt.
If you take one thing from this: check your top-queried domain today. It takes ten seconds, it is the same check that found this, and the reason it matters is that an amplification attack running through your resolver is completely invisible from the inside. Nothing breaks. Nothing gets slow. No alert fires. Your resolver is working perfectly, exactly as designed, at full speed - for somebody else.
Update, 14 July 2026: this story has a part two. We deployed the EDNS 1232 clamp recommended above at the public edge - the internal resolver had been clamped for years and it changed nothing - and the residual amplification on the answered budget halved, 31.8× to ~16.7×. Testing the TC=1 promise the clamp relies on then exposed a silent 4 KB limit in dnscrypt-proxy that had been failing large TCP answers for years, and DNS Cookies (RFC 7873) closed the ladder. The full account: When UDP Clamping Exposes a TCP Bug: Resolving a Silent 4 KB DNS Limit. That clamp is also why an upstream EDNS fix had to be rewritten before we could take it, in our August review of four upstream releases.
Update, 1 September 2026: and a part three. The census added to this article on 31 August showed that the clamp decides when we truncate but not how much we reflect, and that we were still filling the truncated datagram where the large public resolvers send a bare header. Our resolver now empties it: fifteen of the eighteen census names dropped from 1,023-1,256 bytes to 63-71. The measurements, the 28-byte DNS Cookie that accounts for the remainder, and the one number that still exceeds our own 1,232 clamp are in the census section above, and summarised in our note on the change.
Encrypted transports cannot be reflected
This entire attack class needs plain, connectionless UDP/53. DoH, DoT and DoQ require a handshake, and a forged address cannot complete one. Ours are public and free.
Set up encrypted DNSRelated reading: the encrypted DNS protocols compared, spoofing and cache poisoning (the other half of what forged addresses enable), why DNS uses UDP and when it falls back to TCP, and the anatomy of a hardened DNS zone. The same server's admin panel and SSH are invisible to the scans that found this open resolver - Zero Open Admin Ports covers that setup. Hardening can also cut the wrong way: an anti-flood setting on this same server turned out to be refusing every HTTP/2 POST it was sent.
Ozy-666 builds and operates dnsdoh.art, an encrypted DNS resolver serving DoH, DoH3, DoT and DoQ. This is a first-hand account: the resolver in it is his, the attack happened on 12 July 2026, and every number in this article - the 31.8× ratio, the 0.061 ms, the 12,301,925 truncated responses - was measured on the live server while it was happening. The rate limiter described here was designed, shadow-tested and deployed during the incident.