Low watch CVSS 7.5 chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentWASM
Bug ClassLogic Error
Action tierwatch — corruption primitive but gated (local) — 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.
Tracker517316174
Fix commitf3488673f2e2 (v8/v8) +75/-31
CWECWE-269
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (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, whose code can be swapped at runtime (e.g. for debugging) via on-stack replacement.
On-stack replacement (OSR)
the mechanism that redirects an already-running Wasm frame to a newly compiled WasmCode object by rewriting its saved return address.
`kAfterBreakpoint` / `kAfterWasmCall`
the two return-address computation modes: the former resumes execution after a debug breakpoint, the latter after a normal Wasm call instruction, and each maps a byte offset to a different code offset.
`WASM_DEBUG_BREAK` frame
a synthetic stack frame pushed above a WasmFrame when execution is actually suspended at a Wasm breakpoint.

Root Cause Analysis

When the debugger recompiles a Wasm function and calls UpdateReturnAddresses, it must patch the saved return address of every live frame running the old WasmCode so execution resumes in the new_code. The old implementation split the work in two: it unconditionally treated the topmost matching WasmFrame as being paused at a breakpoint (using kAfterBreakpoint) and every other frame as being at a call site (using kAfterWasmCall). That assumption violated the invariant that the top frame is only at a breakpoint when execution was actually suspended there — the top frame could instead be stopped at an ordinary call site, such as a call into JS. If a dead breakpoint happened to sit at the same byte offset as that call site, the kAfterBreakpoint path selected the dead breakpoint’s code offset instead of the call return offset, producing a miscalculated return address.

The fix determines whether the frame is genuinely at a breakpoint by checking for a WASM_DEBUG_BREAK frame directly above the WasmFrame on the stack, and only then uses kAfterBreakpoint, otherwise falling back to kAfterWasmCall.

Key insight
The core mistake was inferring “at a breakpoint” from a frame’s position (topmost) rather than from the actual runtime state; the fix reads that state from the stack itself by testing is_wasm_debug_break() on the frame above, so the return-address mode always matches reality.

Attack Path

  1. Set and remove a breakpoint Attach a debugger and place, then clear, a Wasm breakpoint at a byte offset that coincides with a call instruction, leaving a “dead” breakpoint entry at that offset.
  2. Suspend at the colliding call site Arrange execution so the topmost Wasm frame is stopped at that call instruction (e.g. calling into JS) rather than at a real breakpoint.
  3. Trigger recompilation and OSR Cause the debugger to recompile the Liftoff function, invoking UpdateReturnAddresses, which treats the top frame as kAfterBreakpoint.
  4. Miscalculate the return address The dead breakpoint’s code offset is chosen instead of the call return offset, so the patched return address points into the wrong location of new_code.
  5. Resume with a corrupted control flow Execution resumes at the wrong offset, producing type/stack confusion inside V8 that can be leveraged toward memory corruption.

Impact Assessment

An attacker who can run crafted WebAssembly under a debugging/OSR-triggering path gains control over where a live frame resumes, corrupting the Wasm control flow inside the V8 engine in the renderer process. This is an in-process logic error that requires the debugger-driven recompilation path and a breakpoint offset colliding with a call site; combined with other primitives it can escalate to memory corruption and remote code execution in the sandboxed renderer.

Changed Functions

FunctionChangeNotes
if
src/wasm/wasm-debug.cc
modified
if
test/debugger/regress/regress-crbug-840288.js
modified

Files Changed

  • src/wasm/wasm-debug.cc
  • test/debugger/debugger.status
  • test/debugger/regress/regress-crbug-1032042.js
  • test/debugger/regress/regress-crbug-840288.js
  • test/debugger/regress/wasm/regress-517316174.js

Audit Directions

  • State inferred from position
    Audit any code that infers runtime state (paused, at-breakpoint, top-of-stack) from a frame’s ordinal position instead of querying the actual stack/frame flags.
  • Offset-mode selection
    Review other sites that map byte offsets to code offsets under multiple modes (kAfterBreakpoint vs kAfterWasmCall), where a stale or dead breakpoint could shadow a real call site.
  • Debugger OSR/recompilation paths
    Scrutinize return-address patching during Liftoff recompilation and OSR for missing checks on WASM_DEBUG_BREAK frames and interpreter-entry frames.
From f3488673f2e2ba94683dde07919d433be9a8ce0c Mon Sep 17 00:00:00 2001
From: Clemens Backes <clemensb@chromium.org>
Date: Wed, 03 Jun 2026 18:13:22 +0200
Subject: [PATCH] [wasm][debug] Fix return address calculation during OSR patching

In UpdateReturnAddresses, the topmost Wasm frame was unconditionally
treated as being at a breakpoint (using kAfterBreakpoint semantics).
However, the frame could also be at a standard call site (e.g. calling
into JS). If there is a dead breakpoint at the same byte offset as the
call site, kAfterBreakpoint incorrectly picks the dead breakpoint's
code offset, leading to a miscalculated return address.

This CL fixes this by checking if there is a WASM_DEBUG_BREAK frame
above the Wasm frame on the stack. If so, we are actually at a
breakpoint. Otherwise, we treat it as a regular call site.

Also consolidate Wasm debugger regression tests into a new directory
test/debugger/regress/wasm/ and update debugger.status accordingly.

R=jkummerow@chromium.org

Fixed: 517316174
Change-Id: Ib4718a863d5f358a78c500dec6af4a3ebacca981
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7894740
Commit-Queue: Clemens Backes <clemensb@chromium.org>
Reviewed-by: Jakob Kummerow <jkummerow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107766}
---

