CVE-2026-71498: node-re2 Out-of-bounds Heap Read Vulnerability

MODERATE
Advisory Identifier: GHSA-j4r3-hg7j-8chg
CVSS 5.1
CWE-125
Published
Advisory Summary

CVE-2026-71498 is a MODERATE (CVSS 5.1) out-of-bounds heap read vulnerability (CWE-125: Out-of-bounds Read) affecting node-re2 libraries in the npm ecosystem. The issue involves out-of-bounds heap read in `replace`/`split` via a `buffer` ending in a truncated multi-byte utf-8 character → adjacent heap memory disclosed to javascript. Remediation is available by upgrading affected packages to version 1.26.1.

CVE-2026-71498 at a Glance

CVE Identifier CVE-2026-71498
GHSA Identifier GHSA-j4r3-hg7j-8chg
Severity MODERATE
CVSS v3.1 Base Score 5.1
CWE Classification CWE-125: Out-of-bounds Read
Affected Product node-re2
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 5.1

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L).

Attack Vector Local (AV:L)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity None (I:N)
Availability Low (A:L)

How to Fix CVE-2026-71498

To remediate this vulnerability, upgrade all affected node-re2 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

re2 fixed: 1.26.1
npm install re2@1.26.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls re2

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-71498, inspect your project dependencies using npm:

npm ls re2

Analyze the terminal output to locate any version of node-re2 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.26.1), your application is affected.

