[9.20] fix: usr: Reject RRSIG records covering meta-types

A recursive resolver could accept and cache an RRSIG record whose
Type-Covered field names a meta-type (ANY, AXFR, IXFR, MAILA, MAILB),
even though no real RRset of those types ever exists. Such records
are now rejected by the DNS message parser.

Closes #6002

Backport of MR !12048

Merge branch 'backport-6002-reject-rrsig-covering-meta-types-9.20' into 'bind-9.20'

See merge request isc-projects/bind9!12051
This commit is contained in:
Ondřej Surý 2026-05-28 09:52:19 +02:00
commit 7517e39504
5 changed files with 179 additions and 1 deletions

View file

@ -0,0 +1,63 @@
"""
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.
For any query, returns a hand-crafted RRSIG whose Type-Covered field
is selected by the leftmost label of QNAME. The label is parsed as a
DNS type via `dns.rdatatype.from_text()`, so the resolver can be
probed with any meta-type by querying e.g. `any.attacker.test.`,
`axfr.attacker.test.`, `tsig.attacker.test.`, etc.
"""
from collections.abc import AsyncGenerator
import dns.flags
import dns.rcode
import dns.rdataclass
import dns.rdatatype
import dns.rrset
from isctest.asyncserver import (
AsyncDnsServer,
DnsResponseSend,
QueryContext,
ResponseHandler,
)
class RrsigCoversHandler(ResponseHandler):
async def get_responses(
self, qctx: QueryContext
) -> AsyncGenerator[DnsResponseSend, None]:
covers_label = qctx.qname.labels[0].decode("ascii").upper()
covers = dns.rdatatype.from_text(covers_label)
rrset = dns.rrset.from_text(
qctx.qname,
3600,
dns.rdataclass.IN,
dns.rdatatype.RRSIG,
f"TYPE{int(covers)} 8 2 3600 20300101000000 20200101000000 "
"12345 attacker.test. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
qctx.response.set_rcode(dns.rcode.NOERROR)
qctx.response.flags |= dns.flags.AA
qctx.response.answer.append(rrset)
yield DnsResponseSend(qctx.response)
def main() -> None:
server = AsyncDnsServer()
server.install_response_handler(RrsigCoversHandler())
server.run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1 @@
-m record -c named.conf -d 99 -D qpcache_rrsig_any-ns2 -g

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
key rndc_key {
secret "1234abcd8765";
algorithm @DEFAULT_HMAC@;
};
controls {
inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};
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 no;
};
zone "attacker.test" {
type forward;
forward only;
forwarders { 10.53.0.3 port @PORT@; };
};

View file

@ -0,0 +1,72 @@
#!/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.
"""
A signature cannot cover a DNS meta-type. An RRSIG whose Type-Covered
field is one of NONE/ANY/AXFR/IXFR/MAILA/MAILB/OPT/TSIG/TKEY is
malformed and must be rejected by the resolver. ns3 picks the
Type-Covered field from the leftmost label of QNAME.
"""
import pytest
import isctest
pytestmark = pytest.mark.extra_artifacts(
[
"ans*/ans.run",
"ns*/named.run",
]
)
META_TYPES = ["ANY", "AXFR", "IXFR", "MAILA", "MAILB", "OPT", "TSIG", "TKEY"]
@pytest.mark.parametrize("meta_type", META_TYPES)
def test_rrsig_covers_metatype_is_servfail(meta_type):
qname = f"{meta_type.lower()}.attacker.test."
msg = isctest.query.create(qname, "RRSIG", dnssec=False, ad=False)
res = isctest.query.tcp(msg, "10.53.0.2")
isctest.check.servfail(res)
@pytest.mark.parametrize("meta_type", META_TYPES)
def test_dig_nobesteffort_rejects_malformed_rrsig(meta_type, named_port):
"""
With +nobesteffort, dig uses the same strict parser path that the
recursive resolver uses, so a malformed RRSIG covering a meta-type
is rejected before being printed.
"""
dig = isctest.run.EnvCmd("DIG", f"-p {named_port}")
qname = f"{meta_type.lower()}.attacker.test."
res = dig(
f"+nobesteffort +tries=1 +time=5 @10.53.0.3 {qname} RRSIG",
raise_on_exception=False,
)
assert ";; Got bad packet: FORMERR" in res.out
assert "ANSWER SECTION" not in res.out
@pytest.mark.parametrize("meta_type", META_TYPES)
def test_dig_besteffort_shows_malformed_rrsig(meta_type, named_port):
"""
The default dig parser runs in +besteffort mode, which intentionally
keeps wire-level inspection working: the malformed RRSIG is still
printed so operators can debug what an upstream actually sent.
"""
dig = isctest.run.EnvCmd("DIG", f"-p {named_port}")
qname = f"{meta_type.lower()}.attacker.test."
res = dig(f"+tries=1 +time=5 @10.53.0.3 {qname} RRSIG")
assert "ANSWER SECTION" in res.out
assert "RRSIG" in res.out

View file

@ -1415,7 +1415,10 @@ getsection(isc_buffer_t *source, dns_message_t *msg, dns_decompress_t dctx,
rdata->rdclass = rdclass;
if (rdtype == dns_rdatatype_rrsig && rdata->flags == 0) {
covers = dns_rdata_covers(rdata);
if (covers == 0) {
/* A signature can only cover a real rdata type */
if (covers == dns_rdatatype_none ||
dns_rdatatype_ismeta(covers))
{
DO_ERROR(DNS_R_FORMERR);
}
} else if (rdtype == dns_rdatatype_sig /* SIG(0) */ &&