Commit d55c8fd8ab for bind
commit d55c8fd8abdfd30e925b98a5dd691a1104594318
Author: Michał Kępień <michal@isc.org>
Date: Fri Oct 16 10:53:38 2026 +0200
Simplify nsec3_impersonation test
Replace boilerplate code signing the zones used by the ans1 custom
server with canonical bootstrap() code employing isctest.zone.Zone.
Replace boilerplate code crafting DNSSEC responses in ans1/ans.py with
minimal handler logic implementing the attack attempted by the test.
Zone data is limited to that present in bin/tests/system/_common/zones/
as no extra records are necessary to reproduce this particular attack.
Replace a fixed NSEC3 hash with a dnspython method call that explains
where the hash value comes from. Replace fixed IP addresses with
constants exposed by the isctest.template module. Employ helpers from
the isctest.check module where possible.
diff --git a/bin/tests/system/nsec3_impersonation/ans1/ans.py b/bin/tests/system/nsec3_impersonation/ans1/ans.py
index 80e721bed9..32aca451bb 100644
--- a/bin/tests/system/nsec3_impersonation/ans1/ans.py
+++ b/bin/tests/system/nsec3_impersonation/ans1/ans.py
@@ -10,260 +10,67 @@
# 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, QueryContext, ResponseHandler
from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.matchers import Domain, Qname, Qtype
-
-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)
+from isctest.asyncserver.matchers import Qname, Qtype
-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 Nsec3ParentImpersonationHandler(ResponseHandler):
+ """
+ This handler imitates successfully spoofing a response to a
+ "victim.tld.test/A" query sent to a server authoritative for the
+ "tld.test." domain. The spoofed response contains a crafted NSEC3
+ record whose owner name matches the apex of the attacked zone
+ ("tld.test."), but it is signed using a DNSKEY belonging to a
+ (properly delegated) child zone whose origin is also the NSEC3 hash
+ of the parent zone's apex. A vulnerable resolver validates such a
+ response as secure even though the non-existence proof is signed by
+ the child zone's signing key and not the parent zone's signing key.
+ """
-class SignedResponseHandler(ResponseHandler):
- def __init__(self, keys: dict[str, Key]) -> None:
- self.keys = keys
+ matcher = Qname("victim.tld.test.") & Qtype(dns.rdatatype.A)
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.
- """
-
- matcher = Qname(VICTIM) & 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.
- """
-
- matcher = Qname(ATTACKER) & 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.
- """
-
- matcher = Domain(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])
+ assert qctx.soa
+ qctx.response.authority.append(qctx.soa)
+ if soa_rrsig := qctx.get_rrsig(qctx.soa):
+ qctx.response.authority.append(soa_rrsig)
+
+ assert qctx.zone
+ assert qctx.zone.origin
+ parent_apex_hash = dns.dnssec.nsec3_hash(qctx.zone.origin, None, 0, 1)
+ child_origin = dns.name.from_text(parent_apex_hash, origin=qctx.zone.origin)
+ child_signing_key = qctx.keys[child_origin][0]
+
+ nsec3_rrset = dns.rrset.from_text(
+ child_origin,
+ 300,
+ qctx.qclass,
+ dns.rdatatype.NSEC3,
+ f"1 0 0 - {parent_apex_hash} NS SOA RRSIG DNSKEY NSEC3PARAM",
+ )
+ nsec3_rrsig = qctx.sign(nsec3_rrset, key=child_signing_key)
+ qctx.response.authority.append(nsec3_rrset)
+ qctx.response.authority.append(nsec3_rrsig)
-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.
- """
-
- matcher = Domain(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])
+ yield DnsResponseSend(qctx.response, authoritative=True)
def main() -> None:
- keys = load_keys()
- server = AsyncDnsServer(default_aa=True)
- server.install_response_handlers(
- VictimForgedNxdomainHandler(keys),
- ChildDsHandler(keys),
- AttackerZoneHandler(keys),
- TldZoneHandler(keys),
- )
+ server = AsyncDnsServer()
+ server.install_response_handlers(Nsec3ParentImpersonationHandler())
server.run()
diff --git a/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2 b/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2
index 826e91420d..fe8b771e73 100644
--- a/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2
+++ b/bin/tests/system/nsec3_impersonation/ns2/named.conf.j2
@@ -14,6 +14,4 @@ zone "tld.test" {
server-addresses { 10.53.0.1; };
};
-trust-anchors {
- tld.test. static-key 257 3 13 "@TLD_DNSKEY@";
-};
+{% include "_common/trusted.conf.j2" %}
diff --git a/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py b/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py
index eb29aed3b1..6bfc1e5691 100644
--- a/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py
+++ b/bin/tests/system/nsec3_impersonation/tests_nsec3_impersonation.py
@@ -9,15 +9,7 @@
# 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
@@ -25,51 +17,39 @@ import pytest
import isctest
import isctest.mark
+import isctest.template
+import isctest.zone
-APEX_HASH = "1B40241KFORIOG780N4IKSCRLVETPCTQ"
+APEX_HASH = dns.dnssec.nsec3_hash("tld.test.", None, 0, 1)
ATTACKER = f"{APEX_HASH.lower()}.tld.test."
VICTIM = "victim.tld.test."
-AUTH = "10.53.0.1"
-RESOLVER = "10.53.0.2"
+AUTH = isctest.template.ANS1.ip
+RESOLVER = isctest.template.NS2.ip
pytestmark = [
isctest.mark.with_ecdsa_deterministic,
pytest.mark.extra_artifacts(
[
- "ans*/keys.json",
+ "ans*/dsset-*",
]
),
]
-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():
+ parent_origin = "tld.test"
+ parent_nsec3_hash = dns.dnssec.nsec3_hash(parent_origin, None, 0, 1)
+ child_origin = f"{parent_nsec3_hash}.{parent_origin}"
-def bootstrap():
- zones = ["tld.test.", ATTACKER]
- keys = {zone: _make_key(zone) for zone in zones}
+ child = isctest.zone.Zone(child_origin, isctest.template.ANS1, signed=True)
+ child.configure(csk=True)
- Path("ans1/keys.json").write_text(json.dumps(keys, indent=2), encoding="ascii")
+ parent = isctest.zone.Zone(parent_origin, isctest.template.ANS1, signed=True)
+ parent.delegations = [child]
+ parent.configure(csk=True, sign_params="-3 -")
- tld_dnskey = "".join(keys["tld.test."]["dnskey"].split()[3:])
- return {"TLD_DNSKEY": tld_dnskey}
+ return {"trust_anchors": parent.trust_anchors()}
def check_dnskey_response(zone):
@@ -77,7 +57,7 @@ def check_dnskey_response(zone):
response = isctest.query.tcp(query, AUTH)
isctest.check.noerror(response)
- assert response.flags & dns.flags.AA
+ isctest.check.aaflag(response)
assert (
response.get_rrset(
response.answer,
@@ -94,7 +74,7 @@ def check_ds_response(zone):
response = isctest.query.tcp(query, AUTH)
isctest.check.noerror(response)
- assert response.flags & dns.flags.AA
+ isctest.check.aaflag(response)
assert (
response.get_rrset(
response.answer,
@@ -115,7 +95,7 @@ def test_attack_responses():
response = isctest.query.tcp(query, AUTH)
isctest.check.nxdomain(response)
- assert response.flags & dns.flags.AA
+ isctest.check.aaflag(response)
nsec3_owner = dns.name.from_text(f"{APEX_HASH}.tld.test.")
nsec3 = response.get_rrset(