GHSA-8HCV-X26H-MCGP

Vulnerability from github – Published: 2026-08-06 21:19 – Updated: 2026-08-06 21:19
VLAI
Summary
node-re2: String.prototype.replace(re2, template) aborts the Node process (uncatchable ToLocalChecked on empty MaybeLocal) when the result exceeds V8's max string length
Details

Description

WrappedRE2::Replace builds the replacement result and hands it to V8 with .ToLocalChecked() without checking for the empty MaybeLocal that V8 returns when the string/buffer exceeds its maximum length:

lib/replace.cc (v1.24.1):

// L553 — Buffer return path
info.GetReturnValue().Set(Nan::CopyBuffer(result.data(), result.size()).ToLocalChecked());
// L556 — String return path
info.GetReturnValue().Set(Nan::New(result).ToLocalChecked());

When a global replace uses an output-amplifying template — $' (text after the match) or $` (text before the match) — the result grows to O(input²). For an input of ~40,000+ identical single-char matches the result exceeds V8's String::kMaxLength (~536,870,888 chars on 64-bit). Nan::New(result) then returns an empty MaybeLocal, and the unchecked .ToLocalChecked() calls v8::Utils::ReportApiFailureFATAL ERROR: v8::ToLocalChecked Empty MaybeLocalabort() (SIGABRT).

This is an uncatchable crash: it is not a JavaScript exception, so a surrounding try/catch cannot stop it — the entire Node process (or worker) dies.

The built-in regex engine handles the identical case correctly by throwing a catchable RangeError: Invalid string length. node-re2 diverges from that contract and aborts instead.

Proof of concept

npm i re2
node poc.js
const RE2 = require('re2');

// Built-in engine: same case -> CATCHABLE RangeError (correct)
try { 'a'.repeat(50000).replace(/a/g, "$'"); }
catch (e) { console.log('native:', e.constructor.name, e.message); } // RangeError: Invalid string length

// re2: ABORTS the whole process (uncatchable; try/catch does not help)
'a'.repeat(50000).replace(new RE2('a', 'g'), "$'");
// -> FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal   (process exits 134 / SIGABRT)

Observed (Node v24, clean npm i re2 → re2@1.24.1): native branch prints RangeError: Invalid string length; the re2 branch aborts with FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal, stack top WrappedRE2::Replace, process exit code 134.

Threshold matches the mechanism precisely: input of 30,000 chars completes; 40,000 aborts (30000²/2 ≈ 4.5e8 < 5.37e8 max; 40000²/2 ≈ 8e8 > max). $&/constant templates and non-global replaces do not amplify and do not crash.

Impact

