High patch-now ✔︎ verified CVSS 8.8 chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped 🧬 Unsound Turboshaft elimination and over-loose typer ranges

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in V8
DescriptionUninitialized Use in V8
ComponentV8
Sub-componentTurboshaft
Bug ClassUninitialized Memory
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
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker527385397
Fix commita90e80ff481a (v8/v8) +88/-16
CWECWE-457 (Use of uninitialized)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedPierre Langlois from Arm
Reported2026-06-24 · bounty $500 (per vendor advisory)
Fix landed2026-06-29 (public git)
Disclosed2026-07-08
Patch gap9 days — fix public in source before the advisory

Background

Turboshaft
V8’s newer optimizing compiler IR/pipeline in src/compiler/turboshaft/ where nodes like Store are emitted and optimized.
`maybe_initializing_or_transitioning`
a Store flag telling the compiler the write initializes freshly allocated memory (or transitions a map), which pins the store so later passes may not eliminate or reorder it.
Initializing store
the write that first populates a newly allocated object’s slot with a valid value, which must be observable before the object is exposed to the GC.
Store-store elimination / loop unrolling
optimizations that drop redundant stores and expand small loops, which can wrongly delete an unmarked initializing store when they conclude a later write supersedes it.

Root Cause Analysis

When lowering NewArray in machine-lowering-reducer-inl.h, the loop that fills the fixed-size array wrote each element with StoreNonArrayBufferElement, which forwarded to the underlying Store without any initialization annotation, and the array was marked finished (FinishInitialization) before the fill loop ran. Because these element writes carried maybe_initializing_or_transitioning = false, the compiler treated them as ordinary stores rather than initializing stores. With loop unrolling plus store-store elimination combining in the right way, the initializing stores could be elided even though the object was published to the GC in between, violating the invariant that every slot of a heap object is initialized before the GC can observe it.

The fix threads a maybe_initializing_or_transitioning flag from StoreElement / StoreNonArrayBufferElement / InitializeNonArrayBufferElement down into Store, sets it true on the initialize paths, and reorders the lowering so elements are written via InitializeNonArrayBufferElement on the still-Uninitialized array with FinishInitialization moved after the loop.

Key insight
The array element initializing stores were emitted as plain stores with maybe_initializing_or_transitioning unset, so the optimizer felt free to remove them; marking them as initializing (and finishing initialization only after the fill loop) forbids that elision and keeps the slots defined before the GC sees them.

Attack Path

  1. Trigger optimized NewArray Craft a function like foo that allocates new Array(len) with a length (3) that Turboshaft constant-folds and fully unrolls, lowering to per-element initializing stores.
  2. Elide the initializing stores Turboshaft’s loop unrolling plus store-store elimination remove the unmarked element-initializing Stores, leaving array slots holding uninitialized memory.
  3. Publish to the GC Store the array into a live object (glob.x = arr) and force a collection (%MajorGCForCompilerTesting()), so the GC scans slots that were never initialized.
  4. Uninitialized use A later tagged write (arr[2] = 42) or the GC’s scan interprets the garbage slot as a valid tagged pointer, yielding a type/memory-safety violation exploitable for corruption.

Impact Assessment

An attacker running JavaScript in the renderer can cause the GC to read and treat uninitialized heap memory as live tagged pointers, a memory-safety violation that can be developed into arbitrary read/write and ultimately renderer code execution. It requires only that the victim run attacker-controlled script and that the JIT tier up the function so the vulnerable NewArray lowering and unrolling apply.

Files Changed

  • src/compiler/turboshaft/assembler.h
  • src/compiler/turboshaft/machine-lowering-reducer-inl.h
  • test/mjsunit/turboshaft/regress-527385397.js

Audit Directions

  • Unmarked initializing stores
    Audit other Turboshaft lowerings that populate freshly allocated objects (arrays, maps, backing stores) for element/field writes that reach Store without maybe_initializing_or_transitioning.
  • Premature FinishInitialization
    Look for FinishInitialization calls placed before the loop or block that actually writes all slots, which leaves initializing stores as ordinary stores eligible for elimination.
  • Elision-sensitive optimizations
    Review interactions of loop unrolling and store-store elimination around allocation sequences, especially where a GC-safepoint or publish can occur between allocation and full initialization.
From a90e80ff481a0689ba1a835f3dbcbf9a078076a5 Mon Sep 17 00:00:00 2001
From: Darius Mercadier <[email protected]>
Date: Fri, 26 Jun 2026 17:12:05 +0200
Subject: [PATCH] [turboshaft] Mark array initializing stores as initializing

