public inbox for git-commits@fedoraproject.org
help / color / mirror / Atom feed
* [rpms/bind] f43: Incorrect acceptance of NSEC3 records (CVE-2026-10723)
@ 2026-08-26  9:29 
  0 siblings, 0 replies; only message in thread
From:  @ 2026-08-26  9:29 UTC (permalink / raw)
  To: git-commits

            A new commit has been pushed.

            Repo   : rpms/bind
            Branch : f43
            Commit : 58ff7ce6754b792591e37c0ff030dfbfa20cec38
            Author : Petr Menšík <pemensik@redhat.com>
            Date   : 2026-08-26T11:07:49+02:00
            Stats  : +925/-0 in 3 file(s)
            URL    : https://src.fedoraproject.org/rpms/bind/c/58ff7ce6754b792591e37c0ff030dfbfa20cec38?branch=f43

            Log:
            Incorrect acceptance of NSEC3 records (CVE-2026-10723)

[9.18] [CVE-2026-10723] sec: usr: Correct verification of NSEC3 signer name

BIND 9 accepted child-zone NSEC3 records where the first label equals the hash of the parent zone as valid parent-zone closest encloser proofs. This has been fixed.

ISC thanks Qifan Zhang of Palo Alto Networks for reporting the issue.

Closes isc-projects/bind9#5874

