From: Aydın Mercan Date: Wed, 6 May 2026 13:54:57 +0000 (+0300) Subject: Add system test for out-of-zone nsec dnssec bypass X-Git-Tag: v9.21.24~4^2~2 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=654f9773c0af59965c343bdfeb096b3dffe9dd53;p=thirdparty%2Fbind9.git Add system test for out-of-zone nsec dnssec bypass 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. --- 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 index 00000000000..59ced1831a1 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns1/named.conf.j2 @@ -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 index 00000000000..8d98a0456c3 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns1/root.db @@ -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 index 00000000000..6efcd95e42a --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns1/test.db @@ -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 index 00000000000..e81cee7cac2 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns2/named.conf.j2 @@ -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 index 00000000000..edcc234322e --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns2/victim.db @@ -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 index 00000000000..618f9d3e852 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns3/evil.db @@ -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 index 00000000000..17d3e18e4e1 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns3/named.conf.j2 @@ -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 index 00000000000..039695d9b79 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/ns4/named.conf.j2 @@ -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 index 00000000000..250b5170063 --- /dev/null +++ b/bin/tests/system/dnssec_bypass/tests_bypass.py @@ -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()