From: Nicki Křížek Date: Wed, 15 Apr 2026 17:05:23 +0000 (+0000) Subject: Add dnssec_py/tests_rrsig_labels_signer: reject Labels underflow X-Git-Tag: v9.21.24~12^2 X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=78856e165eb71c8d7dd346487951cdbe97296b71;p=thirdparty%2Fbind9.git Add dnssec_py/tests_rrsig_labels_signer: reject Labels underflow Tests that BIND rejects RRSIGs where Labels < (signer_labels - 1), the underflow condition described in the fix to lib/dns/dnssec.c. A crafted authoritative server (ans4) serves the zone attacker.rrsig-labels-signer. with synthetically constructed responses: an A record covered by an RRSIG whose Labels=1 was produced by signing *.rrsig-labels-signer. with a key whose signer name sits two labels deeper (attacker.rrsig-labels-signer., 3 non-root labels). On unfixed BIND, the resolver accepts the signature and caches the wildcard with SECURE rank, enabling a synth-from-dnssec cache-poisoning attack on subsequent queries. The test verifies two properties: the poison query returns SERVFAIL (DNS_R_SIGINVALID), and a follow-up victim query is not answered from the poisoned wildcard cache. Assisted-by: Claude:claude-opus-4-8 --- diff --git a/bin/tests/system/dnssec_py/ans4/ans.py b/bin/tests/system/dnssec_py/ans4/ans.py index 47c4c0f87e7..af8704370ab 100644 --- a/bin/tests/system/dnssec_py/ans4/ans.py +++ b/bin/tests/system/dnssec_py/ans4/ans.py @@ -9,55 +9,25 @@ # 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() diff --git a/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py b/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py new file mode 100644 index 00000000000..1b37ebc3669 --- /dev/null +++ b/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py @@ -0,0 +1,254 @@ +# 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: + A 192.0.2.1 + RRSIG A 13 1 ... (Labels=1, signed over *.rrsig-labels-signer.) + + AUTHORITY: + .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) diff --git a/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py b/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py new file mode 100644 index 00000000000..c03d76e661a --- /dev/null +++ b/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py @@ -0,0 +1,53 @@ +# 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) diff --git a/bin/tests/system/dnssec_py/common.py b/bin/tests/system/dnssec_py/common.py index 2d09eda4da6..ebece637dbf 100644 --- a/bin/tests/system/dnssec_py/common.py +++ b/bin/tests/system/dnssec_py/common.py @@ -15,6 +15,7 @@ DNSSEC_PY_MARK = pytest.mark.extra_artifacts( [ "ans*/*.db", "ans*/*.run", + "ans*/*.pem", "ns*/dsset-*", "ns*/trusted.conf", "ns*/zones/*.db", diff --git a/bin/tests/system/dnssec_py/tests_rrsig_labels_signer.py b/bin/tests/system/dnssec_py/tests_rrsig_labels_signer.py new file mode 100644 index 00000000000..b9fcb6c2db4 --- /dev/null +++ b/bin/tests/system/dnssec_py/tests_rrsig_labels_signer.py @@ -0,0 +1,125 @@ +# 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 ". "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)