]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add dnssec_py/tests_rrsig_labels_signer: reject Labels underflow
authorNicki Křížek <nicki@isc.org>
Wed, 15 Apr 2026 17:05:23 +0000 (17:05 +0000)
committerMichał Kępień <michal@isc.org>
Fri, 10 Jul 2026 07:26:46 +0000 (09:26 +0200)
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
bin/tests/system/dnssec_py/ans4/ans.py
bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py [new file with mode: 0644]
bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py [new file with mode: 0644]
bin/tests/system/dnssec_py/common.py
bin/tests/system/dnssec_py/tests_rrsig_labels_signer.py [new file with mode: 0644]

index 47c4c0f87e733c383e82bfab03e8facb090578b6..af8704370abcfe10735f4d3347ce3e192db4bc01 100644 (file)
@@ -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 (file)
index 0000000..1b37ebc
--- /dev/null
@@ -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:
+    <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)
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 (file)
index 0000000..c03d76e
--- /dev/null
@@ -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)
index 2d09eda4da611efadb2ffe68b9c170e87e26f0fa..ebece637dbf741baea84b87bad1a0248109ed5b0 100644 (file)
@@ -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 (file)
index 0000000..b9fcb6c
--- /dev/null
@@ -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 <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)