High patch-now ✔︎ verified CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentRegexp
Bug ClassType Confusion
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Omits that the regression test's species constructor returns a real JSRegExp (the pseudo object only supplies lastIndex/flags), and that the test is skipped without pointer compression (31-bit Smis). Downstream confusion after the unchecked add is not shown in diff.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker532595489
Fix commitde11d56041d5 (v8/v8) +22/-1
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSalvatore Gulizia (nickname: Serotav)
Reported2026-07-08 (per vendor advisory)
Fix landed2026-07-09 (public git)
Disclosed2026-07-14
Patch gap5 days — fix public in source before the advisory

Background

`AdvanceStringIndex`
A V8 CodeStubAssembler helper that computes the next search index during a regexp match, adding one (or two for surrogate pairs) to the current lastIndex.
`Smi`
A “small integer”, V8’s tagged immediate representation for integers that fit in the pointer’s payload bits; values outside Smi::kMaxValue must instead be boxed as a HeapNumber.
`Symbol.matchAll`
The regexp method invoked by String.prototype.matchAll, which reads user-controllable properties such as flags and lastIndex off the receiver and drives iteration via AdvanceStringIndex.
`CSA_DCHECK` / `CSA_CHECK`
CodeStubAssembler assertions where CSA_DCHECK is compiled out of release builds while CSA_CHECK is a hard runtime check present in every build.

Root Cause Analysis

The AdvanceStringIndex builtin computes index_plus_one from the receiver’s lastIndex and, on the fast path, assumes the result remains a positive Smi. That assumption was guarded only by a CSA_DCHECK, a debug-only assertion backed by a static_assert reasoning that String::kMaxLength + 2 < Smi::kMaxValue and that V8 “controls the value of index” at all call sites. However, when Symbol.matchAll is invoked on a non-JSRegExp object (a “pseudo” receiver) whose lastIndex is a raw attacker-chosen Smi such as 1073741823, that call-site invariant does not hold, so index + 1 can be produced as a value the fast path treats as a Smi when it is not, leading to type confusion.

The fix upgrades the assertion from CSA_DCHECK to CSA_CHECK so the constraint is enforced in release builds, deterministically crashing (safe fail) instead of proceeding with a mis-tagged index.

Key insight
The single mistake was trusting a call-site invariant with a debug-only CSA_DCHECK that release builds strip away, letting an attacker-controlled lastIndex violate the “index + 1 is a Smi” precondition unchecked; promoting it to a CSA_CHECK enforces the invariant in production and converts a type confusion into a controlled abort.

Attack Path

  1. Craft a pseudo-regexp receiver Build a plain object carrying flags: 'g' and an attacker-chosen lastIndex of 1073741823, bypassing the real JSRegExp allocation path.
  2. Redirect species construction Set the object’s constructor[Symbol.species] to a function returning a genuine regexp, so matchAll proceeds past its species/type gating.
  3. Invoke matchAll on the fake receiver Call RegExp.prototype[Symbol.matchAll].call(pseudo_re, '') and step the iterator via .next() to enter the matching loop.
  4. Overflow the index assumption AdvanceStringIndex computes index_plus_one from the near-Smi::kMaxValue lastIndex, violating the “positive Smi” precondition that only the stripped CSA_DCHECK guarded.
  5. Trigger type confusion The fast path treats the mis-tagged index_plus_one as a Smi, corrupting subsequent index-based operations in the regexp engine.

Impact Assessment

An attacker running script in the renderer gains a V8 type confusion over an integer index, a primitive that typically bootstraps into memory corruption and, ultimately, renderer-process code execution. Reaching it requires only JavaScript execution in the target context and no special permissions, since lastIndex is fully attacker-controlled through a forged matchAll receiver. The fix does not remove the reachable path but hardens it to a deterministic crash, denying the attacker the corrupted-index primitive.

Files Changed

  • src/builtins/builtins-regexp-gen.cc
  • test/mjsunit/mjsunit.status
  • test/mjsunit/regress/regress-532595489.js

