Commit a417bade7f for bind
commit a417bade7f1f81733359c2c72b91ed32a3271bdd
Author: Michał Kępień <michal@isc.org>
Date: Fri Oct 30 14:39:01 2026 +0100
Simplify nsec3_wrong_zone test
Replace boilerplate code signing the zones used by the ans1 custom
server with canonical bootstrap() code employing isctest.zone.Zone.
Replace boilerplate code crafting DNSSEC responses in ans1/*.py with
zone file data in ans1/zones/ signed in bootstrap(), reducing
ans1/ans.py down to just the logic implementing the attack attempted by
the test.
Replace fixed IP addresses with constants exposed by the
isctest.template module.
diff --git a/bin/tests/system/dnssec_nsec3/ans1/ans.py b/bin/tests/system/dnssec_nsec3/ans1/ans.py
index 2d24d71a9f..4150f82ea8 100644
--- a/bin/tests/system/dnssec_nsec3/ans1/ans.py
+++ b/bin/tests/system/dnssec_nsec3/ans1/ans.py
@@ -9,15 +9,63 @@
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
-from dnssec_nsec3.ans1 import common, f025, f055
-from isctest.asyncserver import AsyncDnsServer
+from collections.abc import AsyncGenerator
+
+import dns.rdatatype
+
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
+from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.matchers import Qname, Qtype
+
+from ..dnssec_nsec3_ans import minimal_covering_nsec3
+
+
+class AncestorWildcardNsec3Handler(ResponseHandler):
+ """
+ This handler imitates successfully spoofing a response to a
+ "b.victim2.evil.f025.test/A" query sent to a server authoritative for the
+ "evil.f025.test." domain. The spoofed response contains the wildcard A
+ record from the "evil.f025.test." zone and a forged NSEC3 record proving
+ that the QNAME's parent (and thus also the QNAME itself) does not exist and
+ therefore the wildcard match is valid. However, the forged NSEC3 record is
+ signed using the DNSKEY belonging to the parent zone ("f025.test."). A
+ vulnerable resolver validates such a response as secure, enabling names in
+ the child zone to be redirected to that zone's wildcard target using the
+ parent zone's signing key.
+ """
+
+ matcher = Qname("b.victim2.evil.f025.test.") & Qtype(dns.rdatatype.A)
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[DnsResponseSend, None]:
+ assert qctx.zone
+ assert qctx.zone.origin
+ parent_origin = qctx.zone.origin.parent()
+ parent_signing_key = qctx.keys[parent_origin][0]
+
+ forged_nsec3_covering_rrset = minimal_covering_nsec3(
+ qctx.qname.parent(), parent_origin
+ )
+ forged_nsec3_covering_rrsig = qctx.sign(
+ forged_nsec3_covering_rrset, key=parent_signing_key
+ )
+
+ qctx.response.authority = [
+ forged_nsec3_covering_rrset,
+ forged_nsec3_covering_rrsig,
+ ]
+
+ yield DnsResponseSend(qctx.response)
def main() -> None:
keys = common.load_keys()
server = AsyncDnsServer(default_aa=True)
- server.install_response_handler(f025.F025Handler(keys))
- server.install_response_handler(f055.F055Handler(keys))
+ server.install_response_handlers(
+ AncestorWildcardNsec3Handler(),
+ f055.F055Handler(keys),
+ )
server.run()
diff --git a/bin/tests/system/dnssec_nsec3/ans1/f025.py b/bin/tests/system/dnssec_nsec3/ans1/f025.py
deleted file mode 100644
index 953a5ad221..0000000000
--- a/bin/tests/system/dnssec_nsec3/ans1/f025.py
+++ /dev/null
@@ -1,224 +0,0 @@
-# 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 collections.abc import AsyncGenerator
-from dataclasses import dataclass
-
-import base64
-
-import dns.dnssec
-import dns.flags
-import dns.message
-import dns.rcode
-import dns.rdatatype
-import dns.rrset
-
-from dnssec_nsec3.ans1.common import (
- Key,
- add_signed,
- name,
- nsec3_hash,
- nsec3_rrset,
- rrset,
- rrset_from_rdata,
- soa_rrset,
-)
-from isctest.asyncserver import QueryContext, ResponseHandler
-from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.matchers import Domain
-
-TTL = 300
-PARENT = "f025.test."
-CHILD = f"evil.{PARENT}"
-PARENT_NS = f"ns.{PARENT}"
-CHILD_NS = f"ns.{CHILD}"
-CLOSEST = f"victim2.{CHILD}"
-ATTACK = f"b.{CLOSEST}"
-LEGIT = f"legit.{CHILD}"
-WILDCARD = f"*.{CHILD}"
-FORGED_A = "6.6.6.6"
-
-
-@dataclass(frozen=True)
-class Nsec3Entry:
- owner: str
- owner_hash: str
- types: tuple[str, ...]
-
-
-def base32hex_add(hash_text: str, delta: int) -> str:
- raw = bytearray(base64.b32hexdecode(hash_text.upper()))
- value = int.from_bytes(raw, "big") + delta
- value %= 1 << (8 * len(raw))
- return base64.b32hexencode(value.to_bytes(len(raw), "big")).decode("ascii")
-
-
-class Nsec3Chain:
- def __init__(self, zone: str, entries: list[tuple[str, tuple[str, ...]]]) -> None:
- self.zone = zone
- self.entries = sorted(
- [Nsec3Entry(owner, nsec3_hash(owner), types) for owner, types in entries],
- key=lambda entry: entry.owner_hash,
- )
-
- def rrset_for_entry(self, entry: Nsec3Entry) -> dns.rrset.RRset:
- index = self.entries.index(entry)
- next_hash = self.entries[(index + 1) % len(self.entries)].owner_hash
- return nsec3_rrset(self.zone, entry.owner_hash, next_hash, 0, *entry.types)
-
- def rrsets(self) -> list[dns.rrset.RRset]:
- return [self.rrset_for_entry(entry) for entry in self.entries]
-
-
-def add_nsec3_chain(
- section: list[dns.rrset.RRset], chain: Nsec3Chain, signer: Key
-) -> None:
- for covered in chain.rrsets():
- add_signed(section, covered, signer)
-
-
-def add_tight_parent_nsec3(section: list[dns.rrset.RRset], parent: Key) -> None:
- target_hash = nsec3_hash(f"{CLOSEST}")
- covered = nsec3_rrset(
- PARENT,
- base32hex_add(target_hash, -1),
- base32hex_add(target_hash, 1),
- 0,
- "TXT",
- "RRSIG",
- )
- add_signed(section, covered, parent)
-
-
-def wildcard_rrsig(owner: str, child: Key) -> dns.rrset.RRset:
- wildcard = rrset(WILDCARD, dns.rdatatype.A, FORGED_A)
- rrsig = dns.dnssec.sign(
- wildcard,
- child.private_key,
- child.zone,
- child.dnskey,
- lifetime=86400,
- verify=True,
- )
- return dns.rrset.from_rdata(name(owner), wildcard.ttl, rrsig)
-
-
-def add_wildcard_answer(response: dns.message.Message, owner: str, child: Key) -> None:
- response.answer.append(rrset(owner, dns.rdatatype.A, FORGED_A))
- response.answer.append(wildcard_rrsig(owner, child))
-
-
-class F025Handler(ResponseHandler):
- matcher = Domain(PARENT, CHILD)
-
- def __init__(self, keys: dict[str, Key]) -> None:
- super().__init__()
- self.keys = keys
-
- self.parent = name(PARENT)
- self.child = name(CHILD)
- self.parent_ns = name(PARENT_NS)
- self.child_ns = name(CHILD_NS)
- self.child_nsec3 = Nsec3Chain(
- CHILD,
- [
- (CHILD, ("NS", "SOA", "RRSIG", "DNSKEY", "NSEC3PARAM")),
- (WILDCARD, ("A", "RRSIG")),
- (CHILD_NS, ("A", "RRSIG")),
- ],
- )
-
- def _add_extra_nsec3(self, response: dns.message.Message, qname: str) -> None:
- parent_key = self.keys[PARENT]
- child_key = self.keys[CHILD]
- if "victim2." in qname:
- add_tight_parent_nsec3(response.authority, parent_key)
- else:
- add_nsec3_chain(response.authority, self.child_nsec3, child_key)
-
- async def get_responses(
- self, qctx: QueryContext
- ) -> AsyncGenerator[DnsResponseSend, None]:
- qctx.prepare_new_response(with_zone_data=False)
- qctx.response.flags |= dns.flags.AA
- qctx.response.set_rcode(dns.rcode.NOERROR)
-
- parent_key = self.keys[PARENT]
- child_key = self.keys[CHILD]
- qname = qctx.qname.to_text()
-
- if qctx.qname == self.parent and qctx.qtype == dns.rdatatype.DNSKEY:
- # Priming, parent DNSKEY
- add_signed(
- qctx.response.answer,
- rrset_from_rdata(PARENT, parent_key.dnskey),
- parent_key,
- )
- elif qctx.qname == self.parent and qctx.qtype == dns.rdatatype.SOA:
- # Priming, parent SOA
- add_signed(qctx.response.answer, soa_rrset(PARENT), parent_key)
- elif qctx.qname == self.parent and qctx.qtype == dns.rdatatype.NS:
- # Priming, parent NS
- add_signed(
- qctx.response.answer,
- rrset(PARENT, dns.rdatatype.NS, PARENT_NS),
- parent_key,
- )
- elif qctx.qname == self.parent_ns and qctx.qtype == dns.rdatatype.A:
- # Priming, parent glue
- add_signed(
- qctx.response.answer,
- rrset(PARENT_NS, dns.rdatatype.A, "10.53.0.1"),
- parent_key,
- )
- elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.DS:
- # Priming, child DS
- add_signed(
- qctx.response.answer,
- rrset_from_rdata(CHILD, child_key.ds),
- parent_key,
- )
- elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.DNSKEY:
- # Priming, child DNSKEY
- add_signed(
- qctx.response.answer,
- rrset_from_rdata(CHILD, child_key.dnskey),
- child_key,
- )
- elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.SOA:
- # Priming, child SOA
- add_signed(qctx.response.answer, soa_rrset(CHILD), child_key)
- elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.NS:
- # Priming, child NS
- add_signed(
- qctx.response.answer,
- rrset(CHILD, dns.rdatatype.NS, CHILD_NS),
- child_key,
- )
- elif qctx.qname == self.child_ns and qctx.qtype == dns.rdatatype.A:
- # Priming, child glue
- add_signed(
- qctx.response.answer,
- rrset(CHILD_NS, dns.rdatatype.A, "10.53.0.1"),
- child_key,
- )
- elif qctx.qname.is_subdomain(self.child):
- if qctx.qtype == dns.rdatatype.A:
- add_wildcard_answer(qctx.response, qname, child_key)
- else:
- add_signed(qctx.response.authority, soa_rrset(CHILD), child_key)
- # Adding malicious NSEC3
- self._add_extra_nsec3(qctx.response, qname)
- else:
- # Everything else is NODATA
- add_signed(qctx.response.authority, soa_rrset(PARENT), parent_key)
-
- yield DnsResponseSend(qctx.response, authoritative=True)
diff --git a/bin/tests/system/dnssec_nsec3/ans1/zones/evil.f025.test.db.j2.manual b/bin/tests/system/dnssec_nsec3/ans1/zones/evil.f025.test.db.j2.manual
new file mode 100644
index 0000000000..7e8ca880e1
--- /dev/null
+++ b/bin/tests/system/dnssec_nsec3/ans1/zones/evil.f025.test.db.j2.manual
@@ -0,0 +1,3 @@
+{% include "_common/zones/template.db.j2.manual" %}
+
+* A 6.6.6.6
diff --git a/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py b/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
new file mode 100644
index 0000000000..ff07b20e0e
--- /dev/null
+++ b/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
@@ -0,0 +1,54 @@
+# 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.
+
+import base64
+
+import dns.dnssec
+import dns.name
+import dns.rdataclass
+import dns.rdatatype
+import dns.rrset
+
+
+def base32hex_delta(base32_hash: str, delta: int) -> str:
+ base32_hash_raw = base64.b32hexdecode(base32_hash)
+ base32_hash_raw_length = len(base32_hash_raw)
+ base32_hash_value = int.from_bytes(base32_hash_raw, "big")
+ base32_hash_value_with_delta = base32_hash_value + delta
+ base32_hash_value_with_delta %= 1 << (8 * base32_hash_raw_length)
+ base32_hash_raw_with_delta = base32_hash_value_with_delta.to_bytes(
+ base32_hash_raw_length, "big"
+ )
+ base32_hash_with_delta = base64.b32hexencode(base32_hash_raw_with_delta)
+ return base32_hash_with_delta.decode("ascii")
+
+
+def rrset(
+ owner: dns.name.Name | str,
+ rtype: dns.rdatatype.RdataType,
+ rdata: str,
+ ttl: int = 300,
+) -> dns.rrset.RRset:
+ return dns.rrset.from_text(owner, ttl, dns.rdataclass.IN, rtype, rdata)
+
+
+def minimal_covering_nsec3(
+ name: dns.name.Name, origin: dns.name.Name
+) -> dns.rrset.RRset:
+ nsec3_hash = dns.dnssec.nsec3_hash(name, None, 0, 1)
+ nsec3_hash_just_before = base32hex_delta(nsec3_hash, -1)
+ nsec3_hash_just_after = base32hex_delta(nsec3_hash, +1)
+ nsec3_owner = dns.name.from_text(nsec3_hash_just_before, origin=origin)
+ return rrset(
+ nsec3_owner,
+ dns.rdatatype.NSEC3,
+ f"1 0 0 - {nsec3_hash_just_after} TXT RRSIG",
+ )
diff --git a/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2 b/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
index 66b07753fa..9c3a3e40ca 100644
--- a/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
+++ b/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
@@ -21,7 +21,10 @@ zone "f055.test" {
server-addresses { 10.53.0.1; };
};
+{% if ZONE_DNSKEY is defined %}
trust-anchors {
- f025.test. static-key 257 3 13 "@ZONE_DNSKEY@";
f055.test. static-key 257 3 13 "@ZONE_DNSKEY@";
};
+{% else %}
+{% include "_common/trusted.conf.j2" %}
+{% endif %}
diff --git a/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py b/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py
index 16a1e44a93..47ee6fe5a9 100644
--- a/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py
+++ b/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py
@@ -9,14 +9,6 @@
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
-from pathlib import Path
-
-import json
-
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import ec
-
-import dns.dnssec
import dns.name
import dns.rdataclass
import dns.rdatatype
@@ -24,6 +16,8 @@ import pytest
import isctest
import isctest.mark
+import isctest.template
+import isctest.zone
PARENT = "f025.test."
CHILD = f"evil.{PARENT}"
@@ -31,44 +25,30 @@ CLOSEST = f"victim2.{CHILD}"
ATTACK = f"b.{CLOSEST}"
LEGIT = f"legit.{CHILD}"
FORGED_A = "6.6.6.6"
-AUTH = "10.53.0.1"
-RESOLVER = "10.53.0.2"
+AUTH = isctest.template.ANS1.ip
+RESOLVER = isctest.template.NS2.ip
pytestmark = [
isctest.mark.with_ecdsa_deterministic,
pytest.mark.extra_artifacts(
[
- "ans*/keys.json",
+ "ans*/dsset-*",
+ "ans*/zones/*.db",
+ "ans*/zones/*.db.signed",
]
),
]
-def _make_key(zone):
- private_key = ec.generate_private_key(ec.SECP256R1())
- dnskey = dns.dnssec.make_dnskey(
- private_key.public_key(),
- algorithm="ECDSAP256SHA256",
- flags=257,
- )
- ds = dns.dnssec.make_ds(dns.name.from_text(zone), dnskey, "SHA256")
- private_pem = private_key.private_bytes(
- encoding=serialization.Encoding.PEM,
- format=serialization.PrivateFormat.PKCS8,
- encryption_algorithm=serialization.NoEncryption(),
- ).decode("ascii")
- return {
- "private_pem": private_pem,
- "dnskey": dnskey.to_text(),
- "ds": ds.to_text(),
- }
+def bootstrap():
+ child = isctest.zone.Zone("evil.f025.test.", isctest.template.ANS1, signed=True)
+ child.configure(csk=True, sign_params="-3 -")
+ parent = isctest.zone.Zone("f025.test.", isctest.template.ANS1, signed=True)
+ parent.delegations = [child]
+ parent.configure(csk=True, sign_params="-3 -")
-def bootstrap():
- keys = {zone: _make_key(zone) for zone in [PARENT, CHILD]}
- Path("ans1/keys.json").write_text(json.dumps(keys, indent=2), encoding="ascii")
- zone_dnskey = "".join(keys[PARENT]["dnskey"].split()[3:])
- return {"ZONE_DNSKEY": zone_dnskey}
+ return {"trust_anchors": parent.trust_anchors()}
def _query(server, qname, qtype):