{"id":"CVE-2026-116795","kind":"vulnerability","aliases":["CVE-2026-116795","GHSA-gg6h-894w-4m9c"],"related":[],"title":"RE2JS: Quadratic-time denial of service in re2js lookbehind matching","description":"### Summary\n\nUsing the opt-in `LOOKBEHINDS` flag together with any repeated-matching API\n(`matcher().find()` in a loop, `matchAll()`, `split()`, `matcher().replaceAll()`)\ncauses quadratic-time (Θ(n²)) matching, contradicting the library's own\n\"linear time matching\" guarantee. A single call with a 16,000-character input\ntakes **36.7–38.7 seconds of CPU**.\n\n### Description\n\n#### Root cause\n\nIn `build/index.cjs`, `Machine.match`:\n\n```js\nlet currentPos = this.prog.numLb > 0 ? 0 : pos;   // ~line 1907\n...\nif (this.ncap === 0 && this.matched) break;        // ~line 1953\n```\n\nAny pattern containing a lookbehind sets `prog.numLb > 0`, which forces the\nscan to **always restart at offset 0**, regardless of the requested start\nposition — the lookbehind automaton is re-seeded and the whole input is\nre-walked on every single `find()` call. The early-exit optimization that\nwould otherwise stop at the first match only fires when `ncap === 0`, but\nevery `Matcher`-based API (`find`/`matchAll`/`split`/`replaceAll`) always\nrequests `ncap = 2` (one capture group slot) **even for a captureless\nlookbehind pattern** — so the early exit never triggers through those APIs,\nand the scan continues to the end of the input on every find.\n\nA find-loop over *k* matches in an *n*-character string therefore costs\n*k* × Θ(*n*); for dense matches *k* ∝ *n*, giving **Θ(n²)** total.\n\n#### Isolating control\n\nIdentical find-loop, identical input sizes, same 2-character literal pattern\n*shape* — but **without** the `LOOKBEHINDS` flag:\n\n| n | with `LOOKBEHINDS` (lookbehind pattern) | without (plain literal) | native JS `RegExp` lookbehind |\n|---|---|---|---|\n| 2,000 | 204–228 ms | 3 ms | 0 ms |\n| 4,000 | 877–899 ms | 4 ms | 0 ms |\n| 8,000 | 5,746–6,599 ms | 7 ms | 1 ms |\n| 16,000 | 36,667–38,732 ms | 6 ms | 1 ms |\n\nThis isolates the lookbehind machinery itself — not pattern length, not\nmatch density in the abstract — as the exact and sole cause.\n\n#### Impact\n\n- `re2js`'s entire value proposition is being an RE2-style engine with\n  **linear-time, no-backtracking** matching, specifically to resist\n  algorithmic-complexity attacks that plague backtracking regex engines.\n  This finding defeats that guarantee for any consumer using the advertised\n  `LOOKBEHINDS` feature.\n- Realistic affected code paths: log/text processing that tokenizes or\n  redacts using a lookbehind-based pattern (`matchAll`, `split`,\n  `replaceAll` are the natural APIs for exactly this), applied to\n  attacker-influenced text (user input, uploaded logs, scraped content).\n- A single ~16–32 KB request pins a CPU core for tens of seconds to minutes;\n  Node.js being single-threaded, this freezes the whole worker process for\n  the duration. No authentication or special pattern knowledge is required\n  from the attacker — ordinary alternating text (`\"abababab...\"`) triggers it.\n\n#### Reproduction (copy-paste runnable)\n\ncreate file poc.js\n\n```js\n'use strict';\n// PoC: quadratic-time DoS in re2js's lookbehind matching (opt-in LOOKBEHINDS flag).\n// Usage:\n//   npm pack re2js@2.8.6 --ignore-scripts && tar -xzf re2js-2.8.6.tgz\n//   node poc.js ./package\nconst path = require('path');\nconst pkgDir = process.argv[2] || './package';\nconst { RE2JS } = require(path.resolve(pkgDir, 'build/index.cjs'));\n\nconst re = RE2JS.compile('(?<=a)b', RE2JS.LOOKBEHINDS);\n\nfunction timeFindLoop(n) {\n  const s = 'ab'.repeat(n / 2);\n  const t0 = Date.now();\n  const m = re.matcher(s);\n  let count = 0;\n  while (m.find()) count++;\n  return { ms: Date.now() - t0, count };\n}\n\nconsole.log('n, matches, ms');\nconst sizes = [2000, 4000, 8000, 16000];\nconst results = [];\nfor (const n of sizes) {\n  const r = timeFindLoop(n);\n  results.push(r.ms);\n  console.log(`${n}, ${r.count}, ${r.ms}`);\n}\n\nconst ratio = results[results.length - 1] / results[results.length - 2];\nconsole.log('');\nconsole.log('time ratio for the last doubling of n:', ratio.toFixed(2), '(~2 => linear, ~4 => quadratic)');\nconsole.log(ratio > 3 ? 'PASS  quadratic scaling confirmed on the REAL published package' : 'FAIL  scaling looks linear');\nprocess.exit(ratio > 3 ? 0 : 1);\n```\n\nand run in terminal\n\n```sh\nnpm pack re2js@2.8.6 --ignore-scripts\ntar -xzf re2js-2.8.6.tgz\nnode poc.js ./package\n```\n\n`poc.js` times a `matcher(s).find()` loop over `(?<=a)b` +\n`RE2JS.LOOKBEHINDS` on inputs of 2,000/4,000/8,000/16,000 chars and reports\nthe doubling ratio. Expected: clearly superlinear (ratio ≫ 2, approaching or\nexceeding the 4× quadratic signature), reaching tens of seconds at 16,000\nchars. Run twice; results are deterministic.","summary":"RE2JS, the JavaScript port of Google's RE2 regex engine, rescans input from the start for each match when lookbehinds are enabled and matcher-based APIs are used. Attacker-controlled text with many dense matches can tie up a single-threaded worker and deny service.","severity":"high","cvss":7.5,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"re2js","fixed":["2.8.7"],"affected":">= 2.2.0, < 2.8.7","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cwe-1333","cwe-407","javascript","node","package","re2js","self-hosted"],"not_affected_if":["default-config","trusted-input-only"],"how_to_check":"- Check the installed re2js version in package.json or the lockfile, for example `npm ls re2js`\n- Search the code for `LOOKBEHINDS` or `matcher().find()`, `matchAll()`, `split()`, `replaceAll()` used with RE2JS on untrusted input","fix":"- Upgrade re2js to `2.8.7` or later","status":"open","published_at":"2026-10-28T18:17:14+00:00","first_seen_at":"2026-10-28T18:34:33+00:00","triaged_at":"2026-10-28T18:34:50+00:00","updated_at":"2026-10-28T21:12:44+00:00","category":"package","confidence":0.7,"triage":{"at":"2026-10-28T18:34:50Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade re2js to `2.8.7` or later","reach":"unknown","title":"RE2JS: lookbehind regex matching becomes quadratic, enabling denial of service","impact":"low","summary":"RE2JS, the JavaScript port of Google's RE2 regex engine, rescans input from the start for each match when lookbehinds are enabled and matcher-based APIs are used. Attacker-controlled text with many dense matches can tie up a single-threaded worker and deny service.","category":"package","nicknames":[],"who_cares":["javascript","node","re2js","self-hosted"],"confidence":0.7,"needs_auth":"unknown","worst_case":"Single-threaded worker is monopolized, causing denial of service","analyst_note":"Check the NVD record and the RE2JS repository advisory for the exact affected API list and whether non-lookbehind usage is affected.","how_to_check":"- Check the installed re2js version in package.json or the lockfile, for example `npm ls re2js`\n- Search the code for `LOOKBEHINDS` or `matcher().find()`, `matchAll()`, `split()`, `replaceAll()` used with RE2JS on untrusted input","needs_analyst":true,"not_affected_if":["default-config","trusted-input-only"],"who_is_affected":"Applications that use RE2JS with lookbehinds enabled and call matcher-based APIs (find, matchAll, split, replaceAll) on attacker-influenced text. Applications using RE2JS with default settings or without lookbehinds, or only on trusted input, are less exposed.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"high"},"cost_usd":0.000633,"input_tokens":3443,"output_tokens":577,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-116795':14A,22A '-1333':25B '-2026':13A,21A '-407':27B '/package':556C,565C '0':148C,149C,155C,167C,177C,221C,332C,341C,596C '000':129C,326C,335C,344C,355C '1':352C,363C,636C '16':128C,354C,472C '16000':614C '1907':152C '1953':159C '2':234C,303C,325C,564C,586C,639C,651C,652C '2.8.6':547C '2000':611C '204':327C '228':328C '3':330C,658C '32':473C '36':356C '36.7':133C '38':358C '38.7':134C '4':334C,339C,654C '4000':612C '4m9c':19A '5':345C '599':348C '6':347C,361C '667':357C '7':350C '732':359C '746':346C '8':343C '8000':613C '877':336C '894w':18A '899':337C 'ab':583C 'abababab':514C 'abstract':379C 'advertis':429C 'affect':433C 'algorithm':411C 'algorithmic-complex':410C 'altern':512C 'alway':173C,231C 'api':61B,97C,226C,253C,454C 'appli':458C 'attack':65B,413C,461C,510C 'attacker-control':64B 'attacker-influenc':460C 'authent':501C 'automaton':186C 'b':576C 'backtrack':405C,416C 'base':60B,225C,446C 'break':157C 'build/index.cjs':142C,571C 'call':125C,203C 'captur':236C 'captureless':242C 'caus':107C,140C,385C 'charact':130C,277C,304C 'code':434C 'complex':412C 'confirm':662C 'console.log':605C,628C,640C,641C,656C 'const':557C,561C,566C,572C,581C,587C,590C,609C,615C,618C,622C,632C 'consum':426C 'contain':162C 'content':469C 'continu':257C 'contradict':114C 'control':66B,294C 'copi':519C 'copy-past':518C 'core':479C 'cost':280C 'count':595C,599C,604C 'cpu':137C,478C 'creat':522C 'currentpo':146C 'cve':12A,20A 'cwe':24B,26B 'date.now':589C,602C 'defeat':421C 'deni':81B 'denial':5A 'dens':70B,285C 'densiti':376C 'descript':138C 'dos':532C 'doubl':647C 'durat':499C 'earli':206C,247C 'early-exit':205C 'enabl':56B 'end':260C 'engin':44B,398C,418C 'entir':389C 'even':239C 'everi':200C,223C,265C 'exact':382C,456C 'exit':207C,248C 'featur':431C 'file':523C 'find':99C,202C,227C,266C,269C,297C,420C 'find-loop':268C,296C 'fire':218C 'first':215C 'flag':90C,312C,542C 'forc':169C 'freez':492C 'function':578C 'gg6h':17A 'ghsa':16A 'ghsa-gg6h-894w-4m9c':15A 'give':289C 'googl':40B 'group':237C 'guarante':122C,423C 'host':34B 'ident':295C,299C 'ignor':549C 'ignore-script':548C 'impact':386C 'influenc':462C 'input':46B,131C,194C,263C,300C,465C 'isol':293C,366C 'javascript':28B,37B 'js':144C,322C,525C 'k':272C,281C,287C 'kb':474C 'knowledg':505C 'last':646C 'length':373C 'let':145C,594C 'librari':116C 'line':151C,158C 'linear':119C,401C,653C 'linear-tim':400C 'liter':305C,320C 'log':467C 'log/text':436C 'lookbehind':10A,54B,89C,164C,185C,243C,311C,315C,316C,324C,368C,430C,445C,536C,541C 'lookbehind-bas':444C 'loop':102C,270C,298C 'm':591C 'm.find':598C 'machine.match':143C 'machineri':369C 'mani':69B 'match':11A,52B,71B,96C,113C,121C,216C,273C,286C,375C,406C,537C,607C 'matchal':103C,228C,448C 'matcher':59B,98C,105C,224C 'matcher-bas':58B 'minut':485C 'ms':329C,331C,333C,338C,340C,342C,349C,351C,353C,360C,362C,364C,601C,608C 'n':112C,276C,283C,288C,291C,313C,580C,585C,606C,619C,625C,629C,649C 'nativ':321C 'natur':453C 'ncap':220C,233C 'never':249C 'no-backtrack':403C 'node':29B,554C 'node.js':486C 'npm':544C 'offset':176C 'one':235C 'opt':87C,539C 'opt-in':86C,538C 'optim':208C 'ordinari':511C 'otherwis':211C 'pack':545C 'packag':30B 'pass':659C 'past':520C 'path':435C,558C,560C 'path.resolve':569C 'pattern':161C,244C,306C,317C,372C,447C,504C 'pin':476C 'pkgdir':562C,570C 'plagu':415C 'plain':319C 'poc':528C 'poc.js':524C,555C 'port':38B 'pos':150C 'posit':183C 'process':437C,496C 'process.argv':563C 'prog.numlb':166C 'proposit':391C 'quadrat':3A,109C,530C,655C,660C 'quadratic-tim':2A,108C,529C 'r':623C 'r.count':630C 'r.ms':627C,631C 'ratio':633C,643C,657C 'ratio.tofixed':650C 're':189C,197C,573C 're-seed':188C 're-walk':196C 're.matcher':592C 're2':42B,396C 're2-style':395C 're2js':1A,9A,23A,31B,35B,387C,534C,546C,567C 're2js-2.8.6.tgz':553C 're2js.compile':574C 're2js.lookbehinds':577C 'realist':432C 'redact':441C 'regardless':178C 'regex':43B,417C 'regexp':323C 'repeat':95C,584C 'repeated-match':94C 'replaceal':106C,230C,450C 'reproduct':517C 'request':181C,232C,475C 'requir':507C,559C,568C 'rescan':45B 'resist':409C 'restart':174C 'result':616C,634C,637C 'results.length':635C,638C 'results.push':626C 'return':600C 'root':139C 'runnabl':521C 'scale':661C 'scan':171C,256C 'scrape':468C 'script':550C 'second':135C,483C 'seed':190C 'self':33B 'self-host':32B 'servic':7A,82B 'set':165C 'shape':307C 'singl':77B,124C,201C,471C,489C 'single-thread':76B,488C 'size':301C,610C,621C 'slot':238C 'sole':384C 'special':503C 'specif':407C 'split':104C,229C,449C 'start':49B,182C 'stop':212C 'strict':527C 'string':278C 'style':397C 'summari':83C 't0':588C,603C 'take':132C 'tar':551C 'ten':481C 'text':67B,463C,513C 'therefor':279C 'this.matched':156C 'this.ncap':154C 'this.prog.numlb':147C 'thread':78B,490C 'tie':73B 'time':4A,110C,120C,402C,531C,642C 'timefindloop':579C,624C 'togeth':91C 'token':439C 'total':292C 'trigger':250C,515C 'upload':466C 'usag':543C 'use':63B,84C,427C,442C,526C 'user':464C 'valu':390C 'walk':198C 'whole':193C,494C 'without':309C,318C 'worker':79B,495C 'would':210C 'xzf':552C 'θ':111C,282C,290C","nicknames":[],"mitigations":[],"who_affected":"Applications that use RE2JS with lookbehinds enabled and call matcher-based APIs (find, matchAll, split, replaceAll) on attacker-influenced text. Applications using RE2JS with default settings or without lookbehinds, or only on trusted input, are less exposed.","tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-28T18:17:14+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"nvd","external_id":"CVE-2026-116795","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-116795","severity":null,"published_at":"2026-10-28T18:17:14+00:00","modified_at":"2026-10-28T18:17:14+00:00","first_seen_at":"2026-10-28T18:34:33+00:00","fetched_at":"2026-10-28T18:34:33+00:00","note":null},{"source":"ghsa","external_id":"GHSA-gg6h-894w-4m9c","url":"https://github.com/advisories/GHSA-gg6h-894w-4m9c","severity":"high","published_at":"2026-10-28T21:10:01+00:00","modified_at":"2026-10-28T21:10:03+00:00","first_seen_at":"2026-10-28T21:12:44+00:00","fetched_at":"2026-10-28T21:12:44+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}