Files
Hetvi Thakar 2fb2b9fb1d python3-twisted: Fix CVE-2026-42304
This patch applies the upstream 26.4.0rc2 backport for
CVE-2026-42304. The upstream fix merge is referenced in [1],
and the public CVE advisory is referenced in [2]. The individual
backported commit links are recorded in the patch headers.

[1] https://github.com/twisted/twisted/commit/2d196123264efb0027eecfe1b430be4a9babdbd8
[2] https://github.com/advisories/GHSA-grgv-6hw6-v9g4

Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
2026-08-24 12:58:53 +05:30

300 lines
11 KiB
Diff

From a023e4192dbbdc7d8d6b391ef9b226c2cfffe97e Mon Sep 17 00:00:00 2001
From: tomasilluminati <tomas.illuminati@owasp.org>
Date: Sun, 19 Apr 2026 05:57:33 -0300
Subject: [PATCH] (fix): denial of service in twisted.names mitigation
CVE: CVE-2026-42304
Upstream-Status: Backport [https://github.com/twisted/twisted/commit/be71ecaa113f642f03083bd5ed33af47c59308c8]
(cherry picked from commit be71ecaa113f642f03083bd5ed33af47c59308c8)
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
src/twisted/names/dns.py | 116 +++++++++++++++++++++++++----
src/twisted/names/test/test_dns.py | 91 ++++++++++++++++++++++
2 files changed, 193 insertions(+), 14 deletions(-)
diff --git a/src/twisted/names/dns.py b/src/twisted/names/dns.py
index c7644ef50..7142d9e75 100644
--- a/src/twisted/names/dns.py
+++ b/src/twisted/names/dns.py
@@ -11,6 +11,7 @@ Future Plans:
from __future__ import annotations
# System imports
+import contextvars
import inspect
import random
import socket
@@ -126,6 +127,7 @@ __all__ = [
"OP_UPDATE",
"PORT",
"AuthoritativeDomainError",
+ "DNSDecodeError",
"DNSQueryTimeoutError",
"DomainError",
]
@@ -444,6 +446,64 @@ def readPrecisely(file, l):
return buff
+# Cap the total number of compression-pointer dereferences performed while
+# decoding a single DNS message. A hostile peer can otherwise craft a packet
+# in which every record name chases a long compression chain, forcing O(N*M)
+# work and stalling the reactor.
+MAX_COMPRESSION_POINTERS_PER_MESSAGE = 1000
+
+
+class DNSDecodeError(ValueError):
+ """
+ Raised when a DNS message cannot be decoded because it violates a
+ protocol-level safety limit
+ """
+
+
+class _DecodeContext:
+ """
+ Mutable state shared between the L{IEncodable} decoders invoked while
+ reading a single DNS message.
+
+ The primary purpose is to bound the total number of compression-pointer
+ jumps taken across every name in the message, defending against packets
+ that fan out thousands of records pointing to deeply chained pointers.
+
+ @ivar jumps: The number of compression pointers followed so far.
+ @ivar maxJumps: The inclusive upper bound on C{jumps}. Exceeding it
+ causes L{registerJump} to raise L{DNSDecodeError}.
+ """
+
+ __slots__ = ("jumps", "maxJumps")
+
+ def __init__(self, maxJumps: int = MAX_COMPRESSION_POINTERS_PER_MESSAGE) -> None:
+ self.jumps = 0
+ self.maxJumps = maxJumps
+
+ def registerJump(self) -> None:
+ """
+ Record that a compression pointer has been followed
+
+ @raise DNSDecodeError: if the cumulative number of jumps exceeds
+ L{maxJumps}
+ """
+ self.jumps += 1
+ if self.jumps > self.maxJumps:
+ raise DNSDecodeError(
+ "Too many compression pointers while decoding DNS message "
+ f"(limit is {self.maxJumps})"
+ )
+
+
+# Tracks state across nested calls without altering every record's signature.
+# L{Message.decode} manages the lifecycle per-message, while standalone decoders
+# default to a local context when C{_decodeContextVar} is C{None}
+
+_decodeContextVar: contextvars.ContextVar[_DecodeContext | None] = (
+ contextvars.ContextVar("_dnsDecodeContext", default=None)
+)
+
+
class IEncodable(Interface):
"""
Interface for something which can be encoded to and decoded
@@ -591,7 +651,7 @@ class Name:
strio.write(label)
strio.write(b"\x00")
- def decode(self, strio, length=None):
+ def decode(self, strio, length=None, context=None):
"""
Decode a byte string into this Name.
@@ -599,12 +659,27 @@ class Name:
@param strio: Bytes will be read from this file until the full Name
is decoded.
+ @type context: L{_DecodeContext} or L{None}
+ @param context: Shared decoding state used to cap the total number
+ of compression-pointer jumps taken while decoding the enclosing
+ DNS message. When L{None}, the context installed by
+ L{Message.decode} is used if one is active; otherwise a fresh,
+ call-local context is created so that direct callers remain
+ protected and backwards compatible.
+
@raise EOFError: Raised when there are not enough bytes available
from C{strio}.
- @raise ValueError: Raised when the name cannot be decoded (for example,
- because it contains a loop).
+ @raise ValueError: Raised when the name cannot be decoded because it
+ contains a compression loop.
+
+ @raise DNSDecodeError: Raised when the cumulative number of
+ compression-pointer jumps exceeds the configured limit.
"""
+ if context is None:
+ context = _decodeContextVar.get()
+ if context is None:
+ context = _DecodeContext()
visited = set()
self.name = b""
off = 0
@@ -616,6 +691,7 @@ class Name:
return
if (l >> 6) == 3:
new_off = (l & 63) << 8 | ord(readPrecisely(strio, 1))
+ context.registerJump()
if new_off in visited:
raise ValueError("Compression loop in encoded name")
visited.add(new_off)
@@ -2704,19 +2780,31 @@ class Message(tputil.FancyEqMixin):
self.checkingDisabled = (byte4 >> 4) & 1
self.rCode = byte4 & 0xF
- self.queries = []
- for i in range(nqueries):
- q = Query()
- try:
- q.decode(strio)
- except EOFError:
- return
- self.queries.append(q)
+ # A single shared counter bounds the total compression-pointer work
+ # performed across every name in this message. It is installed on
+ # the context variable so nested record decoders pick it up without
+ # needing to thread it through each signature.
+ token = _decodeContextVar.set(_DecodeContext())
+ try:
+ self.queries = []
+ for i in range(nqueries):
+ q = Query()
+ try:
+ q.decode(strio)
+ except EOFError:
+ return
+ self.queries.append(q)
- items = ((self.answers, nans), (self.authority, nns), (self.additional, nadd))
+ items = (
+ (self.answers, nans),
+ (self.authority, nns),
+ (self.additional, nadd),
+ )
- for l, n in items:
- self.parseRecords(l, n, strio)
+ for l, n in items:
+ self.parseRecords(l, n, strio)
+ finally:
+ _decodeContextVar.reset(token)
def parseRecords(self, list, num, strio):
for i in range(num):
diff --git a/src/twisted/names/test/test_dns.py b/src/twisted/names/test/test_dns.py
index 3b8f6e130..9b27c4b3a 100644
--- a/src/twisted/names/test/test_dns.py
+++ b/src/twisted/names/test/test_dns.py
@@ -352,6 +352,60 @@ class NameTests(unittest.TestCase):
stream = BytesIO(b"\xc0\x00")
self.assertRaises(ValueError, name.decode, stream)
+ def test_rejectTooManyCompressionPointers(self):
+ """
+ L{Name.decode} raises L{dns.DNSDecodeError} when the number of
+ compression-pointer dereferences taken for a single message exceeds
+ the limit carried by the shared L{dns._DecodeContext}.
+ """
+ # Five distinct pointers chained end-to-end, terminated by a zero
+ # label byte. With a maxJumps of three the fourth dereference must
+ # trip the safety limit.
+ payload = b"\xc0\x02\xc0\x04\xc0\x06\xc0\x08\x00"
+ context = dns._DecodeContext(maxJumps=3)
+ self.assertRaises(
+ dns.DNSDecodeError,
+ dns.Name().decode,
+ BytesIO(payload),
+ None,
+ context,
+ )
+
+ def test_compressionPointerCounterIsShared(self):
+ """
+ The L{dns._DecodeContext} counter accumulates across successive
+ L{Name.decode} calls, so that a message whose individual names are
+ each within bounds is still rejected when their aggregate exceeds
+ the configured limit.
+ """
+ payload = b"\xc0\x02\xc0\x04\x00"
+ context = dns._DecodeContext(maxJumps=3)
+
+ stream = BytesIO(payload)
+ dns.Name().decode(stream, context=context)
+ self.assertEqual(context.jumps, 2)
+
+ stream.seek(0)
+ self.assertRaises(
+ dns.DNSDecodeError,
+ dns.Name().decode,
+ stream,
+ None,
+ context,
+ )
+
+ def test_decodeWithoutContextIsBackwardsCompatible(self):
+ """
+ L{Name.decode} continues to work when called without a context,
+ using a fresh per-call counter so existing callers are unaffected.
+ """
+ name = dns.Name()
+ stream = BytesIO()
+ dns.Name(b"example.org").encode(stream)
+ stream.seek(0)
+ name.decode(stream)
+ self.assertEqual(name.name, b"example.org")
+
def test_equality(self):
"""
L{Name} instances are equal as long as they have the same value for
@@ -761,6 +815,43 @@ class MessageTests(unittest.SynchronousTestCase):
"""
self.assertEqual(dns.Message().authenticData, 0)
+ def test_rejectCompressionPointerFlood(self):
+ """
+ L{Message.decode} installs a shared compression-pointer counter and
+ raises L{dns.DNSDecodeError} when the aggregate number of pointer
+ dereferences across every record in the message exceeds
+ L{dns.MAX_COMPRESSION_POINTERS_PER_MESSAGE}.
+ """
+ chainLength = 100
+ numRecords = 8000
+ header = struct.pack(
+ "!H2B4H", 0x1234, 0x80, 0x00, 0, numRecords, 0, 0
+ )
+
+ # Long compression chain inside the RDATA of an unknown
+ # record so that subsequent records can aim pointers at it.
+ owner = b"\x04rrrr\x00"
+ chainBase = len(header) + len(owner) + 10
+ chain = bytearray()
+ for i in range(chainLength):
+ chain += struct.pack("!H", 0xC000 | (chainBase + 2 * (i + 1)))
+ chain += b"\x04test\x00"
+
+ firstRecord = (
+ owner
+ + struct.pack("!HHIH", 999, 1, 0, len(chain))
+ + bytes(chain)
+ )
+ followupRecord = (
+ struct.pack("!H", 0xC000 | chainBase)
+ + struct.pack("!HHIH", 1, 1, 0, 4)
+ + b"\x00\x00\x00\x00"
+ )
+ payload = header + firstRecord + followupRecord * (numRecords - 1)
+
+ message = dns.Message()
+ self.assertRaises(dns.DNSDecodeError, message.decode, BytesIO(payload))
+
def test_authenticDataOverride(self):
"""
L{dns.Message.__init__} accepts a C{authenticData} argument which