Overview

High
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in V8
DescriptionRace condition in V8
ComponentV8
Sub-componentMaglev
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — revised 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 (tier revised from patch-now)
Reviewer notes: Advisory's 'race' label contradicted -- diff is deterministic deopt materialization aliasing. Primitive overclaimed as corruption: diff/test show only object-field value aliasing (identity confusion), no memory corruption; should be logic.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker547819997
Fix commit7d5eb1df2a72 (v8/v8) +55/-24
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedBrendan Dolan-Gavitt, XBOW
Reported2026-08-17 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-09-03
Patch gap15 days — fix public in source before the advisory

Background

Maglev
V8’s mid-tier optimizing JIT; when its assumptions break it deopts back to the interpreter, which requires it to reconstruct (“materialize”) any objects that escape analysis had eliminated.
Mutable `HeapNumber` (double box)
a double-typed object field such as {x: 1.5} is stored not inline but as a heap-allocated, in-place-mutable HeapNumber box that the owning object points to, so o.x = 2 rewrites that box.
Deopt literal vs. captured object
a deopt frame can describe a value either as a deopt literal (a constant object emitted once and referenced by index, so identical literals are shared) or as a captured/virtual object whose fields are rebuilt slot-by-slot at materialization time; captured objects can also be deduplicated by their allocation pointer via GetDuplicatedId.
`VirtualObject` / `VirtualHeapNumberShape`
Maglev’s compile-time description of an escaped object and its field layout, consulted when emitting deopt translations.

Root Cause Analysis

The vulnerable path was the kHeapNumber special case in the deopt-frame builder of maglev-code-generator.cc: instead of emitting an escaped HeapNumber as a freshly materialized captured object, it called BuildHeapNumber, which created one boxed HeapNumber constant and stored it through GetDeoptLiteral(*value). Because deopt literals are content-deduplicated and the slot-traversal method in maglev-ir.h returned early for kHeapNumber (skipping its value_ slot entirely), every deopt frame that needed a double box of the same value referenced the same literal box, violating the invariant that each materialized object must own a distinct, mutable double box for its field. The shipped regression test demonstrates this deterministically: four recursive activations of foo each materialize their own {x: 1.5}, and before the fix all four x fields aliased one shared box, so writing boxes[i].x = i clobbered the others.

The fix removes the literal path, routes kHeapNumber through the normal captured-object path with CreateUnduplicatableId() (which forces kNotDuplicated so the box is never shared), and removes the slot-skipping early return so the value_ slot is populated per materialization. Note that the advisory labels this a “Race condition,” but the diff contains no concurrency, locking, or thread-ordering change — it is a deterministic deopt-materialization aliasing/object-identity bug, and the test reproduces it single-threaded.

Key insight
The single mistake is treating a mutable HeapNumber box as a shareable, value-deduplicated deopt literal, which aliases the double-field storage of independently materialized objects; the fix makes HeapNumber materializations unduplicatable captured objects so each object gets its own fresh box.

Attack Path

  1. Set up escape-analyzed double boxes Run JS that creates objects with double fields (e.g. {x: 1.5}) inside a Maglev-optimized function where escape analysis elides the allocations, so the objects exist only in deopt metadata.
  2. Force multiple simultaneous materializations Trigger a deopt (e.g. via an unfeedback’d call site that emits an unconditional deopt) across several live activations, so Maglev must materialize several objects that each need a HeapNumber box of the same value.
  3. Collapse distinct boxes into one On the pre-fix literal path, the equal-valued boxes are deduplicated into a single shared HeapNumber, so the materialized objects’ double fields all point at one box.
  4. Observe/abuse the aliasing Writing one object’s double field mutates the others’ fields, producing object-state corruption observable at the JS level (the regression test’s boxes[i].x assertions). Any escalation beyond field-value aliasing to memory corruption or type confusion is (inferred) and not shown by the diff.

Impact Assessment

