SevyDevy

Guide · Backend

DNS Compression Pointer Vulnerability Explained: Per-Name vs Per-Packet Caps

20 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The DNS compression pointer vulnerability fixed in October 2026 is a denial-of-service bug: a crafted DNS message makes software burn CPU while it rebuilds the names inside it.
  • DNS messages save space by writing a long name once and then pointing back to it. A pointer is two bytes that say "the rest of this name is at byte N".
  • Following one pointer is cheap. Following long chains of them, for many names in one message, is not, and some DNS software had no cap on that work.
  • Fixed versions: Unbound 1.26.2, PowerDNS Authoritative 4.9.18, 5.0.8 and 5.1.5, and dnspython 2.9.0. Each adds a limit: per name, per whole packet, or both.
  • Beginners can stop at the "Beginners can stop here" box, after the section on why DNS decompression can be made expensive. Engineers then get each project's limits as published, what is still unpublished, and upgrade checks you can run.

What goes wrong when DNS software reads a crafted message?

It can spend far more CPU than it should just reading the names in the message. That is the DNS compression pointer vulnerability fixed in October 2026. Nothing is stolen, but a busy server slows down and may stop answering in time.

Picture a common setup. A payments startup in Pune runs its own Unbound resolver, a server that looks up names for its offices and machines. A Python worker uses dnspython, a DNS library, to check each new merchant's email domain. It asks the merchant domain's own DNS servers, out on the internet, for its MX records. MX records list the servers that receive the domain's email. The setup is made up, but the bugs are real.

Both pieces read DNS messages from servers on the internet that the startup does not control. Before October 2026, a message shaped the wrong way could make either of them work far too hard on the names inside it. The October fixes put a ceiling on that work.

First, three short terms and the bug itself. Then you will see how DNS squeezes a name into a few bytes. That trick is useful, and it is also where the trouble starts.

Which words do you need before reading about DNS compression pointers?

Three terms come up again and again. Each one is short.

  • DNS message: the packet a DNS client and server exchange. It starts with a 12-byte header, then a question section and three sections of records: answer, authority and additional.
  • Wire format: the exact bytes of that packet as they travel on the network. Tools like dig show you a friendly text version instead.
  • Label: one dot-separated piece of a name. The name www.example.com has three labels: www, example and com.

What is the DNS compression pointer vulnerability of October 2026?

The DNS compression pointer vulnerability is a family of denial-of-service bugs in how DNS software rebuilds compressed names. Denial of service means the attacker does not break in; they make the service too slow or busy to answer.

Three projects shipped fixes in the same month, through separate advisories. One is a Python DNS library, one a resolver, and one an authoritative server, which holds a domain's real records and answers for it.

The library's bug was reported by a different research team from the other two, so treat these as one class of bug, not one shared flaw.

How does DNS name compression work?

DNS name compression writes each name in full once, then replaces later copies with a two-byte pointer to the first copy. RFC 1035, the 1987 core DNS specification, defines it in section 4.1.4.

Labels and the zero byte

On the wire, a name is a list of labels. Each label starts with one byte that gives its length, followed by that many characters. A zero byte marks the end of the name.

So www.example.com becomes 03 w w w 07 e x a m p l e 03 c o m 00. That is 17 bytes. RFC 1035 caps each label at 63 bytes and a whole name at 255 bytes.

The pointer: two bytes that start with 11

A pointer is two bytes whose first two bits are both 1. The other 14 bits give an offset: a byte position counted from the very start of the message, where 0 is the first byte of the header.

Software can tell a pointer from a label because a label's length byte always starts with two 0 bits (63 is the largest length). The patterns 10 and 01 are reserved. In hex, a pointer's first byte is c0 or higher.

The RFC allows three shapes for a name: labels ending in a zero byte, a pointer alone, or some labels ending in a pointer. The last shape lets web.example.com write "web" and then point at an earlier example.com.

A real reply, byte by byte

This part is for people who like bytes. If you are new to DNS, skip to the heading "Why does reading a compressed name take extra work?". All you need from it is this: later names point back at earlier ones, so the reply shrinks.

Here is a normal reply built with dnspython 2.9.0. It answers www.example.com with a CNAME, an alias record, to web.example.com, then gives that name's address.

python
import dns.message
import dns.rrset

