Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentMaglev
Bug ClassLogic Error
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: Diff confirms the kForceHeapNumber->kCanonicalizeSmi default flip and BuildCheckSmi DCHECK. 'corruption' is inferred: diff proves the Smi-representation invariant break, not an actual corrupting store.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker454485895
Fix commitaf7644b2de8c (v8/v8) +13/-6
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited303f06e3
Reported2025-10-23 (per vendor advisory)
Fix landed2025-11-04 (public git)
Disclosed2025-11-05
Patch gap1 day — fix public in source before the advisory

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 ConversionMode selecting whether integral values are canonicalized to Smi or always boxed as HeapNumber.
`ConversionMode`
the enum { kCanonicalizeSmi, kForceHeapNumber } controlling whether HoleyFloat64ToTagged produces a Smi for values that fit, versus always emitting a HeapNumber.
`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 a Smi when 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.

Key insight
The single mistake was defaulting the cached tagged conversion to 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

  1. Trigger float64 alternative caching Run JavaScript that causes Maglev to materialize the tagged alternative of a float64 value, which pre-patch is built as HoleyFloat64ToTagged in kForceHeapNumber mode.
  2. 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 by BuildCheckSmi(alt, ...) and assumed to be a real Smi afterward.
  3. 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 boxed HeapNumber.
  4. Store a `HeapNumber` where a `Smi` is assumed The un-canonicalized HeapNumber is written into a slot/element kind that the engine treats as containing only Smis, producing a representation/type confusion (inferred).
  5. Leverage the confusion Subsequent code reading the slot as a Smi operates on a HeapNumber pointer, giving an attacker a primitive from the mismatch (inferred).

Impact Assessment

An attacker running crafted JavaScript in the renderer can violate the 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.cc
  • src/maglev/maglev-ir.h
  • src/maglev/maglev-reducer-inl.h

Audit Directions

  • Default conversion modes vs. downstream guards
    Audit 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 nodes
    Review 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 speculation
    Examine Array/elements-kind speculation (PACKED_SMI_ELEMENTS etc.) that guards values with BuildCheckSmi/GetSmiValue, confirming stored values cannot survive as HeapNumber.
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:
Loading diff…

Original Bug Report

reported by hu...@gmail.com

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 the float64 value loaded from arr[i]. The NodeType for this node is kNumberOrOddball (It’s confusing that kNumberOrOddball suggests this might be a HeapNumber object, but in reality, this node can only be of type float64).
  • n24 : Phi(r5) [n10, n22]: Represents the node for i, with a NodeType of SMI.

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] is kNumberOrOddball, 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 ValueRepresentation of the n55 node is kHoleyFloat64, so BuildCheckSmi() inserts a CheckHoleyFloat64IsSmi() node. Note: This node only checks whether the Float64 value can be represented as an SMI; it does not guarantee that the ValueRepresentation of the n55 node becomes SMI. In this example, the Float64 value loaded by n55 is 1.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]

View on issue tracker