CVE-2026-5873
Overview
Background
- `VisitTruncateInt64ToInt32`
- the arm64 instruction-selector handler for the
TruncateInt64ToInt32operation, which narrows a 64-bit value to a 32-bit value. - `kArm64Mov32`
- an arm64 32-bit register move that writes the low 32 bits and leaves the upper 32 bits of the destination register undefined.
- `ChangeUint32ToUint64`
- a V8 operation that zero-extends a 32-bit value into a 64-bit register, guaranteeing the upper 32 bits are zero.
- `WordPtr`
- the pointer-width
RegisterRepresentation(64-bit on arm64) used to distinguish a genuine 64-bit shift from a 32-bit shift when forming a load/store address.
Root Cause Analysis
The arm64 load/store address matcher (the helper taking index_op and shift_immediate_op) stripped a ChangeUint32ToUint64 off the index and then accepted any Opmask::kShiftLeft as a scaled-index shift without checking its representation, folding the shift directly into the memory addressing mode on the full 64-bit register. Separately, VisitTruncateInt64ToInt32 emitted a kArm64Mov32, which the pipeline treated as fully defining a clean 32-bit value even though the instruction leaves the upper 32 bits undefined. When a dependent load/store index was derived from such a truncation, the matcher used the 64-bit register (including its undefined upper bits) as a scaled index, so the invariant that an index register is zero-extended in its top 32 bits was violated and the computed address could point outside the intended buffer.
The fix replaces the kArm64Mov32 emission with EmitIdentity, forcing any consumer that needs a clean 32-bit value to perform its own explicit zero-extension, and additionally requires the matched shift to have RegisterRepresentation::WordPtr() so only true 64-bit pointer shifts are folded into the addressing mode.
kArm64Mov32) as if it zero-extended the register while simultaneously folding a shift into the address without requiring WordPtr representation, letting undefined upper bits flow into a load/store index. The fix removes both assumptions: EmitIdentity shifts the zero-extension obligation to consumers, and the explicit shift->rep != RegisterRepresentation::WordPtr() check refuses shifts that are not genuinely pointer-width.Attack Path
- Craft optimizable code
Supply JavaScript that TurboFan/Turboshaft compiles into a load or store whose index passes through a
TruncateInt64ToInt32and a left shift feeding the arm64 addressing mode. - Trigger the bad selection
On arm64,
VisitTruncateInt64ToInt32emitskArm64Mov32, leaving the register’s upper 32 bits undefined, and the address matcher folds thekShiftLeftwithout verifyingWordPtrrepresentation. - Poison the index Arrange for the undefined upper 32 bits to be non-zero, so the scaled 64-bit index no longer equals the intended zero-extended 32-bit index.
- Compute an out-of-bounds address The load/store uses this poisoned index, addressing memory outside the backing buffer.
- Read or write out of bounds Read leaked heap data or corrupt adjacent objects, building toward a stronger primitive.
Impact Assessment
Files Changed
src/compiler/backend/arm64/instruction-selector-arm64.cctest/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
Audit Directions
- Truncation as zero-extensionAudit other backends and operations that emit narrowing moves (like
kArm64Mov32) but are consumed as if the full register were zero-extended; preferEmitIdentityor an explicit extension. - Address-matcher representation checksReview load/store index matchers that fold shifts or strip extension ops into addressing modes and confirm they require the correct pointer-width (
WordPtr) representation before trusting the upper bits. - Upper-bit assumptions on 64-bit indicesLook for scaled-index or pointer-arithmetic paths that assume the high 32 bits of a 32-bit-derived value are clean, especially across arm64 and other 64-bit architectures.
Patch
From 4ef5cc27aa50b4a7e3096bbbffaf5058a811a2a9 Mon Sep 17 00:00:00 2001 From: Matthias Liedtke <[email protected]> Date: Fri, 27 Mar 2026 11:53:00 +0100 Subject: [PATCH] [compiler][arm64] Reenable implicit truncation and force explicit zero-extension of load/store index Bug: 496301615 Change-Id: Ibb905e90bee08ce83ce77bd7400e716e47d61882 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7705594 Commit-Queue: Matthias Liedtke <[email protected]> Reviewed-by: Darius Mercadier <[email protected]> Cr-Commit-Position: refs/heads/main@{#106107} --- diff --git a/src/compiler/backend/arm64/instruction-selector-arm64.cc b/src/compiler/backend/arm64/instruction-selector-arm64.cc index 3cb256a..1f8bc26 100644 --- a/src/compiler/backend/arm64/instruction-selector-arm64.cc +++ b/src/compiler/backend/arm64/instruction-selector-arm64.cc @@ -581,13 +581,10 @@ OpIndex index, InstructionOperand* index_op, InstructionOperand* shift_immediate_op) { if (!selector->CanCover(node, index)) return false; - if (const ChangeOp* change = - selector->Get(index).TryCast<Opmask::kChangeUint32ToUint64>(); - change && selector->CanCover(index, change->input())) { - index = change->input(); - } const ShiftOp* shift = selector->Get(index).TryCast<Opmask::kShiftLeft>(); - if (shift == nullptr) return false; + if (shift == nullptr || shift->rep != RegisterRepresentation::WordPtr()) { + return false; + } if (!g->CanBeLoadStoreShiftImmediate(shift->right(), rep)) return false; *index_op = g->UseRegister(shift->left()); *shift_immediate_op = g->UseImmediate(shift->right()); @@ -3126,8 +3123,9 @@ void InstructionSelector::VisitTruncateInt64ToInt32(OpIndex node) { Arm64OperandGenerator g(this); - OpIndex value = Cast<ChangeOp>(node).input(); - Emit(kArm64Mov32, g.DefineAsRegister(node), g.UseRegister(value)); + // The top 32 bits in the 64-bit register will be undefined, and + // must not be used by a dependent node. + EmitIdentity(node); } void InstructionSelector::VisitFloat64Mod(OpIndex node) { diff --git a/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc b/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc index d5dbdbd..f9460c4 100644 --- a/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc +++ b/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc @@ -278,7 +278,7 @@ {{TSUnop::kChangeUint32ToUint64, "ChangeUint32ToUint64", kArm64Mov32, MachineType::Uint64()}, MachineType::Uint32()}, - {{TSUnop::kTruncateWord64ToWord32, "TruncateWord64ToWord32", kArm64Mov32, + {{TSUnop::kTruncateWord64ToWord32, "TruncateWord64ToWord32", kArchNop, MachineType::Int32()}, MachineType::Int64()}, {{TSUnop::kChangeInt32ToFloat64, "ChangeInt32ToFloat64", @@ -2571,17 +2571,12 @@ m.Word64ShiftRightArithmetic(p, m.Int32Constant(32))); m.Return(t); Stream s = m.Build(); - ASSERT_EQ(2U, s.size()); + ASSERT_EQ(1U, s.size()); EXPECT_EQ(kArm64Asr, s[0]->arch_opcode()); ASSERT_EQ(2U, s[0]->InputCount()); EXPECT_EQ(s.ToVreg(p), s.ToVreg(s[0]->InputAt(0))); EXPECT_EQ(32, s.ToInt64(s[0]->InputAt(1))); ASSERT_EQ(1U, s[0]->OutputCount()); - - EXPECT_EQ(kArm64Mov32, s[1]->arch_opcode()); - ASSERT_EQ(1U, s[1]->InputCount()); - EXPECT_EQ(s.ToVreg(s[0]->Output()), s.ToVreg(s[1]->InputAt(0))); - ASSERT_EQ(1U, s[1]->OutputCount()); } TEST_F(TurboshaftInstructionSelectorTest, @@ -2593,17 +2588,12 @@ m.Word64ShiftRightLogical(p, m.Int32Constant(x))); m.Return(t); Stream s = m.Build(); - ASSERT_EQ(2U, s.size()); + ASSERT_EQ(1U, s.size()); EXPECT_EQ(kArm64Lsr, s[0]->arch_opcode()); ASSERT_EQ(2U, s[0]->InputCount()); EXPECT_EQ(s.ToVreg(p), s.ToVreg(s[0]->InputAt(0))); EXPECT_EQ(x, s.ToInt64(s[0]->InputAt(1))); ASSERT_EQ(1U, s[0]->OutputCount()); - - EXPECT_EQ(kArm64Mov32, s[1]->arch_opcode()); - ASSERT_EQ(1U, s[1]->InputCount()); - EXPECT_EQ(s.ToVreg(s[0]->Output()), s.ToVreg(s[1]->InputAt(0))); - ASSERT_EQ(1U, s[1]->OutputCount()); } } @@ -6525,13 +6515,7 @@ m.Return(m.Load(memacc.memory_rep, memacc.result_rep, m.Parameter(0), m.ChangeUint32ToUint64(index))); Stream s = m.Build(); - if (immediate_shift == memacc.memory_rep.SizeInBytesLog2()) { - ASSERT_EQ(1U, s.size()); - EXPECT_EQ(memacc.ldr_opcode, s[0]->arch_opcode()); - EXPECT_EQ(kMode_Operand2_R_LSL_I, s[0]->addressing_mode()); - EXPECT_EQ(3U, s[0]->InputCount()); - EXPECT_EQ(1U, s[0]->OutputCount()); - } else if (memacc.memory_rep == MemoryRepresentation::Simd128()) { + if (memacc.memory_rep == MemoryRepresentation::Simd128()) { ASSERT_EQ(2U, s.size()); EXPECT_EQ(memacc.ldr_opcode, s[1]->arch_opcode()); EXPECT_EQ(kMode_MRR, s[1]->addressing_mode()); @@ -6585,13 +6569,7 @@ m.Parameter(2), kNoWriteBarrier); m.Return(m.Int32Constant(0)); Stream s = m.Build(); - if (immediate_shift == memacc.memory_rep.SizeInBytesLog2()) { - ASSERT_EQ(1U, s.size()); - EXPECT_EQ(memacc.str_opcode, s[0]->arch_opcode()); - EXPECT_EQ(kMode_Operand2_R_LSL_I, s[0]->addressing_mode()); - EXPECT_EQ(4U, s[0]->InputCount()); - EXPECT_EQ(0U, s[0]->OutputCount()); - } else if (memacc.memory_rep == MemoryRepresentation::Simd128()) { + if (memacc.memory_rep == MemoryRepresentation::Simd128()) { ASSERT_EQ(2U, s.size()); EXPECT_EQ(memacc.str_opcode, s[1]->arch_opcode()); EXPECT_EQ(kMode_MRR, s[1]->addressing_mode());
Regression Test / PoC
diff --git a/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc b/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
index d5dbdbd..f9460c4 100644
--- a/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
+++ b/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
@@ -278,7 +278,7 @@
{{TSUnop::kChangeUint32ToUint64, "ChangeUint32ToUint64", kArm64Mov32,
MachineType::Uint64()},
MachineType::Uint32()},
- {{TSUnop::kTruncateWord64ToWord32, "TruncateWord64ToWord32", kArm64Mov32,
+ {{TSUnop::kTruncateWord64ToWord32, "TruncateWord64ToWord32", kArchNop,
MachineType::Int32()},
MachineType::Int64()},
{{TSUnop::kChangeInt32ToFloat64, "ChangeInt32ToFloat64",
@@ -2571,17 +2571,12 @@
m.Word64ShiftRightArithmetic(p, m.Int32Constant(32)));
m.Return(t);
Stream s = m.Build();
- ASSERT_EQ(2U, s.size());
+ ASSERT_EQ(1U, s.size());
EXPECT_EQ(kArm64Asr, s[0]->arch_opcode());
ASSERT_EQ(2U, s[0]->InputCount());
EXPECT_EQ(s.ToVreg(p), s.ToVreg(s[0]->InputAt(0)));
EXPECT_EQ(32, s.ToInt64(s[0]->InputAt(1)));
ASSERT_EQ(1U, s[0]->OutputCount());
-
- EXPECT_EQ(kArm64Mov32, s[1]->arch_opcode());
- ASSERT_EQ(1U, s[1]->InputCount());
- EXPECT_EQ(s.ToVreg(s[0]->Output()), s.ToVreg(s[1]->InputAt(0)));
- ASSERT_EQ(1U, s[1]->OutputCount());
}
TEST_F(TurboshaftInstructionSelectorTest,
@@ -2593,17 +2588,12 @@
m.Word64ShiftRightLogical(p, m.Int32Constant(x)));
m.Return(t);
Stream s = m.Build();
- ASSERT_EQ(2U, s.size());
+ ASSERT_EQ(1U, s.size());
EXPECT_EQ(kArm64Lsr, s[0]->arch_opcode());
ASSERT_EQ(2U, s[0]->InputCount());
EXPECT_EQ(s.ToVreg(p), s.ToVreg(s[0]->InputAt(0)));
EXPECT_EQ(x, s.ToInt64(s[0]->InputAt(1)));
ASSERT_EQ(1U, s[0]->OutputCount());
-
- EXPECT_EQ(kArm64Mov32, s[1]->arch_opcode());
- ASSERT_EQ(1U, s[1]->InputCount());
- EXPECT_EQ(s.ToVreg(s[0]->Output()), s.ToVreg(s[1]->InputAt(0)));
- ASSERT_EQ(1U, s[1]->OutputCount());
}
}
@@ -6525,13 +6515,7 @@
m.Return(m.Load(memacc.memory_rep, memacc.result_rep, m.Parameter(0),
m.ChangeUint32ToUint64(index)));
Stream s = m.Build();
- if (immediate_shift == memacc.memory_rep.SizeInBytesLog2()) {
- ASSERT_EQ(1U, s.size());
- EXPECT_EQ(memacc.ldr_opcode, s[0]->arch_opcode());
- EXPECT_EQ(kMode_Operand2_R_LSL_I, s[0]->addressing_mode());
- EXPECT_EQ(3U, s[0]->InputCount());
- EXPECT_EQ(1U, s[0]->OutputCount());
- } else if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
+ if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
ASSERT_EQ(2U, s.size());
EXPECT_EQ(memacc.ldr_opcode, s[1]->arch_opcode());
EXPECT_EQ(kMode_MRR, s[1]->addressing_mode());
@@ -6585,13 +6569,7 @@
m.Parameter(2), kNoWriteBarrier);
m.Return(m.Int32Constant(0));
Stream s = m.Build();
- if (immediate_shift == memacc.memory_rep.SizeInBytesLog2()) {
- ASSERT_EQ(1U, s.size());
- EXPECT_EQ(memacc.str_opcode, s[0]->arch_opcode());
- EXPECT_EQ(kMode_Operand2_R_LSL_I, s[0]->addressing_mode());
- EXPECT_EQ(4U, s[0]->InputCount());
- EXPECT_EQ(0U, s[0]->OutputCount());
- } else if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
+ if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
ASSERT_EQ(2U, s.size());
EXPECT_EQ(memacc.str_opcode, s[1]->arch_opcode());
EXPECT_EQ(kMode_MRR, s[1]->addressing_mode());
Original Bug Report
ARM64 V8 Backend OOB via incorrect 32-bit index zero-extension in MemoryOperand
Flapjack (go/flapjack), an LLM-powered vulnerability discovery tool, has identified a security issue and generated a PoC.
d8 variant: ‘AsanArm64’
flags: –no-liftoff –no-wasm-lazy-compilation –omit-quit –fuzzing –disallow-unsafe-flags
Overview: The V8 ARM64 backend incorrectly optimizes 32-bit shifted memory accesses by peeling zero-extension nodes and using a 64-bit left shift addressing mode. Because 32-bit truncations do not clear the upper bits of a register on ARM64, attacker-controlled high bits are included in the address calculation. This allows an attacker to bypass the Wasm memory cage and V8 Sandbox, achieving arbitrary read/write and renderer RCE.
Affected files:
v8/src/compiler/backend/arm64/code-generator-arm64.ccv8/src/compiler/backend/arm64/instruction-selector-arm64.cc
Estimated timestamp from git blame: 2024-03-26
Root Cause
In the ARM64 Turbofan/Turboshaft backend, TruncateInt64ToInt32 is implemented as an identity operation; it reuses the virtual register without emitting code to clear the upper 32 bits. Therefore, the upper 32 bits of the 64-bit physical register remain “dirty” and attacker-controlled.
When calculating WebAssembly memory accesses, a 32-bit index is typically zero-extended to 64 bits via ChangeUint32ToUint64 before being added to the memory base pointer. However, in v8/src/compiler/backend/arm64/instruction-selector-arm64.cc, the function TryMatchLoadStoreShift explicitly peels off this ChangeUint32ToUint64 node to fold the underlying shift into the load/store instruction. It then sets the addressing mode to kMode_Operand2_R_LSL_I.
The bug is in how the code generator translates this mode. In v8/src/compiler/backend/arm64/code-generator-arm64.cc, Arm64OperandConverter::MemoryOperand implements kMode_Operand2_R_LSL_I by returning a MemOperand using an LSL shift on the full 64-bit X register:
return MemOperand(InputRegister(index + 0), InputRegister(index + 1), LSL, InputInt32(index + 2));
Because LSL does not zero-extend the index (unlike the UXTW extension mode), the dirty upper 32 bits of the register are shifted and added to the base address.
Impact
An attacker can trigger this by passing a large 64-bit value to a WebAssembly function, truncating it to 32 bits (e.g., i32.wrap_i64), and using it as a memory access index. The upper 32 bits will not be cleared, resulting in a massive 64-bit offset calculation. This bypasses the 8GB WebAssembly guard regions and the V8 Sandbox boundaries, granting arbitrary read/write access within the renderer process, which leads directly to Remote Code Execution (RCE).
Suggested Fix
The ARM64 backend must explicitly zero-extend the 32-bit index during the memory address calculation rather than relying on a 64-bit LSL.
- Introduce a new addressing mode specifically for zero-extended shifted indices (e.g.,
kMode_Operand2_R_UXTW_I) inTARGET_ADDRESSING_MODE_LIST. - In
TryMatchLoadStoreShift, if aChangeUint32ToUint64node is peeled, emit the newkMode_Operand2_R_UXTW_Imode instead ofkMode_Operand2_R_LSL_I. - In
Arm64OperandConverter::MemoryOperand, handle the new mode by constructing aMemOperandthat utilizes theUXTWextension (which correctly zero-extends the lower 32 bits before shifting):MemOperand(base, index, UXTW, shift_amount).
Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b
The description of the vuln is LLM-generated and can contain mistakes. Your feedback is appreciated, and will help us improve Flapjack over time. The PoC was run in a VM and it seemed to be legit - if not, let us know and we can strengthen our checker.