GHSA-gg6h-894w-4m9c: High severity npm/re2js vulnerability
Summary
Using the opt-in LOOKBEHINDS flag together with any repeated-matching API (matcher().find() in a loop, matchAll(), split(), matcher().replaceAll()) causes quadratic-time (Θ(n²)) matching, contradicting the library's own "linear time matching" guarantee. A single call with a 16,000-character input takes 36.7–38.7 seconds of CPU.
Description
Root cause
In build/index.cjs, Machine.match:
js let currentPos = this.prog.numLb > 0 ? 0 : pos; // ~line 1907 ... if (this.ncap === 0 && this.matched) break; // ~line 1953
Any pattern containing a lookbehind sets prog.numLb > 0, which forces the scan to always restart at offset 0, regardless of the requested start position — the lookbehind automaton is re-seeded and the whole input is re-walked on every single find() call. The early-exit optimization that would otherwise stop at the first match only fires when ncap === 0, but every Matcher-based API (find/matchAll/split/replaceAll) always requests ncap = 2 (one capture group slot) even for a captureless lookbehind pattern — so the early exit never triggers through those APIs, and the scan continues to the end of the input on every find.
A find-loop over k matches in an n-character string therefore costs k × Θ(n); for dense matches k ∝ n, giving Θ(n²) total.
Isolating control
Identical find-loop, identical input sizes, same 2-character literal pattern shape — but without the LOOKBEHINDS flag:
| n | with LOOKBEHINDS (lookbehind pattern) | without (plain literal) | native JS RegExp lookbehind | |---|---|---|---| | 2,000 | 204–228 ms | 3 ms | 0 ms | | 4,000 | 877–899 ms | 4 ms | 0 ms | | 8,000 | 5,746–6,599 ms | 7 ms | 1 ms | | 16,000 | 36,667–38,732 ms | 6 ms | 1 ms |
This isolates the lookbehind machinery itself — not pattern length, not match density in the abstract — as the exact and sole cause.
Impact
- re2js's entire value proposition is being an RE2-style engine with linear-time, no-backtracking matching, specifically to resist algorithmic-complexity attacks that plague backtracking regex engines. This finding defeats that guarantee for any consumer using the advertised LOOKBEHINDS feature. - Realistic affected code paths: log/text processing that tokenizes or redacts using a lookbehind-based pattern (matchAll, split, replaceAll are the natural APIs for exactly this), applied to attacker-influenced text (user input, uploaded logs, scraped content). - A single ~16–32 KB request pins a CPU core for tens of seconds to minutes; Node.js being single-threaded, this freezes the whole worker process for the duration. No authentication or special pattern knowledge is required from the attacker — ordinary alternating text ("abababab...") triggers it.
Reproduction (copy-paste runnable)
create file poc.js
js 'use strict'; // PoC: quadratic-time DoS in re2js's lookbehind matching (opt-in LOOKBEHINDS flag). // Usage: // npm pack re2js@2.8.6 --ignore-scripts && tar -xzf re2js-2.8.6.tgz // node poc.js ./package const path = require('path'); const pkgDir = process.argv[2] || './package'; const { RE2JS } = require(path.resolve(pkgDir, 'build/index.cjs'));
const re = RE2JS.compile('(?<=a)b', RE2JS.LOOKBEHINDS);
function timeFindLoop(n) { const s = 'ab'.repeat(n / 2); const t0 = Date.now(); const m = re.matcher(s); let count = 0; while (m.find()) count++; return { ms: Date.now() - t0, count }; }
console.log('n, matches, ms'); const sizes = [2000, 4000, 8000, 16000]; const results = []; for (const n of sizes) { const r = timeFindLoop(n); results.push(r.ms); console.log(${n}, ${r.count}, ${r.ms}); }
const ratio = results[results.length - 1] / results[results.length - 2]; console.log(''); console.log('time ratio for the last doubling of n:', ratio.toFixed(2), '(~2 => linear, ~4 => quadratic)'); console.log(ratio > 3 ? 'PASS quadratic scaling confirmed on the REAL published package' : 'FAIL scaling looks linear'); process.exit(ratio > 3 ? 0 : 1);
and run in terminal
sh npm pack re2js@2.8.6 --ignore-scripts tar -xzf re2js-2.8.6.tgz node poc.js ./package
poc.js times a matcher(s).find() loop over (?<=a)b + RE2JS.LOOKBEHINDS on inputs of 2,000/4,000/8,000/16,000 chars and reports the doubling ratio. Expected: clearly superlinear (ratio ≫ 2, approaching or exceeding the 4× quadratic signature), reaching tens of seconds at 16,000 chars. Run twice; results are deterministic.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/re2jsto a version that resolves this vulnerability.Fixed in 2.8.7 - Configuration
Do not enable the LOOKBEHINDS flag when using Matcher-based repeated-matching APIs such as find(), matchAll(), split(), or replaceAll().
re2js LOOKBEHINDS = disabled
Event History
Frequently Asked Questions
Is an application affected if it does not enable lookbehinds?
No. The issue requires the opt-in LOOKBEHINDS flag; patterns without lookbehinds do not trigger the offset-zero restart behavior described.
Which uses are most exposed to the CPU denial-of-service condition?
Applications are affected when they use LOOKBEHINDS with repeated-matching APIs such as matcher().find() in a loop, matchAll(), split(), or matcher().replaceAll(). These APIs request capture slots in a way that prevents the relevant early-exit optimization from firing.
What conditions are needed to trigger the worst-case behavior?
A pattern containing a lookbehind must be used with one of the repeated-matching APIs. Each matching operation can restart scanning from offset 0 and continue through the input, producing quadratic-time matching; a 16,000-character input was reported to consume 36.7–38.7 seconds of CPU.
What can be done if a fix cannot be applied immediately?
Avoid combining the LOOKBEHINDS flag with repeated-matching APIs. Review uses of find() loops, matchAll(), split(), and replaceAll() that process potentially large inputs, especially inputs an attacker may be able to influence.