CVE-2025-12727
Overview
Background
- Maglev
- V8’s mid-tier optimizing JIT that builds a graph of typed
ValueNodes from bytecode before generating machine code. - `HoleyFloat64ToTagged`
- a Maglev conversion node that boxes a (possibly hole-containing) float64 value into a tagged pointer, with a
ConversionModeselecting whether integral values are canonicalized toSmior always boxed asHeapNumber. - `ConversionMode`
- the enum
{ kCanonicalizeSmi, kForceHeapNumber }controlling whetherHoleyFloat64ToTaggedproduces aSmifor values that fit, versus always emitting aHeapNumber. - `BuildCheckSmi`
- a Maglev helper that inserts a runtime guard asserting a value is a
Smi; downstream code assumes that if this guard passes, the value is genuinely aSmiwhen later stored.
Root Cause Analysis
The alternative-representation cache in MaglevReducer created the tagged form of a float64 value via AddNewNodeNoInputConversion<HoleyFloat64ToTagged> using ConversionMode::kForceHeapNumber, so integral, Smi-range values were boxed as HeapNumber rather than canonicalized to Smi. Consumers of that cached tagged node — notably BuildCheckSmi in maglev-graph-builder.cc — rely on the invariant that a value passing the Smi check is actually a Smi at the point it is stored (for example when Array-constructor speculation selects PACKED_SMI_ELEMENTS and guards each element with BuildCheckSmi). Because the shared tagged alternative was a kForceHeapNumber conversion, the value could remain a boxed HeapNumber, violating that invariant. The pre-patch graph builder tried to paper over this by mutating the shared conversion node with SetMode(kCanonicalizeSmi) at each Smi read, but that per-read workaround did not cover every consumer that depended on canonicalization.
The fix makes kCanonicalizeSmi the default mode at node creation in maglev-reducer-inl.h, so the tagged alternative always canonicalizes Smis and the BuildCheckSmi guarantee holds uniformly; the graph-builder mutation is replaced by a DEBUG-only DCHECK confirming the mode.
kForceHeapNumber, so a Smi-range value guarded by BuildCheckSmi could still be a HeapNumber at store time; the fix flips the default to kCanonicalizeSmi so the Smi invariant every consumer assumes is actually established.Attack Path
- Trigger float64 alternative caching
Run JavaScript that causes Maglev to materialize the tagged alternative of a float64 value, which pre-patch is built as
HoleyFloat64ToTaggedinkForceHeapNumbermode. - Reach a consumer that trusts `BuildCheckSmi`
Drive a code path such as Array-constructor speculation selecting
PACKED_SMI_ELEMENTS, where each stored value is guarded only byBuildCheckSmi(alt, ...)and assumed to be a realSmiafterward. - Bypass the per-read workaround
Use a consumer that does not run the graph-builder’s
SetMode(kCanonicalizeSmi)fixup, so the value passes the Smi guard yet remains a boxedHeapNumber. - Store a `HeapNumber` where a `Smi` is assumed
The un-canonicalized
HeapNumberis written into a slot/element kind that the engine treats as containing onlySmis, producing a representation/type confusion (inferred). - Leverage the confusion
Subsequent code reading the slot as a
Smioperates on aHeapNumberpointer, giving an attacker a primitive from the mismatch (inferred).
Impact Assessment
Smi representation invariant so that a HeapNumber is stored where downstream Maglev-compiled code assumes a Smi, a logic error that yields type/representation confusion inside the V8 sandbox (inferred). The advisory classifies this as a LogicError / inappropriate implementation in V8; it does not report exploitation in the wild, and any escalation to a read/write primitive or beyond is (inferred). Preconditions are only that Maglev compiles the affected code path with the float64-to-tagged alternative caching in play.Files Changed
src/maglev/maglev-graph-builder.ccsrc/maglev/maglev-ir.hsrc/maglev/maglev-reducer-inl.h
Audit Directions
- Default conversion modes vs. downstream guardsAudit every conversion/boxing node whose default mode differs from what a later
Check*/Assert*guard assumes, ensuring the guard’s post-condition is actually established by the producer. - Shared alternative-representation nodesReview places that cache a single tagged/untagged alternative and are consumed by multiple sites; per-consumer mutation of a shared node (like the removed
SetMode) is a fragile pattern that can leave other consumers on the wrong invariant. - Smi-representation assumptions in elements-kind speculationExamine Array/elements-kind speculation (
PACKED_SMI_ELEMENTSetc.) that guards values withBuildCheckSmi/GetSmiValue, confirming stored values cannot survive asHeapNumber.
Patch
From af7644b2de8ca3ccc4069b637b550d076caf4b38 Mon Sep 17 00:00:00 2001
From: Jakob Linke <jgruber@chromium.org>
Date: Tue, 04 Nov 2025 06:45:09 +0100
Subject: [PATCH] [maglev] Let HoleyFloat64ToTagged canonicalize smis by default
.. since current use patterns around BuildCheckSmi rely on this.
The expectation is that if `BuildCheckSmi(value)` passes at runtime,
then the value is guaranteed to be a smi when accessed later (e.g.
when it is stored into a slot).
One example: Array ctor speculation that picks the PACKED_SMI_ELEMENTS
elements kind, and guards values with `BuildCheckSmi`.
Some workarounds for this can now be removed; I've added comments
there and we can attempt that separately.
Bug: 454485895
Change-Id: I7ef81083a50151ea2ddd71626f2d5af7bafaca5c
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7100539
Commit-Queue: Jakob Linke <jgruber@chromium.org>
Reviewed-by: Victor Gomes <victorgomes@chromium.org>
Auto-Submit: Jakob Linke <jgruber@chromium.org>
Cr-Commit-Position: refs/heads/main@{#103479}
---
diff --git a/src/maglev/maglev-graph-builder.cc b/src/maglev/maglev-graph-builder.cc
index baecea7..f32505d 100644
--- a/src/maglev/maglev-graph-builder.cc
+++ b/src/maglev/maglev-graph-builder.cc
@@ -1710,14 +1710,13 @@
auto& alternative = node_info->alternative();
if (ValueNode* alt = alternative.tagged()) {
- // HoleyFloat64ToTagged does not canonicalize Smis by default, since it can
- // be expensive. If we are reading a Smi value, we should try to
- // canonicalize now.
+#ifdef DEBUG
if (HoleyFloat64ToTagged* conversion_node =
alt->TryCast<HoleyFloat64ToTagged>()) {
- conversion_node->SetMode(
- HoleyFloat64ToTagged::ConversionMode::kCanonicalizeSmi);
+ DCHECK_EQ(conversion_node->conversion_mode(),
+ HoleyFloat64ToTagged::ConversionMode::kCanonicalizeSmi);
}
+#endif // DEBUG
return BuildCheckSmi(alt, !value->Is<Phi>());
}
@@ -3260,6 +3259,8 @@
case ContextCell::kSmi:
// HoleyFloat64ToTagged does not canonicalize Smis by default, use
// GetSmiValue to force canonicalization for the value if necessary.
+ // TODO(454485895): Consider removing this workaround since
+ // HoleyFloat64ToTagged now canonicalizes by default.
RETURN_IF_ABORT(GetSmiValue(value));
broker()->dependencies()->DependOnContextCell(slot_ref, state);
return AddNewNode<StoreSmiContextCell>({value}, context_ref, slot_ref,
@@ -4477,6 +4478,8 @@
// canonicalize smis by default in GetTaggedValue. We rely on
// canonicalization though in TryReduceConstructArrayConstructor.
// We should make this more robust.
+ // TODO(454485895): Consider removing this workaround since
+ // HoleyFloat64ToTagged now canonicalizes by default.
MaybeReduceResult res = GetSmiValue(value);
CHECK(res.IsDoneWithValue());
return res.value();
@@ -13711,6 +13714,8 @@
GetRootConstant(RootIndex::kEmptyFixedArray));
// Either the value is a Smi already, or we force a conversion to Smi and
// cache the value in its alternative representation node.
+ // TODO(454485895): Consider removing this workaround since
+ // HoleyFloat64ToTagged now canonicalizes by default.
RETURN_IF_ABORT(GetSmiValue(length));
vobj->set(JSArray::kElementsOffset,
GetRootConstant(RootIndex::kEmptyFixedArray));
diff --git a/src/maglev/maglev-ir.h b/src/maglev/maglev-ir.h
index de59bfd..c5c5208 100644
--- a/src/maglev/maglev-ir.h
+++ b/src/maglev/maglev-ir.h
@@ -4365,6 +4365,8 @@
using Base = FixedInputValueNodeT<1, name>; \
\
public: \
+ /* TODO(454485895): Consider removing kForceHeapNumber since */ \
+ /* it is now unused. */ \
enum class ConversionMode { kCanonicalizeSmi, kForceHeapNumber }; \
explicit name(uint64_t bitfield, ConversionMode mode) \
: Base(ConversionModeBitField::update(bitfield, mode)) {} \
diff --git a/src/maglev/maglev-reducer-inl.h b/src/maglev/maglev-reducer-inl.h
index 3fda3a4..e86ac2a 100644
--- a/src/maglev/maglev-reducer-inl.h
+++ b/src/maglev/maglev-reducer-inl.h
@@ -593,7 +593,7 @@
}
return alternative.set_tagged(
AddNewNodeNoInputConversion<HoleyFloat64ToTagged>(
- {value}, HoleyFloat64ToTagged::ConversionMode::kForceHeapNumber));
+ {value}, HoleyFloat64ToTagged::ConversionMode::kCanonicalizeSmi));
}
case ValueRepresentation::kIntPtr:
Original Bug Report
Incorrect Optimization of ArrayConstructor by Maglev Leads to Creation of Malformed JSArray Objects
VULNERABILITY DETAILS
1 Why Crash?
After Maglev optimization, an incorrect JSArray object was created for arr2, leading to a crash when arr2.join() was executed. The abnormal JSArray object is as follows.
DebugPrint: 0x9b10084a591: [JSArray]
- map: 0x28cd02827311 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x28cd02827339 <JSArray[0]>
- elements: 0x28cd0284a581 <FixedArray[2]> [PACKED_SMI_ELEMENTS]
- length: 2
- properties: 0x28cd020007bd <FixedArray[0]>
- All own properties (excluding elements): {
0x9b100000df1: [String] in ReadOnlySpace: #length: 0x28cd026e8c99 <AccessorInfo name= 0x28cd02000df1 <String[6]: #length>, data= 0x28cd02000011 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x28cd0284a581 <FixedArray[2]> {
0: 0x28cd0284a575 <HeapNumber 1.0>
1: 0
}
Note: elements_kind = PACKED_SMI_ELEMENTS implies that the array should contain only Smis, but in reality, elements[0] is not a Smi, but a pointer to a HeapNumber object.
The vulnerability occurs in the TryReduceConstructArrayConstructor() method of Maglev.
2 Maglev Optimize ArrayConstructor
2.1 Collect Type Info
For the case of new Array(x0, x1, ...), this method first iterates over all argument nodes and collects some type information at compile time.
MaybeReduceResult MaglevGraphBuilder::TryReduceConstructArrayConstructor(
compiler::JSFunctionRef array_function, CallArguments& args,
compiler::OptionalAllocationSiteRef maybe_allocation_site) {
...
// Arity > 1, `new Array(x0, x1, ...)`.
DCHECK_GT(arity, 1);
DCHECK_EQ(variant, InlineArrayCtorVariant::kMultipleArgs);
// Gather the values to store into the newly created array, and remember
// sufficient information about node types so we can select a suitable
// elements_kind below.
bool values_all_smis = true, values_all_numbers = true,
values_any_nonnumber = false;
base::SmallVector<ValueNode*, 16> values;
values.reserve(arity);
for (ValueNode* v : args) {
NodeType node_type = GetType(v);
if (!NodeTypeIs(node_type, NodeType::kSmi)) {
values_all_smis = false;
if (!NodeTypeIs(node_type, NodeType::kNumber)) {
values_all_numbers = false;
if (!NodeTypeCanBe(node_type, NodeType::kNumber)) {
values_any_nonnumber = true;
}
}
}
values.push_back(v);
}
...
}
In this example, the two argument nodes for the ArrayConstructor are:
n55 : LoadHoleyFixedDoubleArrayElement [n51, n26]: Represents thefloat64value loaded fromarr[i]. TheNodeTypefor this node iskNumberOrOddball(It’s confusing thatkNumberOrOddballsuggests this might be aHeapNumberobject, but in reality, this node can only be of typefloat64).n24 : Phi(r5) [n10, n22]: Represents the node fori, with aNodeTypeofSMI.
Because kNumberOrOddball is neither SMI nor Number, the execution results in values_all_smis=false, values_all_numbers=false, and values_any_nonnumber=false. This means we cannot make any definitive inferences about the types of the argument nodes at compile time.
2.2 Speculative Optimization
Subsequently, the elements_kind is updated based on values_all_smis, values_all_numbers, and values_any_nonnumber. However, since they are all false, the elements_kind remains as the initial PACKED_SMI_ELEMENTS (obtained originally from the initial_map). Furthermore, with can_speculate_call enabled by default, execution continues, proceeding with speculative optimization.
MaybeReduceResult MaglevGraphBuilder::TryReduceConstructArrayConstructor(
compiler::JSFunctionRef array_function, CallArguments& args,
compiler::OptionalAllocationSiteRef maybe_allocation_site) {
...
if (values_all_smis) { // false
// Smis can be stored with any elements kind.
} else if (values_all_numbers) { // false
elements_kind = GetMoreGeneralElementsKind(
elements_kind, IsHoleyElementsKind(elements_kind)
? HOLEY_DOUBLE_ELEMENTS
: PACKED_DOUBLE_ELEMENTS);
} else if (values_any_nonnumber) { // false
// We statically know that at least one value is not a number.
elements_kind = GetMoreGeneralElementsKind(
elements_kind,
IsHoleyElementsKind(elements_kind) ? HOLEY_ELEMENTS : PACKED_ELEMENTS);
} else if (!can_speculate_call) { // <=== allow default, continue optimize
// We cannot precisely determine the elements_kind based on static types,
// and speculation has already been disabled via feedback.
return {};
}
...
}
At this point in the code, the type of 56: HoleyFloat64ToTagged [n55] is kNumberOrOddball, but the elements_kind remains PACKED_SMI_ELEMENTS, indicating the array contains only Smis. This is clearly incorrect. However, since we are performing speculative optimization, the expectation is that inserted nodes will detect this issue at runtime and prevent the optimization from proceeding.
2.3 Inline ArrayConstructor
MaybeReduceResult MaglevGraphBuilder::TryReduceConstructArrayConstructor(
compiler::JSFunctionRef array_function, CallArguments& args,
compiler::OptionalAllocationSiteRef maybe_allocation_site) {
...
// insert check node
if (IsSmiElementsKind(elements_kind)) { // If we expect all elements in the array to be SMIs
for (ValueNode* v : args) {
// If node v is already known to be of SMI type at compile time, no runtime check is needed
if (NodeTypeIs(GetType(v), NodeType::kSmi)) continue;
// If it cannot be determined whether it is an SMI at compile time, it's a speculative optimization, so a CheckSmi node must be inserted
RETURN_IF_ABORT(BuildCheckSmi(v));
}
} else if (IsDoubleElementsKind(elements_kind)) {
...
}
// Allocate JSArray and FixedArray Object
return BuildAndAllocateJSArray(
initial_map, GetSmiConstant(arity),
BuildElementsArray(elements_kind, base::VectorOf(values)),
slack_tracking_prediction, allocation_type);
}
Subsequently, check nodes are inserted based on the elements_kind:
- Since the node type of
n55 : LoadHoleyFixedDoubleArrayElement [n51, n26]iskNumberOrOddball, the compile-time cannot guarantee it’s an SMI. Therefore,CheckSmi(v)is called to check at runtime whether the value of this node is an SMI. - The
ValueRepresentationof then55node iskHoleyFloat64, soBuildCheckSmi()inserts aCheckHoleyFloat64IsSmi()node. Note: This node only checks whether theFloat64value can be represented as an SMI; it does not guarantee that theValueRepresentationof then55node becomesSMI. In this example, theFloat64value loaded byn55is1.0, so it passes this node’s check.
Finally, BuildElementsArray() is called to create a VirtualObject, attempting to write the value from the n55 : LoadHoleyFixedDoubleArrayElement [n51, n26] node into arr2->elements[0]. Since this field only accepts values of type kTagged, materializing the VirtualObject generates a n56 : HoleyFloat64ToTagged [n55] node to convert the Float64 into a HeapNumber object. Ultimately, the TaggedPointer of the HeapNumber object is written into the array whose elements_kind is PACKED_SMI_ELEMENTS.
I believe the root cause of this vulnerability lies in the incorrect use of BuildCheckSmi() during the speculative optimization of new Array(x, y, ...). This method only ensures the value can be represented as an SMI. What we actually need is to convert the Float64 into an SMI and write that into the array.
2.4 Maglev Graph
Ultimately, the following Maglev graph will be generated:
// Load the i-th float64 value from `arr`
55: LoadHoleyFixedDoubleArrayElement [n51, n26], 3 uses, cannot truncate to int32
// create HeapNumber Object for float64
56: HoleyFloat64ToTagged [n55], 2 uses
...
// Check float64 can be represented by SMI
59: CheckHoleyFloat64IsSmi [n55]
// ALlocate FixedArray for `arr2`
65: AllocationBlock(Young), 2 uses
66: InlinedAllocation(object 0x28cd020005dd <Map(FIXED_ARRAY_TYPE)>) [n65], 5 uses (5 non escaping uses)
67: StoreMap(0x28cd020005dd <Map(FIXED_ARRAY_TYPE)>, InlinedAllocation) [n66] // map
68: StoreTaggedFieldNoWriteBarrier(0x4) [n66, n60] // length
69: StoreTaggedFieldWithWriteBarrier(0x8) [n66, n56] // values[0] = HeapNumber <===
70: StoreTaggedFieldNoWriteBarrier(0xc) [n66, n24] // values[1] = i
// Allocation JSArray for `arr2`
71: InlinedAllocation(object 0x28cd02827311 <Map[16](PACKED_SMI_ELEMENTS)>) [n65], 5 uses (4 non escaping uses)
72: StoreMap(0x28cd02827311 <Map[16](PACKED_SMI_ELEMENTS)>, InlinedAllocation) [n71] // map, kind=PACKED_SMI_ELEMENTS
73: StoreTaggedFieldNoWriteBarrier(0x4) [n71, n64] // properties
74: StoreTaggedFieldNoWriteBarrier(0x8) [n71, n66] // elements
75: StoreTaggedFieldNoWriteBarrier(0xc) [n71, n60] // length
3 Commit Bisection
This vulnerability was introduced in the following commit, which added speculative optimization for new Array(x, y, ...). It is precisely this feature that introduced the vulnerability.
commit 16d8eb8e376816ed6c666b4aa3bc8308c147259b (HEAD)
Author: Jakob Linke <jgruber@chromium.org>
Date: Thu Sep 11 13:53:40 2025 +0200
4 Maybe Exploitable?
I believe this is a sufficiently powerful bug because elements_kind is used in many places, and I am currently attempting to exploit it.
In fact, when executing this POC in release mode, you will find that after triggering the vulnerability, arr2.join() returns a peculiar string: 4346106,0. The number 4346106 corresponds to 0x4250fa, which is exactly the result of the HeapNumber object pointer from arr2[0] being right-shifted by one. This indicates that join() leaked the pointer by mistaking it for a SMI.
REPRODUCTION CASE
poc.js:
// HOLEY_DOUBLE_ELEMENTS
const arr = [1, , , , , 1.1];
function opt_me() {
for (let i = 0; i < 5; i++) {
const ele = arr[i];
const arr2 = Array(ele, i); // PACKED_SMI_ELEMENTS
function inner() {
arr2.join(); // <=== crash here
arr.__proto__ = ele;
}
inner();
}
}
%PrepareFunctionForOptimization(opt_me);
opt_me();
%OptimizeMaglevOnNextCall(opt_me);
opt_me();
V8 must be built with a debug configuration, Execute v8 as follows:
./d8 \
--allow-natives-syntax \
./poc.js
This will result in the following crash:
abort: CSA_DCHECK failed: Torque assert 'Is<A>(o)' failed [src/builtins/cast.tq:946] [../../src/builtins/array-join.tq:423] [../../src/builtins/array-join.tq:814]
CREDIT INFORMATION
Reporter credit: [303f06e3]