Back to guides
Security

Nine CVEs in One Unbound Release: Reading the Patch Instead of the Advisory

The release note for Unbound 1.26.1 lists nine CVE identifiers, one sentence each, and a credit. It does not say which file changed, which code path is involved, or whether your configuration goes anywhere near it. The Changelog inside the tarball is no help either: its newest entry is dated 24 July and mentions none of the nine.

16 September 2026

Start by confirming the gap is real

This is worth checking rather than taking on trust, because it determines how you have to read everything that follows. Pull the signed release tarball, look at the file that normally explains what changed, and grep it for the identifiers the release announcement gave you:

tar xzf unbound-1.26.1.tar.gz -O unbound-1.26.1/doc/Changelog | head -1
tar xzf unbound-1.26.1.tar.gz -O unbound-1.26.1/doc/Changelog | grep -c CVE-2026-8
24 July 2026: Wouter
0

So the mapping from identifier to code does not exist in the release. Everything below it was recovered by diffing the 1.26.0 and 1.26.1 source trees and matching what changed against the one-line descriptions in the release note. That match is our inference, not an upstream statement - where a fix is unambiguous we say so, and where more than one change could plausibly carry an identifier we say that too. Nothing here is a proof-of-concept and we did not attempt to exploit anything; this is patch reading, which tells you what the code now refuses to do, not how far an attacker could have got before.

Where the changes landed

Thirty-six source files differ between the two trees, two of which are test code. The rest cluster in five places, and the clustering is the useful part - it tells you which subsystem each identifier belongs to:

subsystemfiles changedwhat the fixes there do
validator12bounds check before a digest, and four new caps on work per query
util13network event loop, reply-list handling, two new config options
services6auth zones, cache, the mesh that ties queries to their replies
iterator1packet scrubbing, including the DNAME to CNAME synthesis
daemon2the worker loop and the remote-control interface

Five of the nine are memory-safety bugs, two are resource-exhaustion bugs, one is a verification bypass, and one is a policy bypass. Only the first group is what most people picture when they read "heap buffer overflow", and only one of the nine has "possible remote code execution" attached to it.

The one-line fix: a digest with no bounds check

CVE-2026-81642 is the one carrying "heap buffer overflow and possible remote code execution", and the fix is two lines in validator/val_sigcrypt.c, inside ds_create_dnskey_digest(). This function builds the input to a DS digest: the DNSKEY owner name, followed by the DNSKEY record data. It writes both into a scratch buffer:

/* digest = digest_algorithm( DNSKEY owner name | DNSKEY RDATA );
 *  DNSKEY RDATA = Flags | Protocol | Algorithm | Public Key. */
sldns_buffer_clear(b);
+ if(!sldns_buffer_available(b, dnskey_rrset->rk.dname_len + dnskey_len-2))
+         return 0; /* buffer too small */
sldns_buffer_write(b, dnskey_rrset->rk.dname,
        dnskey_rrset->rk.dname_len);

That is the entire change: ask whether the buffer has room before writing into it. The two quantities being added are the length of a name and the length of a key, both of which arrive from the network in a response. There is no ambiguity about which CVE this is - it is the only bounds check added anywhere in the release, and the release note is the only description of it that exists.

It is worth sitting with how ordinary this is. No clever protocol abuse, no state machine confusion. A length was trusted because in practice it had always fitted.

CONDITION ON YOUR RESOLVER CVEs IT BRINGS INTO SCOPE HERE? Validating DNSSEC (default) 81642, 81634, 85501 yes Following DNAME redirection 82717 yes serve-expired: yes 77860 yes Serves TCP or DoT to clients 80225 no Serves DNS over HTTPS 82720 no Serves DNS over QUIC 78227 no auth-zone with a ZONEMD 77955 no Three of the nine are unavoidable for any validating resolver. The other six depend entirely on what the instance is configured to serve.

All nine identifiers are prefixed CVE-2026-. The right-hand column is this resolver, not yours - the method for filling it in is below.

The CNAME bug, explained by the people who fixed it

CVE-2026-82717 is described in the release note as "CNAME synthesis could lead to heap corruption". The fix is in iterator/iter_scrub.c, where a DNAME redirection is turned into the CNAME record that clients expect, and it is one of the rare cases where the patch explains itself better than any summary could. The change is a deletion - one line removed, and eight lines of comment left in its place:

 sldns_write_uint32(cn->rr_first->ttl_data, ttl);