A remote, unauthenticated denial of service against any service that runs String.prototype.replace / the re2 [Symbol.replace] path where either the replacement template (containing $' or $`) or the input size is attacker-influenced. Because the failure is a native abort(), it cannot be contained by try/catch or domains — one request takes down the whole process/worker. This is especially impactful for re2's core audience, who adopt it specifically to process untrusted patterns/inputs safely.

Suggested fix

Check the MaybeLocal before ToLocalChecked on both return paths (and the intermediate group-string builds), and throw a catchable RangeError to match the built-in engine:

auto maybe = Nan::New(result);
if (maybe.IsEmpty()) { Nan::ThrowRangeError("Invalid string length"); return; }
info.GetReturnValue().Set(maybe.ToLocalChecked());

(Apply equivalently to the Nan::CopyBuffer(...) buffer path at L553 and to the per-group Nan::New(data, size).ToLocalChecked() sites used by the replacer-function path.)

Resolution

Resolved in re2 1.25.1. WrappedRE2::Replace now checks the returned MaybeLocal on every result path and throws a catchable RangeError: Invalid string length (matching the built-in engine) instead of aborting the process with an uncatchable SIGABRT. No API changes --- upgrade to re2 >= 1.25.1 via a plain npm upgrade to receive the fix.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.25.0"
      },
      "package": {
        "ecosystem": "npm",
        "name": "re2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.25.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-71430"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-617"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-06T21:19:36Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Description\n\n`WrappedRE2::Replace` builds the replacement result and hands it to V8 with `.ToLocalChecked()` **without checking for the empty `MaybeLocal`** that V8 returns when the string/buffer exceeds its maximum length:\n\n`lib/replace.cc` (v1.24.1):\n```cpp\n// L553 \u2014 Buffer return path\ninfo.GetReturnValue().Set(Nan::CopyBuffer(result.data(), result.size()).ToLocalChecked());\n// L556 \u2014 String return path\ninfo.GetReturnValue().Set(Nan::New(result).ToLocalChecked());\n```\n\nWhen a global replace uses an output-amplifying template \u2014 `$\u0027` (text after the match) or `` $` `` (text before the match) \u2014 the result grows to **O(input\u00b2)**. For an input of ~40,000+ identical single-char matches the result exceeds V8\u0027s `String::kMaxLength` (~536,870,888 chars on 64-bit). `Nan::New(result)` then returns an **empty `MaybeLocal`**, and the unchecked `.ToLocalChecked()` calls `v8::Utils::ReportApiFailure` \u2192 **`FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`** \u2192 `abort()` (SIGABRT).\n\nThis is an **uncatchable** crash: it is not a JavaScript exception, so a surrounding `try/catch` cannot stop it \u2014 the entire Node process (or worker) dies.\n\n**The built-in regex engine handles the identical case correctly** by throwing a *catchable* `RangeError: Invalid string length`. node-re2 diverges from that contract and aborts instead.\n\n## Proof of concept\n\n```\nnpm i re2\nnode poc.js\n```\n\n```js\nconst RE2 = require(\u0027re2\u0027);\n\n// Built-in engine: same case -\u003e CATCHABLE RangeError (correct)\ntry { \u0027a\u0027.repeat(50000).replace(/a/g, \"$\u0027\"); }\ncatch (e) { console.log(\u0027native:\u0027, e.constructor.name, e.message); } // RangeError: Invalid string length\n\n// re2: ABORTS the whole process (uncatchable; try/catch does not help)\n\u0027a\u0027.repeat(50000).replace(new RE2(\u0027a\u0027, \u0027g\u0027), \"$\u0027\");\n// -\u003e FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal   (process exits 134 / SIGABRT)\n```\n\nObserved (Node v24, clean `npm i re2` \u2192 re2@1.24.1): native branch prints `RangeError: Invalid string length`; the re2 branch aborts with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`, stack top `WrappedRE2::Replace`, process exit code **134**.\n\nThreshold matches the mechanism precisely: input of 30,000 chars completes; 40,000 aborts (30000\u00b2/2 \u2248 4.5e8 \u003c 5.37e8 max; 40000\u00b2/2 \u2248 8e8 \u003e max). `$\u0026`/constant templates and non-global replaces do not amplify and do not crash.\n\n## Impact\n\nA remote, unauthenticated denial of service against any service that runs `String.prototype.replace` / the re2 `[Symbol.replace]` path where either the **replacement template** (containing `$\u0027` or `` $` ``) or the **input size** is attacker-influenced. Because the failure is a native `abort()`, it cannot be contained by `try/catch` or domains \u2014 one request takes down the whole process/worker. This is especially impactful for re2\u0027s core audience, who adopt it specifically to process untrusted patterns/inputs safely.\n\n## Suggested fix\n\nCheck the `MaybeLocal` before `ToLocalChecked` on both return paths (and the intermediate group-string builds), and throw a catchable `RangeError` to match the built-in engine:\n\n```cpp\nauto maybe = Nan::New(result);\nif (maybe.IsEmpty()) { Nan::ThrowRangeError(\"Invalid string length\"); return; }\ninfo.GetReturnValue().Set(maybe.ToLocalChecked());\n```\n\n(Apply equivalently to the `Nan::CopyBuffer(...)` buffer path at L553 and to the per-group `Nan::New(data, size).ToLocalChecked()` sites used by the replacer-function path.)\n\n## Resolution\n\nResolved in `re2` `1.25.1`. `WrappedRE2::Replace` now checks the returned `MaybeLocal` on every result path and throws a catchable `RangeError: Invalid string length` (matching the built-in engine) instead of aborting the process with an uncatchable `SIGABRT`. No API changes --- upgrade to `re2` \u003e= `1.25.1` via a plain `npm upgrade` to receive the fix.",
  "id": "GHSA-8hcv-x26h-mcgp",
  "modified": "2026-08-06T21:19:36Z",
  "published": "2026-08-06T21:19:36Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/uhop/node-re2/security/advisories/GHSA-8hcv-x26h-mcgp"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/uhop/node-re2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "node-re2: String.prototype.replace(re2, template) aborts the Node process (uncatchable ToLocalChecked on empty MaybeLocal) when the result exceeds V8\u0027s max string length"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…