]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Switch UDP fetches to TCP on the first response with a wrong query id 12025/head
authorOndřej Surý <ondrej@isc.org>
Thu, 14 May 2026 10:20:19 +0000 (12:20 +0200)
committerOndřej Surý <ondrej@isc.org>
Fri, 15 May 2026 06:49:19 +0000 (08:49 +0200)
Until now, the dispatcher silently dropped UDP responses from the
expected peer that carried the wrong DNS message id and kept listening
for the correct id to arrive within the read timeout.  An off-path
attacker who knows the destination address and source port of an
outgoing fetch could exploit that quiet retry window to flood the
resolver with guessed responses; with a gigabit link the per-query
success probability grows linearly with the number of guesses that
arrive before the legitimate answer or the timeout.

Treat any such mismatch as a possible spoofing attempt and let the
resolver immediately retry the same query over TCP, the same control
path the truncation handler already uses.

Add a resolver statistics counter - exposed as 'queries retried over TCP
after a response with mismatched query id' in rndc stats and
'MismatchTCP' in the statistics channel

Assisted-by: Claude:claude-opus-4-7
(cherry picked from commit 11bca1051f6ef6658b3602c8d72a2f35abdbdd93)

12 files changed:
bin/named/statschannel.c
bin/tests/system/mismatchtcp/ans2/ans.py [new file with mode: 0644]
bin/tests/system/mismatchtcp/ans2/example.db [new file with mode: 0644]
bin/tests/system/mismatchtcp/ns1/named.conf.j2 [new file with mode: 0644]
bin/tests/system/mismatchtcp/ns1/root.db [new file with mode: 0644]
bin/tests/system/mismatchtcp/tests_mismatchtcp.py [new file with mode: 0644]
lib/dns/dispatch.c
lib/dns/include/dns/stats.h
lib/dns/resolver.c
lib/isc/include/isc/result.h
lib/isc/result.c
tests/dns/dispatch_test.c

index 4a7662ea0cc0e9f174c42d741dc03936a71863f6..8763f18cce6647c7784372f373a759667cc4ab0b 100644 (file)
@@ -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 (file)
index 0000000..365a6f2
--- /dev/null
@@ -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 (file)
index 0000000..47d0234
--- /dev/null
@@ -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 (file)
index 0000000..f83cb2c
--- /dev/null
@@ -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 (file)
index 0000000..7ebebea
--- /dev/null
@@ -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 (file)
index 0000000..b904c0d
--- /dev/null
@@ -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
index 2755652a6c3afcd7ff1179b78dc0d1118ef15040..98912a81917c43b7bf63f936222b5d63a984dc7c 100644 (file)
@@ -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;
        }
 
        /*
index 447ec95277a44aa6af943603d2b2eb83a5632ed8..6807bb8f51963bd4dc9d0742d5b6853540c38c0c 100644 (file)
@@ -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.
index 6827eb2a216ecd22a79a9b22f46a1c8c6252b25c..363fda18a7f41dbce5f606566e4bcaf85ffc995b 100644 (file)
@@ -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;
        }
index f9563e3df0150894d25576eac18b7503b797fdc3..61afeb5f269501e5441a67a4664b51dd0638c7b2 100644 (file)
@@ -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,
index 29b8bc65f4e73f6a6f8775d056cb27aa8c8c14a9..0c608deecdd2ef784d3d70155d91a3bd5bffa051 100644 (file)
@@ -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",
index e8ff0ae3b85ec9404940084e387796fe48acaafa..fd271531feafc7b706fe2de98fb9b638bf9ccc7a 100644 (file)
@@ -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