CVE-2026-85045
Overview
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-mutableHeapNumberbox that the owning object points to, soo.x = 2rewrites 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 acaptured/virtual object whose fields are rebuilt slot-by-slot at materialization time; captured objects can also be deduplicated by their allocation pointer viaGetDuplicatedId. - `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.
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
- 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. - 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
HeapNumberbox of the same value. - 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. - 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].xassertions). Any escalation beyond field-value aliasing to memory corruption or type confusion is(inferred)and not shown by the diff.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifsrc/maglev/maglev-code-generator.cc |
modified | |
fortest/mjsunit/maglev/regress-547819997.js |
modified |
Files Changed
src/maglev/maglev-code-generator.ccsrc/maglev/maglev-ir.htest/mjsunit/maglev/regress-547819997.js
Audit Directions
- Literal vs. captured emission of mutable heap objectsAudit 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, mutableHeapNumbers) must take the unduplicatable captured path. - Early returns that skip field traversalLook for
ForEach*Input/slot-iteration callbacks (as inmaglev-ir.h) that short-circuit for a specificObjectType, since skipping slot traversal can leave materialized fields uninitialized or force a special (sharing) path. - Object deduplication by value or allocation identityReview
GetDuplicatedId/DuplicateObjectcall sites and theVirtualObjectShapedefinitions for any type whose instances can be mutated in place, where deduplication would incorrectly alias distinct runtime objects.
Patch
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);
+}
Regression Test / PoC
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);
+}
Original Bug Report
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