An attacker running untrusted JavaScript in the renderer can make several distinct JavaScript objects share one backing mutable double box after a Maglev deopt, so a write to one object’s double field silently changes another’s — a breach of object identity/state integrity in the renderer process. The preconditions are only that the code reaches Maglev optimization and then deopts while materializing escaped objects with double fields; no special privileges are needed. The advisory rates this high and classifies it as a “Race condition in V8,” but the patch demonstrates a deterministic materialization bug, not a race, and does not itself establish memory corruption, a read/write primitive, or code execution — any such escalation is (inferred); no exploited-in-the-wild status is stated.

Changed Functions

FunctionChangeNotes
if
src/maglev/maglev-code-generator.cc
modified
for
test/mjsunit/maglev/regress-547819997.js
modified

Files Changed

  • src/maglev/maglev-code-generator.cc
  • src/maglev/maglev-ir.h
  • test/mjsunit/maglev/regress-547819997.js

Audit Directions

  • Literal vs. captured emission of mutable heap objects
    Audit every deopt/translation path that emits an object as a deduplicated literal (GetDeoptLiteral, StoreLiteral) and confirm the object is truly immutable; mutable boxes (double fields, mutable HeapNumbers) must take the unduplicatable captured path.
  • Early returns that skip field traversal
    Look for ForEach*Input/slot-iteration callbacks (as in maglev-ir.h) that short-circuit for a specific ObjectType, since skipping slot traversal can leave materialized fields uninitialized or force a special (sharing) path.
  • Object deduplication by value or allocation identity
    Review GetDuplicatedId/DuplicateObject call sites and the VirtualObjectShape definitions for any type whose instances can be mutated in place, where deduplication would incorrectly alias distinct runtime objects.
diff --git a/src/maglev/maglev-code-generator.cc b/src/maglev/maglev-code-generator.cc
index 6ebc3c7..32899ba 100644
--- a/src/maglev/maglev-code-generator.cc
+++ b/src/maglev/maglev-code-generator.cc
@@ -1557,17 +1557,9 @@
     return kNotDuplicated;
   }
 