With the right combination of loop unrolling and a bunch of other
optimizations kicking in in the perfect way, this missing annotation
can lead to the initializing stores being elided and the GC observing
uninitialized memory.

Fixed: 527385397
Change-Id: I581f8bf8b518a64d2a3427ab18746c1242ba6194
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8005985
Reviewed-by: Nico Hartmann <[email protected]>
Auto-Submit: Darius Mercadier <[email protected]>
Commit-Queue: Nico Hartmann <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108296}
---

diff --git a/src/compiler/turboshaft/assembler.h b/src/compiler/turboshaft/assembler.h
index 8c88ebd..07f4614 100644
--- a/src/compiler/turboshaft/assembler.h
+++ b/src/compiler/turboshaft/assembler.h
@@ -3384,15 +3384,19 @@
     return StoreElement(object, access, index, value, true);
   }
   template <typename Base>
-  void StoreNonArrayBufferElement(V<Base> object, const ElementAccess& access,
-                                  V<WordPtr> index, V<Any> value) {
-    return StoreElement(object, access, index, value, false);
+  void StoreNonArrayBufferElement(
+      V<Base> object, const ElementAccess& access, V<WordPtr> index,
+      V<Any> value, bool maybe_initializing_or_transitioning = false) {
+    return StoreElement(object, access, index, value, /*is_array_buffer*/ false,
+                        maybe_initializing_or_transitioning);
   }
 
   template <typename Class, typename T>
   void StoreElement(V<Class> object, const ElementAccessTS<Class, T>& access,
-                    ConstOrV<WordPtr> index, V<T> value) {
-    StoreElement(object, access, index, value, access.is_array_buffer_load);
+                    ConstOrV<WordPtr> index, V<T> value,
+                    bool maybe_initializing_or_transitioning = false) {
+    StoreElement(object, access, index, value, access.is_array_buffer_load,
+                 maybe_initializing_or_transitioning);
   }
 
   template <typename Class, typename T>
@@ -3400,7 +3404,8 @@
                          const ElementAccessTS<Class, T>& access,
                          ConstOrV<WordPtr> index, V<T> value) {
     StoreElement(object.object(), access, index, value,
-                 access.is_array_buffer_load);
+                 access.is_array_buffer_load,
+                 /*maybe_initializing_or_transitioning*/ true);
   }
 
   // TODO(nicohartmann): Remove `InitializeArrayBufferElement` once fully
@@ -3411,13 +3416,14 @@
                                     V<WordPtr> index, V<Any> value) {
     StoreArrayBufferElement(object.object(), access, index, value);
   }
-  // TODO(nicohartmann): Remove `InitializeNoneArrayBufferElement` once fully
+  // TODO(nicohartmann): Remove `InitializeNonArrayBufferElement` once fully
   // transitioned to `ElementAccess`.
   template <typename Base>
   void InitializeNonArrayBufferElement(Uninitialized<Base>& object,
                                        const ElementAccess& access,
                                        V<WordPtr> index, V<Any> value) {
-    StoreNonArrayBufferElement(object.object(), access, index, value);
+    StoreNonArrayBufferElement(object.object(), access, index, value,
+                               /*maybe_initializing_or_transitioning*/ true);
   }
 
 #if V8_STATIC_ROOTS_BOOL
@@ -5882,8 +5888,8 @@
   // instead of StoreElement.
   template <typename Base>
   void StoreElement(V<Base> object, const ElementAccess& access,
-                    ConstOrV<WordPtr> index, V<Any> value,
-                    bool is_array_buffer) {
+                    ConstOrV<WordPtr> index, V<Any> value, bool is_array_buffer,
+                    bool maybe_initializing_or_transitioning = false) {
     if constexpr (is_taggable_v<Base>) {
       DCHECK_EQ(access.base_is_tagged, BaseTaggedness::kTaggedBase);
     } else {
@@ -5895,7 +5901,8 @@
     MemoryRepresentation rep =
         MemoryRepresentation::FromMachineType(access.machine_type);
     Store(object, resolve(index), value, kind, rep, access.write_barrier_kind,
-          access.header_size, rep.SizeInBytesLog2());
+          access.header_size, rep.SizeInBytesLog2(),
+          maybe_initializing_or_transitioning);
   }
 
   // BranchAndBind should be called from GotoIf/GotoIfNot. It will insert a