query = dns.message.make_query("www.example.com", "A", id=0x1234)
query.flags = 0x0100  # recursion desired; fixed so your output matches
reply = dns.message.make_response(query)
reply.answer.append(dns.rrset.from_text(
    "www.example.com.", 300, "IN", "CNAME", "web.example.com."))
reply.answer.append(dns.rrset.from_text(
    "web.example.com.", 300, "IN", "A", "192.0.2.10"))

wire = reply.to_wire()
print(len(wire), "bytes")
for i in range(0, len(wire), 16):
    print(f"{i:3d}: " + " ".join(f"{b:02x}" for b in wire[i:i + 16]))

Run it after pip install dnspython==2.9.0 and you should see exactly these 67 bytes. Version 2.8.0 printed the same bytes in our test, because the fix changes reading, not writing.

text
67 bytes
  0: 12 34 81 00 00 01 00 02 00 00 00 00 03 77 77 77
 16: 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00
 32: 01 c0 0c 00 05 00 01 00 00 01 2c 00 06 03 77 65
 48: 62 c0 10 c0 2d 00 01 00 01 00 00 01 2c 00 04 c0
 64: 00 02 0a

Read it in four pieces: the header (bytes 0 to 11), the question (12 to 32), the CNAME record (33 to 50) and the address record (51 to 66).

At offset 12 the question name is written in full: 03 77 77 77 is "www", and the zero byte at offset 28 ends it.

At offset 33, the first answer's owner name is just c0 0c. That is a pointer to offset 12 (0x0c), so it means www.example.com again in two bytes instead of 17.

At offset 45, the CNAME target is 03 77 65 62 ("web") followed by c0 10, a pointer to offset 16 where "example" begins. At offset 51, the second owner name is c0 2d, a pointer to offset 45.

Without compression, the same reply would be 108 bytes. With it, the reply is 67 bytes, about 38 percent smaller.

What just happened? The question name was written once, in 17 bytes. Each later name either pointed straight at it or wrote one new label and then pointed back. That saved 41 bytes, but a reader must now jump around to rebuild each name.

Why does reading a compressed name take extra work?

Because a reader, the code that rebuilds names from the message, cannot just copy bytes. It must jump to each pointer's target, read labels there, and keep jumping until it reaches a zero byte. Each jump is called a hop.

A book of see-page notes

Think of a reference book where an entry says "continued on page 16". Page 16 might add a line and say "continued on page 12". A name is the entry, a pointer is the see-page note, and a hop is one page turn.

A sensible person gives up after a few silly page turns. The October fixes give DNS readers that rule: a limit on page turns per entry, and in Unbound and PowerDNS also a limit for the whole book.

Where the picture breaks: a person notices a silly trail, but a parser has no judgement and simply follows the bytes. And this book is small: a DNS message over TCP tops out at 65,535 bytes, and pointers reach only its first 16,384.

The same walk in the example reply

Normal replies already contain short chains. If you skipped the bytes, here is the one fact you need. In the example reply, the name web.example.com is stored as a pointer that leads to another pointer.

The diagram follows that path: offset 51 points to 45, and the pointer at 49 then points to 16.

Reading the name at offset 51 in the example reply

One normal name needed two pointer hops. Notice that the reader jumps backwards each time and only stops at the zero byte.

Diagram connections:

  • Offset 51: c0 2d to Offset 45: 03 w e b (Hop 1)
  • Offset 45: 03 w e b to Offset 49: c0 10 (Next byte)
  • Offset 49: c0 10 to Offset 16: 07 example (Hop 2)
  • Offset 16: 07 example to Offset 24: 03 com (Next label)
  • Offset 24: 03 com to Offset 28: 00 (Next byte)
  • Offset 28: 00 to web.example.com. (Stop)
  • Offset 51: c0 2d: Pointer: jump to offset 45
  • Offset 45: 03 w e b: Label web, then keep reading
  • Offset 49: c0 10: Pointer: jump to offset 16
  • Offset 16: 07 example: Label example
  • Offset 24: 03 com: Label com
  • Offset 28: 00: Zero byte: name ends
  • web.example.com.: Rebuilt name, 2 hops

You can watch the same walk in Python. This short reader prints every hop. It uses the wire bytes from the byte-by-byte example, so run that code first. If you skipped the bytes, just read the expected output in the next paragraph. It is a teaching tool only.

