high · 7.5Aug 6, 2026

league/commonmark Denial of Service via Colliding Heading Slugs

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Sending a Markdown document full of identical or punctuation-only headings forces the slug normalizer into O(K²) work, letting an unauthenticated attacker hang a PHP server with a small payload.

Packageleague/commonmark
Ecosystemcomposer
Affected>= 2.0.0, < 2.9.0
Fixed in2.9.0
league/commonmark Denial of Service via Colliding Heading Slugs

The problem

UniqueSlugNormalizer::normalize() deduplicates heading anchors by appending a numeric suffix. The vulnerable implementation restarts its suffix search from 1 on every collision, so the k-th duplicate heading triggers k-1 array lookups. K colliding headings therefore cost O(K²) total lookups.

The path is reachable unauthenticated whenever HeadingPermalinkExtension, TableOfContentsExtension, or FootnoteExtension (anonymous footnote refs) is registered. No authentication or special privilege is required. The impact is pure availability: CPU exhaustion per request.

Proof of concept

A working proof-of-concept for this issue in league/commonmark, with the exact payload below.

text
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 

Each empty ATX heading (# with no text) normalizes to the empty string, so every heading collides on the same base slug. The pre-patch loop incremented a counter starting at 1 and called array_key_exists in a while loop that restarted at 1 each call, producing Σ(k-1) = O(K²) lookups for K headings.

The fix tracks the next available suffix per base slug in a map, making each normalize() call O(1) regardless of collision count. CWE-407 (Inefficient Algorithmic Complexity).

Public PoC not yet available; payload derived directly from the advisory description of the trigger condition (empty or punctuation-only headings that normalize to the empty string).

The fix

Upgrade to league/commonmark 2.9.0, which rewrites the deduplication loop to O(1) per call. If you cannot upgrade immediately, set slug_normalizer/unique to false (loses anchor uniqueness), disable HeadingPermalinkExtension and FootnoteExtension for untrusted input, or cap document size upstream to keep K small.

composer require league/commonmark:^2.9.0

Reported by Colin O'Dell (maintainer self-report).

References: [1][2][3]

Related research