[9.20] fix: usr: Clear serve-stale flags when following the CNAME chains

A stale answer could have been served in case of multiple upstream
failures when following the CNAME chains.  This has been fixed.

Closes #5751

Backport of MR !11558

Merge branch 'backport-5751-clear-staleflags-in-CNAME-chains-9.20' into 'bind-9.20'

See merge request isc-projects/bind9!11583
This commit is contained in:
Matthijs Mekking 2026-02-25 11:44:31 +00:00
commit 68fb231294
9 changed files with 568 additions and 5 deletions

View file

@ -75,6 +75,15 @@ my $TARGET = "target.example 9 IN A $localaddr";
my $SHORTCNAME = "shortttl.cname.example 1 IN CNAME longttl.target.example";
my $LONGTARGET = "longttl.target.example 600 IN A $localaddr";
#
# YWH records
#
my $ywhSOA = "source.stale 300 IN SOA . . 0 0 0 0 300";
my $ywhNS = "source.stale 300 IN NS ns.source.stale";
my $ywhA = "ns.source.stale 300 IN A $localaddr";
my $ywhCNAME = "alias.source.stale 2 IN CNAME www.target.stale";
my $ywhCNAMENX = "aliasnx.source.stale 2 IN CNAME nonexist.target.stale";
sub reply_handler {
my ($qname, $qclass, $qtype) = @_;
my ($rcode, @ans, @auth, @add);
@ -306,6 +315,34 @@ sub reply_handler {
push @auth, $rr;
}
$rcode = "NOERROR";
} elsif ($qname eq "source.stale") {
if ($qtype eq "SOA") {
my $rr = new Net::DNS::RR($ywhSOA);
push @ans, $rr;
} elsif ($qtype eq "NS") {
my $rr = new Net::DNS::RR($ywhNS);
push @ans, $rr;
$rr = new Net::DNS::RR($ywhA);
push @add, $rr;
}
$rcode = "NOERROR";
} elsif ($qname eq "ns.source.stale") {
if ($qtype eq "A") {
my $rr = new Net::DNS::RR($ywhA);
push @ans, $rr;
} else {
my $rr = new Net::DNS::RR($ywhSOA);
push @auth, $rr;
}
$rcode = "NOERROR";
} elsif ($qname eq "alias.source.stale") {
my $rr = new Net::DNS::RR($ywhCNAME);
push @ans, $rr;
$rcode = "NOERROR";
} elsif ($qname eq "aliasnx.source.stale") {
my $rr = new Net::DNS::RR($ywhCNAMENX);
push @ans, $rr;
$rcode = "NOERROR";
} else {
my $rr = new Net::DNS::RR($SOA);
push @auth, $rr;

View file

@ -0,0 +1,164 @@
#!/usr/bin/env perl
# 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.
use strict;
use warnings;
use IO::File;
use IO::Socket;
use Getopt::Long;
use Net::DNS;
use Time::HiRes qw(usleep nanosleep);
my $pidf = new IO::File "ans.pid", "w" or die "cannot open pid file: $!";
print $pidf "$$\n" or die "cannot write pid file: $!";
$pidf->close or die "cannot close pid file: $!";
sub rmpid { unlink "ans.pid"; exit 1; };
$SIG{INT} = \&rmpid;
$SIG{TERM} = \&rmpid;
my $localaddr = "10.53.0.8";
my $localport = int($ENV{'PORT'});
if (!$localport) { $localport = 5300; }
my $udpsock = IO::Socket::INET->new(LocalAddr => "$localaddr",
LocalPort => $localport, Proto => "udp", Reuse => 1) or die "$!";
#
# YWH records
#
my $ywhSOA = "target.stale 300 IN SOA . . 0 0 0 0 300";
my $ywhNS = "target.stale 300 IN NS ns.target.stale";
my $ywhA = "ns.target.stale 300 IN A $localaddr";
my $ywhWWW = "www.target.stale 2 IN A 10.0.0.1";
sub reply_handler {
my ($qname, $qclass, $qtype) = @_;
my ($rcode, @ans, @auth, @add);
print ("request: $qname/$qtype\n");
STDOUT->flush();
# Control what response we send.
if ($qname eq "update" ) {
if ($qtype eq "TXT") {
$ywhWWW = "www.target.stale 2 IN A 10.0.0.2";
my $rr = new Net::DNS::RR("$qname 0 $qclass TXT \"update\"");
push @ans, $rr;
}
$rcode = "NOERROR";
return ($rcode, \@ans, \@auth, \@add, { aa => 1 });
} elsif ($qname eq "restore" ) {
if ($qtype eq "TXT") {
$ywhWWW = "www.target.stale 2 IN A 10.0.0.1";
my $rr = new Net::DNS::RR("$qname 0 $qclass TXT \"restore\"");
push @ans, $rr;
}
$rcode = "NOERROR";
return ($rcode, \@ans, \@auth, \@add, { aa => 1 });
}
if ($qname eq "target.stale") {
if ($qtype eq "SOA") {
my $rr = new Net::DNS::RR($ywhSOA);
push @ans, $rr;
} elsif ($qtype eq "NS") {
my $rr = new Net::DNS::RR($ywhNS);
push @ans, $rr;
$rr = new Net::DNS::RR($ywhA);
push @add, $rr;
}
$rcode = "NOERROR";
} elsif ($qname eq "ns.target.stale") {
if ($qtype eq "A") {
my $rr = new Net::DNS::RR($ywhA);
push @ans, $rr;
} else {
my $rr = new Net::DNS::RR($ywhSOA);
push @auth, $rr;
}
$rcode = "NOERROR";
} elsif ($qname eq "www.target.stale") {
if ($qtype eq "A") {
my $rr = new Net::DNS::RR($ywhWWW);
push @ans, $rr;
} else {
my $rr = new Net::DNS::RR($ywhSOA);
push @auth, $rr;
}
$rcode = "NOERROR";
} else {
my $rr = new Net::DNS::RR($ywhSOA);
push @auth, $rr;
$rcode = "NXDOMAIN";
}
# mark the answer as authoritative (by setting the 'aa' flag)
return ($rcode, \@ans, \@auth, \@add, { aa => 1 });
}
GetOptions(
'port=i' => \$localport,
);
my $rin;
my $rout;
for (;;) {
$rin = '';
vec($rin, fileno($udpsock), 1) = 1;
select($rout = $rin, undef, undef, undef);
if (vec($rout, fileno($udpsock), 1)) {
my ($buf, $request, $err);
$udpsock->recv($buf, 512);
if ($Net::DNS::VERSION > 0.68) {
$request = new Net::DNS::Packet(\$buf, 0);
$@ and die $@;
} else {
my $err;
($request, $err) = new Net::DNS::Packet(\$buf, 0);
$err and die $err;
}
my @questions = $request->question;
my $qname = $questions[0]->qname;
my $qclass = $questions[0]->qclass;
my $qtype = $questions[0]->qtype;
my $id = $request->header->id;
my ($rcode, $ans, $auth, $add, $headermask) = reply_handler($qname, $qclass, $qtype);
if (!defined($rcode)) {
print " Silently ignoring query\n";
next;
}
my $reply = Net::DNS::Packet->new();
$reply->header->qr(1);
$reply->header->aa(1) if $headermask->{'aa'};
$reply->header->id($id);
$reply->header->rcode($rcode);
$reply->push("question", @questions);
$reply->push("answer", @$ans) if $ans;
$reply->push("authority", @$auth) if $auth;
$reply->push("additional", @$add) if $add;
my $num_chars = $udpsock->send($reply->data);
print " Sent $num_chars bytes via UDP\n";
}
}

View file

@ -9,9 +9,12 @@
; See the COPYRIGHT file distributed with this work for additional
; information regarding copyright ownership.
stale. IN SOA ns.stale. matthijs.isc.org. 1 0 0 0 0
stale. IN NS ns.stale.
ns.stale. IN A 10.53.0.6
stale. IN SOA ns.stale. matthijs.isc.org. 1 0 0 0 0
stale. IN NS ns.stale.
ns.stale. IN A 10.53.0.6
serve.stale. IN NS ns.serve.stale.
ns.serve.stale. IN A 10.53.0.6
serve.stale. IN NS ns.serve.stale.
ns.serve.stale. IN A 10.53.0.6
target.stale. IN NS ns.target.stale.
ns.target.stale. IN A 10.53.0.7

View file

@ -0,0 +1,62 @@
/*
* 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.7 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};
options {
query-source address 10.53.0.7;
notify-source 10.53.0.7;
transfer-source 10.53.0.7;
port @PORT@;
pid-file "named.pid";
listen-on { 10.53.0.7; };
listen-on-v6 { none; };
recursion yes;
dnssec-validation no;
qname-minimization off;
stale-answer-enable yes;
stale-cache-enable yes;
max-stale-ttl 3600;
stale-answer-client-timeout off;
stale-refresh-time 30;
max-cache-ttl 300;
max-ncache-ttl 300;
};
zone "." {
type hint;
file "root.db";
};
// Authoritative zone: nonexist.target.stale -> NXDOMAIN
zone "target.stale" {
type primary;
file "target.stale.db";
};
// Forward source.stale queries to ans2
zone "source.stale" {
type forward;
forward only;
forwarders { 10.53.0.2 port @PORT@; };
};

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.
*/
key rndc_key {
secret "1234abcd8765";
algorithm @DEFAULT_HMAC@;
};
controls {
inet 10.53.0.7 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};
options {
query-source address 10.53.0.7;
notify-source 10.53.0.7;
transfer-source 10.53.0.7;
port @PORT@;
pid-file "named.pid";
listen-on { 10.53.0.7; };
listen-on-v6 { none; };
recursion yes;
dnssec-validation no;
qname-minimization off;
stale-answer-enable yes;
stale-cache-enable yes;
max-stale-ttl 3600;
stale-answer-client-timeout off;
stale-refresh-time 30;
max-cache-ttl 300;
max-ncache-ttl 300;
};
zone "." {
type hint;
file "root.db";
};
// Forward source.stale queries to ans2
zone "source.stale" {
type forward;
forward only;
forwarders { 10.53.0.2 port @PORT@; };
};
// Forward target.stale queries to ans8
zone "target.stale" {
type forward;
forward only;
forwarders { 10.53.0.8 port @PORT@; };
};

View file

@ -0,0 +1 @@
../ns1/root.db

View file

@ -0,0 +1,18 @@
; 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.
target.stale. IN SOA ns.target.stale. ywh. 1 0 0 0 0
target.stale. IN NS ns.target.stale.
ns.target.stale. IN A 10.53.0.6
; NOTE: "nonexist.target.stale." is NOT defined here.
; Queries for it will return authoritative NXDOMAIN.
; This is the CNAME target from alias.source.stale.

View file

@ -24,6 +24,212 @@ stale_answer_ttl=$(sed -ne 's,^[[:space:]]*stale-answer-ttl \([[:digit:]]*\).*,\
status=0
n=0
#
# YWH-PGM40640-56:
# Stale/Wrong DNS Data Served via CNAME Flag Leak.
#
echo_i "test server with serve-stale options set"
#
# Variant 1: local authoritative zone
#
# Initial query — populates cache, gets correct NXDOMAIN
n=$((n + 1))
echo_i "prime cache aliasnx.source.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 aliasnx.source.stale A >dig.out.test$n || ret=1
grep "status: NXDOMAIN" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Wait for CNAME TTL to expire
sleep 3
# Kill auth server — source.test becomes unreachable
n=$((n + 1))
echo_i "disable responses from authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.2 txt disable >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"0\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Query via stale CNAME — triggers the bug
n=$((n + 1))
echo_i "check stale aliasnx.source.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 aliasnx.source.stale A >dig.out.test$n || ret=1
grep "status: NXDOMAIN" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Restore auth server
n=$((n + 1))
echo_i "enable responses from authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.2 txt enable >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"1\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
#
# Variant 2: stale/wrong data served
#
n=$((n + 1))
echo_i "updating ns7/named.conf ($n)"
ret=0
cp ns7/named1.conf ns7/named.conf
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
n=$((n + 1))
echo_i "running 'rndc reload' ($n)"
ret=0
rndc_reload ns7 10.53.0.7
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Initial query — caches both CNAME and A record
n=$((n + 1))
echo_i "prime cache alias.source.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 alias.source.stale A >dig.out.test$n || ret=1
grep "status: NOERROR" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 2," dig.out.test$n >/dev/null || ret=1
grep "alias.source.stale.*2.*IN.*CNAME.*www.target.stale." dig.out.test$n >/dev/null || ret=1
grep "www.target.stale.*2.*IN.*A.*10.0.0.1" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Wait for both TTLs to expire
sleep 3
# Kill source.test auth (CNAME becomes stale)
n=$((n + 1))
echo_i "disable responses from authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.2 txt disable >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"0\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Kill target auth, restart with NEW IP (10.0.0.2)
n=$((n + 1))
echo_i "update target authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.8 txt update >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"update\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Query via stale CNAME — triggers the bug
n=$((n + 1))
echo_i "check stale alias.source.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 alias.source.stale A >dig.out.test$n || ret=1
grep "status: NOERROR" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 2," dig.out.test$n >/dev/null || ret=1
grep "alias.source.stale.*30.*IN.*CNAME.*www.target.stale." dig.out.test$n >/dev/null || ret=1
grep "www.target.stale.*2.*IN.*A.*10.0.0.2" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Control: direct query for same name (no stale CNAME involved)
n=$((n + 1))
echo_i "check target www.target.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 www.target.stale A >dig.out.test$n || ret=1
grep "status: NOERROR" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "www.target.stale.*IN.*A.*10.0.0.2" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Restore auth servers
n=$((n + 1))
echo_i "enable responses from authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.2 txt enable >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"1\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
n=$((n + 1))
echo_i "update target authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.8 txt restore >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"restore\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
#
# Variant 3: recursion blocked, servfail
#
# Flush stale data
n=$((n + 1))
echo_i "flush stale data ($n)"
ret=0
$RNDCCMD 10.53.0.7 flushtree stale >/dev/null 2>&1 || ret=1
sleep 1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Initial query — NXDOMAIN via CNAME chain through BOTH forwarders
n=$((n + 1))
echo_i "prime cache aliasnx.source.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 aliasnx.source.stale A >dig.out.test$n || ret=1
grep "status: NXDOMAIN" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "aliasnx.source.stale.*2.*IN.*CNAME.*nonexist.target.stale." dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Wait for CNAME TTL to expire
sleep 3
# Kill source.test auth ONLY (target.test auth stays alive!)
n=$((n + 1))
echo_i "disable responses from authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.2 txt disable >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"0\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Flush target's negative cache entry (simulates cache eviction/pressure)
n=$((n + 1))
echo_i "flush name nonexist.target.stale ($n)"
ret=0
$RNDCCMD 10.53.0.7 flushname nonexist.target.stale >/dev/null 2>&1 || ret=1
sleep 1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Verify target auth is STILL ALIVE and returns correct NXDOMAIN
n=$((n + 1))
echo_i "verify nonexist.target.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.8 nonexist.target.stale A >dig.out.test$n || ret=1
grep "status: NXDOMAIN" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 0," dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
# Query via stale CNAME — triggers the bug
n=$((n + 1))
echo_i "check stale aliasnx.source.stale A ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.7 aliasnx.source.stale A >dig.out.test$n || ret=1
grep "status: NXDOMAIN" dig.out.test$n >/dev/null || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
grep "aliasnx.source.stale.*30.*IN.*CNAME.*nonexist.target.stale." dig.out.test$n >/dev/null || ret=1
# Restore auth server
n=$((n + 1))
echo_i "enable responses from authoritative server ($n)"
ret=0
$DIG -p ${PORT} @10.53.0.2 txt enable >dig.out.test$n || ret=1
grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1
grep "TXT.\"1\"" dig.out.test$n >/dev/null || ret=1
if [ $ret != 0 ]; then echo_i "failed"; fi
status=$((status + ret))
#
# First test server with serve-stale options set.
#

View file

@ -5780,6 +5780,8 @@ root_key_sentinel_detect(query_ctx_t *qctx) {
isc_result_t
ns__query_start(query_ctx_t *qctx) {
isc_result_t result = ISC_R_UNSET;
ns_client_t *client = qctx->client;
CCTRACE(ISC_LOG_DEBUG(3), "ns__query_start");
qctx->want_restart = false;
qctx->authoritative = false;
@ -5788,6 +5790,13 @@ ns__query_start(query_ctx_t *qctx) {
qctx->need_wildcardproof = false;
qctx->rpz = false;
/*
* Clean existing stale options in case ns__query_start was restarted
* due to the CNAME/DNAME chains.
*/
client->query.dboptions &= ~(DNS_DBFIND_STALETIMEOUT |
DNS_DBFIND_STALEOK);
CALL_HOOK(NS_QUERY_START_BEGIN, qctx);
/*