python
def read_name(msg, pos, max_hops=16):
    labels, hops = [], 0
    while msg[pos] != 0:
        if msg[pos] >= 0xC0:                      # top two bits 11: a pointer
            target = (msg[pos] & 0x3F) << 8 | msg[pos + 1]
            hops += 1
            if hops > max_hops:
                raise ValueError("pointer chain too long")
            print(f"  offset {pos}: pointer -> offset {target}")
            pos = target
        else:                                     # top two bits 00: a label
            length = msg[pos]
            labels.append(msg[pos + 1:pos + 1 + length].decode())
            pos += 1 + length
    return ".".join(labels) + ".", hops


for start in (12, 33, 45, 51):
    print(f"name starting at offset {start}:")
    name, hops = read_name(wire, start)
    print(f"  = {name} after {hops} pointer hop(s)")

Paste it under the first example and run it. The last name should print two pointer lines and end with "web.example.com. after 2 pointer hop(s)". The max_hops check mimics the dnspython 2.9.0 limit.

Pause and predict: suppose a reader stops after 16 hops for any one name. What could a sender still do to make it work hard? Hold your guess; the next section answers it.

Why can DNS decompression be made expensive?

Because the cost is roughly the number of names times the hops per name, and before these fixes some readers capped neither, or only one. A sender who controls the reply chooses both numbers.

Each project described its own gap. The CVE record says dnspython's name-reading function, from_wire_parser, "follows attacker-controlled DNS compression pointer chains without a hop limit" before 2.9.0. Unbound's advisory says it already limited hops for each name, but had "no aggregate" budget, meaning no total for the whole message.

PowerDNS describes the result: a malicious server "can send a crafted reply" that causes high resource use while pointers are decompressed. Its fix limits work for a single name and for the packet as a whole.

A reader that rebuilds every name in a reply pays for each hop of each name. A small per-name cap still allows real work when a reply holds many names. That is exactly the case Unbound's aggregate budget targets.

It is also a reply the Pune startup's resolver could get when someone on its network looks up a domain an attacker controls. Unbound's advisory names exactly that pair of conditions.

With dnspython 2.9.0's cap, pointer hops for a whole message are at most 16 times the number of names in it. That bound is our reading of the code, not a statement from the project. A per-packet budget, as in Unbound and PowerDNS, puts one fixed ceiling on the whole message instead.