Note: The npm ls re2 command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-125: Out-of-bounds Read, where the product reads data past the end, or before the beginning, of the intended buffer. In node-re2, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary `re2` infers a character's byte length from its UTF-8 lead byte alone, with no bound on the bytes actually remaining in the input. `Buffer` arguments reach the native layer verbatim — only strings are re-encoded into well-formed UTF-8 — so a `Buffer` whose last byte is a multi-byte lead promises continuation bytes that are not there, and the result builders read up to 3 bytes past the end of the buffer. In `replace()` and `split()` those bytes are copied into the returned `Buffer`, disclosing adjacent heap memory to JavaScript. The trigger is deterministic and requires no special heap grooming. Only `Buffer` input is affected. String input was never at risk: re-encoding guarantees every multi-byte sequence is complete. ## Root cause `getUtf8CharSize` maps a lead byte to a length of 1–4 and never sees the input size: ```cpp // lib/wrapped_re2.h inline size_t getUtf8CharSize(char ch) { return ((0xE5000000 >> ((ch >> 3) & 0x1E)) & 3) + 1; } ``` Callers then read that many bytes. In the zero-width branch of `replace()`, the guard proves only that at least *one* byte remains: ```cpp // lib/replace.cc else if ((size_t)offset < size) { auto sym_size = getUtf8CharSize(data[offset]); // may claim up to 4 bytes result.append(data + offset, sym_size); // reads data[offset .. offset + 3] byteIndex = offset + sym_size; } ``` `offset < size` permits `offset == size - 1`, so a lead byte of `0xF0` makes `append` read `data[size]`, `data[size + 1]` and `data[size + 2]`. Seven read sites shared the defect: | Site | Argument | Disclosed to JS | |---|---|---| | `lib/replace.cc` (zero-width branch) | subject | yes | | `lib/replace.cc` (callback replacer) | subject | yes | | `lib/replace.cc` (replacement scan) | replacement | yes | | `lib/split.cc` | subject | yes | | `lib/pattern.cc` `translateRegExp` (x2) | pattern | no | | `lib/pattern.cc` `escapeRegExp` | pattern | no | Three further callers were **not** vulnerable, because they use the result only to advance an index and never dereference past the end: `getUtf16PositionByCounter` in `lib/wrapped_re2.h` (clamps its return to the buffer size), `lib/match.cc` (the value feeds `RE2::Match`, which rejects `startpos > endpos`), and the `getMaxSubmatch` scan in `lib/replace.cc` (an overshoot just ends the loop). ## Proof of concept Each call returns more bytes than were supplied; the trailing bytes are heap contents and vary between runs. ```js const RE2 = require('re2'); const hex = buf => [...buf].map(b => b.toString(16).padStart(2, '0')).join(' '); // subject: 2 bytes in, 5 bytes out console.log(hex(new RE2('', 'g').replace(Buffer.from([0x41, 0xf0]), ''))); // 41 f0 61 7b eb <- last 3 bytes are adjacent heap memory // replacement argument console.log(hex(new RE2('A', 'g').replace(Buffer.from('A'), Buffer.from([0x42, 0xf0])))); // 42 f0 41 26 d6 // split console.log(new RE2('', 'g').split(Buffer.from([0x41, 0xf0])).map(hex)); // [ '41', 'f0 e2 e4 df' ] ``` `0xC2` (2-byte lead) and `0xE2` (3-byte lead) over-read 1 and 2 bytes respectively; `0xF0` over-reads 3. For the pattern path the over-read occurs in `translateRegExp` / `escapeRegExp`, which run before RE2 validates the pattern, but RE2 then rejects the malformed input, so the bytes are discarded rather than returned: ```js new RE2(Buffer.from([0xf0])); // SyntaxError: invalid UTF-8 — read already happened ``` ## Impact **Information disclosure (`replace`, `split`).** Up to 3 bytes of heap memory adjacent to the input buffer are returned to JavaScript per call. The read is repeatable, so an attacker who controls `Buffer` input and observes output can sample heap memory incrementally. What lands there depends on allocator layout and is not directly steerable, but it may include fragments of other buffers. **Out-of-bounds read (pattern compilation).** No disclosure path, since the malformed pattern is rejected — but the read is still undefined behavior and can fault if the buffer ends on a page boundary. Applications that pass only strings, or only well-formed UTF-8 buffers, are unaffected. The exposure matters most where `re2` is used as intended: running patterns or subjects derived from untrusted input. ## Suggested fix Clamp the inferred character size to the bytes that actually remain, at every site whose result indexes the buffer: ```cpp inline size_t getUtf8CharSize(char ch, size_t remaining) { size_t size = getUtf8CharSize(ch); return size < remaining ? size : remaining; } ``` This is O(1) and changes no algorithm's complexity. A truncated tail then round-trips as the bytes it really holds, which preserves the documented contract that `Buffer` input is passed through verbatim. Rejecting malformed UTF-8 in `Buffer` input would also close the hole, but is a breaking API change. ## Resolution Fixed in `re2@1.26.1`. All seven read sites now clamp the character size to the remaining input, so a `Buffer` ending in a truncated multi-byte character round-trips as its own bytes instead of reading past the end. Regression tests cover the subject, replacement and pattern positions for 2-, 3- and 4-byte leads, including partially truncated sequences. **Remediation:** upgrade to `re2@1.26.1` or later. **Workaround** (if you cannot upgrade): pass strings rather than `Buffer`s, or validate that `Buffer` input is well-formed UTF-8 before calling `replace`, `split`, or the `RE2` constructor — for example `Buffer.compare(Buffer.from(buf.toString('utf8')), buf) === 0`. Reported by [@OvOhao](https://github.com/OvOhao) in [#272](https://github.com/uhop/node-re2/issues/272).

CVE-2026-71498 Affected node-re2 Versions

1 component(s)
Fixed Versions:
1.26.1
Product / Package Ecosystem Affected Versions Fixed Version
re2
npm
< 1.26.1
1.26.1

References

Frequently Asked Questions