Audit Directions

  • Debug-only assertions on attacker-reachable invariants
    Audit CSA_DCHECK (and DCHECK) guards that assume Smi range or call-site control, since values flowing from user-settable properties like lastIndex may reach them in release builds unchecked.
  • Forged builtin receivers
    Review builtins invoked via Function.prototype.call on non-native receivers (e.g. Symbol.matchAll, Symbol.replace, iterator protocols) where properties such as flags and lastIndex are read without validating the object’s type.
  • Index arithmetic near `Smi::kMaxValue`
    Look for index + 1 / index + 2 computations and other length/offset arithmetic that rely on String::kMaxLength bounds to stay in Smi range without a runtime tag check.
From de11d56041d5aa9c5e69b031990ad17068dbef7a Mon Sep 17 00:00:00 2001
From: pthier <[email protected]>
Date: Thu, 09 Jul 2026 17:00:27 +0200
Subject: [PATCH] [regexp] Hard Check that index + 1 is a Smi in AdvanceStringIndex

Bug: 532595489
Change-Id: I3fb2a3246ebbf801b883cc9fe22a7903cf7b1edd
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8070079
Reviewed-by: Jakob Linke <[email protected]>
Commit-Queue: Patrick Thier <[email protected]>
Auto-Submit: Patrick Thier <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108562}
---

diff --git a/src/builtins/builtins-regexp-gen.cc b/src/builtins/builtins-regexp-gen.cc
index b371c20..be445c3 100644
--- a/src/builtins/builtins-regexp-gen.cc
+++ b/src/builtins/builtins-regexp-gen.cc
@@ -1603,7 +1603,8 @@
     // Must be in Smi range on the fast path. We control the value of {index}
     // on all call-sites and can never exceed the length of the string.
     static_assert(String::kMaxLength + 2 < Smi::kMaxValue);
-    CSA_DCHECK(this, TaggedIsPositiveSmi(index_plus_one));
+    // TODO(532595489): Hard CHECK for now as a quick fix.
+    CSA_CHECK(this, TaggedIsPositiveSmi(index_plus_one));
   }
 
   Label if_isunicode(this), out(this);
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index 0606336..0d1d12a 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -86,6 +86,9 @@
   # https://crbug.com/485267831
   'regress/regress-485267831': [SKIP],
 
+  # https://crbug.com/532595489
+  'regress/regress-532595489': [FAIL, CRASH, NO_VARIANTS, ['not pointer_compression', SKIP]],
+
   ##############################################################################
   # Tests where variants make no sense.
   'd8/enable-tracing': [PASS, NO_VARIANTS],
diff --git a/test/mjsunit/regress/regress-532595489.js b/test/mjsunit/regress/regress-532595489.js
new file mode 100644
index 0000000..c80f7e0
--- /dev/null
+++ b/test/mjsunit/regress/regress-532595489.js
@@ -0,0 +1,17 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const re = /(?:)/;
+
+function Species() {
+  return re;
+}
+
+const pseudo_re = {
+  flags: 'g',
+  lastIndex: 1073741823,
+  constructor: { [Symbol.species]: Species },
+};
+
+RegExp.prototype[Symbol.matchAll].call(pseudo_re, '').next();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index 0606336..0d1d12a 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -86,6 +86,9 @@
   # https://crbug.com/485267831
   'regress/regress-485267831': [SKIP],
 
+  # https://crbug.com/532595489
+  'regress/regress-532595489': [FAIL, CRASH, NO_VARIANTS, ['not pointer_compression', SKIP]],
+
   ##############################################################################
   # Tests where variants make no sense.
   'd8/enable-tracing': [PASS, NO_VARIANTS],
diff --git a/test/mjsunit/regress/regress-532595489.js b/test/mjsunit/regress/regress-532595489.js
new file mode 100644
index 0000000..c80f7e0
--- /dev/null
+++ b/test/mjsunit/regress/regress-532595489.js
@@ -0,0 +1,17 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const re = /(?:)/;
+
+function Species() {
+  return re;
+}
+
+const pseudo_re = {
+  flags: 'g',
+  lastIndex: 1073741823,
+  constructor: { [Symbol.species]: Species },
+};
+
+RegExp.prototype[Symbol.matchAll].call(pseudo_re, '').next();
Loading diff…

Original Bug Report

reported by [email protected]

V8 RegExp.matchAll fast path stores HeapNumber lastIndex through Smi no-barrier path

VULNERABILITY DETAILS