RFC 1035 itself sets no limit on how many pointers a reader may follow, and it does not mention loops. It only says pointers lead to a "prior occurance" (the RFC's own spelling) of the name, and every program must understand messages that use them. Limits are left to implementers.

What exactly do the October 2026 fixes limit?

They add a per-name hop cap, a per-packet budget, or both. The right choice depends on where the software sits and how many names it reads from one message.

The table lists what each project published. The severity for Unbound comes from its CVE record.

The October 2026 fixes, from each project's own advisory (as of 24 October 2026)
ProjectAdvisory and IDFixed inWhat the fix limitsSeverity as published
dnspython (Python library)GHSA-45c3-73f7-pv4m, 9 Oct; CVE-2026-1146052.9.0At most 16 pointer hops while reading one nameHigh, CVSS 7.5
Unbound (resolver)CVE-2026-96405, 21 Oct1.26.2A total of 2048 per upstream reply packet and 128000 per zone-transfer packetMedium, CVSS 5.3
PowerDNS AuthoritativeSecurity Advisory 2026-13, CVE-2026-73206, 21 Oct4.9.18, 5.0.8, 5.1.5Decompression work for a single name and for a whole packet; no numbers publishedHigh, CVSS 7.5

One record-keeping note: as of 24 October 2026, the dnspython advisory page still showed "No known CVE". The CVE-2026-114605 record, published on 22 October, links to it, so that page may change.

dnspython credits Evgenios Gkritsis, Constantinos Patsakis and George Stergiopoulos. Unbound and PowerDNS credit Yuheng Zhang, Jianjun Chen, Zihan Wang and Kaiwen Shen, and PowerDNS also credits Zihan Zhang.

dnspython 2.9.0: 16 hops per name

In dnspython 2.9.0, dns/name.py adds MAX_COMPRESSION_POINTER_CHAIN = 16. While reading a name, from_wire_parser counts each pointer it follows and raises dns.name.PointerChainTooLong once the count goes above 16.

So 16 hops are allowed and the 17th is refused. The project's own test, testCompressionChainLength, checks exactly that boundary; it passed when we ran it against 2.9.0. The fix commit adds only this per-name limit.

PointerChainTooLong is a subclass of dns.exception.FormError, the error dnspython already uses for malformed messages. Code that catches FormError keeps working without changes.

Unbound 1.26.2: a budget for the whole packet

Unbound already limited pointer-following per name. Version 1.26.2 adds an aggregate decompression limit of 2048 per upstream reply packet and 128000 per zone-transfer packet. A zone transfer copies a whole zone between servers. The advisory gives no reason for the higher figure, though zone-transfer packets normally carry many more names.

The advisory calls what it counts decompression work and does not spell out the exact unit. Treat 2048 as Unbound's own ceiling, not as a hop count you can compare directly. The advisory does not say how close normal replies come to it.

Unbound's advisory says an attacker needs two things: the ability to send recursive queries to the resolver, and control of an ordinarily delegated domain that serves crafted replies. NLnet Labs rates it Medium; the impact is degraded service.

PowerDNS Authoritative: both kinds of limit

PowerDNS Authoritative 4.9.18, 5.0.8 and 5.1.5 limit decompression work per name and per DNS packet. As of 24 October 2026, the advisory gives no numbers and no workaround; the remedy is to upgrade.

The advisory does not say which server feature receives the crafted reply. It lists affected versions as up to and including 4.9.17, 5.0.7 and 5.1.4, with no risk of system compromise.

Where the two kinds of limit sit in a reader

The per-name check stops one long chain. The per-packet check stops many short chains adding up across one reply. Only dnspython documents the exact error it raises.

Diagram connections:

  • Reply from the internet to Read the next name
  • Read the next name to Too many hops for this name?
  • Too many hops for this name? to Stop reading the reply (Yes)
  • Too many hops for this name? to Packet budget used up? (No)
  • Packet budget used up? to Stop reading the reply (Yes)
  • Packet budget used up? to More names in the reply? (No)
  • More names in the reply? to Read the next name (Yes)
  • More names in the reply? to Use the parsed reply (No)
  • Reply from the internet: Internet server → resolver or library
  • Read the next name: Follow labels and pointers
  • Too many hops for this name?: dnspython: more than 16; PowerDNS: per name
  • Packet budget used up?: Unbound: 2048 per reply; PowerDNS: per packet
  • More names in the reply?: Owner names, CNAME targets and more
  • Stop reading the reply: dnspython raises FormError; others not stated
  • Use the parsed reply: Within every limit

The two checks answer different questions. A per-name cap asks if one name is unreasonable. A per-packet budget asks if the whole reply is. The table compares them.

Per-name cap vs per-packet budget
QuestionPer-name hop capPer-packet budget
What it countsPointer hops while rebuilding one nameDecompression work across every name in one packet
What it stopsOne very long chainMany chains that add up in one reply
Who has it after October 2026dnspython 2.9.0 (16), PowerDNS fixed versions, Unbound before and after 1.26.2Unbound 1.26.2 (2048 per reply), PowerDNS fixed versions
What it leavesUp to 16 hops times the number of names (dnspython)One fixed ceiling per packet
Our one small exampleAt most 2 hops for any name4 pointer hops in total; Unbound does not publish its budget's unit

The last row is our measurement on one small reply, not a guarantee for every normal reply. Both versions of dnspython parsed that reply identically.

Which systems need the October 2026 DNS updates?

Any system that runs an affected version and parses DNS messages from parties it does not control. For Unbound and PowerDNS, the advisories describe replies from servers. For dnspython it is any untrusted wire data, including incoming queries.

dnspython reads question names through the same from_wire_parser function, so a DNS server or proxy built on it is exposed too. In the running example, both the Unbound resolver and the Python worker qualify.

  • Unbound up to and including 1.26.1, used as a resolver that people or machines can send queries to. Upgrade to 1.26.2 or apply the patch NLnet Labs published for 1.26.1.
  • PowerDNS Authoritative up to and including 4.9.17, 5.0.7 or 5.1.4. Upgrade to 4.9.18, 5.0.8 or 5.1.5, the next release in your series.
  • Python code that parses untrusted DNS wire data with dnspython older than 2.9.0. That can include home-grown resolvers, mail checks or monitoring scripts that use it. The dnspython advisory lists no workaround.

Do not assume other DNS software is or is not affected from this guide. PowerDNS says multiple vendors are releasing fixes, so check each product's own advisory page.

How do you check that you have the fixed versions?

Check the version each component reports, then confirm it is at or above the fixed release. For dnspython you can also check for the new limit directly.

  1. Check Unbound

    Run unbound -V, which prints the version number and build options. You want 1.26.2 or later, or a distribution package that says it carries the CVE-2026-96405 fix.

  2. Check PowerDNS Authoritative

    Run pdns_control version, which prints the version of the running daemon. You want 4.9.18, 5.0.8, 5.1.5 or a later release in your series.

  3. Check dnspython in each environment

    Run the script below with the same Python interpreter your service uses. Each virtual environment or container image can hold its own copy.

  4. Pin the fix

    Require dnspython 2.9.0 or later in your requirements or lock file, for example by pinning dnspython==2.9.0. Then a rebuild cannot quietly bring back an older copy.

  5. Check distribution packages separately

    Linux distributions may backport a fix under an older version number. For any distribution package, read that distribution's security notice for the CVE.

python
import dns.name
import dns.version

print("dnspython", dns.version.version)
limit = getattr(dns.name, "MAX_COMPRESSION_POINTER_CHAIN", None)
print("pointer chain limit:", limit if limit else "none (upgrade to 2.9.0)")

On 2.9.0 it prints "dnspython 2.9.0" and "pointer chain limit: 16". On 2.8.0 we saw "pointer chain limit: none (upgrade to 2.9.0)", because the constant does not exist there. A backport that copies the upstream commit would add the constant too; one written differently might not.

How do you explain DNS decompression limits in a system design interview?

Frame it as a parser doing work that an untrusted sender controls, then show where you would cap that work. The same pattern applies to JSON nesting, ZIP files and regular expressions.

  1. Name the input

    DNS replies come from servers you do not control, so treat every byte as untrusted input to a parser.

  2. Explain compression in one line

    A name is written once; later copies are two-byte pointers back to it, defined in RFC 1035 section 4.1.4.

  3. Show where the cost hides

    Rebuilding a name means following pointers. Total work grows with names per message times hops per name.

  4. Cap both dimensions

    Use a per-name hop limit, like dnspython's 16, and a per-message budget, like Unbound's 2048 per reply.

  5. Fail cheaply and safely

    Over the limit, reject the reply as malformed and move on. Do not retry the same bytes or log them in full.

If you have time, add a monitoring point. Count rejected replies per upstream server, so a sudden rise shows up on a dashboard before users notice slow lookups.

Questions people ask

Is the DNS compression pointer vulnerability a remote code execution bug?
No. All three advisories in this guide describe denial of service through excessive CPU or resource use. PowerDNS states no risk of system compromise, and Unbound describes degraded service. Separate Unbound advisories from September 2026 did involve memory corruption, but those are different bugs with their own CVE numbers.
Was this one coordinated disclosure across all DNS vendors?
This guide cannot say that. dnspython published on 9 October 2026 with its own finders. Unbound and PowerDNS published on 21 October 2026 and credit an overlapping research team. PowerDNS thanks that team for coordinating its release with other vendors, without naming the vendors.
Is BIND affected by these compression pointer bugs?
This guide makes no claim about BIND or any product not covered here. The advisories we used come from NLnet Labs, PowerDNS and the dnspython project only. Check the vendor's own security advisory page for any other DNS software you run.
Will the new limits break normal DNS traffic?
For dnspython, our test shows a wide margin. In it, no name in a normal reply needed more than two hops, while dnspython allows 16. Unbound and PowerDNS do not say how close normal traffic comes to their limits, and PowerDNS publishes no numbers. Unbound sets a higher budget for zone-transfer packets; its advisory does not say why.
Can I turn DNS name compression off to avoid the problem?
Not for messages you receive. RFC 1035 lets a program skip pointers in messages it writes, but every program must understand incoming messages that contain them. The fix lives in the reader, so upgrading the software that parses replies is the remedy.
Why did dnspython pick 16 as the limit?
The advisory and commit state the limit but not the reasoning, as of 24 October 2026. What we can say is that 16 is far above the one or two hops normal compression produces in our example. Treat the number as an implementation choice that could change in later releases.
What error does dnspython 2.9.0 raise for a long pointer chain?
It raises dns.name.PointerChainTooLong. That class is a subclass of dns.exception.FormError, which dnspython already raised for other malformed messages. If your code catches FormError or DNSException around message parsing, it already handles the new error without changes.

Sources and further reading

Practise this with Lala

Practise