---
diff --git a/bind-9.18-CVE-2026-10723-test.patch b/bind-9.18-CVE-2026-10723-test.patch
new file mode 100644
index 0000000..04bff8a
--- /dev/null
+++ b/bind-9.18-CVE-2026-10723-test.patch
@@ -0,0 +1,854 @@
+From 2a4786b0edde59274f682c9bd4ae4026c2d08218 Mon Sep 17 00:00:00 2001
+From: Evan Hunt <each@isc.org>
+Date: Wed, 1 Jul 2026 22:56:02 -0700
+Subject: [PATCH] add isctest.mark method for ecdsa_deterinistic
+
+This checks support for ECDSA deterministic mode in the cryptography
+library.
+
+(cherry picked from commit 6e44151466864d3dd783a20da83d01028781d3e2)
+(cherry picked from commit dc3f52388427f4f94087d984d5a2088b925810e2)
+
+Reproducer for #5874 NSEC3 impersonation
+
+LLM generated.
+
+(cherry picked from commit f3e2eb333be3ac636f745aa13cfb8d9ee8af87d8)
+(cherry picked from commit 35e3d49d2222c13786a06021c7ed583d2a656e51)
+
+Update reproducer #5874
+
+Update the llm generated reproducer:
+- Move server.py into ans1/ans.py
+- Remove unnecessary named.conf configuration options
+- Add comments describing the steps (copied from GL issue)
+- Rename system test
+
+(cherry picked from commit c1321fef165a2ef8c2bff971901c58941e8e694c)
+(cherry picked from commit 833dd3b230b92596074e8da15b12298f46c939f2)
+---
+ bin/tests/system/chain/ans3/ans.py            |  10 +-
+ bin/tests/system/chain/ans4/ans.py            |   8 +-
+ bin/tests/system/cookie/ans9/ans.py           |   7 +-
+ bin/tests/system/digdelv/ans8/ans.py          |   7 +-
+ bin/tests/system/dnssec/ans10/ans.py          |  11 +-
+ bin/tests/system/forward/ans11/ans.py         |  12 +-
+ bin/tests/system/isctest/mark.py              |  12 +
+ .../system/nsec3_impersonation/ans1/ans.py    | 280 ++++++++++++++++++
+ .../nsec3_impersonation/ns2/named.conf.j2     |  33 +++
+ .../tests_nsec3_impersonation.py              | 152 ++++++++++
+ bin/tests/system/qmin/ans2/ans.py             |  11 +-
+ bin/tests/system/qmin/ans3/ans.py             |  11 +-
+ bin/tests/system/qmin/ans4/ans.py             |  11 +-
+ bin/tests/system/resolver/ans10/ans.py        |  12 +-
+ 14 files changed, 529 insertions(+), 48 deletions(-)
+ create mode 100644 bin/tests/system/nsec3_impersonation/ans1/ans.py
+ create mode 100644 bin/tests/system/nsec3_impersonation/ns2/named.conf.j2
+ create mode 100644 bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py
+
+diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py
+index 0a031c1145..7c54c3c51a 100644
+--- a/bin/tests/system/chain/ans3/ans.py
++++ b/bin/tests/system/chain/ans3/ans.py
+@@ -19,10 +19,10 @@ import sys
+ import signal
+ import socket
+ import select
+-from datetime import datetime, timedelta
+-import functools
+ 
+-import dns, dns.message, dns.query
++import dns
++import dns.message
++import dns.query
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -173,9 +173,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py
+index c969117368..de536b25bd 100755
+--- a/bin/tests/system/chain/ans4/ans.py
++++ b/bin/tests/system/chain/ans4/ans.py
+@@ -22,7 +22,9 @@ import select
+ from datetime import datetime, timedelta
+ import functools
+ 
+-import dns, dns.message, dns.query
++import dns
++import dns.message
++import dns.query
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -371,9 +373,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/cookie/ans9/ans.py b/bin/tests/system/cookie/ans9/ans.py
+index 3b0f82cc1d..2710386d74 100644
+--- a/bin/tests/system/cookie/ans9/ans.py
++++ b/bin/tests/system/cookie/ans9/ans.py
+@@ -15,9 +15,6 @@ import sys
+ import signal
+ import socket
+ import select
+-from datetime import datetime, timedelta
+-import time
+-import functools
+ 
+ import dns
+ import dns.edns
+@@ -257,9 +254,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/digdelv/ans8/ans.py b/bin/tests/system/digdelv/ans8/ans.py
+index 3e18edc1cc..1896a2bafc 100644
+--- a/bin/tests/system/digdelv/ans8/ans.py
++++ b/bin/tests/system/digdelv/ans8/ans.py
+@@ -17,7 +17,8 @@ import socket
+ import select
+ import struct
+ 
+-import dns, dns.message
++import dns
++import dns.message
+ from dns.rcode import *
+ 
+ modes = [
+@@ -109,9 +110,9 @@ hung_conns = []
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/dnssec/ans10/ans.py b/bin/tests/system/dnssec/ans10/ans.py
+index 84bf0a2642..46af72a147 100644
+--- a/bin/tests/system/dnssec/ans10/ans.py
++++ b/bin/tests/system/dnssec/ans10/ans.py
+@@ -16,10 +16,11 @@ import signal
+ import socket
+ import select
+ from datetime import datetime, timedelta
+-import time
+-import functools
+ 
+-import dns, dns.message, dns.query, dns.flags
++import dns
++import dns.message
++import dns.query
++import dns.flags
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -140,9 +141,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/forward/ans11/ans.py b/bin/tests/system/forward/ans11/ans.py
+index 00b5895f76..d5b8a5b037 100644
+--- a/bin/tests/system/forward/ans11/ans.py
++++ b/bin/tests/system/forward/ans11/ans.py
+@@ -16,11 +16,11 @@ import signal
+ import socket
+ import select
+ import struct
+-from datetime import datetime, timedelta
+-import time
+-import functools
+ 
+-import dns, dns.message, dns.query, dns.flags
++import dns
++import dns.message
++import dns.query
++import dns.flags
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -192,9 +192,9 @@ hung_conns = []
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/isctest/mark.py b/bin/tests/system/isctest/mark.py
+index 53860a806c..098af5acfc 100644
+--- a/bin/tests/system/isctest/mark.py
++++ b/bin/tests/system/isctest/mark.py
+@@ -88,3 +88,15 @@ softhsm2_environment = pytest.mark.skipif(
+     ),
+     reason="SOFTHSM2_CONF and SOFTHSM2_MODULE environmental variables must be set and pkcs11-tool and softhsm2-util tools present",
+ )
++
++ecdsa_deterministic = False
++try:
++    from cryptography.hazmat.backends import default_backend
++
++    ecdsa_deterministic = default_backend().ecdsa_deterministic_supported()
++except Exception:  # pylint: disable=broad-except
++    pass
++
++with_ecdsa_deterministic = pytest.mark.skipif(
++    not ecdsa_deterministic, reason="ECDSA deterministic signing is not supported"
++)
+diff --git a/bin/tests/system/nsec3_impersonation/ans1/ans.py b/bin/tests/system/nsec3_impersonation/ans1/ans.py
+new file mode 100644
+index 0000000000..177e79c195
+--- /dev/null
++++ b/bin/tests/system/nsec3_impersonation/ans1/ans.py
+@@ -0,0 +1,280 @@
++#!/usr/bin/python3
++
++# 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
++from pathlib import Path
++
++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
++TLD = "tld.test."
++APEX_HASH = "1B40241KFORIOG780N4IKSCRLVETPCTQ"
++ATTACKER = f"{APEX_HASH.lower()}.{TLD}"
++VICTIM = f"victim.{TLD}"
++AUTH_IP = "10.53.0.1"
++
++
++@dataclass(frozen=True)
++class Key:
++    zone: dns.name.Name
++    private_key: object
++    dnskey: dns.rdata.Rdata
++    ds: dns.rdata.Rdata
++
++
++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 rrsig_rrset(covered: dns.rrset.RRset, signer: Key) -> dns.rrset.RRset:
++    rrsig = dns.dnssec.sign(
++        covered,
++        signer.private_key,
++        signer.zone,
++        signer.dnskey,
++        lifetime=86400,
++        verify=True,
++    )
++    return dns.rrset.from_rdata(covered.name, covered.ttl, rrsig)
++
++
++def add_signed(
++    section: list[dns.rrset.RRset], covered: dns.rrset.RRset, signer: Key
++) -> None:
++    section.append(covered)
++    section.append(rrsig_rrset(covered, signer))
++
++
++def dnskey_rrset(zone: str, zone_key: Key) -> dns.rrset.RRset:
++    return rrset_from_rdata(zone, zone_key.dnskey)
++
++
++def ds_rrset(zone: str, zone_key: Key) -> dns.rrset.RRset:
++    return rrset_from_rdata(zone, zone_key.ds)
++
++
++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 ns_rrset(zone: str, ns_target: str) -> dns.rrset.RRset:
++    return rrset(zone, dns.rdatatype.NS, ns_target)
++
++
++def glue_rrset(ns_target: str, address: str) -> dns.rrset.RRset:
++    return rrset(ns_target, dns.rdatatype.A, address)
++
++
++def answer_dnskey(response: dns.message.Message, zone: str, zone_key: Key) -> None:
++    add_signed(response.answer, dnskey_rrset(zone, zone_key), zone_key)
++
++
++def answer_soa(response: dns.message.Message, zone: str, zone_key: Key) -> None:
++    add_signed(response.answer, soa_rrset(zone), zone_key)
++
++
++def answer_ns(
++    response: dns.message.Message, zone: str, ns_target: str, zone_key: Key
++) -> None:
++    add_signed(response.answer, ns_rrset(zone, ns_target), zone_key)
++
++
++class SignedResponseHandler(ResponseHandler):
++    def __init__(self, keys: dict[str, Key]) -> None:
++        self.keys = keys
++
++    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)
++        self.respond(qctx)
++        yield DnsResponseSend(qctx.response, authoritative=True)
++
++    def respond(self, qctx: QueryContext) -> None:
++        raise NotImplementedError
++
++
++def child_nsec3_rrset() -> dns.rrset.RRset:
++    rdata = dns.rdata.from_text(
++        dns.rdataclass.IN,
++        dns.rdatatype.NSEC3,
++        f"1 0 0 - {APEX_HASH} NS SOA RRSIG DNSKEY NSEC3PARAM",
++    )
++    return dns.rrset.from_rdata(name(f"{APEX_HASH}.{TLD}"), TTL, rdata)
++
++
++def forged_nxdomain(response: dns.message.Message, keys: dict[str, Key]) -> None:
++    response.set_rcode(dns.rcode.NXDOMAIN)
++
++    add_signed(response.authority, soa_rrset(TLD), keys[TLD])
++
++    # The owner name derives zone "tld.test.", but the RRSIG signer is the
++    # malicious child zone "1b40241kforiog780n4ikscrlvetpctq.tld.test.".
++    add_signed(response.authority, child_nsec3_rrset(), keys[ATTACKER])
++
++
++class VictimForgedNxdomainHandler(SignedResponseHandler):
++    """
++    This serves the forged response for the victim's domain.
++    """
++
++    def match(self, qctx: QueryContext) -> bool:
++        return qctx.qname == name(VICTIM) and qctx.qtype == dns.rdatatype.A
++
++    def respond(self, qctx: QueryContext) -> None:
++        forged_nxdomain(qctx.response, self.keys)
++
++
++class ChildDsHandler(SignedResponseHandler):
++    """
++    This will spoof the response for the malicious zone when qtype is DS.
++    It is actually a validly signed DS response.
++    """
++
++    def match(self, qctx: QueryContext) -> bool:
++        return qctx.qname == name(ATTACKER) and qctx.qtype == dns.rdatatype.DS
++
++    def respond(self, qctx: QueryContext) -> None:
++        response = qctx.response
++        zone = ATTACKER
++        child_key = self.keys[ATTACKER]
++        parent_key = self.keys[TLD]
++
++        add_signed(response.answer, ds_rrset(zone, child_key), parent_key)
++
++
++class AttackerZoneHandler(SignedResponseHandler):
++    """
++    Acts as the malicious authoritative name server. The zone being served
++    is the hashed label of the parent zone (tld.test). This will respond
++    for all queries qtype SOA, DNSKEY, NS at the apex. Any names below
++    the apex are answered with an NXDOMAIN with no NSEC or NSEC3 present.
++    """
++
++    def match(self, qctx: QueryContext) -> bool:
++        return qctx.qname.is_subdomain(name(ATTACKER))
++
++    def respond(self, qctx: QueryContext) -> None:
++        if qctx.qname == name(ATTACKER):
++            if qctx.qtype == dns.rdatatype.DNSKEY:
++                answer_dnskey(qctx.response, ATTACKER, self.keys[ATTACKER])
++            elif qctx.qtype == dns.rdatatype.SOA:
++                answer_soa(qctx.response, ATTACKER, self.keys[ATTACKER])
++            else:
++                answer_ns(
++                    qctx.response, ATTACKER, f"ns.{ATTACKER}", self.keys[ATTACKER]
++                )
++                qctx.response.additional.append(glue_rrset(f"ns.{ATTACKER}", AUTH_IP))
++            return
++
++        qctx.response.set_rcode(dns.rcode.NXDOMAIN)
++        add_signed(qctx.response.authority, soa_rrset(ATTACKER), self.keys[ATTACKER])
++
++
++class TldZoneHandler(SignedResponseHandler):
++    """
++    Acts as the TLD who is being used in the attack, but is not a standard
++    name server. It only responds with validly signed records for DNSKEY, SOA
++    and NS on the apex. Any names below the apex are answered with an NXDOMAIN
++    with no NSEC or NSEC3 present.
++
++    If we turn this into a regular name server than the attack won't work.
++    The attack assumes that the adversary can inject these responses on-path.
++    """
++
++    def match(self, qctx: QueryContext) -> bool:
++        return qctx.qname.is_subdomain(name(TLD))
++
++    def respond(self, qctx: QueryContext) -> None:
++        if qctx.qname == name(TLD):
++            if qctx.qtype == dns.rdatatype.DNSKEY:
++                answer_dnskey(qctx.response, TLD, self.keys[TLD])
++            elif qctx.qtype == dns.rdatatype.SOA:
++                answer_soa(qctx.response, TLD, self.keys[TLD])
++            else:
++                answer_ns(qctx.response, TLD, "ns.tld.test.", self.keys[TLD])
++                qctx.response.additional.append(glue_rrset("ns.tld.test.", AUTH_IP))
++            return
++
++        qctx.response.set_rcode(dns.rcode.NXDOMAIN)
++        add_signed(qctx.response.authority, soa_rrset(TLD), self.keys[TLD])
++
++
++def main() -> None:
++    keys = load_keys()
++    server = AsyncDnsServer(default_aa=True)
++    server.install_response_handlers(
++        VictimForgedNxdomainHandler(keys),
++        ChildDsHandler(keys),
++        AttackerZoneHandler(keys),
++        TldZoneHandler(keys),
++    )
++    server.run()
++
++
++if __name__ == "__main__":
++    main()
+diff --git a/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2 b/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2
+new file mode 100644
+index 0000000000..2c9b0bba9e
+--- /dev/null
++++ b/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2
+@@ -0,0 +1,33 @@
++// 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;
++};
++
++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 "tld.test" {
++	type static-stub;
++	server-addresses { 10.53.0.1; };
++};
++
++trust-anchors {
++	tld.test. static-key 257 3 13 "@TLD_DNSKEY@";
++};
+diff --git a/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py b/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py
+new file mode 100644
+index 0000000000..bd9bd275b6
+--- /dev/null
++++ b/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py
+@@ -0,0 +1,152 @@
++#!/usr/bin/python3
++
++# 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 pathlib import Path
++
++import json
++
++from cryptography.hazmat.primitives import serialization
++from cryptography.hazmat.primitives.asymmetric import ec
++
++import dns.dnssec
++import dns.flags
++import dns.name
++import dns.rdataclass
++import dns.rdatatype
++import pytest
++
++import isctest
++import isctest.mark
++
++APEX_HASH = "1B40241KFORIOG780N4IKSCRLVETPCTQ"
++ATTACKER = f"{APEX_HASH.lower()}.tld.test."
++VICTIM = "victim.tld.test."
++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():
++    zones = ["tld.test.", ATTACKER]
++    keys = {zone: _make_key(zone) for zone in zones}
++
++    Path("ans1/keys.json").write_text(json.dumps(keys, indent=2), encoding="ascii")
++
++    tld_dnskey = "".join(keys["tld.test."]["dnskey"].split()[3:])
++    return {"TLD_DNSKEY": tld_dnskey}
++
++
++def check_dnskey_response(zone):
++    query = isctest.query.create(zone, "DNSKEY")
++    response = isctest.query.tcp(query, AUTH)
++
++    isctest.check.noerror(response)
++    assert response.flags & dns.flags.AA
++    assert (
++        response.get_rrset(
++            response.answer,
++            dns.name.from_text(zone),
++            dns.rdataclass.IN,
++            dns.rdatatype.DNSKEY,
++        )
++        is not None
++    ), response
++
++
++def check_ds_response(zone):
++    query = isctest.query.create(zone, "DS")
++    response = isctest.query.tcp(query, AUTH)
++
++    isctest.check.noerror(response)
++    assert response.flags & dns.flags.AA
++    assert (
++        response.get_rrset(
++            response.answer,
++            dns.name.from_text(zone),
++            dns.rdataclass.IN,
++            dns.rdatatype.DS,
++        )
++        is not None
++    ), response
++
++
++def test_attack_responses():
++    check_dnskey_response("tld.test.")
++    check_dnskey_response(ATTACKER)
++    check_ds_response(ATTACKER)
++
++    query = isctest.query.create(VICTIM, "A")
++    response = isctest.query.tcp(query, AUTH)
++
++    isctest.check.nxdomain(response)
++    assert response.flags & dns.flags.AA
++
++    nsec3_owner = dns.name.from_text(f"{APEX_HASH}.tld.test.")
++    nsec3 = response.get_rrset(
++        response.authority,
++        nsec3_owner,
++        dns.rdataclass.IN,
++        dns.rdatatype.NSEC3,
++    )
++    rrsig = response.get_rrset(
++        response.authority,
++        nsec3_owner,
++        dns.rdataclass.IN,
++        dns.rdatatype.RRSIG,
++        covers=dns.rdatatype.NSEC3,
++    )
++
++    assert nsec3 is not None, response
++    assert rrsig is not None, response
++    assert rrsig[0].signer == dns.name.from_text(ATTACKER)
++
++
++def test_nsec3_impersonation():
++    """
++    Reproducer for #5874:
++    F-006 DNSSEC Validation Bypass NSEC3 Apex Hash Label Parent Impersonation
++    """
++    query = isctest.query.create(VICTIM, "A")
++    response = isctest.query.tcp(query, RESOLVER)
++
++    isctest.check.noadflag(response)
++    isctest.check.servfail(response)
+diff --git a/bin/tests/system/qmin/ans2/ans.py b/bin/tests/system/qmin/ans2/ans.py
+index d372c2003b..9343fbc8ef 100755
+--- a/bin/tests/system/qmin/ans2/ans.py
++++ b/bin/tests/system/qmin/ans2/ans.py
+@@ -15,11 +15,12 @@ import sys
+ import signal
+ import socket
+ import select
+-from datetime import datetime, timedelta
+ import time
+-import functools
+ 
+-import dns, dns.message, dns.query, dns.flags
++import dns
++import dns.message
++import dns.query
++import dns.flags
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -432,9 +433,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/qmin/ans3/ans.py b/bin/tests/system/qmin/ans3/ans.py
+index b5ae73c3fa..4e7250790f 100755
+--- a/bin/tests/system/qmin/ans3/ans.py
++++ b/bin/tests/system/qmin/ans3/ans.py
+@@ -15,11 +15,12 @@ import sys
+ import signal
+ import socket
+ import select
+-from datetime import datetime, timedelta
+ import time
+-import functools
+ 
+-import dns, dns.message, dns.query, dns.flags
++import dns
++import dns.message
++import dns.query
++import dns.flags
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -261,9 +262,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/qmin/ans4/ans.py b/bin/tests/system/qmin/ans4/ans.py
+index 517217aec1..2d5556daff 100755
+--- a/bin/tests/system/qmin/ans4/ans.py
++++ b/bin/tests/system/qmin/ans4/ans.py
+@@ -15,11 +15,12 @@ import sys
+ import signal
+ import socket
+ import select
+-from datetime import datetime, timedelta
+ import time
+-import functools
+ 
+-import dns, dns.message, dns.query, dns.flags
++import dns
++import dns.message
++import dns.query
++import dns.flags
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -320,9 +321,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+diff --git a/bin/tests/system/resolver/ans10/ans.py b/bin/tests/system/resolver/ans10/ans.py
+index 6e95dbbfc6..d637c63e5a 100644
+--- a/bin/tests/system/resolver/ans10/ans.py
++++ b/bin/tests/system/resolver/ans10/ans.py
+@@ -15,11 +15,11 @@ import sys
+ import signal
+ import socket
+ import select
+-from datetime import datetime, timedelta
+-import time
+-import functools
+ 
+-import dns, dns.message, dns.query, dns.flags
++import dns
++import dns.message
++import dns.query
++import dns.flags
+ from dns.rdatatype import *
+ from dns.rdataclass import *
+ from dns.rcode import *
+@@ -128,9 +128,9 @@ else:
+ while running:
+     try:
+         inputready, outputready, exceptready = select.select(input, [], [])
+-    except select.error as e:
++    except select.error:
+         break
+-    except socket.error as e:
++    except socket.error:
+         break
+     except KeyboardInterrupt:
+         break
+-- 
+2.55.0
+