Pure JavaScript can make RegExp.prototype[Symbol.matchAll] store a young HeapNumber into an old JSRegExp.lastIndex field through a fast path typed as Smi.

That Smi store lowers to StoreObjectFieldNoWriteBarrier, so the old-to-new edge is not recorded. A later young GC can reclaim/reuse the young HeapNumber while the old JSRegExp.lastIndex field still points at the stale young-space address.

Minimal trigger shape:

  1. A fake receiver passes flags: "g" and lastIndex: 1073741823 (Smi::kMaxValue on x64 pointer-compression builds).
  2. constructor[Symbol.species] returns a different fast JSRegExp.
  3. matchAll iterator-next handles an empty match and advances the species regexp’s lastIndex.
  4. AdvanceStringIndexFast produces 1073741824, which is a HeapNumber.
  5. FastStoreLastIndex(JSRegExp, Smi) stores it through the Smi/no-barrier overload.

Relevant current-source refs:

src/builtins/regexp-match-all.tq:178-188
  FastLoadLastIndex -> AdvanceStringIndexFast -> FastStoreLastIndex

src/builtins/builtins-regexp-gen.h:217-220
  AdvanceStringIndexFast(...) is declared TNode<Smi> but casts
  AdvanceStringIndex(...): Number

src/builtins/builtins-regexp-gen.cc:163-168
  FastStoreLastIndex(TNode<JSRegExp>, TNode<Smi>) stores JSRegExp::kLastIndexOffset

src/codegen/code-stub-assembler.cc:4043-4046
  StoreObjectField(TNode<Smi>) uses StoreObjectFieldNoWriteBarrier

Expected behavior: the fast path should not store a heap object through a Smi no-write-barrier store. A non-Smi result should use a generic tagged-object store with the normal write barrier, or the path should deopt/fallback before writing.

Actual behavior: valid JavaScript reaches the Smi store with a HeapNumber, causing stale/corrupt lastIndex state after GC.

I also paired this RegExp primitive with an already known sandbox escape and used the combined chain to flag the Chrome 150 v8CTF target.

VERSION

Chrome Version:

d8 / V8 15.2.0 (candidate)
V8 source commit: 0c7a9d0d8c05491e8053d7259a1a6b11e75c1907
Release d8 build id: 0c3af5e5a4d734b5
ASAN d8 build id: cd9b6305dec563bd

The combined exploitability check was against Chrome for Testing M150 in the v8CTF environment.

Operating System:

Ubuntu 24.04.4 LTS x86_64
Linux 6.8.0-101-generic

REPRODUCTION CASE

Please attach the files in attachments/ directly.

Invariant break, no flags

File:

attachments/poc_invariant_lastindex_heapnumber.js

Command:

d8 attachments/poc_invariant_lastindex_heapnumber.js

Observed:

1073741824 number

This shows the fast path stored Smi::kMaxValue + 1 into lastIndex as a HeapNumber.

Release crash, no flags

File:

attachments/poc_missing_barrier_crash.js

Command:

d8 attachments/poc_missing_barrier_crash.js

Observed 5/5 locally:

Received signal 11 SEGV_ACCERR ...
exit=139

Supporting files:

attachments/release_crash_summary.txt
attachments/release_crash_sample.txt
attachments/release_build_info.txt

ASAN crash

Command:

ASAN_OPTIONS=symbolize=1:detect_leaks=0 \
  out.gn/x64.asan/d8 --disable-in-process-stack-traces \
  attachments/poc_missing_barrier_crash.js

The --disable-in-process-stack-traces flag is only used so ASAN owns the crash report instead of V8’s in-process signal handler.

Supporting files:

attachments/asan_stack.txt
attachments/asan_build_info.txt

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash:

d8 process crash. In Chrome this is renderer-reachable V8 code.

Crash State:

AddressSanitizer: SEGV READ
#0 Builtins_TypeOfHandler
#3 v8::internal::Invoke(...) src/execution/execution.cc:475
#5 v8::Script::Run(...) src/api/api.cc:2048
#6 v8::Shell::ExecuteSource(...) src/d8/d8.cc:1150

Full symbolized ASAN output is in:

attachments/asan_stack.txt

Client ID:

N/A

CREDIT INFORMATION

Reporter credit:

Salvatore Gulizia (nickname: Serotav)
View on issue tracker