Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in V8
DescriptionOut of bounds read and write in V8
ComponentV8
Sub-componentCompiler
Bug ClassOOB
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: Accurate; bug is arm64-only (instruction-selector path), so 'default' preconditions are architecture-gated, not universal across targets.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker496301615
Fix commit4ef5cc27aa50 (v8/v8) +11/-35
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-03-27 (public git)
Disclosed2026-04-07
Patch gap11 days — fix public in source before the advisory

Background

`VisitTruncateInt64ToInt32`
the arm64 instruction-selector handler for the TruncateInt64ToInt32 operation, 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.

Key insight
The core mistake was treating a 32-bit truncation move (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

  1. Craft optimizable code Supply JavaScript that TurboFan/Turboshaft compiles into a load or store whose index passes through a TruncateInt64ToInt32 and a left shift feeding the arm64 addressing mode.
  2. Trigger the bad selection On arm64, VisitTruncateInt64ToInt32 emits kArm64Mov32, leaving the register’s upper 32 bits undefined, and the address matcher folds the kShiftLeft without verifying WordPtr representation.
  3. 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.
  4. Compute an out-of-bounds address The load/store uses this poisoned index, addressing memory outside the backing buffer.
  5. Read or write out of bounds Read leaked heap data or corrupt adjacent objects, building toward a stronger primitive.

Impact Assessment

An attacker who can run script in the renderer gains an out-of-bounds read and write in the V8 heap on arm64, delivering an information leak and memory corruption that can be escalated toward renderer code execution. It applies to arm64 targets where the vulnerable instruction-selector path is used, and requires only that the attacker get their JavaScript JIT-compiled through the affected truncation-and-scaled-index pattern.

Files Changed

  • src/compiler/backend/arm64/instruction-selector-arm64.cc
  • test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc

Audit Directions

  • Truncation as zero-extension
    Audit other backends and operations that emit narrowing moves (like kArm64Mov32) but are consumed as if the full register were zero-extended; prefer EmitIdentity or an explicit extension.
  • Address-matcher representation checks
    Review 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 indices
    Look 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.
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());
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 [email protected]

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