Please report vulnerabilities privately through GitHub's security advisory form rather than opening a public issue.
dnsbox is written for an attacker who controls the bytes it is given:
- DNS messages from the network (UDP datagrams, TCP streams), in any role: queries reaching a server, responses reaching a client or resolver, zone transfers, dynamic updates, DSO sessions;
- presentation-format text: master (zone) files and their
$INCLUDEd parts, record text, and the human-readableserdeforms; - DNSSEC, TSIG and SIG(0) material: arbitrary keys, signatures, NSEC/NSEC3 chains and ZONEMD records, including validly signed but stale or contradictory records replayed from elsewhere, and messages forged or altered on the path.
The attacker wins if they can crash the process, read or corrupt memory, make it spend work out of proportion to their input, or get data accepted as authenticated (or a negative answer accepted as proven) that the signer did not vouch for.
Out of scope: denial of service by volume (the caller decides how much
input to accept; dnsbox bounds what one input can cost), side channels of the private-key operations in
purecrypto (signing is done on the caller's own data), and what a
holder of a zone's keys can sign about that zone's own names (DNSSEC
trusts the zone for them; dnsbox still rejects records no correct signer
produces, as defence in depth).
- dnsbox is
#![forbid(unsafe_code)]. (Its optionalpurecryptodependency has its own policy; dnsbox only calls its safe API.) - No input makes a public function panic: parsers, iterators,
Display, builders, presentation-format and zone-file parsing, DNSSEC, TSIG and SIG(0) checks all return anError(or aDenialStatus) instead. Slices are read with checked accessors, arithmetic on lengths and counts cannot overflow, and this also holds with overflow checks on (debug builds); the fuzzers run with them. - Buffers larger than a DNS message (a whole read buffer passed to
Message::parse) are handled like any other input.
Every operation does work proportional to its input, with these limits.
Where an input could make dnsbox work or allocate out of proportion to
its size ($GENERATE, $INCLUDE, colliding DNSSEC keys, NSEC3 proofs for
deep names), the bound is a library limit that is on by default and
that callers can raise for trusted input; going over it is
Error::LimitExceeded (or BogusReason::LimitExceeded for a denial
proof), never unbounded work.
| Area | Bound |
|---|---|
| Name decompression | pointers must point strictly before the labels they end (no loops, no forward or self pointers), at most 128 hops, 255 octets and 127 labels per name; the record iterators' suffix cache never skips a check |
| Message iteration | linear in the message; answers() / additional() re-skip the earlier sections once per call (use records() to walk everything in one pass) |
| Building | name compression verifies one table entry per label and at most 32 false candidates per name (the table holds 128 labels); messages stop at 65535 octets; RDLENGTH and section counts are never truncated (BufferTooSmall, CountOverflow); copy_section / copy_message visit at most 65 536 records per section looking for the rest of the RRsets they drop (then set TC) |
| Presentation text | linear in the text; parentheses nest at most 16 deep |
| Zone files | linear in the text, within ZoneLimits (defaults): at most 1 000 000 records in all ($GENERATEd and $INCLUDEd ones included; the reader then stops), 65 536 per $GENERATE directive (MAX_GENERATE) whose templates and their expansions are at most 1024 characters each, $INCLUDE 8 deep and 256 files, 256 MiB of text in all files together, lines of 1 MiB and tokens of 256 KiB; errors carry line and column |
$INCLUDE from files |
FsIncludes::new serves only regular files inside its directory: relative paths without .., root or drive prefix, and no symbolic link leading out (checked on the canonical path); no devices or pipes (both constructors), and never more than the input limit left (read with a bound, not after the fact) |
| Canonical RRsets | heapsort in the output buffer: O(n log n) comparisons, no allocation |
TrustedKeys (chain of trust) |
a ValidationBudget (KeyTrap, CVE-2023-50387): per call at most 8 RRSIGs over the RRset's type, 4 keys per RRSIG (key tag collisions), the first 32 DNSKEYs and 16 signature verifications and DS digests (MAX_CRYPTO_OPERATIONS), plus one key-tag computation per (RRSIG, key) pair looked at; per budget (one per response, *_with_budget) at most 32 verifications and DS digests in all. All configurable (ValidationLimits) |
| Signature backend | RSA moduli of 512–4096 bits (1024–4096 for RSASHA512) and public exponents of at most 256 bits; ECDSA points must be on the curve |
| NSEC proofs | a constant number of passes over the records |
| NSEC3 proofs | at most one hash per label of the name plus one wildcard, each after the iteration count was checked against Nsec3Limits (default: insecure above 100, bogus above 500, RFC 9276); one pass over the records per hash; with ValidationBudget::nsec3_hasher, at most 64 hashes per budget across all checks (CVE-2023-50868) |
| ZONEMD | collation and verification in O(n log n), however many apex ZONEMD records there are |
| TSIG streams | at most 99 unsigned messages in a row |
| TKEY | a constant number of passes over the message per call; Diffie-Hellman: at most one key agreement per call (purecrypto's public-value check and one exponentiation), only in the local group (a peer's prime is compared, never validated); RSA: KEYs of 1024–4096 bits with exponents of at most 256 bits, at most MAX_ENCRYPTED_BLOCKS (4) blocks of key data per message, decrypted with implicit rejection (no padding oracle) |
| Zone transfers | constant state per transfer, linear work per message; XfrProcessor::with_max_records / with_max_messages bound the stream (no default: zones range from one record to millions) |
| Owned messages | OwnedMessage::from_message reserves no more entries than the message can hold, whatever its header counts claim; serde refuses sections of more than 65535 entries while reading them, and RDATA of more than 65535 octets |
- Malformed input is rejected, not repaired: wrong lengths, octets left over inside RDATA, oversized labels and names, compression pointers where RFC 3597 §4 forbids them, reserved label types, out-of-range fields (ECS prefixes and address bits beyond them, LOC coordinates, cookie lengths, ...).
- Every record type parses to a value that re-encodes to exactly the
octets it came from (when they contain no compression pointer). DNSSEC
signs the re-encoded form of parsed records, so a signature can never
vouch for a different encoding of the data (checked by the
rdatafuzzer). Message::parseis lazy and ignores octets after the last record;Message::parse_validatedandMessage::validatereject them (TrailingData), and so does every signature check (below).
- TSIG (RFC 8945): the record must be the last of the message, with
nothing after it, CLASS ANY and TTL 0; key name and algorithm must
match a configured key (no algorithm substitution); the MAC must be
between max(10, half the digest) octets and the full digest, and is
compared in constant time by
purecrypto; the time window is checked after the MAC; MACs shorter than the key's policy are BADTRUNC. Responses and streams chain from the request MAC; unsigned error responses are reported, never accepted. - SIG(0) (RFC 2931): last record, nothing after it, owner root, CLASS ANY, TTL 0, labels and original TTL 0; validity window in serial arithmetic; the KEY must match signer name, algorithm and key tag.
- RRSIG (RFC 4035 §5.3): the signer is the key's owner and the zone,
the RRset owner is in the zone, the labels field lies between the
signer's and the owner's label counts (a wildcard expansion is always
from a wildcard inside the zone), validity window in serial arithmetic,
algorithm and key tag match, protocol 3, Zone Key flag set, every
record of the RRset of the type covered;
TrustedKeysalso never uses revoked keys (RFC 5011) and only verifies RRsets of its own class. - DS / chain of trust: digests of unsupported types are ignored, SHA-1 ones too when a stronger digest is present (RFC 4509 §3); no usable DS means insecure, never secure.
- Denial of existence (RFC 4035 §5.4, RFC 5155 §8, RFC 6840 §4,
RFC 7129): records from the parent side of a zone cut or at a DNAME
never deny names below them; parent-side records deny nothing but DS;
child-apex records never deny DS; a zone's apex is never its own
delegation; NSEC3 records must agree on parameters and have hashes of
the hash function's length (a short hash covers nothing); Opt-Out and
over-limit iteration counts give
Insecure, neverSecure; a spent hash budget givesBogus, neverInsecureorSecure. - Work limits never turn a failure into a success: a limit reached
before a valid signature is found is
Error::LimitExceeded, which a validator treats like bogus (SERVFAIL), not as insecure; a valid signature found within the limits is accepted as before. - ZONEMD (RFC 8976 §4): SOA and ZONEMD serials, unique (scheme, algorithm) tuples, digest length, then the digest.
- Cookies (RFC 7873, RFC 9018): server cookies are checked in constant
time (SipHash-2-4 from
purecrypto); lengths are enforced on parse.
dnsbox checks messages and records; deciding what to trust is the caller's job:
- Use
nowfrom a trustworthy clock for every time check (TSIG, SIG(0), RRSIG, cookies). - Select and authenticate inputs.
verify_rrsigandverify_rrsetcheck the RRset you give them; you choose it (owner, class, type) and the keys: authenticate keys throughTrustedKeys::from_ds/from_anchors(or your own configuration) before use. Denial proofs (NsecProof,Nsec3Proof) take records you have already verified, from one zone, as the zone's signer. - Check wildcard answers with
TrustedKeys::verify_answer(orDenialProof::wildcard_answer): a verified wildcard expansion only proves the wildcard exists. - Apply the TTL rules of RFC 4035 §5.3.3: cap TTLs at the RRSIG's
original TTL and never cache past its expiration (
Verifiedcarries both). - Treat
Insecureas unauthenticated. It does not mean bogus, but nothing in the response is proven. RFC 9276 and Opt-Out make this verdict reachable by replaying still-valid NSEC3 records of an older chain (high iteration count or Opt-Out); useNsec3Limits::new(n, n)to turn over-limit iteration counts intoBogusif you prefer to fail closed. - Use one
ValidationBudgetper response. EachTrustedKeyscall is bounded on its own, but a validator calls it once per RRset: pass the same budget to the*_with_budgetmethods for every RRset of a response, and hash NSEC3 proofs with itsnsec3_hasher, so that the whole response is bounded. TreatError::LimitExceededandBogusReason::LimitExceededas bogus (SERVFAIL), never as insecure. - TSIG: replay protection beyond the time window (remember the last
Time Signed per key), and matching responses to queries (ID, question)
are yours. A custom
TsigMac::verifymust compare in constant time. - TKEY: authenticate every TKEY message but GSS-API ones (TSIG or
SIG(0) under a key established before, RFC 2930 §3) before handing it
to
tkey, and sign what it builds; check that the signer may delete a key before deleting it (§4.2), refuse key names already in use, choose the validity you grant, and check it later (SharedKey::is_valid_atwith a trustworthynow). Use fresh random nonces and secrets from a cryptographically secure generator. The well-known Diffie-Hellman groups (768 to 1536 bits) are weak: prefer an RFC 3526 group where the peer allows it. GSS-API (RFC 3645) is not implemented. - Validate early when you need to:
Message::parseis lazy and reports an error only when the faulty part is reached;parse_validatedchecks everything up front. - Zone files: the default
ZoneLimitssuit untrusted files; raise them (or useZoneLimits::UNLIMITED) only for trusted ones. Resolve$INCLUDEs of untrusted files withFsIncludes::new(confined to one directory, which must hold nothing the uploader may not read) or with anIncludeResolverof your own that decides which paths it serves and implementsload_limited;FsIncludes::unconfinedopens any path, as BIND does, and is for trusted files only. The confinement holds against the zone file, not against someone changing the directory concurrently. - Zone transfers from servers you do not trust: set
XfrProcessor::with_max_records/with_max_messages. - Cap input sizes for the allocating conveniences (
OwnedMessage,serde,Records), which allocate in proportion to their input (never more than it can hold). - Custom backends (
Verifier,Signer,TsigKey/TsigMac,Nsec3Hasher,Sig0Verifier) must not panic and must bound their own work; the RSA and exponent limits above are the bundled backend's.
- Fuzzing: ten cargo-fuzz targets (
fuzz/), run with overflow checks for 60 seconds each on every push:message,name,rdata,edns,roundtripandtextfor parsing, building and presentation format;denial,dnssec,signandzonefor the trust decisions (proof consistency, every non-cryptographic RRSIG check, tamper detection of TSIG and SIG(0), zone files with includes and ZONEMD). Seeds and every fixed finding are replayed bycargo test(tests/fuzz_regressions.rs). For the audit below, after earlier rounds, every target ran for 400 seconds on four workers with overflow checks against the final code (0.6 to 9.6 million executions per target) without a crash. After the work-limits round and the new record types, every target ran again for 120 seconds on four workers with overflow checks (0.4 to 6.1 million executions per target), without a crash. After the code review round, with the TKEY key agreement and the draft-only types, every target ran for 120 seconds with overflow checks (0.2 to 4.0 million executions per target, themessagetarget reading the new TKEY messages,rdataandtextthe new types), without a crash. - Tests: truncation at every offset for every parser, property tests
(
tests/proptest_roundtrip.rs), a corpus of real responses from BIND, NSD, Knot, PowerDNS and Unbound (tests/corpus/), tool-level interop with BIND, ldns, dnspython, and Knot DNS, Unbound and BIND in CI (whose resolvers' bogus, insecure and secure verdicts dnsbox's validation must match within one defaultValidationBudgetper response, so the KeyTrap limits are checked against legitimate chains,tests/interop_knot.rs,tests/interop_bind.rs), RFC test vectors, and one regression test per audit finding (tests/security_audit.rs).
A review of the whole crate after the work-limits round, each finding
verified with a reproduction before it was fixed; every fix has a
regression test next to the code (dnssec/chain/tests.rs,
zone/tests.rs, zone/lexer.rs, builder/truncate/tests.rs,
edns/tests.rs, rdata/tests.rs, dso.rs, tests/serde.rs).
| Severity | Finding | Fix |
|---|---|---|
| High | A malformed $INCLUDE path (\256, non-UTF-8) inside an $INCLUDEd file returned before the file's state was put back: the file restarted from its top, at the root origin, so Records never ended (a file holding only that line) or re-read the file up to the record limit |
the state is kept before the error is returned, which names the file |
| Medium | TrustedKeys::from_ds took DS records of digest types it cannot compute (GOST R 34.11-2012, SM3) as usable: such a DS first in the RRset turned a secure zone insecure, displaced SHA-1 DS records, and turned a forged DNSKEY RRset whose key tag matched it from bogus into insecure |
only SHA-1, SHA-256 and SHA-384 DS records count; others are disregarded (RFC 4035 §5.2) |
| Medium | $GENERATE rescanned its templates for every record: a 250 KB ${000…0} modifier that expands to one character cost about 20 s of CPU per directive, within the default limits |
templates longer than 1024 characters are LimitExceeded |
| Low | A quote inside an unquoted token grouped up to the next quote anywhere in the file, folding whole records into one string without an error | the grouping ends with the line (InvalidText) |
| Low | Type bitmaps (NSEC, NSEC3, CSYNC) accepted empty blocks and trailing zero octets (RFC 4034 §4.1.2): several encodings of one type set, which changed on a text round trip (breaking the RRSIG over them) | a block ending in a zero octet is InvalidRdata |
| Low | copy_section / copy_message grouped only consecutive records into RRsets: truncation could keep part of an interleaved additional-section RRset with TC clear |
the RRset's other parts are dropped too, or TC is set; at most 8 split RRsets and 65 536 record visits per section |
| Low | An OPT record in the answer or authority section was ignored: Message::edns and start_response_edns took such a query for one without EDNS |
Error::MisplacedOpt (FORMERR) |
| Low | DsoBuilder::pad_to on a Vec wrote padding of any size the caller asked for before checking the 65 535-octet limit (1 GiB written, larger sizes aborted on allocation failure) |
checked before anything is written |
| Low | push_edns_padded sized padding against the limit, not the limit minus the reserve, so padding failed when room was kept for a TSIG or SIG(0) record |
padded up to the limit minus the reserve |
| Low | OwnedRData's serde form of a class-specific type in a class other than IN did not deserialize |
read back as the opaque data it is |
The caller duties of the Milestone 9 policy that concerned work were
turned into library limits, on by default, and the remaining APIs were
reviewed for work or allocation an attacker controls. Each change has
regression tests next to the code (zone/tests.rs, dnssec/chain/tests.rs,
dnssec/denial/tests.rs, owned/tests.rs, xfr/tests.rs,
tests/serde.rs) and the dnssec fuzz target checks the budgets.
| Severity | Finding | Fix |
|---|---|---|
| Medium | KeyTrap (CVE-2023-50387): the 16 verifications per TrustedKeys call bounded each RRset, but not a response (the caller's duty), and every (RRSIG, key) pair of a response cost a key tag computation |
ValidationBudget shared by the calls of a response; RRSIGs per RRset, keys per RRSIG and DNSKEYs looked at are capped (ValidationLimits); Error::LimitExceeded |
| Medium | NSEC3 closest-encloser proofs (CVE-2023-50868): one hash per label of the name, in every check of a response | ValidationBudget::nsec3_hasher caps the hashes of all checks; BogusReason::LimitExceeded |
| Medium | $GENERATE let a short zone file yield 65 536 records per line, which zone::parse collected without limit (the caller's duty) |
ZoneLimits on ZoneReader, Records and zone::parse_with_limits, with a default record limit |
| Medium | FsIncludes opened any path a zone file named: absolute paths, .., symbolic links, and devices or pipes (/dev/zero read until memory ran out, a FIFO blocked forever) |
confined to its directory by default (FsIncludes::unconfined for trusted files), regular files only, read with the input limit as bound |
| Low | OwnedMessage::from_message reserved room for the entries the header counts claimed: a 12-octet header made it allocate some 70 MB before failing |
room reserved for what the message can hold |
| Low | serde read message sections of any length (a section has at most 65535 entries) |
refused beyond 65535 entries while reading |
| Info | The length of a zone transfer was unbounded | opt-in XfrProcessor::with_max_records / with_max_messages |
An adversarial review of the whole crate (parsing, building, text and
zone files, DNSSEC, denial proofs, TSIG, SIG(0), cookies, ECS, ZONEMD)
and a fuzzing campaign with overflow checks. Findings, all fixed, each
with a regression test in tests/security_audit.rs:
| Severity | Finding | Fix |
|---|---|---|
| Medium | ZONEMD collation and verification compared every apex ZONEMD record with every other (duplicate removal, RFC 8976 §4 step 4 tuple check): a zone with 40 000 of them took minutes of CPU to reject | duplicates removed and tuples counted with a sort: O(n log n) |
| Low | TSIG and SIG(0) verification accepted octets after the signature record, which no MAC or signature covers, when the message was parsed lazily | the record must end the message (Error::TrailingData, FORMERR) |
| Low | An RRSIG labels field smaller than the signer's label count was accepted, making the RRset a "wildcard expansion" of a name above the zone (*. for 0) |
the labels field must be at least the signer's label count (RrsetMismatch); signing refuses such templates too |
| Low | An NSEC3 record whose hashes were shorter than the hash output (e.g. one octet, 00 to ff) covered almost every name of the zone |
hashes of another length never match or cover (also Nsec3::covers) |
| Low | LOC presentation format: an altitude close to 2^63 cm overflowed (a panic with overflow checks) | checked addition (InvalidText) |
| Info | TrustedKeys verified RRsets of another class than its keys' |
RrsetMismatch |
| Info | Denial proofs accepted a zone's own apex as one of its delegations, and its own records as denying the DS at its apex | Bogus(ZoneCut) |
The rdata fuzzer now also checks that every type re-encodes to the
octets it was parsed from, and all fuzzers run with overflow checks.