Twenty-Three Defects in Memory-Safe DNS, and Not One Is a Memory-Safety Defect

Two Go DNS forwarders and a Rust DNS server, across eleven surfaces, with 23 defects between them. Not one is an overflow. What takes the overflow's place is a cache that answers a question nobody asked and a parser that does unbounded work on a legal message.

Rewriting a network parser in a memory-safe language removes an entire class of defect, and the three implementations in this article show that working exactly as advertised.

There is no overflow below. No use-after-free, no double free, no read past the end of a buffer, and nothing a sanitizer would have flagged on a stock build.

We tested eleven surfaces across three implementations, two of them written in Go and one in Rust, and uncovered 23 defects. Two of the eleven came back clean.

What the language changed is where the defect lives, not how many there are. A cache that answers the wrong question, a decompressor that does quadratic work on a legal message, and a filter that inspects the wrong record type are all reachable by the same attacker, from the same position, with the same datagram. The difference is that the process stays up, which can be the worse outcome, because a crash is at least noticed.

The cache key that does not identify the answer

This is the largest group here, five defects across two products, and it is the one with no equivalent in a C codebase.

A DNS cache is a map from a question to an answer. If the key omits part of what makes a question distinct, then two different questions share an entry, and whoever asks first decides what everyone else is told.

Product C gets this wrong four separate ways in one key. The query type is written into the key through a shift that discards it, so that byte is always zero and every type collides with every other. The query class is read while the key is built and then never written into it. The DNSSEC OK bit has a named constant, an explicit branch and a standards citation next to it, and still cannot reach the key. Client subnet is copied from the client's query into the query sent upstream, and left out of the key entirely.

The consequence is the same in all four cases. An attacker who can ask a question can place an answer where a later, different question will find it.

Product A arrives at the same place from the other direction. Its DNS-over-HTTPS entry point accepts a message carrying more than one question, forwards it, and stores the single upstream answer under every question's key. Caching is on in the stock configuration, and the poisoned entries are then served to every client on every transport, not only to whoever sent the HTTPS request. The behaviour needs an upstream that echoes the question section back unchanged, which one major public resolver was measured doing.

Work without a budget

Eight defects, spread across all three products, and the shape is identical every time: a small legal message that costs the receiver far more than it cost the sender.

Product B's name decompressor follows compression pointers with no limit on how many hops it will take. The cost is quadratic in message size, and on a default build over a stream transport a single message took 170 milliseconds against a baseline of 0.71. Its record type bitmap decoder does one insert per set bit with nothing capping the total, reached through an ordinary record type that carries a bitmap: 53.8 milliseconds against 0.47, again on a stock build.

Product A's DNSSEC validator holds three of these at once. A hash cache grows for the life of the process and is never evicted. A diagnostic line stringifies every record in a set before the logger discards it at the default level. And the answer path verifies every signature offered to it with no budget at all, while every other path through the same validator is capped.

Product C adds two more. A single TCP connection dispatches one concurrent handler per message with nothing bounding how many run at once, so one connection can exhaust the process. And every datagram that fails to parse is written to the log in full and base64 encoded, which turns a stream of malformed packets into a disk amplifier.

A parsed field that decides something

Four defects, and this is the class that survives a rewrite untouched, because it has nothing to do with memory.

Product A takes the client's identity from a forwarded-for request header with no trusted-proxy check behind it. Rate limiting, per-client policy and the query log all key on that value, and the client chooses it.

Product B's answer filter, the one an operator configures to deny particular addresses, inspects only A and AAAA records. An address delivered instead as a service binding hint inside an SVCB or HTTPS record is never looked at, and reaches the client that the filter exists to protect.

Product C matches upstream responses on the transaction id alone, and hands out those ids from a plain counter, so the next one is predictable. Separately, its bootstrap resolver takes the first address record out of whatever datagram arrives in response to its query.

Values relayed onward unchanged

The last group is three defects where an implementation accepts a value, declines to normalise it, and passes it to somebody downstream who trusts it.

Product B's recursor clamps the time-to-live on the copy it keeps and returns the original, unclamped, to its client, so a downstream cache can be handed the maximum possible value. This one needs a build feature that is off by default.

Product A pins a cache entry for 136 years when an upstream time-to-live arrives with its top bit set, a value the standard requires to be treated as zero.

Product C answers with a start-of-authority record naming the root zone whenever the question count is anything other than one.

The two that came back clean

The blocklist matcher and the client query name handlers in Product A produced nothing across a full run each.

Both are worth naming, because they are the parts of a forwarder most people would guess are riskiest: a string matcher running against attacker-supplied names, and the handlers that rewrite those names before resolution. They held.

Reproducing this with Penzzer

Every defect above is reachable from the position the protocol already grants an attacker: a client that can send a query, or an upstream that can answer one.

| Module | The shape it provokes | | --- | --- | | Compression pointer loops | Self-referential and unbounded pointer chains | | Question echo integrity | A reply whose question section decodes to a different name | | Type bitmap decoding cost | A bitmap whose set bits drive the decoder's work | | Cache poisoning | An answer stored where a different question will find it | | Entry point parity with the DNS listeners | A message the HTTPS endpoint accepts and the DNS listener would reject | | Per query allocation growth | Memory kept after the answer is sent | | EDNS option accounting | Repeated and client-supplied options against a single budget | | Oversized datagram stall | A message larger than the reader expects |

‍

Select DNS Server for the server direction and DNS Client for the forwarder and resolver paths, then run the protocol-aware target so the test cases come from the structure of the message rather than from a corpus.

What this article is, and is not

The 23 defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's DNS coverage. They are what three specific implementations happened to get wrong.

The attack library spans defect classes well beyond what this article shows: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling. Across the 107 protocols with a protocol-aware target, a run also generates its test cases from the structure of the protocol itself, walking each field through the boundaries where parsers break.

The same limit runs in the other direction. What a test looks for is a property of the test, not a verdict on your product. Two of the eleven surfaces here came back clean, and that is what a clean result means.

If you rewrote something in a safe language

The rewrite worked. Nothing here is the kind of defect it was meant to remove, and that is a real result rather than a consolation.

Three questions for what remains. Does your cache key contain every field that distinguishes one question from another, including the ones a reader would assume are already there? Does every loop driven by a length, a count or a pointer in the message carry a budget, rather than only the loops somebody thought to cap? And does every policy check inspect all the record types that can carry the thing it is checking for?

A memory-safe language answers the first question in a parser for you. It answers none of these three.

Other Post
Uncover Hidden Vulnerabilities

Identify security flaws before attackers do, automatically and at scale with Penzzer's intelligent fuzzing engine.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.