What is CVE-2026-71498?
CVE-2026-71498 is a security vulnerability in node-re2 involving Out-of-bounds heap read in `replace`/`split` via a `Buffer` ending in a truncated multi-byte UTF-8 character → adjacent heap memory disclosed to JavaScript under CWE-125: Out-of-bounds Read.
Which node-re2 versions are affected?
Affected versions include releases prior to 1.26.1 across affected packages: re2.
What version fixes CVE-2026-71498?
Upgrading to 1.26.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is re2 affected?
Yes, re2 is affected in releases prior to 1.26.1. Upgrade your dependency to 1.26.1 or later.
What is the CVSS score of CVE-2026-71498?
CVE-2026-71498 has a CVSS v3.1 base score of 5.1 (MODERATE) with vector string CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L.
What CWE is associated with CVE-2026-71498?
CVE-2026-71498 is classified under CWE-125: Out-of-bounds Read (The product reads data past the end, or before the beginning, of the intended buffer.).
How can I check npm dependencies for CVE-2026-71498?
Run the dependency tree command: 'npm ls re2'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-j4r3-hg7j-8chg",
  "modified" : "2026-08-06T21:26:14Z",
  "published" : "2026-08-06T21:26:14Z",
  "aliases" : [ "CVE-2026-71498" ],
  "summary" : "node-re2: Out-of-bounds heap read in `replace`/`split` via a `Buffer` ending in a truncated multi-byte UTF-8 character → adjacent heap memory disclosed to JavaScript",
  "details" : "## Summary\n\n`re2` infers a character's byte length from its UTF-8 lead byte alone, with no bound on the\nbytes actually remaining in the input. `Buffer` arguments reach the native layer verbatim —\nonly strings are re-encoded into well-formed UTF-8 — so a `Buffer` whose last byte is a\nmulti-byte lead promises continuation bytes that are not there, and the result builders read\nup to 3 bytes past the end of the buffer. In `replace()` and `split()` those bytes are copied\ninto the returned `Buffer`, disclosing adjacent heap memory to JavaScript. The trigger is\ndeterministic and requires no special heap grooming.\n\nOnly `Buffer` input is affected. String input was never at risk: re-encoding guarantees every\nmulti-byte sequence is complete.\n\n## Root cause\n\n`getUtf8CharSize` maps a lead byte to a length of 1–4 and never sees the input size:\n\n```cpp\n// lib/wrapped_re2.h\ninline size_t getUtf8CharSize(char ch)\n{\n      return ((0xE5000000 >> ((ch >> 3) & 0x1E)) & 3) + 1;\n}\n```\n\nCallers then read that many bytes. In the zero-width branch of `replace()`, the guard proves\nonly that at least *one* byte remains:\n\n```cpp\n// lib/replace.cc\nelse if ((size_t)offset < size)\n{\n      auto sym_size = getUtf8CharSize(data[offset]);   // may claim up to 4 bytes\n      result.append(data + offset, sym_size);          // reads data[offset .. offset + 3]\n      byteIndex = offset + sym_size;\n}\n```\n\n`offset < size` permits `offset == size - 1`, so a lead byte of `0xF0` makes `append` read\n`data[size]`, `data[size + 1]` and `data[size + 2]`.\n\nSeven read sites shared the defect:\n\n| Site | Argument | Disclosed to JS |\n|---|---|---|\n| `lib/replace.cc` (zero-width branch) | subject | yes |\n| `lib/replace.cc` (callback replacer) | subject | yes |\n| `lib/replace.cc` (replacement scan) | replacement | yes |\n| `lib/split.cc` | subject | yes |\n| `lib/pattern.cc` `translateRegExp` (x2) | pattern | no |\n| `lib/pattern.cc` `escapeRegExp` | pattern | no |\n\nThree further callers were **not** vulnerable, because they use the result only to advance an\nindex and never dereference past the end: `getUtf16PositionByCounter` in `lib/wrapped_re2.h`\n(clamps its return to the buffer size), `lib/match.cc` (the value feeds `RE2::Match`, which\nrejects `startpos > endpos`), and the `getMaxSubmatch` scan in `lib/replace.cc` (an overshoot\njust ends the loop).\n\n## Proof of concept\n\nEach call returns more bytes than were supplied; the trailing bytes are heap contents and vary\nbetween runs.\n\n```js\nconst RE2 = require('re2');\nconst hex = buf => [...buf].map(b => b.toString(16).padStart(2, '0')).join(' ');\n\n// subject: 2 bytes in, 5 bytes out\nconsole.log(hex(new RE2('', 'g').replace(Buffer.from([0x41, 0xf0]), '')));\n// 41 f0 61 7b eb   <- last 3 bytes are adjacent heap memory\n\n// replacement argument\nconsole.log(hex(new RE2('A', 'g').replace(Buffer.from('A'), Buffer.from([0x42, 0xf0]))));\n// 42 f0 41 26 d6\n\n// split\nconsole.log(new RE2('', 'g').split(Buffer.from([0x41, 0xf0])).map(hex));\n// [ '41', 'f0 e2 e4 df' ]\n```\n\n`0xC2` (2-byte lead) and `0xE2` (3-byte lead) over-read 1 and 2 bytes respectively; `0xF0`\nover-reads 3.\n\nFor the pattern path the over-read occurs in `translateRegExp` / `escapeRegExp`, which run\nbefore RE2 validates the pattern, but RE2 then rejects the malformed input, so the bytes are\ndiscarded rather than returned:\n\n```js\nnew RE2(Buffer.from([0xf0]));   // SyntaxError: invalid UTF-8 — read already happened\n```\n\n## Impact\n\n**Information disclosure (`replace`, `split`).** Up to 3 bytes of heap memory adjacent to the\ninput buffer are returned to JavaScript per call. The read is repeatable, so an attacker who\ncontrols `Buffer` input and observes output can sample heap memory incrementally. What lands\nthere depends on allocator layout and is not directly steerable, but it may include fragments\nof other buffers.\n\n**Out-of-bounds read (pattern compilation).** No disclosure path, since the malformed pattern\nis rejected — but the read is still undefined behavior and can fault if the buffer ends on a\npage boundary.\n\nApplications that pass only strings, or only well-formed UTF-8 buffers, are unaffected. The\nexposure matters most where `re2` is used as intended: running patterns or subjects derived\nfrom untrusted input.\n\n## Suggested fix\n\nClamp the inferred character size to the bytes that actually remain, at every site whose result\nindexes the buffer:\n\n```cpp\ninline size_t getUtf8CharSize(char ch, size_t remaining)\n{\n      size_t size = getUtf8CharSize(ch);\n      return size < remaining ? size : remaining;\n}\n```\n\nThis is O(1) and changes no algorithm's complexity. A truncated tail then round-trips as the\nbytes it really holds, which preserves the documented contract that `Buffer` input is passed\nthrough verbatim. Rejecting malformed UTF-8 in `Buffer` input would also close the hole, but\nis a breaking API change.\n\n## Resolution\n\nFixed in `re2@1.26.1`.\n\nAll seven read sites now clamp the character size to the remaining input, so a `Buffer` ending\nin a truncated multi-byte character round-trips as its own bytes instead of reading past the\nend. Regression tests cover the subject, replacement and pattern positions for 2-, 3- and\n4-byte leads, including partially truncated sequences.\n\n**Remediation:** upgrade to `re2@1.26.1` or later.\n\n**Workaround** (if you cannot upgrade): pass strings rather than `Buffer`s, or validate that\n`Buffer` input is well-formed UTF-8 before calling `replace`, `split`, or the `RE2`\nconstructor — for example `Buffer.compare(Buffer.from(buf.toString('utf8')), buf) === 0`.\n\nReported by [@OvOhao](https://github.com/OvOhao) in [#272](https://github.com/uhop/node-re2/issues/272).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "re2"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.26.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.26.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/uhop/node-re2/security/advisories/GHSA-j4r3-hg7j-8chg"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/uhop/node-re2/issues/272"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/uhop/node-re2/commit/9d72042a6a0da5bc523908b04808ea0e23867cc4"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/uhop/node-re2"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-125" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-06T21:26:14Z",
    "nvd_published_at" : null
  }
}