diff --git a/bin/named/statschannel.c b/bin/named/statschannel.c index 4a7662ea0c..8763f18cce 100644 --- a/bin/named/statschannel.c +++ b/bin/named/statschannel.c @@ -464,6 +464,10 @@ init_desc(void) { "ClientQuota"); SET_RESSTATDESC(nextitem, "waited for next item", "NextItem"); SET_RESSTATDESC(priming, "priming queries", "Priming"); + SET_RESSTATDESC(mismatchtcp, + "queries retried over TCP after a response with " + "mismatched query id", + "MismatchTCP"); INSIST(i == dns_resstatscounter_max); diff --git a/bin/tests/system/mismatchtcp/ans2/ans.py b/bin/tests/system/mismatchtcp/ans2/ans.py new file mode 100644 index 0000000000..365a6f22b6 --- /dev/null +++ b/bin/tests/system/mismatchtcp/ans2/ans.py @@ -0,0 +1,66 @@ +# 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. + +""" +Authoritative server that simulates Kaminsky-style off-path spoofing on UDP: +for every UDP query for trigger.example./A it sends one response with a +deliberately flipped DNS message id. A resolver that escalates to TCP on +the first id mismatch will still get the correct answer over TCP, which +this server serves normally. +""" + +from collections.abc import AsyncGenerator + +import dns.name +import dns.rdatatype + +from isctest.asyncserver import ( + AsyncDnsServer, + DnsProtocol, + DnsResponseSend, + QueryContext, + ResponseAction, + ResponseHandler, +) + + +class MismatchOnUdpHandler(ResponseHandler): + """ + Spoof UDP queries for trigger.example./A with a properly-formed + response whose DNS message id does not match the request. Answer + the same query normally on TCP using the zone data prepared by the + framework. + """ + + def __init__(self) -> None: + self._trigger = dns.name.from_text("trigger.example.") + + def match(self, qctx: QueryContext) -> bool: + return qctx.qname == self._trigger and qctx.qtype == dns.rdatatype.A + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[ResponseAction, None]: + if qctx.protocol == DnsProtocol.UDP: + qctx.response.id = qctx.query.id ^ 0xFFFF + yield DnsResponseSend(qctx.response) + else: + yield DnsResponseSend(qctx.response) + + +def main() -> None: + server = AsyncDnsServer() + server.install_response_handler(MismatchOnUdpHandler()) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/mismatchtcp/ans2/example.db b/bin/tests/system/mismatchtcp/ans2/example.db new file mode 100644 index 0000000000..47d0234e98 --- /dev/null +++ b/bin/tests/system/mismatchtcp/ans2/example.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 300 +example. SOA ns.example. . 0 0 0 0 0 +example. NS ns.example. +ns.example. A 10.53.0.2 +trigger.example. A 192.0.2.42 diff --git a/bin/tests/system/mismatchtcp/ns1/named.conf.j2 b/bin/tests/system/mismatchtcp/ns1/named.conf.j2 new file mode 100644 index 0000000000..f83cb2cbc9 --- /dev/null +++ b/bin/tests/system/mismatchtcp/ns1/named.conf.j2 @@ -0,0 +1,33 @@ +/* + * 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. + */ + +include "../../_common/rndc.key"; + +controls { + inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +options { + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.1; }; + listen-on-v6 { none; }; + query-source address 10.53.0.1; + recursion yes; + dnssec-validation no; +}; + +zone "." { + type primary; + file "root.db"; +}; diff --git a/bin/tests/system/mismatchtcp/ns1/root.db b/bin/tests/system/mismatchtcp/ns1/root.db new file mode 100644 index 0000000000..7ebebea094 --- /dev/null +++ b/bin/tests/system/mismatchtcp/ns1/root.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 300 +. SOA . . 0 0 0 0 0 +. NS ns.nil. +ns.nil. A 10.53.0.1 +example. NS ns.example. +ns.example. A 10.53.0.2 diff --git a/bin/tests/system/mismatchtcp/tests_mismatchtcp.py b/bin/tests/system/mismatchtcp/tests_mismatchtcp.py new file mode 100644 index 0000000000..b904c0d106 --- /dev/null +++ b/bin/tests/system/mismatchtcp/tests_mismatchtcp.py @@ -0,0 +1,88 @@ +# 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. + +""" +End-to-end check for the immediate UDP-to-TCP fallback on a query-id +mismatch. + +The fake authoritative server at 10.53.0.2 answers every UDP query for +trigger.example./A with a response whose DNS message id has been flipped. +The resolver at 10.53.0.1 must escalate to TCP on the first such response +and return the correct A record that the fake server serves over TCP. +""" + +from pathlib import Path + +import dns.message +import dns.rdatatype +import pytest + +import isctest + +pytestmark = pytest.mark.extra_artifacts( + [ + "ans*/ans.run", + "ns*/named.stats*", + ] +) + + +MISMATCH_LABEL = "mismatch responses received" +MISMATCHTCP_LABEL = "queries retried over TCP after a response with mismatched query id" + + +def _named_stats(ns1) -> str: + stats_path = Path(ns1.directory) / "named.stats" + if stats_path.exists(): + stats_path.unlink() + ns1.rndc("stats") + return stats_path.read_text(encoding="utf-8") + + +def _counter(stats: str, label: str) -> int: + for line in stats.splitlines(): + line = line.strip() + if line.endswith(label): + return int(line.split()[0]) + return 0 + + +def test_mismatch_tcp_fallback(ns1): + """ + Issue a single recursive query for a name whose UDP responses are + being spoofed. The resolver must escalate to TCP on the first + near-miss and return the correct A record. + """ + msg = dns.message.make_query("trigger.example.", dns.rdatatype.A, want_dnssec=False) + res = isctest.query.udp(msg, ns1.ip, timeout=10) + isctest.check.noerror(res) + + answers = [rrset for rrset in res.answer if rrset.rdtype == dns.rdatatype.A] + assert answers, f"no A RRset in response: {res}" + addresses = {item.address for rrset in answers for item in rrset} + assert "192.0.2.42" in addresses, f"unexpected answer: {addresses}" + + +def test_mismatch_counter(ns1): + """ + After the spoofed exchange completes the resolver's existing + "mismatch responses received" counter must be non-zero, confirming + the dispatcher actually saw the wrong-id response, and the new + "queries retried over TCP after a response with mismatched query + id" counter must also be non-zero, confirming that the TCP + fallback path actually fired in response to that mismatch. + """ + msg = dns.message.make_query("trigger.example.", dns.rdatatype.A, want_dnssec=False) + isctest.query.udp(msg, ns1.ip, timeout=10) + + stats = _named_stats(ns1) + assert _counter(stats, MISMATCH_LABEL) > 0, stats + assert _counter(stats, MISMATCHTCP_LABEL) > 0, stats diff --git a/lib/dns/dispatch.c b/lib/dns/dispatch.c index 2755652a6c..98912a8191 100644 --- a/lib/dns/dispatch.c +++ b/lib/dns/dispatch.c @@ -584,13 +584,18 @@ udp_recv(isc_nmhandle_t *handle, isc_result_t eresult, isc_region_t *region, } /* - * The QID and the address must match the expected ones. + * The QID and the address must match the expected ones. A + * mismatch can happen during normal operation only when a stale + * response from a previous query arrives late, which is rare in + * practice; treat any mismatch as a possible spoofing attempt and + * let the caller retry over TCP to prevent off-path spoofing. */ if (resp->id != id || !isc_sockaddr_equal(&peer, &resp->peer)) { dispentry_log(resp, ISC_LOG_DEBUG(90), "response doesn't match"); inc_stats(disp->mgr, dns_resstatscounter_mismatch); - goto next; + eresult = DNS_R_MISMATCH; + goto done; } /* diff --git a/lib/dns/include/dns/stats.h b/lib/dns/include/dns/stats.h index 447ec95277..6807bb8f51 100644 --- a/lib/dns/include/dns/stats.h +++ b/lib/dns/include/dns/stats.h @@ -74,7 +74,8 @@ enum { dns_resstatscounter_clientquota = 43, dns_resstatscounter_nextitem = 44, dns_resstatscounter_priming = 45, - dns_resstatscounter_max = 46, + dns_resstatscounter_mismatchtcp = 46, + dns_resstatscounter_max = 47, /* * DNSSEC stats. diff --git a/lib/dns/resolver.c b/lib/dns/resolver.c index 6827eb2a21..363fda18a7 100644 --- a/lib/dns/resolver.c +++ b/lib/dns/resolver.c @@ -8170,6 +8170,22 @@ rctx_dispfail(respctx_t *rctx) { rctx->finish = NULL; rctx->no_response = true; break; + case DNS_R_MISMATCH: + /* + * The dispatcher saw a UDP response from the expected peer with + * the wrong DNS message id. Retry the same query over TCP. + */ + if ((rctx->retryopts & DNS_FETCHOPT_TCP) == 0) { + rctx->retryopts |= DNS_FETCHOPT_TCP; + rctx->resend = true; + rctx->next_server = false; + inc_stats(fctx->res, dns_resstatscounter_mismatchtcp); + FCTXTRACE3("mismatched response; retrying over TCP", + rctx->result); + rctx_done(rctx, ISC_R_SUCCESS); + return ISC_R_COMPLETE; + } + break; default: break; } diff --git a/lib/isc/include/isc/result.h b/lib/isc/include/isc/result.h index f9563e3df0..61afeb5f26 100644 --- a/lib/isc/include/isc/result.h +++ b/lib/isc/include/isc/result.h @@ -230,6 +230,7 @@ typedef enum isc_result { DNS_R_NOSKRFILE, DNS_R_NOSKRBUNDLE, DNS_R_LOOPDETECTED, + DNS_R_MISMATCH, DST_R_UNSUPPORTEDALG, DST_R_CRYPTOFAILURE, diff --git a/lib/isc/result.c b/lib/isc/result.c index 29b8bc65f4..0c608deecd 100644 --- a/lib/isc/result.c +++ b/lib/isc/result.c @@ -230,6 +230,7 @@ static const char *description[ISC_R_NRESULTS] = { [DNS_R_NOSKRFILE] = "no SKR file", [DNS_R_NOSKRBUNDLE] = "no available SKR bundle", [DNS_R_LOOPDETECTED] = "fetch loop detected", + [DNS_R_MISMATCH] = "response with mismatched query id", [DST_R_UNSUPPORTEDALG] = "algorithm is unsupported", [DST_R_CRYPTOFAILURE] = "crypto failure", @@ -485,6 +486,7 @@ static const char *identifier[ISC_R_NRESULTS] = { [DNS_R_NOSKRFILE] = "DNS_R_NOSKRFILE", [DNS_R_NOSKRBUNDLE] = "DNS_R_NOSKRBUNDLE", [DNS_R_LOOPDETECTED] = "DNS_R_LOOPDETECTED", + [DNS_R_MISMATCH] = "DNS_R_MISMATCH", [DST_R_UNSUPPORTEDALG] = "DST_R_UNSUPPORTEDALG", [DST_R_CRYPTOFAILURE] = "DST_R_CRYPTOFAILURE", diff --git a/tests/dns/dispatch_test.c b/tests/dns/dispatch_test.c index e8ff0ae3b8..fd271531fe 100644 --- a/tests/dns/dispatch_test.c +++ b/tests/dns/dispatch_test.c @@ -445,6 +445,41 @@ response_shutdown(isc_result_t eresult, isc_region_t *region ISC_ATTR_UNUSED, test_dispatch_shutdown(test); } +static void +response_mismatch(isc_result_t eresult, isc_region_t *region ISC_ATTR_UNUSED, + void *arg) { + test_dispatch_t *test = arg; + + assert_int_equal(eresult, DNS_R_MISMATCH); + + test_dispatch_shutdown(test); +} + +static void +nameserver_mismatch(isc_nmhandle_t *handle, isc_result_t eresult, + isc_region_t *region, void *arg ISC_ATTR_UNUSED) { + /* + * Reply with a single response whose DNS message id has been + * flipped; the dispatcher must escalate immediately rather than + * waiting for a "correct" id to arrive. + */ + static unsigned char buf[16]; + static isc_region_t resp; + + if (eresult != ISC_R_SUCCESS) { + return; + } + + memmove(buf, region->base, 12); + memset(buf + 12, 0, 4); + buf[0] ^= 0xff; /* flip id high byte */ + buf[1] ^= 0xff; /* flip id low byte */ + buf[2] |= 0x80; /* qr=1 */ + resp.base = buf; + resp.length = sizeof(buf); + isc_nm_send(handle, &resp, server_senddone, NULL); +} + static void response_timeout(isc_result_t eresult, isc_region_t *region ISC_ATTR_UNUSED, void *arg) { @@ -767,6 +802,47 @@ ISC_LOOP_TEST_IMPL(dispatch_getnext) { dns_dispatch_connect(test->dispentry); } +/* + * Verify that a UDP response carrying the wrong DNS message id causes the + * dispatcher to deliver DNS_R_MISMATCH to the response callback, instead + * of silently waiting for the correct id to arrive. + */ +ISC_LOOP_TEST_IMPL(dispatch_mismatch_tcp) { + isc_result_t result; + test_dispatch_t *test = isc_mem_get(mctx, sizeof(*test)); + *test = (test_dispatch_t){ 0 }; + + /* Server: replies with a single wrong-id response. */ + result = isc_nm_listenudp(netmgr, ISC_NM_LISTEN_ONE, &udp_server_addr, + nameserver_mismatch, NULL, &sock); + assert_int_equal(result, ISC_R_SUCCESS); + + isc_loop_teardown(isc_loop_main(loopmgr), stop_listening, sock); + + /* Client */ + testdata.region.base = testdata.message; + testdata.region.length = sizeof(testdata.message); + + result = dns_dispatchmgr_create(mctx, loopmgr, connect_nm, + &test->dispatchmgr); + assert_int_equal(result, ISC_R_SUCCESS); + + result = dns_dispatch_createudp(test->dispatchmgr, &udp_connect_addr, + &test->dispatch); + assert_int_equal(result, ISC_R_SUCCESS); + + result = dns_dispatch_add( + test->dispatch, isc_loop_main(loopmgr), 0, T_CLIENT_CONNECT, + &udp_server_addr, NULL, NULL, connected, client_senddone, + response_mismatch, test, &test->id, &test->dispentry); + assert_int_equal(result, ISC_R_SUCCESS); + + testdata.message[0] = (test->id >> 8) & 0xff; + testdata.message[1] = test->id & 0xff; + + dns_dispatch_connect(test->dispentry); +} + ISC_LOOP_TEST_IMPL(dispatch_sharedtcp) { isc_result_t result; test_dispatch_t *test = isc_mem_get(mctx, sizeof(*test)); @@ -811,6 +887,7 @@ ISC_TEST_ENTRY_CUSTOM(dispatch_timeout_tcp_connect, setup_test, teardown_test) ISC_TEST_ENTRY_CUSTOM(dispatch_tcp_response, setup_test, teardown_test) ISC_TEST_ENTRY_CUSTOM(dispatch_tls_response, setup_test, teardown_test) ISC_TEST_ENTRY_CUSTOM(dispatch_getnext, setup_test, teardown_test) +ISC_TEST_ENTRY_CUSTOM(dispatch_mismatch_tcp, setup_test, teardown_test) ISC_TEST_LIST_END ISC_TEST_MAIN