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);
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;
--- /dev/null
+#!/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";
+ }
+}
; 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
--- /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.
+ */
+
+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@; };
+};
--- /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.
+ */
+
+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@; };
+};
--- /dev/null
+../ns1/root.db
\ No newline at end of file
--- /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.
+
+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.
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.
#