Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker530663440
Fix commit2c3e7f7d4d65 (v8/v8) +56/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-06

Changed Functions

FunctionChangeNotes
if
test/unittests/assembler/macro-assembler-arm64-unittest.cc
modified

Files Changed

  • src/codegen/arm64/macro-assembler-arm64.cc
  • test/mjsunit/regress/wasm/regress-530663440.js
  • test/unittests/assembler/macro-assembler-arm64-unittest.cc
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;
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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

  1. An attacker compiles a Wasm module defining an array of mutable references: (type $arr (array (mut anyref))).
  2. The attacker grooms the V8 sandbox memory to allocate an ArrayBuffer backing store mapped into the [4GB, 8GB) range, filling it with forged 64-bit values that decode as old-generation, non-shared heap pages.
  3. The attacker instantiates a Wasm array of type $arr, ensuring it survives to the old generation.
  4. The attacker crafts a function invoking array.fill on the old-generation array, filling it with a young-generation reference.
  5. 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).
  6. The attacker ensures incremental marking is active (v8_flags.incremental_marking).
  7. During Liftoff compilation, ArrayFillImpl invokes StoreTaggedPointer(dst_addr=x5, offset_reg=x1, offset_imm=0, src, ...) (src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h:694).
  8. Liftoff constructs a 32-bit offset Operand(offset_reg.W(), UXTW) which resolves to Operand(w1, UXTW).
  9. The store is emitted: StoreTaggedField(src, MemOperand(x5, Operand(w1, UXTW))). The young-generation reference is written to the correct array slot.
  10. A write barrier is emitted: CallRecordWriteStubSaveRegisters(x5, Operand(w1, UXTW), ...).
  11. The generated code enters MoveObjectAndSlot to arrange arguments. dst_object must be x1 and dst_slot must be x5 (WriteBarrierDescriptor::registers()). MoveObjectAndSlot receives dst_object=x1, dst_slot=x5, object=x5, offset=Operand(w1, UXTW).
  12. The check offset.reg() != dst_object evaluates w1 != x1, which erroneously evaluates to true.
  13. Mov(x1, x5) executes. The 64-bit array pointer in x5 clobbers the lower 32 bits (w1) in x1.
  14. Add(x5, x5, w1, uxtw) executes. x5 becomes x5 + UXTW(w1). Due to pointer compression, x5 stores cage_base + 2 * compressed(array) + 2 * kHeapObjectTag.
  15. The RecordWrite stub (src/builtins/builtins-internal-gen.cc:242) receives this corrupted slot address.
  16. Because the address lands within the groomed ArrayBuffer in the [4GB, 8GB) range, the loaded value decodes as an old-generation, non-shared page. InYoungGeneration (:367) and InSharedHeap (:376) both return false.
  17. 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.
  18. 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.
  19. A subsequent Wasm array.get instruction 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:

  1. Reachability depends on a highly specific Liftoff register allocation (dst_addr=x5 and offset_reg=x1). While the report’s trace of PopToRegister makes this theoretically plausible, achieving it requires a precisely crafted Wasm module.
  2. The miscalculated slot address often triggers a release crash (e.g., failing the CSA_CHECK at builtins-internal-gen.cc:211 or 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 at src/codegen/arm64/register-arm64.h:160-165. Compares reg_size_. Aliases() is present at :156 and correctly ignores reg_size_.
  • Operand Registration: Operand::reg() verified to return the exact 32-bit W register instance at src/codegen/arm64/assembler-arm64-inl.h:375-378.
  • Liftoff Offset Emission: Verified at src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h:699. When offset_imm==0, Liftoff explicitly creates Operand(offset_reg.W(), UXTW).
  • ArrayFillImpl Logic: Confirmed at src/wasm/baseline/liftoff-compiler.cc:11020 calling StoreObjectField(..., obj.gp(), offset.gp(), 0, ...), mapping perfectly to the offset_imm==0 trigger condition.
  • Register Assignment Trace: Verified kGpParamRegisters[5]=x5 and kGpReturnRegisters[1]=x1 at src/wasm/wasm-linkage.h:65-66. Liftoff’s PopToRegister and PopToModifiableRegister deterministically 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:362 through :422. Incremental marking triggers InYoungGeneration(value, ...) and InSharedHeap(value, ...). If a controlled value (via ArrayBuffer overlap) is read that tests false for both, the stub returns cleanly without invoking CSA_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 force x5 and x1 allocation, 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.

View on issue tracker