diff --git a/src/wasm/wasm-debug.cc b/src/wasm/wasm-debug.cc
index 1ae12eb..bb8d6f6 100644
--- a/src/wasm/wasm-debug.cc
+++ b/src/wasm/wasm-debug.cc
@@ -725,37 +725,24 @@
   // code. The frame layout itself should be independent of breakpoints.
   void UpdateReturnAddresses(Isolate* isolate, WasmCode* new_code,
                              StackFrameId stepping_frame) {
-    auto matches = [new_code](WasmFrame* frame) {
-      return frame->native_module() == new_code->native_module() &&
-             FrameSummary::GetTop(frame).AsWasm().function_index() ==
-                 static_cast<uint32_t>(new_code->index()) &&
-             frame->wasm_code()->is_liftoff();
-    };
-
-    // 1. Find and handle the first Wasm frame (potential breakpoint resume).
-    DebuggableStackFrameIterator it(isolate);
-    while (!it.done() && !it.is_wasm()) it.Advance();
-
-    if (!it.done()) {
-      WasmFrame* frame = WasmFrame::cast(it.frame());
-      // We still need the flooded function for stepping.
-      if (frame->id() != stepping_frame &&
-#if V8_ENABLE_DRUMBRAKE
-          // TODO(paolosev@microsoft.com) - Implement for Wasm interpreter.
-          !it.is_wasm_interpreter_entry() &&
-#endif
-          matches(frame)) {
-        UpdateReturnAddress(frame, new_code, kAfterBreakpoint);
-      }
-      it.Advance();
-    }
-
-    // 2. Handle all remaining frames (always at call sites).
+    StackFrameIterator it(isolate);
     for (; !it.done(); it.Advance()) {
-      if (!it.is_wasm()) continue;
+      bool at_breakpoint = it.frame()->is_wasm_debug_break();
+      if (at_breakpoint) {
+        it.Advance();
+        CHECK(!it.done());
+      }
+      if (!it.frame()->is_wasm()) continue;
+#if V8_ENABLE_DRUMBRAKE
+      if (it.frame()->is_wasm_interpreter_entry()) continue;
+#endif
       WasmFrame* frame = WasmFrame::cast(it.frame());
-      if (!matches(frame)) continue;
-      UpdateReturnAddress(frame, new_code, kAfterWasmCall);
+      if (frame->native_module() != new_code->native_module()) continue;
+      WasmCode* code = frame->wasm_code();
+      if (!code->is_liftoff() || code->index() != new_code->index()) continue;
+      if (frame->id() == stepping_frame) continue;
+      UpdateReturnAddress(frame, new_code,
+                          at_breakpoint ? kAfterBreakpoint : kAfterWasmCall);
     }
   }
 
