From 440e510f75f386905512ff37f3274692b1f2e194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99pie=C5=84?= Date: Mon, 22 Dec 2025 11:58:39 +0100 Subject: [PATCH 1/6] Add a reusable, bare-bones AsyncDnsServer Add bin/tests/system/ans.py, a bare-bones DNS server that can be used in system tests instead of full-blown named instances when a server is only required to return zone-based data. Where applicable, this reduces load on the test host and the amount of generated logs. --- bin/tests/system/ans.py | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 bin/tests/system/ans.py diff --git a/bin/tests/system/ans.py b/bin/tests/system/ans.py new file mode 100644 index 0000000000..9dbdee11c1 --- /dev/null +++ b/bin/tests/system/ans.py @@ -0,0 +1,48 @@ +""" +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. +""" + +""" +This is a bare-bones DNS server that only serves data from zone files. It is +meant to be used as a replacement for full-blown named instances in system +tests when a given server is only required to return zone-based data. + +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + BEWARE! THIS SERVER DOES NOT NECESSARILY RETURN PROTOCOL-COMPLIANT ANSWERS! +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +See AsyncDnsServer._abort_if_*() methods in isctests/asyncserver.py for more +details. Use a regular named instance for anything non-trivial. + +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + DO NOT ADD CUSTOM LOGIC TO THIS FILE. IT IS ONLY MEANT TO BE SYMLINKED INTO +ansX/ SUBDIRECTORIES IN SYSTEM TESTS TO REDUCE THE AMOUNT OF BOILERPLATE CODE. +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +If you need to customize server behavior, implement it in a dedicated ans.py +server in the system test at hand. If an extension you are working on can be +useful in other system tests, please consider opening a merge request extending +isctest/asyncserver.py. +""" + +from isctest.asyncserver import ( + AsyncDnsServer, +) + + +def main() -> None: + server = AsyncDnsServer() + server.run() + + +if __name__ == "__main__": + main() From 607974b1bcb08334f2f5ab0efcc069ba69eb3130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 11 Jul 2025 18:37:57 +0200 Subject: [PATCH 2/6] Add a common base for CVE-2025-40778 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the zone files, configuration, and code that will be reused by all tests related to CVE-2025-40778. Co-authored-by: Michał Kępień --- .gitlab-ci.yml | 2 +- bin/tests/system/bailiwick/ans1/ans.py | 37 +++++++ bin/tests/system/bailiwick/ans1/root.db | 24 +++++ bin/tests/system/bailiwick/ans2/ans.py | 37 +++++++ bin/tests/system/bailiwick/ans2/victim.db | 17 +++ bin/tests/system/bailiwick/ans3/ans.py | 1 + bin/tests/system/bailiwick/ans3/attacker.db | 17 +++ bin/tests/system/bailiwick/ans3/victim.db | 16 +++ bin/tests/system/bailiwick/bailiwick_ans.py | 102 ++++++++++++++++++ bin/tests/system/bailiwick/ns4/named.conf.j2 | 36 +++++++ bin/tests/system/bailiwick/tests_bailiwick.py | 79 ++++++++++++++ 11 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 bin/tests/system/bailiwick/ans1/ans.py create mode 100644 bin/tests/system/bailiwick/ans1/root.db create mode 100644 bin/tests/system/bailiwick/ans2/ans.py create mode 100644 bin/tests/system/bailiwick/ans2/victim.db create mode 120000 bin/tests/system/bailiwick/ans3/ans.py create mode 100644 bin/tests/system/bailiwick/ans3/attacker.db create mode 100644 bin/tests/system/bailiwick/ans3/victim.db create mode 100644 bin/tests/system/bailiwick/bailiwick_ans.py create mode 100644 bin/tests/system/bailiwick/ns4/named.conf.j2 create mode 100644 bin/tests/system/bailiwick/tests_bailiwick.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dfb583ce5c..243a98c88f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -679,7 +679,7 @@ vulture: <<: *python_triggering_rules needs: [] script: - - vulture --exclude "*ans.py,conftest.py,re_compile_checker.py,isctest" --ignore-names "after_servers_start,bootstrap,pytestmark" bin/tests/system/ + - vulture --exclude "*ans.py,conftest.py,re_compile_checker.py,isctest" --ignore-names "after_servers_start,bootstrap,pytestmark,autouse_*" bin/tests/system/ ci-variables: <<: *precheck_job diff --git a/bin/tests/system/bailiwick/ans1/ans.py b/bin/tests/system/bailiwick/ans1/ans.py new file mode 100644 index 0000000000..be072a39e1 --- /dev/null +++ b/bin/tests/system/bailiwick/ans1/ans.py @@ -0,0 +1,37 @@ +""" +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 typing import AsyncGenerator + +import dns.rdatatype +import dns.rrset + +from isctest.asyncserver import ( + DnsResponseSend, + QueryContext, + ResponseAction, +) + +from bailiwick_ans import ResponseSpoofer, spoofing_server + + +ATTACKER_IP = "10.53.0.3" +TTL = 3600 + + +def main() -> None: + spoofing_server().run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/bailiwick/ans1/root.db b/bin/tests/system/bailiwick/ans1/root.db new file mode 100644 index 0000000000..64693727b6 --- /dev/null +++ b/bin/tests/system/bailiwick/ans1/root.db @@ -0,0 +1,24 @@ +; 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. + +$ORIGIN . +$TTL 3600 +. SOA . . 0 0 0 0 3600 +. NS a.root-servers.nil. +a.root-servers.nil. A 10.53.0.1 + +; queries should go here ... unless the attack succeeded +victim. NS ns.victim. +ns.victim. A 10.53.0.2 + +; no query should go here +attacker. NS ns.attacker. +ns.attacker. A 10.53.0.3 diff --git a/bin/tests/system/bailiwick/ans2/ans.py b/bin/tests/system/bailiwick/ans2/ans.py new file mode 100644 index 0000000000..be072a39e1 --- /dev/null +++ b/bin/tests/system/bailiwick/ans2/ans.py @@ -0,0 +1,37 @@ +""" +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 typing import AsyncGenerator + +import dns.rdatatype +import dns.rrset + +from isctest.asyncserver import ( + DnsResponseSend, + QueryContext, + ResponseAction, +) + +from bailiwick_ans import ResponseSpoofer, spoofing_server + + +ATTACKER_IP = "10.53.0.3" +TTL = 3600 + + +def main() -> None: + spoofing_server().run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/bailiwick/ans2/victim.db b/bin/tests/system/bailiwick/ans2/victim.db new file mode 100644 index 0000000000..d5b4d258f4 --- /dev/null +++ b/bin/tests/system/bailiwick/ans2/victim.db @@ -0,0 +1,17 @@ +; 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. + +$TTL 3600 +@ SOA ns.victim. . 0 0 0 0 3600 +@ NS ns +ns A 10.53.0.2 +prime TXT "this record is used for priming the cache of the targeted resolver" +canary TXT "correct answer from the domain under attack" diff --git a/bin/tests/system/bailiwick/ans3/ans.py b/bin/tests/system/bailiwick/ans3/ans.py new file mode 120000 index 0000000000..2416f57e65 --- /dev/null +++ b/bin/tests/system/bailiwick/ans3/ans.py @@ -0,0 +1 @@ +../../ans.py \ No newline at end of file diff --git a/bin/tests/system/bailiwick/ans3/attacker.db b/bin/tests/system/bailiwick/ans3/attacker.db new file mode 100644 index 0000000000..e7474bbf30 --- /dev/null +++ b/bin/tests/system/bailiwick/ans3/attacker.db @@ -0,0 +1,17 @@ +; 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. + +$TTL 3600 +@ SOA ns.attacker. . 0 0 0 0 3600 +@ NS ns +ns A 10.53.0.3 +only-if-hijacked TXT "this record only exists in the hijacked version of the zone" +canary TXT "fake answer from attacker" diff --git a/bin/tests/system/bailiwick/ans3/victim.db b/bin/tests/system/bailiwick/ans3/victim.db new file mode 100644 index 0000000000..8f56c8d29d --- /dev/null +++ b/bin/tests/system/bailiwick/ans3/victim.db @@ -0,0 +1,16 @@ +; 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. + +$TTL 3600 +@ SOA ns.attacker. . 0 0 0 0 3600 +@ NS ns.attacker. +only-if-hijacked TXT "this record only exists in the hijacked version of the zone" +canary TXT "fake answer from attacker's auth" diff --git a/bin/tests/system/bailiwick/bailiwick_ans.py b/bin/tests/system/bailiwick/bailiwick_ans.py new file mode 100644 index 0000000000..28353a0a09 --- /dev/null +++ b/bin/tests/system/bailiwick/bailiwick_ans.py @@ -0,0 +1,102 @@ +""" +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 typing import Dict, List, Optional, Type + +import abc + +import dns.name +import dns.rcode +import dns.rdatatype + +from isctest.asyncserver import ( + ControlCommand, + ControllableAsyncDnsServer, + DnsProtocol, + QueryContext, + ResponseHandler, +) + + +class ResponseSpoofer(ResponseHandler, abc.ABC): + + spoofers: Dict[str, Type["ResponseSpoofer"]] = {} + + def __init_subclass__(cls, mode: str) -> None: + assert mode not in cls.spoofers + cls.spoofers[mode] = cls + + @classmethod + def get_spoofer(cls, mode: str) -> Optional["ResponseSpoofer"]: + try: + return cls.spoofers[mode]() + except KeyError: + return None + + @property + @abc.abstractmethod + def qname(self) -> str: + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return ( + qctx.qname == dns.name.from_text(self.qname) + and qctx.qtype == dns.rdatatype.TXT + and qctx.protocol == DnsProtocol.UDP + ) + + +class SetSpoofingModeCommand(ControlCommand): + """ + Select the ResponseSpoofer to use while handling queries from the resolver + under test (ns4). This control command is used at the start of each test + function in tests_bailiwick.py. + """ + + control_subdomain = "set-spoofing-mode" + + def __init__(self) -> None: + self._current_handler: Optional[ResponseSpoofer] = None + + def handle( + self, args: List[str], server: ControllableAsyncDnsServer, qctx: QueryContext + ) -> Optional[str]: + if len(args) != 1: + qctx.response.set_rcode(dns.rcode.SERVFAIL) + return "invalid control command" + + mode = args[0] + + if mode == "none": + if self._current_handler: + server.uninstall_response_handler(self._current_handler) + self._current_handler = None + return "response spoofing disabled" + + spoofer = ResponseSpoofer.get_spoofer(mode) + if not spoofer: + qctx.response.set_rcode(dns.rcode.SERVFAIL) + return f"unknown spoofing mode {mode}" + + if self._current_handler: + server.uninstall_response_handler(self._current_handler) + server.install_response_handler(spoofer) + self._current_handler = spoofer + + return f"response spoofing enabled (mode: {mode})" + + +def spoofing_server() -> ControllableAsyncDnsServer: + server = ControllableAsyncDnsServer(default_rcode=dns.rcode.NOERROR) + server.install_control_command(SetSpoofingModeCommand()) + return server diff --git a/bin/tests/system/bailiwick/ns4/named.conf.j2 b/bin/tests/system/bailiwick/ns4/named.conf.j2 new file mode 100644 index 0000000000..449cad22e6 --- /dev/null +++ b/bin/tests/system/bailiwick/ns4/named.conf.j2 @@ -0,0 +1,36 @@ +/* + * 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. + */ + +options { + query-source address 10.53.0.4; + notify-source 10.53.0.4; + transfer-source 10.53.0.4; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.4; }; + listen-on-v6 { none; }; + recursion yes; + dnssec-validation no; + qname-minimization off; +}; + +controls { + inet 10.53.0.4 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +include "../../_common/rndc.key"; + +zone "." { + type hint; + file "../../_common/root.hint"; +}; diff --git a/bin/tests/system/bailiwick/tests_bailiwick.py b/bin/tests/system/bailiwick/tests_bailiwick.py new file mode 100644 index 0000000000..79ee8a4364 --- /dev/null +++ b/bin/tests/system/bailiwick/tests_bailiwick.py @@ -0,0 +1,79 @@ +# 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 typing import Dict + +import dns.message + +import pytest + +# isctest.asyncserver requires dnspython >= 2.0.0 +pytest.importorskip("dns", minversion="2.0.0") + +import isctest +from isctest.instance import NamedInstance + + +@pytest.fixture(autouse=True) +def autouse_flush_resolver_cache(servers: Dict[str, NamedInstance]) -> None: + servers["ns4"].rndc("flush") + + +def set_spoofing_mode(ans1: str, ans2: str) -> None: + for ip, mode in (("10.53.0.1", ans1), ("10.53.0.2", ans2)): + msg = dns.message.make_query(f"{mode}.set-spoofing-mode._control.", "TXT") + res = isctest.query.tcp(msg, ip) + isctest.check.noerror(res) + + +def prime_cache(ns4: NamedInstance) -> None: + msg = dns.message.make_query("prime.victim.", "TXT") + res = isctest.query.tcp(msg, ns4.ip) + isctest.check.noerror(res) + + assert res.answer[0] == dns.rrset.from_text( + "prime.victim.", + 0, + "IN", + "TXT", + '"this record is used for priming the cache of the targeted resolver"', + ) + + +def send_trigger_query(ns4: NamedInstance, qname: str) -> None: + msg = dns.message.make_query(qname, "TXT") + isctest.query.tcp(msg, ns4.ip) + # The contents of the resolver's response to the trigger query do not + # matter, so they are not checked in any way; what matters is whether the + # spoofed response succeeded in hijacking the "victim." domain, which is + # checked below. + + +def check_domain_hijack(ns4: NamedInstance) -> None: + # Not necessary for triggering bugs, but useful for troubleshooting test + # behavior. + ns4.rndc("dumpdb -cache") + + msg = dns.message.make_query("only-if-hijacked.victim.", "TXT") + res = isctest.query.tcp(msg, ns4.ip) + isctest.check.nxdomain(res) + + msg = dns.message.make_query("canary.victim.", "TXT") + res = isctest.query.tcp(msg, ns4.ip) + isctest.check.noerror(res) + + assert res.answer[0] == dns.rrset.from_text( + "canary.victim.", + 0, + "IN", + "TXT", + '"correct answer from the domain under attack"', + ) From 26eed16d619f9957d0d252ceda06a2b4ea67b6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 11 Jul 2025 18:37:57 +0200 Subject: [PATCH 3/6] Test that positive answer cannot overwrite sibling NS RRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the fixes for CVE-2025-40778, a positive answer was allowed to overwrite sibling NS RRs. The answer had to be a positive AA=1 answer with a fake NS along with it. This combination of conditions avoided the code path with "unrelated " detection logic. If it were some other answer, named from the main branch would detect the attempt and log: DNS format error from 10.53.0.1#16386 resolving trigger/A for : unrelated NS victim in trigger authority section In short, the attacker tries to spoof at least one answer that has the following form: opcode QUERY rcode NOERROR flags QR AA ;QUESTION trigger$RANDOM. IN A ;ANSWER trigger$RANDOM. 3600 IN A 10.53.0.3 ;AUTHORITY victim. 3600 IN NS ns.attacker. ;ADDITIONAL ns.attacker. 3600 IN A 10.53.0.3 This attack was originally reported as "test case 1c". Co-authored-by: Michał Kępień --- bin/tests/system/bailiwick/ans1/ans.py | 31 +++++++++++++++++++ bin/tests/system/bailiwick/tests_bailiwick.py | 8 +++++ 2 files changed, 39 insertions(+) diff --git a/bin/tests/system/bailiwick/ans1/ans.py b/bin/tests/system/bailiwick/ans1/ans.py index be072a39e1..f0b152ac3d 100644 --- a/bin/tests/system/bailiwick/ans1/ans.py +++ b/bin/tests/system/bailiwick/ans1/ans.py @@ -29,6 +29,37 @@ ATTACKER_IP = "10.53.0.3" TTL = 3600 +class SiblingNsSpoofer(ResponseSpoofer, mode="sibling-ns"): + + qname = "trigger." + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[ResponseAction, None]: + response = qctx.prepare_new_response(with_zone_data=False) + + txt_rrset = dns.rrset.from_text( + qctx.qname, + TTL, + qctx.qclass, + dns.rdatatype.TXT, + '"spoofed answer with extra NS"', + ) + response.answer.append(txt_rrset) + + ns_rrset = dns.rrset.from_text( + "victim.", TTL, qctx.qclass, dns.rdatatype.NS, "ns.attacker." + ) + response.authority.append(ns_rrset) + + a_rrset = dns.rrset.from_text( + "ns.attacker.", TTL, qctx.qclass, dns.rdatatype.A, ATTACKER_IP + ) + response.additional.append(a_rrset) + + yield DnsResponseSend(response, authoritative=True) + + def main() -> None: spoofing_server().run() diff --git a/bin/tests/system/bailiwick/tests_bailiwick.py b/bin/tests/system/bailiwick/tests_bailiwick.py index 79ee8a4364..b068180265 100644 --- a/bin/tests/system/bailiwick/tests_bailiwick.py +++ b/bin/tests/system/bailiwick/tests_bailiwick.py @@ -77,3 +77,11 @@ def check_domain_hijack(ns4: NamedInstance) -> None: "TXT", '"correct answer from the domain under attack"', ) + + +def test_bailiwick_sibling_ns_referral(servers: Dict[str, NamedInstance]) -> None: + set_spoofing_mode(ans1="sibling-ns", ans2="none") + + ns4 = servers["ns4"] + send_trigger_query(ns4, "trigger.") + check_domain_hijack(ns4) From 658d2e9f8ec8408f227af034bd87cf4ff3189a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 23 Jul 2025 17:25:18 +0200 Subject: [PATCH 4/6] Test that unsolicited NS in positive answer cannot overwrite current NS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the fixes for CVE-2025-40778, an unsolicited in-bailiwick NS record was accepted from a (spoofed) answer, enabling a single spoofed A query/response to redirect traffic for a whole delegation. In short, the attacker tries to spoof at least one answer that has the following form: rcode NOERROR flags QR AA ;QUESTION trigger$RANDOM.victim. IN TXT ;ANSWER trigger$RANDOM.victim. 3600 IN TXT "spoofed answer with extra NS" ;AUTHORITY victim. 3600 IN NS ns.attacker. ;ADDITIONAL This attack was originally reported as "test case 1". Co-authored-by: Michał Kępień --- bin/tests/system/bailiwick/ans2/ans.py | 26 +++++++++++++++++++ bin/tests/system/bailiwick/tests_bailiwick.py | 9 +++++++ 2 files changed, 35 insertions(+) diff --git a/bin/tests/system/bailiwick/ans2/ans.py b/bin/tests/system/bailiwick/ans2/ans.py index be072a39e1..d0ddccbe62 100644 --- a/bin/tests/system/bailiwick/ans2/ans.py +++ b/bin/tests/system/bailiwick/ans2/ans.py @@ -29,6 +29,32 @@ ATTACKER_IP = "10.53.0.3" TTL = 3600 +class UnsolicitedNsSpoofer(ResponseSpoofer, mode="unsolicited-ns"): + + qname = "trigger.victim." + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[ResponseAction, None]: + response = qctx.prepare_new_response(with_zone_data=False) + + txt_rrset = dns.rrset.from_text( + qctx.qname, + TTL, + qctx.qclass, + dns.rdatatype.TXT, + '"spoofed answer with extra NS"', + ) + response.answer.append(txt_rrset) + + ns_rrset = dns.rrset.from_text( + "victim.", TTL, qctx.qclass, dns.rdatatype.NS, "ns.attacker." + ) + response.authority.append(ns_rrset) + + yield DnsResponseSend(response, authoritative=True) + + def main() -> None: spoofing_server().run() diff --git a/bin/tests/system/bailiwick/tests_bailiwick.py b/bin/tests/system/bailiwick/tests_bailiwick.py index b068180265..644fe5f24a 100644 --- a/bin/tests/system/bailiwick/tests_bailiwick.py +++ b/bin/tests/system/bailiwick/tests_bailiwick.py @@ -85,3 +85,12 @@ def test_bailiwick_sibling_ns_referral(servers: Dict[str, NamedInstance]) -> Non ns4 = servers["ns4"] send_trigger_query(ns4, "trigger.") check_domain_hijack(ns4) + + +def test_bailiwick_unsolicited_authority(servers: Dict[str, NamedInstance]) -> None: + set_spoofing_mode(ans1="none", ans2="unsolicited-ns") + + ns4 = servers["ns4"] + prime_cache(ns4) + send_trigger_query(ns4, "trigger.victim.") + check_domain_hijack(ns4) From b5dc46fe6e0e8f05767377ae171c043ed72552d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 23 Jul 2025 20:26:43 +0200 Subject: [PATCH 5/6] Test that fake child delegation cannot overwrite parent's glue RR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In short, the attacker tries to spoof at least one answer that has the following form: rcode NOERROR flags QR ;QUESTION trigger$RANDOM.victim. IN TXT ;ANSWER ;AUTHORITY trigger$RANDOM.victim. 3600 IN NS ns.victim. ;ADDITIONAL ns.victim. 3600 IN A 10.53.0.3 This attack was originally reported as "test case 2". Co-authored-by: Michał Kępień --- bin/tests/system/bailiwick/ans2/ans.py | 22 +++++++++++++++++++ bin/tests/system/bailiwick/tests_bailiwick.py | 15 +++++++++++++ 2 files changed, 37 insertions(+) diff --git a/bin/tests/system/bailiwick/ans2/ans.py b/bin/tests/system/bailiwick/ans2/ans.py index d0ddccbe62..d1627fd5bd 100644 --- a/bin/tests/system/bailiwick/ans2/ans.py +++ b/bin/tests/system/bailiwick/ans2/ans.py @@ -55,6 +55,28 @@ class UnsolicitedNsSpoofer(ResponseSpoofer, mode="unsolicited-ns"): yield DnsResponseSend(response, authoritative=True) +class ParentGlueSpoofer(ResponseSpoofer, mode="parent-glue"): + + qname = "trigger.victim." + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[ResponseAction, None]: + response = qctx.prepare_new_response(with_zone_data=False) + + ns_rrset = dns.rrset.from_text( + "trigger.victim.", TTL, qctx.qclass, dns.rdatatype.NS, "ns.victim." + ) + response.authority.append(ns_rrset) + + glue_rrset = dns.rrset.from_text( + "ns.victim.", TTL, qctx.qclass, dns.rdatatype.A, ATTACKER_IP + ) + response.additional.append(glue_rrset) + + yield DnsResponseSend(response, authoritative=False) + + def main() -> None: spoofing_server().run() diff --git a/bin/tests/system/bailiwick/tests_bailiwick.py b/bin/tests/system/bailiwick/tests_bailiwick.py index 644fe5f24a..2e1c845a22 100644 --- a/bin/tests/system/bailiwick/tests_bailiwick.py +++ b/bin/tests/system/bailiwick/tests_bailiwick.py @@ -11,6 +11,8 @@ from typing import Dict +import time + import dns.message import pytest @@ -94,3 +96,16 @@ def test_bailiwick_unsolicited_authority(servers: Dict[str, NamedInstance]) -> N prime_cache(ns4) send_trigger_query(ns4, "trigger.victim.") check_domain_hijack(ns4) + + +def test_bailiwick_parent_glue(servers: Dict[str, NamedInstance]) -> None: + set_spoofing_mode(ans1="none", ans2="parent-glue") + + ns4 = servers["ns4"] + prime_cache(ns4) + send_trigger_query(ns4, "trigger.victim.") + + isctest.log.info("Waiting 61 seconds for the ns.victim. ADB entry to expire") + time.sleep(61) + + check_domain_hijack(ns4) From e223ee709765e6eff2bb6ae980ec5eec83d64390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Mon, 28 Jul 2025 11:33:14 +0200 Subject: [PATCH 6/6] Test that spoofed DNAME is not accepted via spoofable transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single spoofed DNAME answer can impact many names, and because of the nature of DNAME, the attacker can use randomized query names to get unlimited number of tries to spoof the answer. To limit impact, we should not be accepting DNAME over insecure transport, like UDP without cookies etc. In short, the attacker tries to spoof at least one answer that has the following form: opcode QUERY rcode NOERROR flags QR AA ;QUESTION trigger$RANDOM.test. IN A ;ANSWER trigger$RANDOM.test. 3600 IN CNAME trigger$RANDOM.attacker.net. test. 3600 IN DNAME attacker.net. ;AUTHORITY ;ADDITIONAL This has been discovered internally. Co-authored-by: Michał Kępień --- bin/tests/system/bailiwick/ans2/ans.py | 25 +++++++++++++++++++ bin/tests/system/bailiwick/tests_bailiwick.py | 8 ++++++ 2 files changed, 33 insertions(+) diff --git a/bin/tests/system/bailiwick/ans2/ans.py b/bin/tests/system/bailiwick/ans2/ans.py index d1627fd5bd..1a9be3d931 100644 --- a/bin/tests/system/bailiwick/ans2/ans.py +++ b/bin/tests/system/bailiwick/ans2/ans.py @@ -77,6 +77,31 @@ class ParentGlueSpoofer(ResponseSpoofer, mode="parent-glue"): yield DnsResponseSend(response, authoritative=False) +class DnameSpoofer(ResponseSpoofer, mode="dname"): + + qname = "trigger.victim." + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[ResponseAction, None]: + response = qctx.prepare_new_response(with_zone_data=False) + + cname_rrset = dns.rrset.from_text( + qctx.qname, + TTL, + qctx.qclass, + dns.rdatatype.CNAME, + "trigger.attacker.", + ) + dname_rrset = dns.rrset.from_text( + "victim.", TTL, qctx.qclass, dns.rdatatype.DNAME, "attacker." + ) + response.answer.append(cname_rrset) + response.answer.append(dname_rrset) + + yield DnsResponseSend(response, authoritative=True) + + def main() -> None: spoofing_server().run() diff --git a/bin/tests/system/bailiwick/tests_bailiwick.py b/bin/tests/system/bailiwick/tests_bailiwick.py index 2e1c845a22..7245c71bce 100644 --- a/bin/tests/system/bailiwick/tests_bailiwick.py +++ b/bin/tests/system/bailiwick/tests_bailiwick.py @@ -109,3 +109,11 @@ def test_bailiwick_parent_glue(servers: Dict[str, NamedInstance]) -> None: time.sleep(61) check_domain_hijack(ns4) + + +def test_bailiwick_spoofed_dname(servers: Dict[str, NamedInstance]) -> None: + set_spoofing_mode(ans1="none", ans2="dname") + + ns4 = servers["ns4"] + send_trigger_query(ns4, "trigger.victim.") + check_domain_hijack(ns4)