diff --git a/src/compiler/turboshaft/machine-lowering-reducer-inl.h b/src/compiler/turboshaft/machine-lowering-reducer-inl.h
index 27b0e78..ec9e63f 100644
--- a/src/compiler/turboshaft/machine-lowering-reducer-inl.h
+++ b/src/compiler/turboshaft/machine-lowering-reducer-inl.h
@@ -2060,19 +2060,17 @@
                        AccessBuilder::ForFixedArrayLengthPadding(),
                        __ Word32Constant(0));
 #endif
-    // TODO(nicohartmann@): Should finish initialization only after all elements
-    // have been initialized.
-    auto array = __ FinishInitialization(std::move(uninitialized_array));
 
     ScopedVar<WordPtr> index(this, 0);
 
     WHILE(__ UintPtrLessThan(index, length)) {
-      __ StoreNonArrayBufferElement(array, access, index, the_hole_value);
+      __ InitializeNonArrayBufferElement(uninitialized_array, access, index,
+                                         the_hole_value);
       // Advance the {index}.
       index = __ WordPtrAdd(index, 1);
     }
 
-    GOTO(done, array);
+    GOTO(done, __ FinishInitialization(std::move(uninitialized_array)));
 
     BIND(done, result);
     return result;
diff --git a/test/mjsunit/turboshaft/regress-527385397.js b/test/mjsunit/turboshaft/regress-527385397.js
new file mode 100644
index 0000000..e8d929f
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-527385397.js
@@ -0,0 +1,67 @@
+// 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.
+
+// Flags: --allow-natives-syntax
+
+// {glob} will be used to publish the allocation to the GC. Its {x} and {y}
+// field must not be at the same offset than `arr[2]` below. And its {y} field
+// must be a Tagged field (rather than Float64) so that it forces tagging when
+// we store to it, which can trigger a GC. (the "offset_16" field is at offset
+// 16 and is needed to that the "y" field isn't).
+let glob = { x : 42, offset_16 : "skipping offset 16", y : "abc" };
+
+function foo(x, ...rest) {
+  // Turboshaft can recognize rotations in xor trees, which Turbofan cannot. As
+  // a result, {lhs} and {rhs} below will not be recognized as being equal by
+  // Turbofan, but Turboshaft will realize that it's the case...
+  let lhs = (x << 2) ^ x ^ (x >>> 30);
+  let rhs = (x >>> 30) ^ x ^ (x << 2);
+  // ... which means that {cond} will be constant-folded to true but only in
+  // Turboshaft...
+  let cond = lhs == rhs;
+  // ... and len will thus be a Phi for Turbofan but 3 for Turboshaft.
+  let len = cond ? 3 : 4;
+
+  // Note that length 3 is the only one that works because:
+  //
+  //   - Only loops with length 3 or less are fully unrolled
+  //
+  //   - `new Array` does 2 allocation: the FixedArray backing store and the
+  //     JSArray, and to initialize the latter it does 4 stores, at offsets 0
+  //     (Map), 4 (properties_or_hash), 8 (elements == the FixedArra backing
+  //     store) and 12 (length), and it's important that none of those have the
+  //     same offset as the final `arr[2] = 42` (which will be at offset 16).
+
+  // Allocating fixed-sized array. This will be lowered to a NewArray, which
+  // itself will be lowered to a loop with 3 iterations, which will then be
+  // unrolled.
+  let arr = new Array(len);
+
+  // Publishing the array for the GC...
+  glob.x = arr;
+  // ... so that it's seen by the GC here.
+  %MajorGCForCompilerTesting();
+
+  // Storing at offset 2 in {arr}. This should not store-store eliminate with
+  // the initializing store since there is a GC in the middle.
+  arr[2] = 42;
+
+  // Making sure that {arr} escapes.
+  return arr;
+}
+
+// We need to warmup feedback with a non-smi int32 in order to avoid that `x<<2`
+// is lowered to `(x>>1)<<2` because of the untagging, since it would then be
+// optimized to `x<<1`, and the rotation would thus not be matched anymore since
+// `x<<1^x>>30` cannot be optimized to a rotation. With HeapNumber feedback, the
+// untagging will handle both Smi and HeapNumber and thus return a Phi, so
+// Turboshaft won't be able to optimize `x>>1<<2` to `x<<1` anymore.
+let non_smi_int32 = 0x80000000;
+
+%PrepareFunctionForOptimization(foo);
+foo(non_smi_int32);
+foo(42);
+
+%OptimizeFunctionOnNextCall(foo);
+foo(42);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/turboshaft/regress-527385397.js b/test/mjsunit/turboshaft/regress-527385397.js
new file mode 100644
index 0000000..e8d929f
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-527385397.js
@@ -0,0 +1,67 @@
+// 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.
+
+// Flags: --allow-natives-syntax
+
+// {glob} will be used to publish the allocation to the GC. Its {x} and {y}
+// field must not be at the same offset than `arr[2]` below. And its {y} field
+// must be a Tagged field (rather than Float64) so that it forces tagging when
+// we store to it, which can trigger a GC. (the "offset_16" field is at offset
+// 16 and is needed to that the "y" field isn't).
+let glob = { x : 42, offset_16 : "skipping offset 16", y : "abc" };
+
+function foo(x, ...rest) {
+  // Turboshaft can recognize rotations in xor trees, which Turbofan cannot. As
+  // a result, {lhs} and {rhs} below will not be recognized as being equal by
+  // Turbofan, but Turboshaft will realize that it's the case...
+  let lhs = (x << 2) ^ x ^ (x >>> 30);
+  let rhs = (x >>> 30) ^ x ^ (x << 2);
+  // ... which means that {cond} will be constant-folded to true but only in
+  // Turboshaft...
+  let cond = lhs == rhs;
+  // ... and len will thus be a Phi for Turbofan but 3 for Turboshaft.
+  let len = cond ? 3 : 4;
+
+  // Note that length 3 is the only one that works because:
+  //
+  //   - Only loops with length 3 or less are fully unrolled
+  //
+  //   - `new Array` does 2 allocation: the FixedArray backing store and the
+  //     JSArray, and to initialize the latter it does 4 stores, at offsets 0
+  //     (Map), 4 (properties_or_hash), 8 (elements == the FixedArra backing
+  //     store) and 12 (length), and it's important that none of those have the
+  //     same offset as the final `arr[2] = 42` (which will be at offset 16).
+
+  // Allocating fixed-sized array. This will be lowered to a NewArray, which
+  // itself will be lowered to a loop with 3 iterations, which will then be
+  // unrolled.
+  let arr = new Array(len);
+
+  // Publishing the array for the GC...
+  glob.x = arr;
+  // ... so that it's seen by the GC here.
+  %MajorGCForCompilerTesting();
+
+  // Storing at offset 2 in {arr}. This should not store-store eliminate with
+  // the initializing store since there is a GC in the middle.
+  arr[2] = 42;
+
+  // Making sure that {arr} escapes.
+  return arr;
+}
+
+// We need to warmup feedback with a non-smi int32 in order to avoid that `x<<2`
+// is lowered to `(x>>1)<<2` because of the untagging, since it would then be
+// optimized to `x<<1`, and the rotation would thus not be matched anymore since
+// `x<<1^x>>30` cannot be optimized to a rotation. With HeapNumber feedback, the
+// untagging will handle both Smi and HeapNumber and thus return a Phi, so
+// Turboshaft won't be able to optimize `x>>1<<2` to `x<<1` anymore.
+let non_smi_int32 = 0x80000000;
+
+%PrepareFunctionForOptimization(foo);
+foo(non_smi_int32);
+foo(42);
+
+%OptimizeFunctionOnNextCall(foo);
+foo(42);
Loading diff…

Original Bug Report

reported by [email protected]

StoreStoreElimination can eliminate initializing stores

The TurboShaft assembler does not set the maybe_initializing_or_transitioning properties for element stores in a few places:

  • In the NewArray node that initialized a fixed array using StoreNonArrayBufferElement().
  • Strings use InitializeElement() to initialize the data, this does not set the maybe_initializing_or_transitioning despite the name.
  • WasmGC ArraySet and StructSet use regular stores too 2.

The problem is that when store-store-elimination runs, it may delete stores that are alias with a later store, but it should opt out if the maybe_initializing_or_transitioning. As a result, uninitialized memory could be visible to the GC in the above cases.

At the time of reporting however, we weren’t able to find a real reproducer, given:

  • The store-store-elimination pass doesn’t run for wasm code
  • The NewArray, and many of the string initializations, uses loops that cannot be unrolled, this could change in the future
  • Strings are immutable, so another store cannot alias. At least for the place we found where a single-char string was initialised with a store that could be eliminated, as it’s a fixed-offset store 1.

I’ve set a low severity given we’re unsure how uninitialised memory visible to the GC would be exploited. And we couldn’t actually trigger store-store elimination on main.

View on issue tracker