GHSA-FF84-5F28-78QJ
Vulnerability from github – Published: 2026-07-31 16:53 – Updated: 2026-07-31 16:53Summary
re2 validates the user-settable lastIndex against the subject's UTF-8 byte length but then uses it as a UTF-16 code-unit count to walk the subject buffer, with no bounds check. For any non-ASCII subject, the byte length is larger than the true character count, so a lastIndex between those two values passes validation while pointing past the end of the buffer. The subsequent walk reads out of bounds. With a large subject the read marches into unmapped memory and the process dies with SIGABRT/SIGSEGV — an uncatchable crash (try/catch cannot stop it), i.e. a denial of service for any worker/process that runs the match. In some cases the out-of-bounds bytes are copied into the returned value (a bounded, best-effort heap information leak).
Root cause
The subject wrapper stores the UTF-8 byte length in StrVal::length:
lib/addon.cc:200auto argLength = utf8Length(s, isolate);— UTF-8 byte countlib/addon.cc:209lastStringValue.reset(buffer, argSize, argLength, startFrom, false, isAscii);
setIndex then validates the (UTF-16) lastIndex against that byte length and walks the buffer by character count:
// lib/addon.cc:229
void StrVal::setIndex(size_t newIndex) {
isValidIndex = newIndex <= length; // length == UTF-8 BYTE length, not UTF-16 length
if (!isValidIndex) { index = newIndex; byteIndex = 0; return; }
...
// addon.cc:263
byteIndex = index < newIndex
? getUtf16PositionByCounter(data, byteIndex, newIndex - index)
: getUtf16PositionByCounter(data, 0, newIndex);
index = newIndex;
}
getUtf16PositionByCounter reads data[from] and advances by the UTF-8 char size with no check of from against the buffer size:
// lib/wrapped_re2.h:264
inline size_t getUtf16PositionByCounter(const char *data, size_t from, size_t n) {
for (; n > 0; --n) {
size_t s = getUtf8CharSize(data[from]); // <-- OOB read once `from` passes the buffer end
from += s;
if (s == 4 && n >= 2) --n;
}
return from;
}
lastIndex is user-settable to any positive integer (capped only at >= 0, no upper bound):
// lib/accessors.cc:166
NAN_SETTER(WrappedRE2::SetLastIndex) {
...
int n = value->NumberValue(...).FromMaybe(0);
re2->lastIndex = n <= 0 ? 0 : n; // no upper bound relative to the subject
}
For an ASCII subject the byte length equals the UTF-16 length, so the guard is correct — this only triggers on non-ASCII subjects. The out-of-bounds read happens inside prepareArgument for any global/sticky regex, reached by exec, test, String.prototype.match, replace, and split.
Proof of concept
Minimal (AddressSanitizer, deterministic OOB read):
const RE2 = require('re2');
const re = new RE2('a', 'y'); // sticky; 'g' also works
re.lastIndex = 3; // 3 <= byteLen(4) passes the guard; only 2 real chars exist
re.exec('éé'); // U+00E9 = 2 bytes each
Built with -fsanitize=address, this aborts with:
ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 1
#0 getUtf16PositionByCounter wrapped_re2.h:268
#1 StrVal::reset addon.cc:277
#2 WrappedRE2::prepareArgument addon.cc:209
#3 WrappedRE2::Exec exec.cc:17
The overflowed region is the subject buffer allocated by node::Buffer::New at addon.cc:205.
Real-world impact on the shipped prebuilt binary (no ASAN) — uncatchable crash:
const RE2 = require('re2');
const s = '中'.repeat(40000000); // UTF-16 length 40M, UTF-8 bytes 120M
const re = new RE2('a', 'y');
re.lastIndex = Buffer.byteLength(s) - 1; // passes the byte-length guard, far exceeds real char count
re.exec(s); // walks into unmapped memory -> SIGSEGV (exit 139)
try { ... } catch (e) {} around the call does not prevent termination — it is a native fault, not a JS exception. Validated on a clean npm install re2@1.25.1 (latest): stock prebuilt → SIGSEGV; ASAN build → the heap-buffer-overflow read above.
Impact
- Denial of service (primary): an uncatchable native crash that terminates the Node process/worker. Reachable remotely and without authentication wherever an application (a) uses a
globalorstickyRE2, (b) applies it to a non-ASCII subject, and (c) setslastIndexfrom attacker-influenced data (e.g. resuming a scan/pagination at a client-supplied offset). - Information disclosure (secondary, best-effort): the out-of-bounds
byteIndexcan cause adjacent heap bytes to be copied into the returned value (e.g. the leading segment of areplaceresult). This is bounded and unreliable — the subject buffer iscalloc-allocated (zero-filled) and the over-read distance depends on interpreting out-of-bounds bytes as UTF-8 sizes — so it is noted for completeness, not as a dependable primitive.
This is distinct from GHSA-8hcv-x26h-mcgp (the global replace() output-amplification abort), which was fixed in 1.25.1. This lastIndex out-of-bounds read is a separate defect and remains present in 1.25.1.
Suggested fix
Two independent hardenings; either closes the crash, both is safest:
- Bound the walk so it can never read past the buffer:
inline size_t getUtf16PositionByCounter(const char *data, size_t size, size_t from, size_t n) {
for (; n > 0 && from < size; --n) {
size_t s = getUtf8CharSize(data[from]);
from += s;
if (s == 4 && n >= 2) --n;
}
return from > size ? size : from;
}
(thread size through the two call sites in StrVal::setIndex).
- Validate
lastIndexagainst the true UTF-16 length, not the UTF-8 byte length — e.g. stores->Length()(UTF-16 units) as the value compared inisValidIndex = newIndex <= <utf16Length>, so an out-of-rangelastIndextakes the existing!isValidIndexearly-return path.
Resolution
Fixed in re2 1.25.2.
lastIndex is now validated against the subject's UTF-16 length instead of its
UTF-8 byte length (lib/addon.cc), so an out-of-range lastIndex is rejected
before the buffer is walked. As defense in depth, the code-unit walk
(getUtf16PositionByCounter in lib/wrapped_re2.h) is now bounded by the
buffer size and can no longer read past the end.
Remediation: upgrade to re2@1.25.2 or later.
Workaround (if you cannot upgrade): do not assign lastIndex from untrusted
input, or clamp it to the subject's string length (str.length) before calling
exec/test/match/replace/split on a non-ASCII subject.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.25.1"
},
"package": {
"ecosystem": "npm",
"name": "re2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.25.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-67550"
],
"database_specific": {
"cwe_ids": [
"CWE-125"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-31T16:53:16Z",
"nvd_published_at": "2026-07-30T20:18:15Z",
"severity": "MODERATE"
},
"details": "## Summary\n\n`re2` validates the user-settable `lastIndex` against the subject\u0027s **UTF-8 byte length** but then uses it as a **UTF-16 code-unit count** to walk the subject buffer, with no bounds check. For any non-ASCII subject, the byte length is larger than the true character count, so a `lastIndex` between those two values passes validation while pointing past the end of the buffer. The subsequent walk reads out of bounds. With a large subject the read marches into unmapped memory and the process dies with **SIGABRT/SIGSEGV** \u2014 an uncatchable crash (`try/catch` cannot stop it), i.e. a denial of service for any worker/process that runs the match. In some cases the out-of-bounds bytes are copied into the returned value (a bounded, best-effort heap information leak).\n\n## Root cause\n\nThe subject wrapper stores the UTF-8 **byte** length in `StrVal::length`:\n\n- `lib/addon.cc:200` `auto argLength = utf8Length(s, isolate);` \u2014 UTF-8 **byte** count\n- `lib/addon.cc:209` `lastStringValue.reset(buffer, argSize, argLength, startFrom, false, isAscii);`\n\n`setIndex` then validates the (UTF-16) `lastIndex` against that byte length and walks the buffer by character count:\n\n```cpp\n// lib/addon.cc:229\nvoid StrVal::setIndex(size_t newIndex) {\n isValidIndex = newIndex \u003c= length; // length == UTF-8 BYTE length, not UTF-16 length\n if (!isValidIndex) { index = newIndex; byteIndex = 0; return; }\n ...\n // addon.cc:263\n byteIndex = index \u003c newIndex\n ? getUtf16PositionByCounter(data, byteIndex, newIndex - index)\n : getUtf16PositionByCounter(data, 0, newIndex);\n index = newIndex;\n}\n```\n\n`getUtf16PositionByCounter` reads `data[from]` and advances by the UTF-8 char size with **no check of `from` against the buffer size**:\n\n```cpp\n// lib/wrapped_re2.h:264\ninline size_t getUtf16PositionByCounter(const char *data, size_t from, size_t n) {\n for (; n \u003e 0; --n) {\n size_t s = getUtf8CharSize(data[from]); // \u003c-- OOB read once `from` passes the buffer end\n from += s;\n if (s == 4 \u0026\u0026 n \u003e= 2) --n;\n }\n return from;\n}\n```\n\n`lastIndex` is user-settable to any positive integer (capped only at `\u003e= 0`, no upper bound):\n\n```cpp\n// lib/accessors.cc:166\nNAN_SETTER(WrappedRE2::SetLastIndex) {\n ...\n int n = value-\u003eNumberValue(...).FromMaybe(0);\n re2-\u003elastIndex = n \u003c= 0 ? 0 : n; // no upper bound relative to the subject\n}\n```\n\nFor an ASCII subject the byte length equals the UTF-16 length, so the guard is correct \u2014 this only triggers on non-ASCII subjects. The out-of-bounds read happens inside `prepareArgument` for any `global`/`sticky` regex, reached by `exec`, `test`, `String.prototype.match`, `replace`, and `split`.\n\n## Proof of concept\n\n**Minimal (AddressSanitizer, deterministic OOB read):**\n\n```js\nconst RE2 = require(\u0027re2\u0027);\nconst re = new RE2(\u0027a\u0027, \u0027y\u0027); // sticky; \u0027g\u0027 also works\nre.lastIndex = 3; // 3 \u003c= byteLen(4) passes the guard; only 2 real chars exist\nre.exec(\u0027\u00e9\u00e9\u0027); // U+00E9 = 2 bytes each\n```\n\nBuilt with `-fsanitize=address`, this aborts with:\n\n```\nERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 1\n #0 getUtf16PositionByCounter wrapped_re2.h:268\n #1 StrVal::reset addon.cc:277\n #2 WrappedRE2::prepareArgument addon.cc:209\n #3 WrappedRE2::Exec exec.cc:17\n```\n\nThe overflowed region is the subject buffer allocated by `node::Buffer::New` at `addon.cc:205`.\n\n**Real-world impact on the shipped prebuilt binary (no ASAN) \u2014 uncatchable crash:**\n\n```js\nconst RE2 = require(\u0027re2\u0027);\nconst s = \u0027\u4e2d\u0027.repeat(40000000); // UTF-16 length 40M, UTF-8 bytes 120M\nconst re = new RE2(\u0027a\u0027, \u0027y\u0027);\nre.lastIndex = Buffer.byteLength(s) - 1; // passes the byte-length guard, far exceeds real char count\nre.exec(s); // walks into unmapped memory -\u003e SIGSEGV (exit 139)\n```\n\n`try { ... } catch (e) {}` around the call does **not** prevent termination \u2014 it is a native fault, not a JS exception. Validated on a clean `npm install re2@1.25.1` (latest): stock prebuilt \u2192 SIGSEGV; ASAN build \u2192 the heap-buffer-overflow read above.\n\n## Impact\n\n- **Denial of service (primary):** an uncatchable native crash that terminates the Node process/worker. Reachable remotely and without authentication wherever an application (a) uses a `global` or `sticky` `RE2`, (b) applies it to a non-ASCII subject, and (c) sets `lastIndex` from attacker-influenced data (e.g. resuming a scan/pagination at a client-supplied offset).\n- **Information disclosure (secondary, best-effort):** the out-of-bounds `byteIndex` can cause adjacent heap bytes to be copied into the returned value (e.g. the leading segment of a `replace` result). This is bounded and unreliable \u2014 the subject buffer is `calloc`-allocated (zero-filled) and the over-read distance depends on interpreting out-of-bounds bytes as UTF-8 sizes \u2014 so it is noted for completeness, not as a dependable primitive.\n\nThis is distinct from GHSA-8hcv-x26h-mcgp (the global `replace()` output-amplification abort), which was fixed in 1.25.1. This `lastIndex` out-of-bounds read is a separate defect and remains present in 1.25.1.\n\n## Suggested fix\n\nTwo independent hardenings; either closes the crash, both is safest:\n\n1. **Bound the walk** so it can never read past the buffer:\n\n```cpp\ninline size_t getUtf16PositionByCounter(const char *data, size_t size, size_t from, size_t n) {\n for (; n \u003e 0 \u0026\u0026 from \u003c size; --n) {\n size_t s = getUtf8CharSize(data[from]);\n from += s;\n if (s == 4 \u0026\u0026 n \u003e= 2) --n;\n }\n return from \u003e size ? size : from;\n}\n```\n(thread `size` through the two call sites in `StrVal::setIndex`).\n\n2. **Validate `lastIndex` against the true UTF-16 length**, not the UTF-8 byte length \u2014 e.g. store `s-\u003eLength()` (UTF-16 units) as the value compared in `isValidIndex = newIndex \u003c= \u003cutf16Length\u003e`, so an out-of-range `lastIndex` takes the existing `!isValidIndex` early-return path.\n\n## Resolution\n\nFixed in re2 1.25.2.\n\n`lastIndex` is now validated against the subject\u0027s UTF-16 length instead of its\nUTF-8 byte length (`lib/addon.cc`), so an out-of-range `lastIndex` is rejected\nbefore the buffer is walked. As defense in depth, the code-unit walk\n(`getUtf16PositionByCounter` in `lib/wrapped_re2.h`) is now bounded by the\nbuffer size and can no longer read past the end.\n\n**Remediation:** upgrade to `re2@1.25.2` or later.\n\n**Workaround** (if you cannot upgrade): do not assign `lastIndex` from untrusted\ninput, or clamp it to the subject\u0027s string length (`str.length`) before calling\n`exec`/`test`/`match`/`replace`/`split` on a non-ASCII subject.",
"id": "GHSA-ff84-5f28-78qj",
"modified": "2026-07-31T16:53:16Z",
"published": "2026-07-31T16:53:16Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/uhop/node-re2/security/advisories/GHSA-ff84-5f28-78qj"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-67550"
},
{
"type": "WEB",
"url": "https://github.com/uhop/node-re2/commit/56293de4fc0914d7bc35f92e98de25b0d9bb417d"
},
{
"type": "PACKAGE",
"url": "https://github.com/uhop/node-re2"
},
{
"type": "WEB",
"url": "https://github.com/uhop/node-re2/releases/tag/1.25.2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "re2: Out-of-bounds heap read in `exec`/`test`/`match` via attacker-influenced `lastIndex` on a non-ASCII subject \u2192 uncatchable process crash (DoS)"
}
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.