Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in V8
DescriptionOut of bounds read and write in V8
ComponentV8
Bug ClassOOB
Tracker496301615
Fix commit4ef5cc27aa50 (v8/v8) +11/-35
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-07

Files Changed

  • src/compiler/backend/arm64/instruction-selector-arm64.cc
  • test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
From 4ef5cc27aa50b4a7e3096bbbffaf5058a811a2a9 Mon Sep 17 00:00:00 2001
From: Matthias Liedtke <mliedtke@chromium.org>
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 <mliedtke@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
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());
Loading diff…

Regression Test / PoC

shipped with the fix
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());
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • v8/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.

  1. Introduce a new addressing mode specifically for zero-extended shifted indices (e.g., kMode_Operand2_R_UXTW_I) in TARGET_ADDRESSING_MODE_LIST.
  2. In TryMatchLoadStoreShift, if a ChangeUint32ToUint64 node is peeled, emit the new kMode_Operand2_R_UXTW_I mode instead of kMode_Operand2_R_LSL_I.
  3. In Arm64OperandConverter::MemoryOperand, handle the new mode by constructing a MemOperand that utilizes the UXTW extension (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.

View on issue tracker