-  void BuildHeapNumber(const VirtualObject* vobject) {
-    DCHECK_EQ(vobject->object_type(), vobj::ObjectType::kHeapNumber);
-    ValueNode* value_node = vobject->get(offsetof(HeapNumber, value_));
-    return BuildHeapNumber(value_node->Cast<Float64Constant>()->value());
-  }
-
-  void BuildHeapNumber(Float64 number) {
-    DirectHandle<Object> value =
-        local_isolate_->factory()->NewHeapNumberFromBits<AllocationType::kOld>(
-            number.get_bits());
-    translation_array_builder_->StoreLiteral(GetDeoptLiteral(*value));
+  int CreateUnduplicatableId() {
+    object_ids_.push_back(kNotDuplicated);
+    return kNotDuplicated;
   }
 
   void BuildNestedValue(const ValueNode* value,
@@ -1622,13 +1614,16 @@
                           const InputLocation*& input_location,
                           const VirtualObjectList& virtual_objects) {
     vobj::ObjectType object_type = object->object_type();
-    if (object_type == vobj::ObjectType::kHeapNumber) {
-      // TODO(jgruber): Could we use the standard path below instead?
-      return BuildHeapNumber(object);
-    }
     DCHECK_NOT_NULL(object->allocation());
+    // HeapNumbers may be mutable object fields; each materialization must
+    // create a fresh box, so they are never deduplicated.
+    // TODO(victorgomes): Constrain which objects may contain mutable
+    // HeapNumbers. Immutable HeapNumbers can be stored as a literal object
+    // instead of a captured object.
     int dup_id =
-        GetDuplicatedId(reinterpret_cast<intptr_t>(object->allocation()));
+        object_type == vobj::ObjectType::kHeapNumber
+            ? CreateUnduplicatableId()
+            : GetDuplicatedId(reinterpret_cast<intptr_t>(object->allocation()));
     if (dup_id != kNotDuplicated) {
       translation_array_builder_->DuplicateObject(dup_id);
       object->ForEachNestedRuntimeInput(
@@ -1796,7 +1791,7 @@
   ZoneVector<IndirectHandle<TrustedObject>>* protected_deopt_literals_vector_;
   ZoneVector<IndirectHandle<Object>>* deopt_literals_vector_;
 
-  static const int kNotDuplicated = -1;
+  static constexpr int kNotDuplicated = -1;
   std::vector<intptr_t> object_ids_;
 };
 
diff --git a/src/maglev/maglev-ir.h b/src/maglev/maglev-ir.h
index fc76ae5..c9f1bd8 100644
--- a/src/maglev/maglev-ir.h
+++ b/src/maglev/maglev-ir.h
@@ -5874,11 +5874,9 @@
         vobj::Field snd = FieldForOffset(offsetof(ConsString, second_));
         return callback(slots_[snd.slot_index], snd);
       }
-      if (object_type() == vobj::ObjectType::kHeapNumber) {
-        // HeapNumber materialization creates a literal object instead of
-        // slot traversal.
-        return true;
-      }
+      // TODO(victorgomes): Constrain which objects may contain mutable
+      // HeapNumbers. Immutable HeapNumbers can be stored as a literal object
+      // instead of traversing their slots.
     }
     for (int i = 0; i < slot_count(); i++) {
       vobj::Field field = FieldForSlot(i);
@@ -6243,8 +6241,8 @@
 
 struct VirtualHeapNumberShape : VirtualPrimitiveHeapObjectShape {
   using T = HeapNumber;
-  // Special handling needed; deopt materialization uses a special path.
-  // TODO(jgruber): .. but could it take the standard path instead?
+  // Special handling needed; instances may be mutable object fields and thus
+  // must never be deduplicated in deopt frames.
   static constexpr vobj::ObjectType kObjectType = vobj::ObjectType::kHeapNumber;
 #define FIELD_LIST(V) V(value, offsetof(T, value_), vobj::FieldType::kFloat64)
   DEF_SHAPE(VirtualPrimitiveHeapObjectShape, FIELD_LIST);
diff --git a/test/mjsunit/maglev/regress-547819997.js b/test/mjsunit/maglev/regress-547819997.js
new file mode 100644
index 0000000..43a8e39
--- /dev/null
+++ b/test/mjsunit/maglev/regress-547819997.js
@@ -0,0 +1,38 @@
+// 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 --maglev
+
+const boxes = [];
+
+// Never called during warm-up, so this call site has no feedback and Maglev
+// emits an unconditional deopt for it. The literal below is therefore only
+// used by deopt frames and gets elided by escape analysis.
+function sink(o) {
+  boxes.push(o);
+}
+
+function foo(depth, take) {
+  const o = {x: 1.5};
+  if (depth > 0) foo(depth - 1, take);
+  if (take) sink(o);
+}
+
+%PrepareFunctionForOptimization(foo);
+foo(3, false);
+foo(3, false);
+%OptimizeMaglevOnNextCall(foo);
+foo(3, false);
+
+// Deopts the innermost activation eagerly and the outer ones lazily, so every
+// activation materializes its own object.
+foo(3, true);
+
+assertEquals(4, boxes.length);
+for (let i = 0; i < boxes.length; i++) {
+  boxes[i].x = i;
+}
+for (let i = 0; i < boxes.length; i++) {
+  assertEquals(i, boxes[i].x);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/maglev/regress-547819997.js b/test/mjsunit/maglev/regress-547819997.js
new file mode 100644
index 0000000..43a8e39
--- /dev/null
+++ b/test/mjsunit/maglev/regress-547819997.js
@@ -0,0 +1,38 @@
+// 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 --maglev
+
+const boxes = [];
+
+// Never called during warm-up, so this call site has no feedback and Maglev
+// emits an unconditional deopt for it. The literal below is therefore only
+// used by deopt frames and gets elided by escape analysis.
+function sink(o) {
+  boxes.push(o);
+}
+
+function foo(depth, take) {
+  const o = {x: 1.5};
+  if (depth > 0) foo(depth - 1, take);
+  if (take) sink(o);
+}
+
+%PrepareFunctionForOptimization(foo);
+foo(3, false);
+foo(3, false);
+%OptimizeMaglevOnNextCall(foo);
+foo(3, false);
+
+// Deopts the innermost activation eagerly and the outer ones lazily, so every
+// activation materializes its own object.
+foo(3, true);
+
+assertEquals(4, boxes.length);
+for (let i = 0; i < boxes.length; i++) {
+  boxes[i].x = i;
+}
+for (let i = 0; i < boxes.length; i++) {
+  assertEquals(i, boxes[i].x);
+}
Loading diff…

Original Bug Report

reported by mo...@xbow.com

Reused Maglev deoptimization `HeapNumber` enables cage-wide read/write

VULNERABILITY DETAILS

Maglev represents a virtualized double field in deoptimization metadata by allocating one old-space HeapNumber and storing it as a literal. Every deoptimization of that code then uses the same mutable object as the backing storage for the field of each newly materialized JavaScript object. Nested activations can therefore produce distinct objects whose numeric properties alias one physical HeapNumber.

JavaScript can mutate that box through one materialized object after another owner has exposed it as a primitive Number. This also changes the literal used by future deoptimizations and violates the compiler’s assumption that a HeapNumberRef is immutable. The attached PoC races repeated compiler reads of that nominal constant to make Array.prototype.at prove bounds using a safe index but emit a load at an attacker-selected index. It uses three compiled load functions to construct a fake packed-double JSArray and obtains cage-wide read/write.

The issue is reachable from an ordinary web page with the renderer sandbox enabled. It requires no permission, user gesture, V8 flag, native syntax, testing API, feature switch, cross-origin isolation, or prepared profile.

Root cause

MaglevCodeGenerator::BuildHeapNumber() in src/maglev/maglev-code-generator.cc creates an old-space HeapNumber and passes it to StoreLiteral(GetDeoptLiteral(*value)). StoreLiteral places one heap object in the optimized Code object’s literal array. It does not describe per-materialization storage.

In src/deoptimizer/translated-state.cc, the materialized double field is marked kStoreHeapObject. InitializeJSObjectAt() installs the literal’s slot->storage() directly into the field. If nested activations lazily deoptimize at the same exit, each resulting JSObject receives the same box. The field remains writable by ordinary JavaScript, so changing one owner mutates the other owner’s apparent primitive Number and the optimized code’s future materialization literal.

This contradicts the documented invariant in src/compiler/heap-refs.h:

// HeapNumberRef is only created for immutable HeapNumbers. Mutable
// HeapNumbers ... are not exposed to the compiler through HeapNumberRef.

HeapNumberRef::value() reads the live object. Once the reused literal is exposed to JavaScript, concurrent compilation can observe different values for one HeapConstant.

From the alias to cage read/write

TryGetInt32Constant(HeapConstant) reads HeapNumberRef::value() to test whether it is an int32 and reads it again to return the integer. The Array.prototype.at reducer independently obtains the constant index for its bounds proof and again when constructing the load.

The PoC rotates the shared box between a safe index and selected out-of-bounds indices while three functions compile. One observation makes the bounds branch succeed; another selects the raw load offset. TryBuildLoadFixedArrayElementConstantIndex() validates that later value only against the structural range [0, FixedArray::kMaxLength), relying on the earlier proof for the actual backing-store bound.

The three compiled load functions disclose adjacent compressed pointers, recover the packed-double-array map and properties word, and return an attacker-supplied compressed tagged pointer. The script places a fake packed-double-array header in controlled double elements and obtains a reference to it. Rewriting the fake array’s elements pointer then retargets reads and writes throughout the pointer-compression cage.

Security impact

Arbitrary read/write within the V8 cage from ordinary web content. The V8 heap sandbox contains this primitive; native renderer code execution requires a separate sandbox escape.

VERSION

Chrome Version: 152.0.7977.13 and 152.0.7977.30 (Beta); 151.0.7922.71 (Stable)

Operating System: Ubuntu 24.04.4 LTS x86-64; Windows Server 2022 Datacenter 10.0.20348 x64

The full cage read/write PoC succeeded in signed, normally sandboxed Chrome Stable 151.0.7922.71 on Linux x64 (10/10 fresh headed runs), Chrome Beta 152.0.7977.13 on Linux x64 (10/10), and Chrome Beta 152.0.7977.30 on Windows x64.

Source was rechecked on 2026-08-17. Chromium main 096c31af75365c2a3ef7c1854d71045d2ce5ec89, pinning V8 c3e736e5bd2264c5a62749f05ed0b57702c97144, and standalone V8 main 445f45796516823984c123282865478b0c4fc2f4 all retain the vulnerable code.

REPRODUCTION CASE

Native d8 crash PoC

Run the attached JavaScript with an x64 Release build of d8:

d8 poc.js

After about five seconds, d8 receives SIGSEGV at an address ending in 0xfffff000, for example:

Received signal 11 SEGV_ACCERR ...fffff000

The PoC forges a packed-double JSArray, uses it to replace a live array’s elements pointer with the attacker-selected compressed value 0xffffeff9, and reads element zero. That access resolves to cage_base + 0xfffff000. A compiler-race miss exits with status 2 instead of crashing.

The attached poc.js crashed 10/10 times with no flags on V8 15.1.206.16, the version in Chrome Stable 151, and 10/10 times on V8 15.3.30 (tip at time of testing). An ASAN build of the Stable version reported SEGV_ACCERR at the selected ...fffff000 address.

Stock Chrome crash PoC

Download poc.html and poc.js into one directory and serve them:

cd /directory/containing/poc.html
python3 -m http.server 8000 --bind 127.0.0.1

Open http://127.0.0.1:8000/poc.html in Chrome and wait about 5-10 seconds. On success, the renderer crashes at the same attacker-selected cage address. If the compiler race misses, the page automatically starts a fresh document and tries again, up to five attempts.

The attached poc.html/poc.js pair reproduced on its first attempt in a headed, normally sandboxed launch of signed Chrome Beta 152.0.7977.13, producing the renderer crash page with error code 11. The native crash record reports SIGSEGV/SEGV_ACCERR at cage_base + 0xfffff000, with a register holding the decompressed attacker-selected pointer.

Suggested fix

Do not encode mutable materialized-field storage as a reusable literal. Use a captured-object translation, or equivalent logic that allocates a fresh box for every owner and deoptimization activation. A regression test should materialize nested activations, assert that the resulting objects are distinct, mutate one double property, and verify that the other is unchanged.

As defense in depth, compiler access to a HeapNumberRef should use an immutable broker snapshot rather than repeated reads from the live object. The Array.prototype.at reduction should also carry one stable constant from the bounds decision to the load, or revalidate it against the actual backing store at the load site.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: d8 process crash; renderer tab crash in Chrome (SIGSEGV)

Crash State: SIGSEGV/SEGV_ACCERR at 0x1a5fffff000, exactly cage_base + 0xfffff000, after the PoC replaces a live array’s elements pointer with compressed value 0xffffeff9. Register r14 held cage base 0x1a500000000; r12 held the decompressed pointer 0x1a5ffffeff9.

Client ID (if relevant): N/A

CREDIT INFORMATION

Reporter credit: Brendan Dolan-Gavitt, XBOW

View on issue tracker
Links in the report