-sldns_write_uint32(rrset->rr_first->ttl_data, ttl);
+/* Do NOT write the clamp back into the packet buffer:
+ * parse_packet already sized every name from the original
+ * bytes and rdata_copy re-walks them trusting those sizes;
+ * mutating packet bytes between the walks breaks that
+ * invariant (compression pointers can target these TTL
+ * bytes). The DNAME rrset receives the same clamp at store
+ * time in rdata_copy, so the DNAME and the synthesized
+ * CNAME still carry equal TTLs in the cache. */

Read that carefully, because it describes a class of bug rather than one instance. The packet is parsed once to measure where every name starts and how long it is. Those measurements are then trusted by a second pass that copies the data out. Between the two passes, this line reached back into the raw packet and overwrote four bytes of it - a TTL - to keep the DNAME and its synthesised CNAME consistent.

That is harmless unless something else in the packet points at those four bytes. DNS name compression lets a name be encoded as a pointer to an earlier offset, and nothing prevents that offset from being inside a TTL field. Do that, and the second pass walks a name whose bytes changed after it was measured. The lengths no longer describe the data.

The generalisable rule, which costs nothing to adopt: a buffer that has been measured is immutable until everything derived from those measurements is finished with it. The fix keeps the two TTLs consistent by applying the same clamp later, at the point where the data is copied into the cache, rather than by editing the evidence.

Two use-after-frees with one cause

CVE-2026-82720 is a use-after-free in the DoH stream cleanup path and CVE-2026-78227 is a use-after-free in the DoQ stream output buffer. Separate identifiers, separate transports, and one shape - which is visible in services/mesh.c, where a query's pending replies are delivered.

The old code detached the whole reply list, then walked the detached pointer:

- struct mesh_reply* rep = mstate->reply_list;
- mstate->reply_list = NULL;
- for(; rep; rep=rep->next) {
+ while((rep = mesh_reply_list_pop_first(mstate)) != NULL) {
+         if(rep->query_reply.c->tcp_req_info)
+                 tcp_req_info_remove_mesh_state(...);
+         else if(rep->query_reply.c->use_h2)
+                 http2_stream_remove_mesh_state(rep->h2_stream);
+         else if(rep->query_reply.doq_stream)
+                 doq_stream_remove_mesh_state(rep->query_reply.doq_stream);

The maintainers' comment on the replacement names the problem exactly: sending one reply can cause the connection to be dropped, and dropping an HTTP/2 or QUIC connection tears down every stream on it. That can delete list entries both before and after the one currently being held - so the rep->next the loop is about to follow may already be freed.

Popping one entry at a time removes the iterator, and therefore removes the stale pointer. The per-transport calls that follow exist so that when an entry is popped, the connection-side structure stops referring to it in the same breath. Both directions of the reference are cleared together, which is the actual invariant.

This is why multiplexed transports are harder than they look. On plain UDP, one query is one datagram and dropping it affects nothing else. On DoH or DoQ, dozens of in-flight queries share one connection, and any of them can take the connection down underneath all the others.

One connection could hold the event loop

CVE-2026-80225 is described as "possible degradation of service from continuous queries on the same TCP/DoT connection". The fix in util/netevent.c introduces one constant and applies it in two places:

/** The number of TCP queries over a TCP connection, per read indication
 * from select. */
#define NUM_TCP_PER_SELECT 100

Unbound is single-threaded per worker and event-driven: one pass through the loop services whichever descriptors are ready. A client that keeps a TCP or DoT connection continuously full of pipelined queries could be drained without limit inside one visit, and every other descriptor - every other client - waited for it to stop. No packets were dropped and no memory was corrupted. Everyone else simply got slower, which is exactly the kind of degradation that gets blamed on the network.

Now each visit drains at most a hundred queries and re-arms itself with a zero-delay timer, so the remainder is picked up on the next turn of the loop and other descriptors get serviced in between. The maintainers noted one subtlety in the comment worth repeating: for TLS the undrained remainder sits in the SSL library's own user-space buffer, not in the kernel socket, so the event loop will not be woken by it - hence the explicit timer rather than simply returning.

Four new limits, two of which you can change

CVE-2026-85501 is named "Retrap" and is described as algorithmic complexity attacks on DNSSEC validation: responses crafted so that validating them costs far more than serving them. There is no single bug to fix, so the response is a set of caps on how much work one query may demand. Two are compile-time constants, two are new configuration options:

limitdefaultcaps
val-validation-attempts32RRSIG validation attempts per query, configurable
val-hash-attempts32hash operations per query, configurable
MAX_VALIDATE_NSECS8NSEC and NSEC3 validations per message, compiled in
MAX_TAG_MATCHES256key-tag matches examined, compiled in

When a limit is hit the query is answered bogus, with a log line at the algorithm verbosity level naming which limit it was. That matters operationally: a cap like this can turn a slow answer into a failed one. A legitimately deep delegation chain with many keys is rare but it is not impossible, and if you start seeing unexplained bogus answers after this upgrade, these are the first thing to check rather than the last.

Both options are readable from a running instance, which is the quickest way to confirm you are on a build that has them at all:

unbound-control get_option val-validation-attempts
unbound-control get_option val-hash-attempts
32
32

On an older build both return an error instead, which is a cheap way to tell whether a running daemon is really the version you think it is. The manual page shipped in the tarball does not document them; doc/unbound.conf.rst does.

Working out which ones are yours

Six of the nine depend on what the instance is configured to do. Three commands settle it, and they take about ten seconds:

# 1. which transports does it actually serve, and to whom?
ss -lntup | grep unbound

# 2. serve-expired, and any authoritative zones?
unbound-control get_option serve-expired
unbound-control list_auth_zones

# 3. is it validating at all? (no AD flag means no)
dig @127.0.0.1 -p 5353 dnsdoh.art A +dnssec | grep -E 'flags:|RRSIG'

The second command is the one people get wrong. serve-expired is off by default but is widely turned on, because it keeps answers flowing when an upstream is unreachable. list_auth_zones printing nothing means CVE-2026-77955 does not apply to you at all: it is a bypass in verifying ZONEMD, the digest record that lets a resolver check a zone it has been handed as a file rather than resolved, and a resolver hosting no zones never runs that check.

The first command is the one that does most of the work, and it is worth being precise about what counts. A DoT, DoH or DoQ port that exists but is bound to loopback behind another daemon is not reachable by the clients the CVE describes. That is the case here: this instance listens on 127.0.0.1 only, with the encrypted transports terminated in front of it, so the three transport CVEs do not describe a path anything outside the host can take.

Not reachable is not the same as not present. The vulnerable code is in the binary either way, and the boundary keeping it out of reach is a configuration choice that a future change can undo without anybody remembering this article. Reachability is a reason to be calm about the timing of an upgrade. It is not a reason to skip one.

For the record, the sequence here: the release was published at 08:22 UTC on 16 September and the rebuilt binary was serving at 11:48 UTC, about three and a half hours later. That is not a target anybody should hold themselves to for a resolver that answers real users - it is a small, single-purpose install where a rebuild is cheap. The useful number to know about your own is not how fast you can do it, but whether you know how long it currently takes.

What this article does not tell you

Three limits worth stating plainly, since a patch-reading exercise invites over-reading.

We did not verify exploitability. A missing bounds check is a missing bounds check; whether a given one can be driven to a useful outcome by a remote attacker is a separate investigation, and the people who found these did that work and reported it privately. Assume the CVSS scores rather than our reading of the diff.

The identifier-to-file mapping is inference. It is well-supported where a fix is unique - there is exactly one bounds check added, exactly one deletion in the CNAME synthesis path, exactly one new per-select budget. It is weaker for CVE-2026-77955 and CVE-2026-85501, where several changes across the validator and the auth-zone code could each plausibly carry part of the identifier. If you need certainty for a compliance record, the release note is the citable source and this is background.

Nine identifiers in one release is not nine times the risk. The release note says these were reported over a period of time and consolidated. Several were found by the same two research groups, which is what a sustained audit of one codebase looks like from the outside - it is evidence of attention, not of decay.

The general lesson

An advisory is written to make you upgrade. It is not written to help you understand your own exposure, and it usually cannot be - the people writing it do not know your configuration. The gap between "nine CVEs" and "three of these reach my instance, and here is the command that proves it" is work that only you can do, and on this release it took about twenty minutes of reading a diff.

The upgrade is still the answer. Reachability analysis is for deciding whether to do it at 3am or on Thursday, and for knowing which log lines to watch afterwards. It is never a substitute, because the thing that makes an unreachable code path reachable is a configuration change, and configuration changes do not come with advisories.

The other habit worth keeping: when a release does not explain itself, the source usually does. Two extracted tarballs and diff -ru answered every question here that the release note left open, including the ones we did not think to ask - the new tunables are not in any advisory, and they change how a validating resolver behaves under load.