diff --git a/bind-9.18-CVE-2026-10723.patch b/bind-9.18-CVE-2026-10723.patch
new file mode 100644
index 0000000..a519b38
--- /dev/null
+++ b/bind-9.18-CVE-2026-10723.patch
@@ -0,0 +1,67 @@
+From 608026780a43abe5b23a9af3af21808369032158 Mon Sep 17 00:00:00 2001
+From: Evan Hunt <each@isc.org>
+Date: Thu, 21 May 2026 14:41:55 -0700
+Subject: [PATCH] Check NSEC3 signer matches the owning zone
+
+When validating NSEC3 records, reject any signature whose signer field
+does not match the zone owning the NSEC3.
+
+This ensures that a child zone cannot impersonate its parent and forge
+NXDOMAIN responses for sibling domains.
+
+Fixes: isc-projects/bind9#5874
+(cherry picked from commit 6e5066bb1f0f12d090e8707adb7d6ccf74f8012b)
+(cherry picked from commit c9cb6a5e24e43489cf3fd4d4cc2193b6a74499cb)
+---
+ lib/dns/dnssec.c | 19 +++++++++++++++++--
+ lib/isc/result.c |  2 +-
+ 2 files changed, 18 insertions(+), 3 deletions(-)
+
+diff --git a/lib/dns/dnssec.c b/lib/dns/dnssec.c
+index b12529b5d5..9b9b1f2bb2 100644
+--- a/lib/dns/dnssec.c
++++ b/lib/dns/dnssec.c
+@@ -424,10 +424,25 @@ dns_dnssec_verify(const dns_name_t *name, dns_rdataset_t *set, dst_key_t *key,
+ 	}
+ 
+ 	/*
+-	 * NS, SOA and DNSKEY records are signed by their owner.
+-	 * DS records are signed by the parent.
++	 * NS, SOA and DNSKEY records are signed by their owners.
++	 * NSEC3 records are signed by the apex, exactly one level up
++	 * from their owner names.
++	 * DS records are signed by the parent zone.
+ 	 */
+ 	switch (set->type) {
++	case dns_rdatatype_nsec3: {
++		dns_name_t apex = DNS_NAME_INITEMPTY;
++		labels = dns_name_countlabels(name);
++		if (labels <= 1) {
++			inc_stat(dns_dnssecstats_fail);
++			return DNS_R_INVALIDNSEC3;
++		}
++		dns_name_split(name, labels - 1, NULL, &apex);
++		if (!dns_name_equal(&apex, &sig.signer)) {
++			inc_stat(dns_dnssecstats_fail);
++			return DNS_R_SIGINVALID;
++		}
++	} break;
+ 	case dns_rdatatype_ns:
+ 	case dns_rdatatype_soa:
+ 	case dns_rdatatype_dnskey:
+diff --git a/lib/isc/result.c b/lib/isc/result.c
+index 83e8cfeed7..b76c3d1f7b 100644
+--- a/lib/isc/result.c
++++ b/lib/isc/result.c
+@@ -198,7 +198,7 @@ static const char *description[ISC_R_NRESULTS] = {
+ 	[DNS_R_COVERINGNSEC] = "covering NSEC record returned",
+ 	[DNS_R_MXISADDRESS] = "MX is an address",
+ 	[DNS_R_DUPLICATE] = "duplicate query",
+-	[DNS_R_INVALIDNSEC3] = "invalid NSEC3 owner name (wildcard)",
++	[DNS_R_INVALIDNSEC3] = "invalid NSEC3 owner name",
+ 	[DNS_R_NOTPRIMARY] = "not primary",
+ 	[DNS_R_BROKENCHAIN] = "broken trust chain",
+ 	[DNS_R_EXPIRED] = "expired",
+-- 
+2.55.0
+

diff --git a/bind.spec b/bind.spec
index 0c9e439..6ac624f 100644
--- a/bind.spec
+++ b/bind.spec
@@ -160,6 +160,9 @@ Patch43: bind-9.18-CVE-2026-11622.patch
 # https://gitlab.isc.org/isc-projects/bind9/commit/58812f64bf14b3930f5bb90a2d0e1f59bc8260b4
 Patch44: bind-9.18-CVE-2026-11721.patch
 Patch45: bind-9.18-CVE-2026-11721-test.patch
+# https://gitlab.isc.org/isc-projects/bind9/commit/348fd47f7636f610a39ba98427fcacab8e62389b
+Patch46: bind-9.18-CVE-2026-10723.patch
+Patch47: bind-9.18-CVE-2026-10723-test.patch
 
 %{?systemd_ordering}
 # https://fedoraproject.org/wiki/Changes/RPMSuportForSystemdSysusers
@@ -966,6 +969,7 @@ fi;
 - Record ordering based unexpected exit with CNAME or DNAME (CVE-2026-12617)
 - Potential memory usage beyond configured limits (CVE-2026-11622)
 - Cache poisoning via label count discrepancy, RRSIG, wildcards (CVE-2026-11721)
+- Incorrect acceptance of NSEC3 records (CVE-2026-10723)
 
 * Wed Jun 17 2026 Petr Menšík <pemensik@redhat.com> - 32:9.18.50-1
 - Update to 9.18.50 (rhbz#2489833)

^ permalink raw reply related	[flat|nested] only message in thread

only message in thread, other threads:[~2026-08-26  9:29 UTC | newest]

Thread overview: (only message) (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-08-26  9:29 [rpms/bind] f43: Incorrect acceptance of NSEC3 records (CVE-2026-10723) 

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox