Low watch CVSS 8.8 chrome UAF 🔧 Commit mapped

Overview

Low
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentWASM
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker513413942
Fix commit3b8a85d10a8a (v8/v8) +155/-52
CWECWE-416 (Use-after-free)
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-05-15 (per vendor advisory)
Fix landed2026-06-03 (public git)
Disclosed2026-07-29
Patch gap56 days — fix public in source before the advisory

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.

Key insight
The core mistake was reloading only the cached instance data — but not the cached memory start register — after out-of-line code that could invoke 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

  1. 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.
  2. 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.
  3. 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.
  4. Resume with a stale base Return to inline Liftoff code, which reloads only the instance data and keeps the now-dangling cached_mem_start pointing at the freed allocation.
  5. 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

An attacker gains a use-after-free read/write primitive against freed heap memory inside the V8/renderer process, which can be leveraged toward information disclosure and memory corruption leading to remote code execution in the renderer sandbox. It executes in the context of any process running V8 with Liftoff-compiled Wasm; the primary preconditions are the ability to run a crafted Wasm module and to trigger an out-of-line path (most reliably a debugger breakpoint condition) that calls memory.grow.

Changed Functions

FunctionChangeNotes
if
src/wasm/baseline/liftoff-assembler-inl.h
modified
if
src/wasm/baseline/liftoff-assembler.cc
modified

Files Changed

  • src/wasm/baseline/liftoff-assembler-inl.h
  • src/wasm/baseline/liftoff-assembler.cc
  • src/wasm/baseline/liftoff-assembler.h
  • src/wasm/baseline/liftoff-compiler.cc

Audit Directions

  • Cached-register invalidation across cold paths
    Audit 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 reentrancy
    Trace 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 completeness
    Verify that every field added to structures like OutOfLineCode is consistently populated, propagated, and honored by the restoration routine, since a missing field silently reuses stale state.
From 3b8a85d10a8aa1e044938529e7a9fa64f822f6bb Mon Sep 17 00:00:00 2001
From: Clemens Backes <[email protected]>
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.

[email protected]

Fixed: 513413942
Change-Id: I901d7dbed94919cc1009b6bcea2bf28772bb6595
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7864144
Reviewed-by: Matthias Liedtke <[email protected]>
Commit-Queue: Clemens Backes <[email protected]>
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 =
Loading diff…

Regression Test / PoC

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

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.