Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentMaglev
Bug ClassUAF
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
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker550360762
Fix commitdd7e9b5ef6d7 (v8/v8) +56/-2
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Reported2026-08-21 (per vendor advisory)
Fix landed2026-09-01 (public git)
Disclosed2026-09-08
Patch gap7 days — fix public in source before the advisory

Background

for-in fast path
Maglev optimization where a for-in loop 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; when false, Maglev emits enum-index property loads that skip the receiver map check.
Eager inlining
BuildEagerInlineCall splices a callee’s Maglev graph into the caller, running a nested MaglevGraphBuilder (the inlinee) with its own current_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.

Key insight
A boolean copied from the inlinee’s final state is a snapshot, not a summary: an unrelated 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

  1. Set up for-in Attacker starts a for-in loop over a victim object so Maglev caches its map and enum cache with receiver_needs_map_check=false.
  2. Inline a mutator The loop body calls erasePressureThenRevalidate, which Maglev eagerly inlines into the caller.
  3. Transition then mask The inlinee does delete victim.x (migrating victim’s map) and then other.hasOwnProperty(key), leaving the inlinee’s final receiver_needs_map_check=false.
  4. Stale index load Back in the caller, receiver_needs_map_check is overwritten to false, so victim[key] is loaded by the now-stale enum index without a map check.
  5. Type confusion The enum-index load reads the migrated object with the wrong layout, yielding a type-confused/dangling access.

Impact Assessment

This is a renderer-side use-after-free / out-of-bounds condition in V8’s Maglev tier, reachable from unprivileged JavaScript on any page. A stale enum-index load against a migrated object produces a type confusion that an attacker can shape into a controlled read/write primitive, a strong foundation for full renderer compromise. Exploitation requires only that the victim script be JIT-compiled by Maglev, which is reliably triggerable from attacker-controlled JavaScript.

Changed Functions

FunctionChangeNotes
for
test/mjsunit/maglev/regress/regress-550360762.js
modified

Files Changed

  • src/maglev/maglev-graph-builder.h
  • test/mjsunit/maglev/regress/regress-550360762.js
  • src/maglev/maglev-graph-builder.cc

Audit Directions

  • Snapshot-vs-cumulative state across inlining
    Audit 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 effects
    Review all paths that can clear receiver_needs_map_check and confirm no inlined or called code can perform a map transition on the loop receiver without forcing a re-check.
  • Map-change tracking completeness
    Verify 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.
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));
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

View on issue tracker