SET_RESSTATDESC(priming, "priming queries", "Priming");
SET_RESSTATDESC(forwardonlyfail, "all forwarders failed",
"ForwardOnlyFail");
+ SET_RESSTATDESC(mismatchtcp,
+ "queries retried over TCP after a response with "
+ "mismatched query id",
+ "MismatchTCP");
INSIST(i == dns_resstatscounter_max);
--- /dev/null
+# 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()
--- /dev/null
+; 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
--- /dev/null
+/*
+ * 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";
+};
--- /dev/null
+; 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
--- /dev/null
+# 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
}
/*
- * 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;
}
/*
dns_resstatscounter_nextitem = 38,
dns_resstatscounter_priming = 39,
dns_resstatscounter_forwardonlyfail = 40,
- dns_resstatscounter_max = 41,
+ dns_resstatscounter_mismatchtcp = 41,
+ dns_resstatscounter_max = 42,
/*
* DNSSEC stats.
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;
}
DNS_R_NOSKRBUNDLE,
DNS_R_LOOPDETECTED,
DNS_R_INVALIDDSYNC,
+ DNS_R_MISMATCH,
DST_R_UNSUPPORTEDALG,
DST_R_CRYPTOFAILURE,
[DNS_R_NOSKRBUNDLE] = "no available SKR bundle",
[DNS_R_LOOPDETECTED] = "fetch loop detected",
[DNS_R_INVALIDDSYNC] = "invalid DSYNC response",
+ [DNS_R_MISMATCH] = "response with mismatched query id",
[DST_R_UNSUPPORTEDALG] = "algorithm is unsupported",
[DST_R_CRYPTOFAILURE] = "crypto failure",
[DNS_R_NOSKRBUNDLE] = "DNS_R_NOSKRBUNDLE",
[DNS_R_LOOPDETECTED] = "DNS_R_LOOPDETECTED",
[DNS_R_INVALIDDSYNC] = "DNS_R_INVALIDDSYNC",
+ [DNS_R_MISMATCH] = "DNS_R_MISMATCH",
[DST_R_UNSUPPORTEDALG] = "DST_R_UNSUPPORTEDALG",
[DST_R_CRYPTOFAILURE] = "DST_R_CRYPTOFAILURE",
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) {
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(isc_g_mctx, sizeof(*test));
+ *test = (test_dispatch_t){ 0 };
+
+ /* Server: replies with a single wrong-id response. */
+ result = isc_nm_listenudp(ISC_NM_LISTEN_ONE, &udp_server_addr,
+ nameserver_mismatch, NULL, &sock);
+ assert_int_equal(result, ISC_R_SUCCESS);
+
+ isc_loop_teardown(isc_loop_main(), stop_listening, sock);
+
+ /* Client */
+ testdata.region.base = testdata.message;
+ testdata.region.length = sizeof(testdata.message);
+
+ result = dns_dispatchmgr_create(isc_g_mctx, &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(), 0,
+ T_CLIENT_CONNECT, T_CLIENT_INIT,
+ &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(isc_g_mctx, sizeof(*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