CVE-2026-19151
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
iftest/unittests/assembler/macro-assembler-arm64-unittest.cc |
modified |
Files Changed
src/codegen/arm64/macro-assembler-arm64.cctest/mjsunit/regress/wasm/regress-530663440.jstest/unittests/assembler/macro-assembler-arm64-unittest.cc
Patch
From 2c3e7f7d4d65f274951d58eec078b2aacdfba58e Mon Sep 17 00:00:00 2001 From: Jakob Kummerow <[email protected]> Date: Tue, 28 Jul 2026 12:51:35 +0200 Subject: [PATCH] [arm64] Fix write barrier register aliasing Use `r1.Aliases(r2)` to handle W and X registers correctly. Fixed: 530663440 Change-Id: Ib17e8f186bbea17d2b845202399ce39d21dbf107 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8158501 Auto-Submit: Jakob Kummerow <[email protected]> Reviewed-by: Manos Koukoutos <[email protected]> Commit-Queue: Manos Koukoutos <[email protected]> Cr-Commit-Position: refs/heads/main@{#108911} --- diff --git a/src/codegen/arm64/macro-assembler-arm64.cc b/src/codegen/arm64/macro-assembler-arm64.cc index 0da0f6d..f6af185 100644 --- a/src/codegen/arm64/macro-assembler-arm64.cc +++ b/src/codegen/arm64/macro-assembler-arm64.cc @@ -4223,7 +4223,7 @@ ASM_CODE_COMMENT(this); DCHECK_NE(dst_object, dst_slot); // If `offset` is a register, it cannot overlap with `object`. - DCHECK_IMPLIES(!offset.IsImmediate(), offset.reg() != object); + DCHECK_IMPLIES(!offset.IsImmediate(), !offset.reg().Aliases(object)); // If the slot register does not overlap with the object register, we can // overwrite it. @@ -4237,19 +4237,19 @@ // If the destination object register does not overlap with the offset // register, we can overwrite it. - if (offset.IsImmediate() || (offset.reg() != dst_object)) { + if (offset.IsImmediate() || !offset.reg().Aliases(dst_object)) { Mov(dst_object, dst_slot); Add(dst_slot, dst_slot, offset); return; } - DCHECK_EQ(dst_object, offset.reg()); + DCHECK(dst_object.Aliases(offset.reg())); // We only have `dst_slot` and `dst_object` left as distinct registers so we // have to swap them. We write this as a add+sub sequence to avoid using a // scratch register. - Add(dst_slot, dst_slot, dst_object); - Sub(dst_object, dst_slot, dst_object); + Add(dst_slot, dst_slot, offset); + Sub(dst_object, dst_slot, offset); } // If lr_status is kLRHasBeenSaved, lr will be clobbered. diff --git a/test/mjsunit/regress/wasm/regress-530663440.js b/test/mjsunit/regress/wasm/regress-530663440.js new file mode 100644 index 0000000..6e67423 --- /dev/null +++ b/test/mjsunit/regress/wasm/regress-530663440.js @@ -0,0 +1,36 @@ +// 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. + +// Flags: --expose-gc + +d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js'); + +const builder = new WasmModuleBuilder(); +const arrT = builder.addArray(kWasmAnyRef); + +let fill_sig = makeSig( + [kWasmI32, kWasmI32, kWasmI32, kWasmI32, wasmRefType(arrT), kWasmAnyRef], + []); +builder.addFunction('fill', fill_sig).exportFunc().addBody([ + kExprLocalGet, 4, + kExprLocalGet, 0, + kExprI32Eqz, + kExprLocalGet, 5, + kExprI32Const, 1, + kGCPrefix, kExprArrayFill, arrT, + ]); + +builder.addFunction('mkarr', makeSig([kWasmI32], [kWasmAnyRef])).exportFunc() + .addBody([ + kExprLocalGet, 0, + kGCPrefix, kExprArrayNewDefault, arrT, + ]); + +const instance = builder.instantiate(); + +const arr = instance.exports.mkarr(4); +gc(); // Promote {arr} to old space. +gc(); + +instance.exports.fill(1, 0, 0, 0, arr, {}); diff --git a/test/unittests/assembler/macro-assembler-arm64-unittest.cc b/test/unittests/assembler/macro-assembler-arm64-unittest.cc index 26a1e3b..2ef1b31 100644 --- a/test/unittests/assembler/macro-assembler-arm64-unittest.cc +++ b/test/unittests/assembler/macro-assembler-arm64-unittest.cc @@ -141,25 +141,33 @@ Register dst_slot; Register object; Register offset_register = no_reg; + Extend offset_extend = NO_EXTEND; }; const MoveObjectAndSlotTestCase kMoveObjectAndSlotTestCases[] = { {"no overlap", x0, x1, x2}, {"no overlap", x0, x1, x2, x3}, + {"no overlap", x0, x1, x2, w3, UXTW}, {"object == dst_object", x2, x1, x2}, {"object == dst_object", x2, x1, x2, x3}, + {"object == dst_object", x2, x1, x2, w3, UXTW}, {"object == dst_slot", x1, x2, x2}, {"object == dst_slot", x1, x2, x2, x3}, + {"object == dst_slot", x1, x2, x2, w3, UXTW}, {"offset == dst_object", x0, x1, x2, x0}, + {"offset == dst_object", x0, x1, x2, w0, UXTW}, {"offset == dst_object && object == dst_slot", x0, x1, x1, x0}, + {"offset == dst_object && object == dst_slot", x0, x1, x1, w0, UXTW}, {"offset == dst_slot", x0, x1, x2, x1}, + {"offset == dst_slot", x0, x1, x2, w1, UXTW}, - {"offset == dst_slot && object == dst_object", x0, x1, x0, x1}}; + {"offset == dst_slot && object == dst_object", x0, x1, x0, x1}, + {"offset == dst_slot && object == dst_object", x0, x1, x0, w1, UXTW}}; // Make sure we include offsets that cannot be encoded in an add instruction. const int kOffsets[] = {0, 42, kMaxRegularHeapObjectSize, 0x101001}; @@ -194,7 +202,12 @@ offset_operand = Operand(offset); } else { __ Mov(test_case.offset_register, Operand(offset)); - offset_operand = Operand(test_case.offset_register); + if (test_case.offset_extend == NO_EXTEND) { + offset_operand = Operand(test_case.offset_register); + } else { + offset_operand = + Operand(test_case.offset_register, test_case.offset_extend); + } } std::stringstream comment;
Regression Test / PoC
diff --git a/test/mjsunit/regress/wasm/regress-530663440.js b/test/mjsunit/regress/wasm/regress-530663440.js
new file mode 100644
index 0000000..6e67423
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-530663440.js
@@ -0,0 +1,36 @@
+// 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.
+
+// Flags: --expose-gc
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+const builder = new WasmModuleBuilder();
+const arrT = builder.addArray(kWasmAnyRef);
+
+let fill_sig = makeSig(
+ [kWasmI32, kWasmI32, kWasmI32, kWasmI32, wasmRefType(arrT), kWasmAnyRef],
+ []);
+builder.addFunction('fill', fill_sig).exportFunc().addBody([
+ kExprLocalGet, 4,
+ kExprLocalGet, 0,
+ kExprI32Eqz,
+ kExprLocalGet, 5,
+ kExprI32Const, 1,
+ kGCPrefix, kExprArrayFill, arrT,
+ ]);
+
+builder.addFunction('mkarr', makeSig([kWasmI32], [kWasmAnyRef])).exportFunc()
+ .addBody([
+ kExprLocalGet, 0,
+ kGCPrefix, kExprArrayNewDefault, arrT,
+ ]);
+
+const instance = builder.instantiate();
+
+const arr = instance.exports.mkarr(4);
+gc(); // Promote {arr} to old space.
+gc();
+
+instance.exports.fill(1, 0, 0, 0, arr, {});
diff --git a/test/unittests/assembler/macro-assembler-arm64-unittest.cc b/test/unittests/assembler/macro-assembler-arm64-unittest.cc
index 26a1e3b..2ef1b31 100644
--- a/test/unittests/assembler/macro-assembler-arm64-unittest.cc
+++ b/test/unittests/assembler/macro-assembler-arm64-unittest.cc
@@ -141,25 +141,33 @@
Register dst_slot;
Register object;
Register offset_register = no_reg;
+ Extend offset_extend = NO_EXTEND;
};
const MoveObjectAndSlotTestCase kMoveObjectAndSlotTestCases[] = {
{"no overlap", x0, x1, x2},
{"no overlap", x0, x1, x2, x3},
+ {"no overlap", x0, x1, x2, w3, UXTW},
{"object == dst_object", x2, x1, x2},
{"object == dst_object", x2, x1, x2, x3},
+ {"object == dst_object", x2, x1, x2, w3, UXTW},
{"object == dst_slot", x1, x2, x2},
{"object == dst_slot", x1, x2, x2, x3},
+ {"object == dst_slot", x1, x2, x2, w3, UXTW},
{"offset == dst_object", x0, x1, x2, x0},
+ {"offset == dst_object", x0, x1, x2, w0, UXTW},
{"offset == dst_object && object == dst_slot", x0, x1, x1, x0},
+ {"offset == dst_object && object == dst_slot", x0, x1, x1, w0, UXTW},
{"offset == dst_slot", x0, x1, x2, x1},
+ {"offset == dst_slot", x0, x1, x2, w1, UXTW},
- {"offset == dst_slot && object == dst_object", x0, x1, x0, x1}};
+ {"offset == dst_slot && object == dst_object", x0, x1, x0, x1},
+ {"offset == dst_slot && object == dst_object", x0, x1, x0, w1, UXTW}};
// Make sure we include offsets that cannot be encoded in an add instruction.
const int kOffsets[] = {0, 42, kMaxRegularHeapObjectSize, 0x101001};
@@ -194,7 +202,12 @@
offset_operand = Operand(offset);
} else {
__ Mov(test_case.offset_register, Operand(offset));
- offset_operand = Operand(test_case.offset_register);
+ if (test_case.offset_extend == NO_EXTEND) {
+ offset_operand = Operand(test_case.offset_register);
+ } else {
+ offset_operand =
+ Operand(test_case.offset_register, test_case.offset_extend);
+ }
}
std::stringstream comment;
Original Bug Report
Missed write-barrier UAF via register aliasing flaw in ARM64 MoveObjectAndSlot
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports.
Overview: In the ARM64 macro-assembler, MoveObjectAndSlot incorrectly uses CPURegister::operator!= to check for register aliasing. Because this operator includes a register size check, a 32-bit W-register offset mis-evaluates as distinct from its 64-bit X-register counterpart. This causes Liftoff write barriers to clobber the offset register, passing a corrupted slot address to the RecordWrite stub and potentially leading to an in-cage Use-After-Free.
Affected files:
src/codegen/arm64/macro-assembler-arm64.ccsrc/codegen/arm64/register-arm64.h
1. Summary of the Issue (Meant for Human Triage)
A potential layer-1 vulnerability exists in V8’s ARM64 macro-assembler due to a flawed register aliasing check in MacroAssembler::MoveObjectAndSlot. The function uses the standard equality operator (operator!=) on CPURegister objects to determine whether the offset register aliases the destination dst_object register before emitting instructions to set up the write barrier stub arguments.
However, CPURegister::operator== strictly compares the register’s size (reg_size_) alongside its code and type. Consequently, a 32-bit W register and a 64-bit X register with the same physical register code (e.g., w1 and x1) will evaluate as unequal (w1 != x1 returns true).
When V8’s baseline WebAssembly compiler (Liftoff) emits a write barrier for a Wasm GC array operation (like array.fill) using a 32-bit offset (e.g., Operand(w1, UXTW)), it passes this Operand down to the RecordWrite stub. If the register allocator assigns x1 as the destination object and x5 as the slot, MoveObjectAndSlot mis-evaluates the aliasing condition and mistakenly clobbers the w1 offset register with the 64-bit array pointer before the offset is read. This forces the RecordWrite stub to calculate and record a corrupted slot address (cage_base + 2 * compressed(array)). Because the correct slot is never recorded in the garbage collector’s remembered set, any young-generation object written to the array will not be tracked. Upon the next minor GC, this unrecorded old-to-young edge leads to a stale pointer, exposing an in-cage Use-After-Free (UAF) primitive.
2. Proof-of-Concept & Detailed Execution Flow
Note: Our tooling agent does not have the ability to run code. The following execution flow traces the verified mechanism in the codebase, and the steps below represent the potential attack path to trigger the vulnerability.
The Defect:
The issue resides in src/codegen/arm64/macro-assembler-arm64.cc inside MoveObjectAndSlot:
// src/codegen/arm64/macro-assembler-arm64.cc:4221-4253
void MacroAssembler::MoveObjectAndSlot(Register dst_object, Register dst_slot,
Register object, Operand offset) {
...
DCHECK_IMPLIES(!offset.IsImmediate(), offset.reg() != object); // Passes
if (dst_slot != object) { ... return; } // Skips if equal
DCHECK_EQ(dst_slot, object);
// BUG: operator!= checks reg_size_. w1 != x1 evaluates to true.
if (offset.IsImmediate() || (offset.reg() != dst_object)) {
Mov(dst_object, dst_slot); // x1 := x5 (Clobbers w1!)
Add(dst_slot, dst_slot, offset); // reads clobbered w1
return;
}
...
}
The check relies on operator!=, defined in src/codegen/arm64/register-arm64.h:160-165:
constexpr bool operator==(const CPURegister& other) const {
return RegisterBase::operator==(other) && reg_size_ == other.reg_size_ &&
reg_type_ == other.reg_type_;
}
Because w1.reg_size_ == 32 and x1.reg_size_ == 64, w1 != x1 evaluates to true.
Potential Trigger Steps & Execution Trace:
- An attacker compiles a Wasm module defining an array of mutable references:
(type $arr (array (mut anyref))). - The attacker grooms the V8 sandbox memory to allocate an
ArrayBufferbacking store mapped into the[4GB, 8GB)range, filling it with forged 64-bit values that decode as old-generation, non-shared heap pages. - The attacker instantiates a Wasm array of type
$arr, ensuring it survives to the old generation. - The attacker crafts a function invoking
array.fillon the old-generation array, filling it with a young-generation reference. - The attacker shapes the preceding Wasm instructions to force Liftoff to allocate the array object to the 5th general-purpose parameter (
kGpParamRegisters[5]=x5) and the 32-bit index operand to the 1st return value of a preceding call (kGpReturnRegisters[1]=x1). - The attacker ensures incremental marking is active (
v8_flags.incremental_marking). - During Liftoff compilation,
ArrayFillImplinvokesStoreTaggedPointer(dst_addr=x5, offset_reg=x1, offset_imm=0, src, ...)(src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h:694). - Liftoff constructs a 32-bit offset
Operand(offset_reg.W(), UXTW)which resolves toOperand(w1, UXTW). - The store is emitted:
StoreTaggedField(src, MemOperand(x5, Operand(w1, UXTW))). The young-generation reference is written to the correct array slot. - A write barrier is emitted:
CallRecordWriteStubSaveRegisters(x5, Operand(w1, UXTW), ...). - The generated code enters
MoveObjectAndSlotto arrange arguments.dst_objectmust bex1anddst_slotmust bex5(WriteBarrierDescriptor::registers()).MoveObjectAndSlotreceivesdst_object=x1,dst_slot=x5,object=x5,offset=Operand(w1, UXTW). - The check
offset.reg() != dst_objectevaluatesw1 != x1, which erroneously evaluates totrue. Mov(x1, x5)executes. The 64-bit array pointer inx5clobbers the lower 32 bits (w1) inx1.Add(x5, x5, w1, uxtw)executes.x5becomesx5 + UXTW(w1). Due to pointer compression,x5storescage_base + 2 * compressed(array) + 2 * kHeapObjectTag.- The
RecordWritestub (src/builtins/builtins-internal-gen.cc:242) receives this corrupted slot address. - Because the address lands within the groomed
ArrayBufferin the[4GB, 8GB)range, the loadedvaluedecodes as an old-generation, non-shared page.InYoungGeneration(:367) andInSharedHeap(:376) both return false. - The stub falls through to
IncrementalWriteBarrierMinor(:422), which takes no action on old-gen values. The stub returns cleanly without crashing and without recording the actual array slot. - On the next minor GC, the young-generation reference is scavenged/reclaimed. Because the slot was not recorded, it is not updated by the GC, leaving a dangling stale pointer.
- A subsequent Wasm
array.getinstruction dereferences the stale pointer, achieving an in-cage UAF primitive.
Suggested Fix:
In src/codegen/arm64/macro-assembler-arm64.cc, line 4240 (and 4226) should be updated to use CPURegister::Aliases instead of !=:
if (offset.IsImmediate() || (!offset.reg().Aliases(dst_object))) {
Additionally, the swap-case at :4251-4252 must be adjusted. It currently naively assumes dst_object and offset are identical without their extension/shift operand modifiers. A complete fix requires converting both != checks to !Aliases and utilizing a scratch register to safely handle extended operands during swaps.
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
Prior Critic Verdict:
“The report accurately details a layer-1 bug in MacroAssembler::MoveObjectAndSlot on arm64 (classes H and I). The bug stems from using CPURegister::operator!=, which checks reg_size_, to determine if the offset register aliases the destination object register. A W-register offset (e.g., w1) will not match its corresponding X-register (x1). When Liftoff emits a write barrier and passes a W-register offset with UXTW (e.g. in array.fill), MoveObjectAndSlot mis-evaluates the condition and clobbers the offset register with a Mov before reading it with an Add.
Consequently, the RecordWrite stub receives the wrong slot address (array + compressed(array) instead of array + offset). The correct slot is never recorded in the remembered set. This unrecorded old-to-young edge leads to a stale pointer and an in-cage Use-After-Free (UAF) upon the next minor GC.
This is a layer-1 write primitive on a tier-1 architecture with default flags. The baseline severity for an in-cage UAF is high (1). However, the report correctly applies the ‘contrived-but-plausible’ modifier, downgrading the severity to medium (2) due to two significant constraints:
- Reachability depends on a highly specific Liftoff register allocation (
dst_addr=x5andoffset_reg=x1). While the report’s trace ofPopToRegistermakes this theoretically plausible, achieving it requires a precisely crafted Wasm module. - The miscalculated slot address often triggers a release crash (e.g., failing the
CSA_CHECKatbuiltins-internal-gen.cc:211or hitting an unmapped page), narrowing the exploitation window. Exploitation requires incremental marking to be active and the garbage slot to reside within the mapped cage and decode as an old-gen non-shared page.
All codebase citations, including the operator!= semantics and the MoveObjectAndSlot logic, were verified. The severity rating is sound and adheres to the classification criteria.”
Audit Logs & Code-Reachability Proofs:
CPURegister::operator!=: Confirmed atsrc/codegen/arm64/register-arm64.h:160-165. Comparesreg_size_.Aliases()is present at:156and correctly ignoresreg_size_.- Operand Registration:
Operand::reg()verified to return the exact 32-bitWregister instance atsrc/codegen/arm64/assembler-arm64-inl.h:375-378. - Liftoff Offset Emission: Verified at
src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h:699. Whenoffset_imm==0, Liftoff explicitly createsOperand(offset_reg.W(), UXTW). - ArrayFillImpl Logic: Confirmed at
src/wasm/baseline/liftoff-compiler.cc:11020callingStoreObjectField(..., obj.gp(), offset.gp(), 0, ...), mapping perfectly to theoffset_imm==0trigger condition. - Register Assignment Trace: Verified
kGpParamRegisters[5]=x5andkGpReturnRegisters[1]=x1atsrc/wasm/wasm-linkage.h:65-66. Liftoff’sPopToRegisterandPopToModifiableRegisterdeterministically assign based on virtual stack cache locations (src/wasm/baseline/liftoff-assembler.h:433-452). - WriteBarrier Stub Fast-Paths: Traced via
builtins-internal-gen.cc:362through:422. Incremental marking triggersInYoungGeneration(value, ...)andInSharedHeap(value, ...). If a controlled value (via ArrayBuffer overlap) is read that tests false for both, the stub returns cleanly without invokingCSA_CHECK(bucket_index < num_buckets)at:211. - Environmental Assumptions: The severity evaluation relies on standard 64-bit sandbox pointer compression behavior where
low32(x5) == compressed(array). - Limitation Caveat: The validator acknowledges
[repro-not-constructed]regarding the exact Wasm bytecode sequence required to forcex5andx1allocation, but accepts it as mechanistically feasible through Liftoff’s single-pass cache state resolution.
Evaluated with target repo at version: HEAD (Google3)
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time.
Note: This bug has been automatically redirected to the top-level Chromium component. Please move it to the actual component: https://b.corp.google.com/components/1456566 once PoCs have been generated.