Add forward-first referral poisoning reproducer

Add a system test covering authority-section NS referrals returned by
configured forwarders under forward first.

The test verifies that a forwarder for fwd.hack cannot install the
parent hack zone cut and redirect resolution for the sibling zone
sibling.hack.
This commit is contained in:
Alessio Podda 2026-05-07 10:03:49 +02:00
parent cef614cdf1
commit ee34bbd208
9 changed files with 244 additions and 0 deletions

View file

@ -0,0 +1,22 @@
"""
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 isctest.asyncserver import AsyncDnsServer
def main() -> None:
AsyncDnsServer().run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,7 @@
$TTL 300
@ SOA ns.hack. . 0 0 0 0 300
@ NS ns
ns A 10.53.0.1
sibling NS ns.sibling
ns.sibling A 10.53.0.1

View file

@ -0,0 +1,8 @@
$ORIGIN .
$TTL 300
@ SOA a.root-servers.nil. . 0 0 0 0 300
@ NS a.root-servers.nil.
a.root-servers.nil. A 10.53.0.1
hack. NS ns.hack.
ns.hack. A 10.53.0.1

View file

@ -0,0 +1,5 @@
$TTL 300
@ SOA ns.sibling.hack. . 0 0 0 0 300
@ NS ns
ns A 10.53.0.1
victim A 203.0.113.42

View file

@ -0,0 +1,45 @@
"""
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.
"""
import dns.rcode
import dns.rdataclass
import dns.rdatatype
import dns.rrset
from isctest.asyncserver import AsyncDnsServer, DomainHandler, StaticResponseHandler
class PoisonReferralForwarder(DomainHandler, StaticResponseHandler):
domains = ["fwd.hack."]
authority = [
dns.rrset.from_text(
"hack.", 300, dns.rdataclass.IN, dns.rdatatype.NS, "ns.fwd.hack."
)
]
additional = [
dns.rrset.from_text(
"ns.fwd.hack.", 300, dns.rdataclass.IN, dns.rdatatype.A, "10.53.0.3"
)
]
rcode = dns.rcode.NOERROR
authoritative = False
def main() -> None:
server = AsyncDnsServer()
server.install_response_handler(PoisonReferralForwarder())
server.run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,50 @@
"""
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
import dns.rcode
import dns.rdatatype
import dns.rrset
from isctest.asyncserver import (
AsyncDnsServer,
DnsResponseSend,
QueryContext,
ResponseAction,
ResponseHandler,
)
class AttackerAuthority(ResponseHandler):
async def get_responses(
self, qctx: QueryContext
) -> AsyncGenerator[ResponseAction, None]:
if qctx.qtype == dns.rdatatype.A:
qctx.response.answer.append(
dns.rrset.from_text(
qctx.qname, 300, qctx.qclass, dns.rdatatype.A, "6.6.6.6"
)
)
yield DnsResponseSend(qctx.response)
def main() -> None:
server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
server.install_response_handler(AttackerAuthority())
server.run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,31 @@
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;
allow-query { any; };
allow-recursion { any; };
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";
};
zone "fwd.hack" {
type forward;
forward first;
forwarders { 10.53.0.2 port @PORT@; };
};

View file

@ -0,0 +1,27 @@
options {
query-source address 10.53.0.5;
notify-source 10.53.0.5;
transfer-source 10.53.0.5;
port @PORT@;
pid-file "named.pid";
listen-on { 10.53.0.5; };
listen-on-v6 { none; };
recursion yes;
allow-query { any; };
allow-recursion { any; };
dnssec-validation no;
qname-minimization off;
forward first;
forwarders { 10.53.0.2 port @PORT@; };
};
controls {
inet 10.53.0.5 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};
include "../../_common/rndc.key";
zone "." {
type hint;
file "../../_common/root.hint";
};

View file

@ -0,0 +1,49 @@
# 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.
import dns.name
import dns.rdataclass
import dns.rdatatype
import pytest
from isctest.instance import NamedInstance
import isctest
def _a_records(response, qname: str) -> set[str]:
rrset = response.get_rrset(
response.answer,
dns.name.from_text(qname),
dns.rdataclass.IN,
dns.rdatatype.A,
)
if rrset is None:
return set()
return {rdata.address for rdata in rrset}
@pytest.mark.parametrize("resolver_name", ["ns4", "ns5"])
def test_forward_first_referral_ns_above_forward_zone_not_cached(
servers: dict[str, NamedInstance],
resolver_name: str,
) -> None:
resolver = servers[resolver_name]
resolver.rndc("flush")
trigger = isctest.query.create("trigger.fwd.hack.", "A", dnssec=False)
isctest.query.tcp(trigger, resolver.ip)
sibling = isctest.query.create("victim.sibling.hack.", "A", dnssec=False)
response = isctest.query.tcp(sibling, resolver.ip)
isctest.check.noerror(response)
assert _a_records(response, "victim.sibling.hack.") == {"203.0.113.42"}