diff --git a/test/debugger/debugger.status b/test/debugger/debugger.status
index 3f1459c..492e1e7 100644
--- a/test/debugger/debugger.status
+++ b/test/debugger/debugger.status
@@ -131,8 +131,7 @@
 # TODO(v8:7777): Change this once wasm is supported in jitless mode.
 ['not has_webassembly or variant == jitless', {
   'debug/wasm/*': [SKIP],
-  'regress/regress-crbug-840288': [SKIP],
-  'regress/regress-crbug-1032042': [SKIP],
+  'regress/wasm/*': [SKIP],
 }],  # not has_webassembly or variant == jitless
 
 ##############################################################################
diff --git a/test/debugger/regress/regress-crbug-1032042.js b/test/debugger/regress/regress-crbug-1032042.js
deleted file mode 100644
index b92735f..0000000
--- a/test/debugger/regress/regress-crbug-1032042.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2019 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.
-
-d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
-
-const Debug = new DebugWrapper();
-Debug.enable();
-
-// Record the ID of the first script reported. This is to ignore
-// the (now deprecated) fake scripts that are generated for every
-// Wasm module.
-let scriptId;
-Debug.setListener((eventType, execState, eventData, data) => {
-    assertEquals(Debug.DebugEvent.AfterCompile, eventType);
-    if (scriptId === undefined) scriptId = eventData.scriptId;
-});
-
-// Create a simple Wasm script, which will be caught by the event listener.
-const builder = new WasmModuleBuilder();
-builder.addFunction('sub', kSig_i_ii)
-// input is 2 args of type int and output is int
-.addBody([
-  kExprLocalGet, 0, // local.get i0
-  kExprLocalGet, 1, // local.get i1
-  kExprI32Sub]) // i32.sub i0 i1
-.exportFunc();
-const instance = builder.instantiate();
-
-// By now we should have recorded the ID of the Wasm script above.
-assertNotEquals(undefined, scriptId);
-
-// Disable and re-enable the Debugger and collect the reported
-// script IDs.
-const scriptIds = new Set();
-Debug.disable();
-Debug.setListener((eventType, execState, eventData, data) => {
-    assertEquals(Debug.DebugEvent.AfterCompile, eventType);
-    scriptIds.add(eventData.scriptId);
-});
-Debug.enable();
-
-// Make sure the Wasm script was reported.
-assertTrue(scriptIds.has(scriptId));
diff --git a/test/debugger/regress/regress-crbug-840288.js b/test/debugger/regress/regress-crbug-840288.js
deleted file mode 100644
index 6d34928..0000000
--- a/test/debugger/regress/regress-crbug-840288.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright 2018 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.
-
-d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
-
-Debug = debug.Debug
-
-function listener(event, exec_state, event_data, data) {
-  if (event == Debug.DebugEvent.AfterCompile) {
-    // The actual source doesn't matter, just don't crash.
-    var source = event_data.script().source();
-    // Source will be empty for the script representing the entire module,
-    // disassembly for the script representing just the function.
-    assertTrue(source == "func $main\nend\n" || source == "");
-  }
-};
-
-// Add the debug event listener.
-Debug.setListener(listener);
-
-var builder = new WasmModuleBuilder();
-builder.addFunction('main', kSig_v_v).addBody([]).exportFunc();
-var promise = WebAssembly.compile(builder.toBuffer());
-
-// Clear the debug listener only after the event fired.
-promise.then(() => Debug.setListener(null), assertUnreachable);
diff --git a/test/debugger/regress/wasm/regress-517316174.js b/test/debugger/regress/wasm/regress-517316174.js
new file mode 100644
index 0000000..23c398d
--- /dev/null
+++ b/test/debugger/regress/wasm/regress-517316174.js
@@ -0,0 +1,58 @@
+// 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.
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+const builder = new WasmModuleBuilder();
+const num_params = 100;
+// Use a large signature to maximize the call instruction size.
+const sig_idx = builder.addType(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/debugger/regress/regress-crbug-1032042.js b/test/debugger/regress/regress-crbug-1032042.js
deleted file mode 100644
index b92735f..0000000
--- a/test/debugger/regress/regress-crbug-1032042.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2019 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.
-
-d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
-
-const Debug = new DebugWrapper();
-Debug.enable();
-
-// Record the ID of the first script reported. This is to ignore
-// the (now deprecated) fake scripts that are generated for every
-// Wasm module.
-let scriptId;
-Debug.setListener((eventType, execState, eventData, data) => {
-    assertEquals(Debug.DebugEvent.AfterCompile, eventType);
-    if (scriptId === undefined) scriptId = eventData.scriptId;
-});
-
-// Create a simple Wasm script, which will be caught by the event listener.
-const builder = new WasmModuleBuilder();
-builder.addFunction('sub', kSig_i_ii)
-// input is 2 args of type int and output is int
-.addBody([
-  kExprLocalGet, 0, // local.get i0
-  kExprLocalGet, 1, // local.get i1
-  kExprI32Sub]) // i32.sub i0 i1
-.exportFunc();
-const instance = builder.instantiate();
-
-// By now we should have recorded the ID of the Wasm script above.
-assertNotEquals(undefined, scriptId);
-
-// Disable and re-enable the Debugger and collect the reported
-// script IDs.
-const scriptIds = new Set();
-Debug.disable();
-Debug.setListener((eventType, execState, eventData, data) => {
-    assertEquals(Debug.DebugEvent.AfterCompile, eventType);
-    scriptIds.add(eventData.scriptId);
-});
-Debug.enable();
-
-// Make sure the Wasm script was reported.
-assertTrue(scriptIds.has(scriptId));
diff --git a/test/debugger/regress/regress-crbug-840288.js b/test/debugger/regress/regress-crbug-840288.js
deleted file mode 100644
index 6d34928..0000000
--- a/test/debugger/regress/regress-crbug-840288.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright 2018 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.
-
-d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
-
-Debug = debug.Debug
-
-function listener(event, exec_state, event_data, data) {
-  if (event == Debug.DebugEvent.AfterCompile) {
-    // The actual source doesn't matter, just don't crash.
-    var source = event_data.script().source();
-    // Source will be empty for the script representing the entire module,
-    // disassembly for the script representing just the function.
-    assertTrue(source == "func $main\nend\n" || source == "");
-  }
-};
-
-// Add the debug event listener.
-Debug.setListener(listener);
-
-var builder = new WasmModuleBuilder();
-builder.addFunction('main', kSig_v_v).addBody([]).exportFunc();
-var promise = WebAssembly.compile(builder.toBuffer());
-
-// Clear the debug listener only after the event fired.
-promise.then(() => Debug.setListener(null), assertUnreachable);
diff --git a/test/debugger/regress/wasm/regress-517316174.js b/test/debugger/regress/wasm/regress-517316174.js
new file mode 100644
index 0000000..23c398d
--- /dev/null
+++ b/test/debugger/regress/wasm/regress-517316174.js
@@ -0,0 +1,58 @@
+// 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.
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+const builder = new WasmModuleBuilder();
+const num_params = 100;
+// Use a large signature to maximize the call instruction size.
+const sig_idx = builder.addType(
+    makeSig(new Array(num_params).fill(kWasmI64), []));
+const imp_idx = builder.addImport('m', 'js', sig_idx);
+builder.addTable(kWasmFuncRef, 1);
+builder.addActiveElementSegment(0, [kExprI32Const, 0], [imp_idx]);
+
+const func_main = builder.addFunction('main', kSig_v_v)
+    .addBody([
+      kExprNop, // Offset 1
+      ...new Array(num_params).fill([kExprI64Const, 0]).flat(),
+      kExprI32Const, 0, // table index
+      kExprCallIndirect, sig_idx, 0,
+      kExprI64Const, 0,
+      kExprDrop,
+      kExprI64Const, 0,
+      kExprDrop,
+    ])
+    .exportFunc();
+
+const instance = builder.instantiate({
+  m: {
+    js: (...args) => {
+      if (globalThis.recompiled) return;
+      globalThis.recompiled = true;
+
+      // Trigger UpdateReturnAddresses(isolate, ..., kAfterBreakpoint)
+      // for the topmost Wasm frame ('main') which is currently at the call
+      // site.
+      debug.Debug.clearBreakPoint(bp_call);
+
+      // Set a new breakpoint to force recompilation.
+      debug.Debug.setBreakPoint(instance.exports.main, 0, 0);
+    }
+  }
+});
+
+// Calculate the offset of the call_indirect instruction.
+// 1 (nop) + 100 * 2 (i64.const 0) + 2 (i32.const 0) = 203.
+const call_offset = 1 + num_params * 2 + 2;
+
+// Breakpoint 1: At nop (offset 1). This ensures 'UpdateReturnAddresses'
+// has some existing state to work with and triggers the buggy path.
+debug.Debug.setBreakPoint(instance.exports.main, 0, 1);
+
+// Breakpoint 2: Specifically at the call site.
+const bp_call =
+    debug.Debug.setBreakPoint(instance.exports.main, 0, call_offset);
+
+instance.exports.main();
diff --git a/test/debugger/regress/wasm/regress-crbug-1032042.js b/test/debugger/regress/wasm/regress-crbug-1032042.js
new file mode 100644
index 0000000..b92735f
--- /dev/null
+++ b/test/debugger/regress/wasm/regress-crbug-1032042.js
@@ -0,0 +1,44 @@
+// Copyright 2019 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.
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+const Debug = new DebugWrapper();
+Debug.enable();
+
+// Record the ID of the first script reported. This is to ignore
+// the (now deprecated) fake scripts that are generated for every
+// Wasm module.
+let scriptId;
+Debug.setListener((eventType, execState, eventData, data) => {
+    assertEquals(Debug.DebugEvent.AfterCompile, eventType);
+    if (scriptId === undefined) scriptId = eventData.scriptId;
+});
+
+// Create a simple Wasm script, which will be caught by the event listener.
+const builder = new WasmModuleBuilder();
+builder.addFunction('sub', kSig_i_ii)
+// input is 2 args of type int and output is int
+.addBody([
+  kExprLocalGet, 0, // local.get i0
+  kExprLocalGet, 1, // local.get i1
+  kExprI32Sub]) // i32.sub i0 i1
+.exportFunc();
+const instance = builder.instantiate();
+
+// By now we should have recorded the ID of the Wasm script above.
+assertNotEquals(undefined, scriptId);
+
+// Disable and re-enable the Debugger and collect the reported
+// script IDs.
+const scriptIds = new Set();
+Debug.disable();
+Debug.setListener((eventType, execState, eventData, data) => {
+    assertEquals(Debug.DebugEvent.AfterCompile, eventType);
+    scriptIds.add(eventData.scriptId);
+});
+Debug.enable();
+
+// Make sure the Wasm script was reported.
+assertTrue(scriptIds.has(scriptId));
diff --git a/test/debugger/regress/wasm/regress-crbug-840288.js b/test/debugger/regress/wasm/regress-crbug-840288.js
new file mode 100644
index 0000000..6d34928
--- /dev/null
+++ b/test/debugger/regress/wasm/regress-crbug-840288.js
@@ -0,0 +1,27 @@
+// Copyright 2018 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.
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+Debug = debug.Debug
+
+function listener(event, exec_state, event_data, data) {
+  if (event == Debug.DebugEvent.AfterCompile) {
+    // The actual source doesn't matter, just don't crash.
+    var source = event_data.script().source();
+    // Source will be empty for the script representing the entire module,
+    // disassembly for the script representing just the function.
+    assertTrue(source == "func $main\nend\n" || source == "");
+  }
+};
+
+// Add the debug event listener.
+Debug.setListener(listener);
+
+var builder = new WasmModuleBuilder();
+builder.addFunction('main', kSig_v_v).addBody([]).exportFunc();
+var promise = WebAssembly.compile(builder.toBuffer());
+
+// Clear the debug listener only after the event fired.
+promise.then(() => Debug.setListener(null), assertUnreachable);
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.