]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add system test for out-of-zone nsec dnssec bypass
authorAydın Mercan <aydin@isc.org>
Wed, 6 May 2026 13:54:57 +0000 (16:54 +0300)
committerMichał Kępień <michal@isc.org>
Fri, 10 Jul 2026 07:26:46 +0000 (09:26 +0200)
A malicious zone with out-of-zone NSEC entries can get a DNSSEC
validating resolver's cache to cover the victim zone for non-existence
and prevent nameserver queries without DNSSEC failure.

Test for this case with an `evil.test` zone that tries to cover the
`victim.test` zone.

bin/tests/system/dnssec_bypass/ns1/named.conf.j2 [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns1/root.db [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns1/test.db [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns2/named.conf.j2 [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns2/victim.db [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns3/evil.db [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns3/named.conf.j2 [new file with mode: 0644]
bin/tests/system/dnssec_bypass/ns4/named.conf.j2 [new file with mode: 0644]
bin/tests/system/dnssec_bypass/tests_bypass.py [new file with mode: 0644]

diff --git a/bin/tests/system/dnssec_bypass/ns1/named.conf.j2 b/bin/tests/system/dnssec_bypass/ns1/named.conf.j2
new file mode 100644 (file)
index 0000000..59ced18
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+
+options {
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.1; };
+       listen-on-v6 { none; };
+       recursion no;
+       dnssec-validation no;
+};
+
+zone "test." {
+       type primary;
+       file "test.db.signed";
+};
+
+zone "." {
+       type primary;
+       file "root.db.signed";
+};
diff --git a/bin/tests/system/dnssec_bypass/ns1/root.db b/bin/tests/system/dnssec_bypass/ns1/root.db
new file mode 100644 (file)
index 0000000..8d98a04
--- /dev/null
@@ -0,0 +1,19 @@
+; 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 3600
+.                      IN SOA  a.nil. a.nil. 1 3600 600 86400 300
+.                      IN NS   a.root-servers.nil.
+
+a.root-servers.nil.    IN A    10.53.0.1
+
+test.                  IN NS   ns1.test.
+ns1.test.              IN A    10.53.0.1
diff --git a/bin/tests/system/dnssec_bypass/ns1/test.db b/bin/tests/system/dnssec_bypass/ns1/test.db
new file mode 100644 (file)
index 0000000..6efcd95
--- /dev/null
@@ -0,0 +1,23 @@
+; 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 test.
+$TTL 3600
+
+@              IN SOA a a 1 3600 600 86400 300
+               IN NS   ns1.test.
+ns1            IN A    10.53.0.1
+
+evil           IN NS   ns1.evil
+ns1.evil       IN A    10.53.0.3
+
+victim         IN NS   ns1.victim
+ns1.victim     IN A    10.53.0.2
diff --git a/bin/tests/system/dnssec_bypass/ns2/named.conf.j2 b/bin/tests/system/dnssec_bypass/ns2/named.conf.j2
new file mode 100644 (file)
index 0000000..e81cee7
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+options {
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.2; };
+       listen-on-v6 { none; };
+       recursion no;
+       dnssec-validation no;
+};
+
+zone "victim.test." {
+       type primary;
+       file "victim.db.signed";
+};
diff --git a/bin/tests/system/dnssec_bypass/ns2/victim.db b/bin/tests/system/dnssec_bypass/ns2/victim.db
new file mode 100644 (file)
index 0000000..edcc234
--- /dev/null
@@ -0,0 +1,18 @@
+; 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 victim.test.
+$TTL 3600
+
+@              IN SOA  ns1 hostmaster 1 3600 600 86400 2147483647
+               IN NS   ns1
+
+ns1            IN A    10.53.0.2
diff --git a/bin/tests/system/dnssec_bypass/ns3/evil.db b/bin/tests/system/dnssec_bypass/ns3/evil.db
new file mode 100644 (file)
index 0000000..618f9d3
--- /dev/null
@@ -0,0 +1,23 @@
+; 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 300
+
+@              IN SOA  ns1 hostmaster 1 3600 600 86400 300
+               IN NS   ns1
+; Try to poison the victim zone in a resolver cache.
+; If admitted, the aggressive NSEC cache will accept a range such as
+; [evil.test, b.victim.test) and will cause the victim nameserver to
+; be never queried.
+               IN NSEC b.victim.test. NS SOA RRSIG NSEC DNSKEY
+
+ns1            IN A    10.53.0.3
diff --git a/bin/tests/system/dnssec_bypass/ns3/named.conf.j2 b/bin/tests/system/dnssec_bypass/ns3/named.conf.j2
new file mode 100644 (file)
index 0000000..17d3e18
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+options {
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.3; };
+       listen-on-v6 { none; };
+       recursion no;
+       dnssec-validation no;
+};
+
+zone "evil.test." {
+       type primary;
+       file "evil.db.signed";
+};
diff --git a/bin/tests/system/dnssec_bypass/ns4/named.conf.j2 b/bin/tests/system/dnssec_bypass/ns4/named.conf.j2
new file mode 100644 (file)
index 0000000..039695d
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+options {
+       query-source address 10.53.0.4;
+       notify-source 10.53.0.4;
+       transfer-source 10.53.0.4;
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.4; };
+       listen-on-v6 { none; };
+       allow-transfer { any; };
+       recursion yes;
+       dnssec-validation yes;
+       synth-from-dnssec yes;
+};
+
+trust-anchors {
+       @root.domain@ @root.type@ @root.contents@;
+};
+
+zone "." {
+       type hint;
+       file "../../_common/root.hint";
+};
diff --git a/bin/tests/system/dnssec_bypass/tests_bypass.py b/bin/tests/system/dnssec_bypass/tests_bypass.py
new file mode 100644 (file)
index 0000000..250b517
--- /dev/null
@@ -0,0 +1,154 @@
+# 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.
+
+from datetime import datetime, timedelta, timezone
+
+import shutil
+
+from cryptography.hazmat.primitives.asymmetric import ec
+
+import dns.dnssec
+import dns.name
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.rrset
+import dns.zone
+
+from isctest.run import EnvCmd
+from isctest.vars.algorithms import Algorithm
+
+import isctest
+
+TTL = 3600
+
+
+def bootstrap():
+    alg = Algorithm.default()
+    keygen = EnvCmd("KEYGEN", f"-q -a {alg.number} -b {alg.bits}")
+    signer = EnvCmd("SIGNER", "-S -g -O full")
+
+    def sign_regular_zone(ns: str, zone: str, database: str) -> isctest.kasp.Key:
+        isctest.log.info(f"{zone}: generate keys")
+        keygen(zone, cwd=ns).out.strip()
+        ksk = keygen(f"-f KSK {zone}", cwd=ns).out.strip()
+
+        isctest.log.info(f"{zone}: sign zone")
+        signer(f"-o {zone} {database}", cwd=ns)
+
+        if ns != "ns1":
+            shutil.copy(f"{ns}/dsset-{zone}", f"ns1/dsset-{zone}")
+            shutil.copy(f"{ns}/{ksk}.key", f"ns1/{ksk}.key")
+
+        return isctest.kasp.Key(ksk, keydir=ns)
+
+    # dnssec-signzone and `dns.dnssec.sign_zone` correctly disregard the invalid
+    # NSEC entries when signing the zone. However, for this test we actualy *want*
+    # to serve invalid yet signed zones. To accomplish this we sign the zone and then
+    # replace the correct entries with the faulty ones accompanied by its RRSIG.
+    #
+    # TODO(aydin): move this to `isctest` to sign broken zones
+    def sign_rogue_zone(ns: str, zone: str, database: str) -> None:
+        # Read zone.
+        origin = dns.name.from_text(zone)
+        data = dns.zone.from_file(f"{ns}/{database}", origin=origin, relativize=False)
+
+        # Get key for signing.
+        isctest.log.info(f"{zone}: generate keys")
+        private_key = ec.generate_private_key(ec.SECP256R1())
+        dnskey = dns.dnssec.make_dnskey(
+            public_key=private_key.public_key(),
+            algorithm=dns.dnssec.Algorithm.ECDSAP256SHA256,
+            flags=257,
+        )
+
+        # Sign zone.
+        isctest.log.info(f"{zone}: sign zone")
+        now = datetime.now(timezone.utc)
+        inception = now - timedelta(hours=1)
+        expiration = now + timedelta(days=30)
+
+        for name, node in data.nodes.items():
+            owner = name.derelativize(origin)
+            rdatasets = list(node.rdatasets)
+
+            for rdataset in rdatasets:
+                rrset = dns.rrset.RRset(owner, rdataset.rdclass, rdataset.rdtype)
+                rrset.update(rdataset)
+
+                rrsig = dns.dnssec.sign(
+                    rrset=rrset,
+                    private_key=private_key,
+                    signer=origin,
+                    dnskey=dnskey,
+                    inception=inception,
+                    expiration=expiration,
+                    deterministic=False,
+                )
+
+                rdataset = dns.rdataset.Rdataset(rrset.rdclass, dns.rdatatype.RRSIG)
+                rdataset.add(rrsig, rrset.ttl)
+                node.replace_rdataset(rdataset)
+
+        # Sign DNSKEY RRset.
+        dnskey_rrset = dns.rrset.RRset(origin, dns.rdataclass.IN, dns.rdatatype.DNSKEY)
+        dnskey_rrset.add(dnskey, ttl=TTL)
+
+        apex_node = data.nodes[origin]
+        apex_node.replace_rdataset(dnskey_rrset)
+
+        rrsig = dns.dnssec.sign(
+            rrset=dnskey_rrset,
+            private_key=private_key,
+            signer=origin,
+            dnskey=dnskey,
+            inception=inception,
+            expiration=expiration,
+            deterministic=False,
+        )
+        rdataset = dns.rdataset.Rdataset(rrset.rdclass, dns.rdatatype.RRSIG)
+        rdataset.add(rrsig, dnskey_rrset.ttl)
+        apex_node.replace_rdataset(rdataset)
+
+        # Output zone.
+        data.to_file(f"{ns}/{database}.signed", relativize=False)
+
+        # Output DS.
+        ds = dns.dnssec.make_ds(name=origin, key=dnskey, algorithm="SHA256")
+        with open(f"ns1/dsset-{zone}", "w", encoding="utf-8") as f:
+            f.write(f"{zone} {TTL} IN DS {ds.to_text()}\n")
+
+    sign_rogue_zone("ns3", "evil.test.", "evil.db")
+    sign_regular_zone("ns2", "victim.test.", "victim.db")
+    sign_regular_zone("ns1", "test.", "test.db")
+    root_ksk = sign_regular_zone("ns1", ".", "root.db")
+
+    return {
+        "root": root_ksk.into_ta("static-key"),
+    }
+
+
+def test_out_of_zone_nsec(ns4):
+    isctest.log.info("trying to poison aggressive nsec cache")
+    msg = isctest.query.create("nx.evil.test", "A")
+    res = isctest.query.tcp(msg, ns4.ip)
+    isctest.check.noadflag(res)
+
+    isctest.log.info("query victim from recursive")
+    msg = isctest.query.create("victim.test", "SOA")
+    res = isctest.query.tcp(msg, ns4.ip, attempts=1)
+    isctest.check.noerror(res)
+    isctest.check.adflag(res)
+    isctest.check.rr_count_eq(res.answer, 2)
+
+    isctest.log.info("checking for query history on victim nameserver")
+    with open("ns2/named.run", "r", encoding="utf-8") as f:
+        assert "(victim.test): query 'victim.test/SOA/IN' approved" in f.read()