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
This commit is contained in:
parent
e1ee923bdc
commit
58ff7ce675
3 changed files with 925 additions and 0 deletions
854
bind-9.18-CVE-2026-10723-test.patch
Normal file
854
bind-9.18-CVE-2026-10723-test.patch
Normal file
|
|
@ -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
|
||||
|
||||
67
bind-9.18-CVE-2026-10723.patch
Normal file
67
bind-9.18-CVE-2026-10723.patch
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue