chg: usr: Fall back to TCP on a UDP response with a mismatched query id

BIND used to wait silently for the correct DNS message id on a UDP fetch
even after receiving a response from the expected server with the wrong
id, leaving room for off-path spoofing attempts to keep guessing within
that window.  The resolver now retries the fetch over TCP on the first
such response, and a new MismatchTCP statistics counter tracks how
often the fallback fires.

Closes #5449

Merge branch '5449-immediate-tcp-fallback-on-id-mismatch' into 'main'

See merge request isc-projects/bind9!12023
This commit is contained in:
Ondřej Surý 2026-05-15 06:57:00 +02:00
commit ef405bfa6d
12 changed files with 329 additions and 3 deletions

View file

@ -539,6 +539,10 @@ init_desc(void) {
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);

View file

@ -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()

View file

@ -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

View file

@ -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";
};

View file

@ -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

View file

@ -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

View file

@ -587,13 +587,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;
}
/*

View file

@ -71,7 +71,8 @@ enum {
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.

View file

@ -8084,6 +8084,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;
}

View file

@ -211,6 +211,7 @@ typedef enum isc_result {
DNS_R_NOSKRBUNDLE,
DNS_R_LOOPDETECTED,
DNS_R_INVALIDDSYNC,
DNS_R_MISMATCH,
DST_R_UNSUPPORTEDALG,
DST_R_CRYPTOFAILURE,

View file

@ -214,6 +214,7 @@ static const char *description[ISC_R_NRESULTS] = {
[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",
@ -448,6 +449,7 @@ static const char *identifier[ISC_R_NRESULTS] = {
[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",

View 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) {
@ -763,6 +798,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(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));
@ -806,6 +882,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