From 45c9bd26032c6bc93a1d04acc94f4db895b59b1b Mon Sep 17 00:00:00 2001 From: Evan Hunt Date: Tue, 16 Jun 2026 12:06:21 -0700 Subject: [PATCH 1/2] Check wildcard signer and NOQNAME signer match A positive wildcard answer, and the NSEC3 proof that the requested name doesn't exist in the zone, must both be from the same zone. Otherwise, an NSEC3 from an ancestor zone could be used to interfere with validation. We now retrieve the signer name from a wildcard response's signature. An NSEC3 record cannot be used as a NOQNAME proof for the wildcard unless it exactly matches the name one level above the NSEC3. Fixes: isc-projects/bind9#5971 --- bin/dnssec/dnssec-cds.c | 2 +- bin/dnssec/dnssec-signzone.c | 4 ++-- lib/dns/dnssec.c | 7 +++++-- lib/dns/include/dns/dnssec.h | 9 ++++++--- lib/dns/include/dns/validator.h | 1 + lib/dns/validator.c | 30 ++++++++++++++++++------------ lib/dns/zone.c | 7 +++---- lib/dns/zoneverify.c | 2 +- 8 files changed, 37 insertions(+), 25 deletions(-) diff --git a/bin/dnssec/dnssec-cds.c b/bin/dnssec/dnssec-cds.c index 50239e6178..d57d410fa3 100644 --- a/bin/dnssec/dnssec-cds.c +++ b/bin/dnssec/dnssec-cds.c @@ -636,7 +636,7 @@ matching_sigs(keyinfo_t *keytbl, dns_rdataset_t *rdataset, result = dns_dnssec_verify(name, rdataset, ki->dst, false, isc_g_mctx, &sigrdata, - NULL); + NULL, NULL); if (result != ISC_R_SUCCESS && result != DNS_R_FROMWILDCARD) diff --git a/bin/dnssec/dnssec-signzone.c b/bin/dnssec/dnssec-signzone.c index 71a89991e0..0a526a5494 100644 --- a/bin/dnssec/dnssec-signzone.c +++ b/bin/dnssec/dnssec-signzone.c @@ -295,7 +295,7 @@ signwithkey(dns_name_t *name, dns_rdataset_t *rdataset, dst_key_t *key, if (tryverify) { result = dns_dnssec_verify(name, rdataset, key, true, - isc_g_mctx, &trdata, NULL); + isc_g_mctx, &trdata, NULL, NULL); if (result == ISC_R_SUCCESS || result == DNS_R_FROMWILDCARD) { vbprintf(3, "\tsignature verified\n"); INCSTAT(nverified); @@ -456,7 +456,7 @@ setverifies(dns_name_t *name, dns_rdataset_t *set, dst_key_t *key, dns_rdata_t *rrsig) { isc_result_t result; result = dns_dnssec_verify(name, set, key, false, isc_g_mctx, rrsig, - NULL); + NULL, NULL); if (result == ISC_R_SUCCESS || result == DNS_R_FROMWILDCARD) { INCSTAT(nverified); return true; diff --git a/lib/dns/dnssec.c b/lib/dns/dnssec.c index f7a6a9ca7f..991a5e9fff 100644 --- a/lib/dns/dnssec.c +++ b/lib/dns/dnssec.c @@ -342,7 +342,7 @@ cleanup_databuf: isc_result_t dns_dnssec_verify(const dns_name_t *name, dns_rdataset_t *set, dst_key_t *key, bool ignoretime, isc_mem_t *mctx, dns_rdata_t *sigrdata, - dns_name_t *wild) { + dns_name_t *wild, dns_name_t *wildsigner) { dns_rdata_rrsig_t sig; dns_fixedname_t fnewname; isc_region_t r; @@ -550,6 +550,9 @@ cleanup_struct: dns_fixedname_name(&fnewname), wild) == ISC_R_SUCCESS); } + if (wildsigner != NULL) { + dns_name_copy(&sig.signer, wildsigner); + } inc_stat(dns_dnssecstats_wildcard); result = DNS_R_FROMWILDCARD; } @@ -1035,7 +1038,7 @@ dns_dnssec_signs(dns_rdata_t *rdata, const dns_name_t *name, if (sig.algorithm == key.algorithm && sig.keyid == keytag) { result = dns_dnssec_verify(name, rdataset, dstkey, ignoretime, mctx, &sigrdata, - NULL); + NULL, NULL); if (result == ISC_R_SUCCESS) { dst_key_free(&dstkey); return true; diff --git a/lib/dns/include/dns/dnssec.h b/lib/dns/include/dns/dnssec.h index 14f9e468ae..11d2759c7d 100644 --- a/lib/dns/include/dns/dnssec.h +++ b/lib/dns/include/dns/dnssec.h @@ -141,7 +141,7 @@ dns_dnssec_sign(const dns_name_t *name, dns_rdataset_t *set, dst_key_t *key, isc_result_t dns_dnssec_verify(const dns_name_t *name, dns_rdataset_t *set, dst_key_t *key, bool ignoretime, isc_mem_t *mctx, dns_rdata_t *sigrdata, - dns_name_t *wild); + dns_name_t *wild, dns_name_t *wildsigner); /*%< * Verifies the RRSIG record covering this rdataset signed by a specific * key. This does not determine if the key's owner is authorized to sign @@ -156,13 +156,16 @@ dns_dnssec_verify(const dns_name_t *name, dns_rdataset_t *set, dst_key_t *key, *\li 'key' is a valid key *\li 'mctx' is not NULL *\li 'sigrdata' is a valid rdata containing a SIG record - *\li 'wild' if non-NULL then is a valid and has a buffer. + *\li 'wild' if non-NULL then is a valid name and has a buffer. + *\li 'wildsigner' if non-NULL then is a valid name and has a buffer. * * Returns: *\li #ISC_R_SUCCESS *\li #DNS_R_FROMWILDCARD - the signature is valid and is from * a wildcard expansion. dns_dnssec_verify2() only. - * 'wild' contains the name of the wildcard if non-NULL. + * 'wild', if non-NULL, contains the name of the wildcard. + * 'wildsigner', if non-NULL, contains the 'signer' name + * from the RRSIG signing the wildcard. *\li #DNS_R_SIGINVALID - the signature fails to verify *\li #DNS_R_SIGEXPIRED - the signature has expired *\li #DNS_R_SIGFUTURE - the signature's validity period has not begun diff --git a/lib/dns/include/dns/validator.h b/lib/dns/include/dns/validator.h index 7676fe534b..b7d0d8e712 100644 --- a/lib/dns/include/dns/validator.h +++ b/lib/dns/include/dns/validator.h @@ -140,6 +140,7 @@ struct dns_validator { dns_rdataset_t dsrdataset; dns_fixedname_t fname; dns_fixedname_t wild; + dns_fixedname_t wildsigner; dns_fixedname_t closest; ISC_LINK(dns_validator_t) link; unsigned int depth; diff --git a/lib/dns/validator.c b/lib/dns/validator.c index 38ccb5611c..576c02acb2 100644 --- a/lib/dns/validator.c +++ b/lib/dns/validator.c @@ -1491,9 +1491,9 @@ selfsigned_dnskey(dns_validator_t *val) { } consume_validation(val); - result = dns_dnssec_verify(name, rdataset, - dstkey, true, mctx, - &sigrdata, NULL); + result = dns_dnssec_verify( + name, rdataset, dstkey, true, mctx, + &sigrdata, NULL, NULL); switch (result) { case DNS_R_SIGFUTURE: case DNS_R_SIGEXPIRED: @@ -1550,9 +1550,10 @@ static isc_result_t verify(dns_validator_t *val, dst_key_t *key, dns_rdata_t *rdata, uint16_t keyid) { isc_result_t result; - dns_fixedname_t fixed; + dns_fixedname_t fwild, fsigner; bool ignore = false; - dns_name_t *wild = dns_fixedname_initname(&fixed); + dns_name_t *wild = dns_fixedname_initname(&fwild); + dns_name_t *wildsigner = dns_fixedname_initname(&fsigner); if (DNS_TRUST_SECURE(val->rdataset->trust)) { /* @@ -1569,7 +1570,7 @@ verify(dns_validator_t *val, dst_key_t *key, dns_rdata_t *rdata, again: result = dns_dnssec_verify(val->name, val->rdataset, key, ignore, - val->view->mctx, rdata, wild); + val->view->mctx, rdata, wild, wildsigner); if ((result == DNS_R_SIGEXPIRED || result == DNS_R_SIGFUTURE) && val->view->acceptexpired) { @@ -1595,17 +1596,18 @@ again: } if (result == DNS_R_FROMWILDCARD) { if (!dns_name_equal(val->name, wild)) { - dns_name_t *closest; - unsigned int labels; + dns_name_t *closest = dns_fixedname_name(&val->closest); /* * Compute the closest encloser in case we need it * for the NSEC3 NOQNAME proof. */ - closest = dns_fixedname_name(&val->closest); dns_name_copy(wild, closest); - labels = dns_name_countlabels(closest) - 1; - dns_name_getlabelsequence(closest, 1, labels, closest); + dns_name_getlabelsequence( + closest, 1, dns_name_countlabels(closest) - 1, + closest); + dns_name_copy(wildsigner, + dns_fixedname_name(&val->wildsigner)); val->attributes |= VALATTR_NEEDNOQNAME; } result = ISC_R_SUCCESS; @@ -2796,10 +2798,13 @@ findnsec3proofs(dns_validator_t *val) { * have a valid closest encloser. Otherwise we could still be looking * at proofs from the parent zone. */ + dns_name_t *wildsigner = dns_fixedname_name(&val->wildsigner); if (dns_name_countlabels(closest) > 0 && dns_name_countlabels(nearest) == dns_name_countlabels(closest) + 1 && - dns_name_issubdomain(nearest, closest)) + dns_name_issubdomain(nearest, closest) && + (dns_name_countlabels(wildsigner) == 0 || + dns_name_equal(zonename, wildsigner))) { val->attributes |= VALATTR_FOUNDCLOSEST; result = dns_name_concatenate(dns_wildcardname, closest, @@ -3811,6 +3816,7 @@ dns_validator_create(dns_view_t *view, dns_name_t *name, dns_rdatatype_t type, dns_rdataset_init(&val->fsigrdataset); dns_rdataset_init(&val->dsrdataset); dns_fixedname_init(&val->wild); + dns_fixedname_init(&val->wildsigner); dns_fixedname_init(&val->closest); val->start = isc_stdtime_now(); val->magic = VALIDATOR_MAGIC; diff --git a/lib/dns/zone.c b/lib/dns/zone.c index 0909127c8f..d6a597abfd 100644 --- a/lib/dns/zone.c +++ b/lib/dns/zone.c @@ -8967,8 +8967,6 @@ revocable(dns_zonefetch_t *fetch, dns_rdata_keydata_t *keydata) { /* See if that key generated any of the signatures */ DNS_RDATASET_FOREACH(&fetch->sigset) { dns_rdata_t sigrr = DNS_RDATA_INIT; - dns_fixedname_t fixed; - dns_fixedname_init(&fixed); dns_rdataset_current(&fetch->sigset, &sigrr); result = dns_rdata_tostruct(&sigrr, &sig, NULL); @@ -8981,7 +8979,7 @@ revocable(dns_zonefetch_t *fetch, dns_rdata_keydata_t *keydata) { { result = dns_dnssec_verify(keyname, &fetch->rrset, dstkey, false, mctx, &sigrr, - dns_fixedname_name(&fixed)); + NULL, NULL); dnssec_log(fetch->zone, ISC_LOG_DEBUG(3), "Confirm revoked DNSKEY is self-signed: %s", @@ -9198,7 +9196,8 @@ keyfetch_done(dns_zonefetch_t *fetch, isc_result_t eresult) { } result = dns_dnssec_verify(keyname, dnskeys, dstkey, - false, mctx, &sigrr, NULL); + false, mctx, &sigrr, NULL, + NULL); dst_key_free(&dstkey); dnssec_log(zone, ISC_LOG_DEBUG(3), diff --git a/lib/dns/zoneverify.c b/lib/dns/zoneverify.c index 70e21b4270..aeb9b1e5b3 100644 --- a/lib/dns/zoneverify.c +++ b/lib/dns/zoneverify.c @@ -185,7 +185,7 @@ goodsig(const vctx_t *vctx, dns_rdata_t *sigrdata, const dns_name_t *name, continue; } result = dns_dnssec_verify(name, rdataset, dstkeys[key], false, - vctx->mctx, sigrdata, NULL); + vctx->mctx, sigrdata, NULL, NULL); if (result == ISC_R_SUCCESS || result == DNS_R_FROMWILDCARD) { return true; } From 680e0d8532ead079ae3f40f35e13dc620e50d567 Mon Sep 17 00:00:00 2001 From: Alessio Podda Date: Fri, 5 Jun 2026 12:11:58 +0200 Subject: [PATCH 2/2] Reproducer for #5971 NSEC3 from ancestor zone Create a new nsec3_wrong_zone system test as a regression test. Co-Authored By: Evan Hunt --- bin/tests/system/nsec3_wrong_zone/ans1/ans.py | 303 ++++++++++++++++++ .../system/nsec3_wrong_zone/ns2/named.conf.j2 | 34 ++ .../tests_nsec3_wrong_zone.py | 148 +++++++++ 3 files changed, 485 insertions(+) create mode 100644 bin/tests/system/nsec3_wrong_zone/ans1/ans.py create mode 100644 bin/tests/system/nsec3_wrong_zone/ns2/named.conf.j2 create mode 100644 bin/tests/system/nsec3_wrong_zone/tests_nsec3_wrong_zone.py diff --git a/bin/tests/system/nsec3_wrong_zone/ans1/ans.py b/bin/tests/system/nsec3_wrong_zone/ans1/ans.py new file mode 100644 index 0000000000..49cf43d25f --- /dev/null +++ b/bin/tests/system/nsec3_wrong_zone/ans1/ans.py @@ -0,0 +1,303 @@ +#!/usr/bin/python3 + +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 + +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from pathlib import Path + +import base64 +import json + +from cryptography.hazmat.primitives import serialization + +import dns.dnssec +import dns.flags +import dns.message +import dns.name +import dns.rcode +import dns.rdata +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +from isctest.asyncserver import ( + AsyncDnsServer, + DnsResponseSend, + QueryContext, + ResponseHandler, +) + +TTL = 300 +PARENT = "p025.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 Key: + zone: dns.name.Name + private_key: object + dnskey: dns.rdata.Rdata + ds: dns.rdata.Rdata + + +@dataclass(frozen=True) +class Nsec3Entry: + owner: str + owner_hash: str + types: tuple[str, ...] + + +def name(text: str) -> dns.name.Name: + return dns.name.from_text(text) + + +def load_keys() -> dict[str, Key]: + path = Path(__file__).resolve().parent / "keys.json" + with path.open(encoding="utf-8") as keys_file: + raw_keys = json.load(keys_file) + + keys = {} + for zone, raw_key in raw_keys.items(): + private_key = serialization.load_pem_private_key( + raw_key["private_pem"].encode("ascii"), + password=None, + ) + dnskey = dns.rdata.from_text( + dns.rdataclass.IN, dns.rdatatype.DNSKEY, raw_key["dnskey"] + ) + ds = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, raw_key["ds"]) + keys[zone] = Key(name(zone), private_key, dnskey, ds) + return keys + + +def rrset(owner: str, rdtype: dns.rdatatype.RdataType, *rdatas: str) -> dns.rrset.RRset: + return dns.rrset.from_text(owner, TTL, dns.rdataclass.IN, rdtype, *rdatas) + + +def rrset_from_rdata(owner: str, rdata: dns.rdata.Rdata) -> dns.rrset.RRset: + return dns.rrset.from_rdata(name(owner), TTL, rdata) + + +def add_signed( + section: list[dns.rrset.RRset], covered: dns.rrset.RRset, signer: Key +) -> None: + rrsig = dns.dnssec.sign( + covered, + signer.private_key, + signer.zone, + signer.dnskey, + lifetime=86400, + verify=True, + ) + section.append(covered) + section.append(dns.rrset.from_rdata(covered.name, covered.ttl, rrsig)) + + +def soa_rrset(zone: str) -> dns.rrset.RRset: + return rrset( + zone, + dns.rdatatype.SOA, + f"ns.{zone} hostmaster.{zone} 1 3600 600 86400 300", + ) + + +def nsec3_hash(owner: str) -> str: + return dns.dnssec.nsec3_hash(owner, salt=None, iterations=0, algorithm=1).upper() + + +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") + + +def nsec3_rrset( + zone: str, owner_hash: str, next_hash: str, *types: str +) -> dns.rrset.RRset: + return rrset( + f"{owner_hash}.{zone}", + dns.rdatatype.NSEC3, + f"1 0 0 - {next_hash} {' '.join(types)}", + ) + + +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, *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), + "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 WrongZoneNsec3Handler(ResponseHandler): + def __init__(self, keys: dict[str, Key]) -> None: + 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 match(self, qctx: QueryContext) -> bool: + return qctx.qname.is_subdomain(self.parent) + + 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) + + +def main() -> None: + server = AsyncDnsServer(default_aa=True) + server.install_response_handlers(WrongZoneNsec3Handler(load_keys())) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/nsec3_wrong_zone/ns2/named.conf.j2 b/bin/tests/system/nsec3_wrong_zone/ns2/named.conf.j2 new file mode 100644 index 0000000000..ae1f7b8395 --- /dev/null +++ b/bin/tests/system/nsec3_wrong_zone/ns2/named.conf.j2 @@ -0,0 +1,34 @@ +// validating resolver + +options { + query-source address 10.53.0.2; + notify-source 10.53.0.2; + transfer-source 10.53.0.2; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.2; }; + listen-on-v6 { none; }; + recursion yes; + dnssec-validation yes; + minimal-responses no; +}; + +controls { + inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +include "../../_common/rndc.key"; + +zone "." { + type hint; + file "../../_common/root.hint"; +}; + +zone "p025.test" { + type static-stub; + server-addresses { 10.53.0.1; }; +}; + +trust-anchors { + p025.test. static-key 257 3 13 "@PARENT_DNSKEY@"; +}; diff --git a/bin/tests/system/nsec3_wrong_zone/tests_nsec3_wrong_zone.py b/bin/tests/system/nsec3_wrong_zone/tests_nsec3_wrong_zone.py new file mode 100644 index 0000000000..0733565b9d --- /dev/null +++ b/bin/tests/system/nsec3_wrong_zone/tests_nsec3_wrong_zone.py @@ -0,0 +1,148 @@ +#!/usr/bin/python3 + +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 + +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 +import pytest + +import isctest +import isctest.mark + +PARENT = "p025.test." +CHILD = f"evil.{PARENT}" +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" + +pytestmark = [ + isctest.mark.with_ecdsa_deterministic, + pytest.mark.extra_artifacts( + [ + "ans*/ans.run", + "ans*/keys.json", + ] + ), +] + + +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(): + keys = {zone: _make_key(zone) for zone in [PARENT, CHILD]} + Path("ans1/keys.json").write_text(json.dumps(keys, indent=2), encoding="ascii") + parent_dnskey = "".join(keys[PARENT]["dnskey"].split()[3:]) + return {"PARENT_DNSKEY": parent_dnskey} + + +def _query(server, qname, qtype): + query = isctest.query.create(qname, qtype) + return isctest.query.tcp(query, server) + + +def _rrset(response, section, owner, rdtype, covers=None): + if covers is None: + return response.get_rrset( + section, dns.name.from_text(owner), dns.rdataclass.IN, rdtype + ) + return response.get_rrset( + section, + dns.name.from_text(owner), + dns.rdataclass.IN, + rdtype, + covers=covers, + ) + + +def _has_a(response, section, owner, address): + rrset = _rrset(response, section, owner, dns.rdatatype.A) + return rrset is not None and any(rdata.address == address for rdata in rrset) + + +def _has_nsec3_signed_by(response, signer): + signer_name = dns.name.from_text(signer) + for rrset in response.authority: + if rrset.rdtype != dns.rdatatype.NSEC3: + continue + rrsig = _rrset( + response, + response.authority, + rrset.name.to_text(), + dns.rdatatype.RRSIG, + covers=dns.rdatatype.NSEC3, + ) + if rrsig is not None and rrsig[0].signer == signer_name: + return True + return False + + +def _check_rrsig(response, section, owner, rdtype, signer, labels=None): + rrsig = _rrset(response, section, owner, dns.rdatatype.RRSIG, covers=rdtype) + assert rrsig is not None, response.to_text() + assert rrsig[0].signer == dns.name.from_text(signer), response.to_text() + if labels is not None: + assert rrsig[0].labels == labels, response.to_text() + + +def test_forged_response(): + response = _query(AUTH, ATTACK, "A") + isctest.check.noerror(response) + assert _has_a(response, response.answer, ATTACK, FORGED_A), response.to_text() + _check_rrsig(response, response.answer, ATTACK, dns.rdatatype.A, CHILD, labels=3) + assert _has_nsec3_signed_by(response, PARENT), response.to_text() + + +def test_resolver_rejects_wrong_zone_nsec3_noqname_proof(): + child_soa = _query(RESOLVER, CHILD, "SOA") + isctest.check.noerror(child_soa) + isctest.check.adflag(child_soa) + + # The verified wildcard answer RRSet in the response provides the validator + # with a (candidate) closest encloser for QNAME. The validator MUST check that + # the closest encloser is from the correct zone. If this is not the case, + # as is with this test case, the response must be treated as bogus. + response = _query(RESOLVER, ATTACK, "A") + isctest.check.servfail(response) + isctest.check.noadflag(response) + assert not _has_a(response, response.answer, ATTACK, FORGED_A), response.to_text() + + +def test_resolver_accepts_own_zone_nsec3_wildcard_proof(): + # Send query eligble for wildcard expansion (1-label expansion + correct own-zone NSEC3): + response = _query(RESOLVER, LEGIT, "A") + isctest.check.noerror(response) + isctest.check.adflag(response) + assert _has_a(response, response.answer, LEGIT, FORGED_A), response.to_text() + assert _has_nsec3_signed_by(response, CHILD), response.to_text()