Low chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in V8
DescriptionMemory corruption in V8
ComponentV8
Bug ClassMemory Corruption
Tracker539453394
Fix commit429577a9437e (v8/v8) +217/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Background

On-Stack Replacement (OSR)
a V8 optimization that swaps a long-running function’s interpreted/baseline frame for freshly TurboFan-compiled machine code while that frame is still executing mid-loop.
`Deoptimizer::DeoptimizeFunction`
the V8 routine that invalidates a specific Code object and rewrites live stack frames running it back to unoptimized bytecode.
Lazy deoptimization
deferred invalidation where optimized code is marked for deopt and unwound when control returns to or breaks in the affected frame, here triggered by LazyDeoptimizeReason::kDebugger.
`StackFrame::LookupCode`
a frame accessor that returns the exact Code object whose instructions the frame’s program counter is actually executing, which for an OSR frame is the OSR machine code rather than the function’s default entrypoint.

Root Cause Analysis

When the debugger hit a breakpoint or restarted a frame in src/debug/debug.cc, it called Deoptimizer::DeoptimizeFunction passing only the JSFunction and LazyDeoptimizeReason::kDebugger, omitting the target Code argument. With no explicit Code, the deoptimizer defaults to function->code(isolate), which is the function’s default entrypoint (unoptimized bytecode, baseline, or non-OSR optimized code) and is distinct from the OSR-compiled machine code actually running on the paused stack frame. The invariant being violated is that a debugger break/restart must deoptimize the code executing on the live frame, but here an OSR frame was left running optimized machine code the debugger believed to be deoptimized.

The fix passes frame->LookupCode(), which resolves to the real Code object executing in that frame, so the OSR code is the one invalidated and the frame is correctly unwound. This closes the mismatch between the frame the debugger operated on and the code object it invalidated, which the new tests confirm by asserting the topmost OSR frame is no longer TurboFanned after a break.

Key insight
The single mistake was letting DeoptimizeFunction default to the function’s canonical entrypoint Code instead of the OSR Code actually on the stack, so the wrong (or no) code got deoptimized; the fix supplies frame->LookupCode() to target the exact code running in the paused frame.

Attack Path

  1. Trigger OSR Run a hot loop so the enclosing function is replaced on-stack with OSR-compiled TurboFan machine code while its frame stays live.
  2. Enter the debugger Hit a debugger statement or breakpoint (or restart the frame) so Debug calls Deoptimizer::DeoptimizeFunction on the paused optimized frame.
  3. Miss the OSR code Because no Code is passed, the default function->code(isolate) targets a different code object, leaving the OSR machine code on the stack un-deoptimized.
  4. Continue on stale code Execution resumes in the still-optimized OSR frame under debugger-mutated state whose assumptions the OSR code no longer holds, producing memory corruption.

Impact Assessment

An attacker who can drive OSR compilation and interact with the debugger/inspector obtains memory corruption inside the V8/renderer process by executing an OSR-optimized frame that should have been deoptimized. The corruption arises in the same process context that runs the affected JavaScript, and the preconditions are inducing an OSR frame and triggering a debugger break or frame restart on it. The metadata rates this low severity with no assigned CVSS, consistent with the debugger-interaction precondition.

Changed Functions

FunctionChangeNotes
for
test/inspector/debugger/restart-frame/restart-osr-frame-expected.txt
modified
for
test/inspector/debugger/restart-frame/restart-osr-frame.js
modified
if
test/mjsunit/debug-osr-debug-break.js
modified
for
test/mjsunit/debug-osr-debug-break.js
modified
if
test/mjsunit/debug-osr-step-on-throw.js
modified

Files Changed

  • src/debug/debug.cc
  • test/inspector/debugger/restart-frame/restart-osr-frame-expected.txt
  • test/inspector/debugger/restart-frame/restart-osr-frame.js
  • test/mjsunit/debug-osr-debug-break.js
  • test/mjsunit/debug-osr-step-on-throw.js

Audit Directions

  • Defaulted `Code` arguments
    Audit every Deoptimizer::DeoptimizeFunction call that omits the Code parameter, since defaulting to function->code(isolate) silently mistargets OSR and other non-entrypoint frames.
  • Frame-vs-function code mismatch
    Flag debugger, profiler, and stack-walking paths that deoptimize by JSFunction rather than by the frame’s actual LookupCode(), since a live frame may run OSR or inlined code distinct from the function’s canonical Code.
  • OSR interaction with debug state mutation
    Review breakpoint, step, throw, and restart-frame handlers for correct handling of OSR frames, as debugger-driven state changes on un-deoptimized OSR code violate the compiled code’s assumptions.
From 429577a9437e269d4bbb5d529458a1e74643c856 Mon Sep 17 00:00:00 2001
From: Darius Mercadier <dmercadier@chromium.org>
Date: Tue, 28 Jul 2026 12:58:59 +0200
Subject: [PATCH] [debug] Deoptimize OSR frames on debugger break and restart

When Deoptimizer::DeoptimizeFunction is called without explicitly
passing the target frame's Code object, it defaults to
unction->code(isolate), which points to the function's default
entrypoint code (e.g. unoptimized bytecode, baseline code, or non-OSR
optimized code) rather than the OSR machine code executing on the
stack frame.

TAG=agy
CONV=978a8699-6f5a-469d-a61e-d8cbf7aa4c0d

Fixed: 539453394, 531297707
Change-Id: Ibb577dfd2169e4d76517bb661410ff5609fb2ad5
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8159443
Auto-Submit: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108910}
---

diff --git a/src/debug/debug.cc b/src/debug/debug.cc
index b239a5c..9ba9a7e 100644
--- a/src/debug/debug.cc
+++ b/src/debug/debug.cc
@@ -2911,8 +2911,8 @@
       // caller frames are at a call site, which acts as a memory serialization
       // barrier, forcing them to reload all heap state upon return anyway.
       if (frame->is_optimized()) {
-        Deoptimizer::DeoptimizeFunction(*function,
-                                        LazyDeoptimizeReason::kDebugger);
+        Deoptimizer::DeoptimizeFunction(
+            *function, LazyDeoptimizeReason::kDebugger, frame->LookupCode());
       }
 
       // kScheduled breaks are triggered by the stack check. While we could
@@ -3434,7 +3434,8 @@
                                 int inlined_frame_index) {
   if (frame->is_optimized()) {
     Deoptimizer::DeoptimizeFunction(frame->function(),
-                                    LazyDeoptimizeReason::kDebugger);
+                                    LazyDeoptimizeReason::kDebugger,
+                                    frame->LookupCode());
   }
 
   thread_local_.restart_frame_id_ = frame->id();
diff --git a/test/inspector/debugger/restart-frame/restart-osr-frame-expected.txt b/test/inspector/debugger/restart-frame/restart-osr-frame-expected.txt
new file mode 100644
index 0000000..f36a138
--- /dev/null
+++ b/test/inspector/debugger/restart-frame/restart-osr-frame-expected.txt
@@ -0,0 +1,18 @@
+Checks that restarting an OSR-optimized frame works.
+Paused at (after evaluation):
+function foo() {
+  #debugger;
+}
+
+Pause stack:
+  foo:2 (canBeRestarted = true)
+  osr_caller:9 (canBeRestarted = true)
+
+Restarting osr_caller frame...
+Restarting function "osr_caller" ...
+Paused at (after restart):
+function osr_caller() {
+  for (let i = #0; i < 1000; i++) {
+    if (i == 10) %OptimizeOsr();
+
+Resuming...
diff --git a/test/inspector/debugger/restart-frame/restart-osr-frame.js b/test/inspector/debugger/restart-frame/restart-osr-frame.js
new file mode 100644
index 0000000..591d7fc
--- /dev/null
+++ b/test/inspector/debugger/restart-frame/restart-osr-frame.js
@@ -0,0 +1,41 @@
+// 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: --allow-natives-syntax --turbofan --no-maglev
+
+const {session, contextGroup, Protocol} =
+  InspectorTest.start('Checks that restarting an OSR-optimized frame works.');
+
+session.setupScriptMap();
+
+contextGroup.addScript(`
+function foo() {
+  debugger;
+}
+
+function osr_caller() {
+  for (let i = 0; i < 1000; i++) {
+    if (i == 10) %OptimizeOsr();
+  }
+  foo();
+}
+`, 0, 0, 'test.js');
+
+(async () => {
+  await Protocol.Debugger.enable();
+  await Protocol.Runtime.enable();
+
+  const { callFrames } = await InspectorTest.evaluateAndWaitForPause(
+      '%PrepareFunctionForOptimization(osr_caller); osr_caller();');
+
+  InspectorTest.log('Restarting osr_caller frame...');
+  await InspectorTest.restartFrameAndWaitForPause(callFrames, 1);
+
+  InspectorTest.log('Resuming...');
+  Protocol.Debugger.resume();
+  await Protocol.Debugger.oncePaused();
+  await Protocol.Debugger.resume();
+
+  InspectorTest.completeTest();
+})();
diff --git a/test/mjsunit/debug-osr-debug-break.js b/test/mjsunit/debug-osr-debug-break.js
new file mode 100644
index 0000000..bd65449
--- /dev/null
+++ b/test/mjsunit/debug-osr-debug-break.js
@@ -0,0 +1,44 @@
+// 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: --allow-natives-syntax --enable-inspector --turbofan --no-maglev
+
+let msgId = 1;
+function cmd(method, params) {
+  return JSON.stringify({id: msgId++, method: method, params: params || {}});
+}
+
+function receive(msg) {
+  let obj = JSON.parse(msg);
+  if (obj.method === "Debugger.paused") {
+    send(cmd("Debugger.resume"));
+  }
+}
+
+let top_frame_status_after_break = -1;
+function check_deopt() {
+  eval("");
+  top_frame_status_after_break = %GetOptimizationStatus(osr_top);
+}
+%NeverOptimizeFunction(check_deopt);
+
+function osr_top() {
+  for (let i = 0; i < 20; i++) {
+    if (i === 10) {
+      %OptimizeOsr();
+    }
+  }
+  debugger;
+  check_deopt();
+}
+
+send(cmd("Debugger.enable"));
+%PrepareFunctionForOptimization(osr_top);
+osr_top();
+
+// kTopmostFrameIsTurboFanned is bit 11 (1 << 11 = 2048) of GetOptimizationStatus
+const kTopmostFrameIsTurboFanned = 1 << 11;
+const isTopFrameTurboFanned = (top_frame_status_after_break & kTopmostFrameIsTurboFanned) !== 0;
+
+assertFalse(isTopFrameTurboFanned, "OSR topmost frame should be deoptimized after debugger break");
diff --git a/test/mjsunit/debug-osr-step-on-throw.js b/test/mjsunit/debug-osr-step-on-throw.js
new file mode 100644
index 0000000..de09c11
--- /dev/null
+++ b/test/mjsunit/debug-osr-step-on-throw.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.
+
+// Flags: --allow-natives-syntax --enable-inspector --turbofan --no-maglev
+
+let msgId = 1;
+function cmd(method, params) {
+  return JSON.stringify({id: msgId++, method: method, params: params || {}});
+}
+
+let paused_locations = [];
+
+function receive(msg) {
+  let obj = JSON.parse(msg);
+  if (obj.method === "Debugger.paused") {
+    let fnName = obj.params.callFrames[0].functionName;
+    paused_locations.push(fnName);
+    if (fnName !== "caught_target") {
+      send(cmd("Debugger.stepInto"));
+    } else {
+      send(cmd("Debugger.resume"));
+    }
+  }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/debug-osr-debug-break.js b/test/mjsunit/debug-osr-debug-break.js
new file mode 100644
index 0000000..bd65449
--- /dev/null
+++ b/test/mjsunit/debug-osr-debug-break.js
@@ -0,0 +1,44 @@
+// 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: --allow-natives-syntax --enable-inspector --turbofan --no-maglev
+
+let msgId = 1;
+function cmd(method, params) {
+  return JSON.stringify({id: msgId++, method: method, params: params || {}});
+}
+
+function receive(msg) {
+  let obj = JSON.parse(msg);
+  if (obj.method === "Debugger.paused") {
+    send(cmd("Debugger.resume"));
+  }
+}
+
+let top_frame_status_after_break = -1;
+function check_deopt() {
+  eval("");
+  top_frame_status_after_break = %GetOptimizationStatus(osr_top);
+}
+%NeverOptimizeFunction(check_deopt);
+
+function osr_top() {
+  for (let i = 0; i < 20; i++) {
+    if (i === 10) {
+      %OptimizeOsr();
+    }
+  }
+  debugger;
+  check_deopt();
+}
+
+send(cmd("Debugger.enable"));
+%PrepareFunctionForOptimization(osr_top);
+osr_top();
+
+// kTopmostFrameIsTurboFanned is bit 11 (1 << 11 = 2048) of GetOptimizationStatus
+const kTopmostFrameIsTurboFanned = 1 << 11;
+const isTopFrameTurboFanned = (top_frame_status_after_break & kTopmostFrameIsTurboFanned) !== 0;
+
+assertFalse(isTopFrameTurboFanned, "OSR topmost frame should be deoptimized after debugger break");
diff --git a/test/mjsunit/debug-osr-step-on-throw.js b/test/mjsunit/debug-osr-step-on-throw.js
new file mode 100644
index 0000000..de09c11
--- /dev/null
+++ b/test/mjsunit/debug-osr-step-on-throw.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.
+
+// Flags: --allow-natives-syntax --enable-inspector --turbofan --no-maglev
+
+let msgId = 1;
+function cmd(method, params) {
+  return JSON.stringify({id: msgId++, method: method, params: params || {}});
+}
+
+let paused_locations = [];
+
+function receive(msg) {
+  let obj = JSON.parse(msg);
+  if (obj.method === "Debugger.paused") {
+    let fnName = obj.params.callFrames[0].functionName;
+    paused_locations.push(fnName);
+    if (fnName !== "caught_target") {
+      send(cmd("Debugger.stepInto"));
+    } else {
+      send(cmd("Debugger.resume"));
+    }
+  }
+}
+
+function caught_target() {
+  eval("");
+  // Should step into here after exception unwinds to catch handler in osr_caller()
+}
+
+function thrower() {
+  eval("");
+  debugger;
+  throw new Error("test");
+}
+
+%NeverOptimizeFunction(thrower);
+%NeverOptimizeFunction(caught_target);
+
+function osr_caller() {
+  for (let i = 0; i < 20; i++) {
+    if (i === 10) {
+      %OptimizeOsr();
+    }
+  }
+  try {
+    thrower();
+  } catch (e) {
+    caught_target();
+  }
+}
+
+send(cmd("Debugger.enable"));
+%PrepareFunctionForOptimization(osr_caller);
+osr_caller();
+
+assertEquals(["thrower", "thrower", "osr_caller", "caught_target"], paused_locations);
diff --git a/test/mjsunit/debug-osr-step-out.js b/test/mjsunit/debug-osr-step-out.js
new file mode 100644
index 0000000..9044d75
--- /dev/null
+++ b/test/mjsunit/debug-osr-step-out.js
@@ -0,0 +1,52 @@
+// 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: --allow-natives-syntax --enable-inspector --turbofan --no-maglev
+
+let msgId = 1;
+function cmd(method, params) {
+  return JSON.stringify({id: msgId++, method: method, params: params || {}});
+}
+
+let paused_locations = [];
+function receive(msg) {
+  let obj = JSON.parse(msg);
+  if (obj.method === "Debugger.paused") {
+    let fnName = obj.params.callFrames[0].functionName;
+    paused_locations.push(fnName);
+    if (fnName === "foo") {
+      send(cmd("Debugger.stepOut"));
+    } else if (fnName === "osr_caller") {
+      send(cmd("Debugger.stepInto"));
+    } else {
+      send(cmd("Debugger.resume"));
+    }
+  }
+}
+
+function bar() {
+  // Should pause here when stepping out of foo() from osr_caller()
+}
+%NeverOptimizeFunction(bar);
+
+function foo() {
+  debugger;
+}
+%NeverOptimizeFunction(foo);
+
+function osr_caller() {
+  for (let i = 0; i < 20; i++) {
+    if (i === 10) {
+      %OptimizeOsr();
+    }
+  }
+  foo();
+  bar();
+}
+
+send(cmd("Debugger.enable"));
+%PrepareFunctionForOptimization(osr_caller);
+osr_caller();
+
+assertEquals(["foo", "osr_caller", "bar"], paused_locations);
Loading diff…

Original Bug Report

reported by jg...@chromium.org

[v8] Debugger break fails to deoptimize OSR frames, retaining UAF/OOB vulnerability

Note: this issue was posted via CodeMind.

> Experimental – AI-generated report, not reviewed by a human. > Filed automatically by airc. An AI agent reviewed the commit and wrote the > reproduction below; an orchestrator then re-ran it and observed the failure, > so the reproduction is machine-verified. The analysis is model-written > and unverified – treat it as a lead, not a conclusion. Please reassign, > downgrade, or close this freely if it is wrong; no justification needed.

Pre-existing defect: reproduces but was NOT introduced by v8@73b4712d0fac (it reproduces on the parent tree too).

Defect. The DeoptimizeFunction call uses the default code argument, which evaluates to function->code(isolate). For OSR-optimized frames, this is not the code object currently executing. Consequently, the actual executing OSR code is not marked for deoptimization, and DeoptimizeMarkedCode skips it. When the debugger resumes, the OSR code continues executing with stale load-eliminated values, leading to a Use-After-Free or Out-Of-Bounds access if heap state was modified during the pause.

Wrote a test that triggers OSR inside a loop, pauses execution via the inspector, and alters array length. It asserts that the OSR code is deoptimized, successfully failing with an un-deoptimized OSR frame.

Reproduction (test/mjsunit/compiler/regress-osr-debug.js).

diff --git a/test/mjsunit/compiler/regress-osr-debug.js b/test/mjsunit/compiler/regress-osr-debug.js
new file mode 100644
index 00000000000..bc5063c093d
--- /dev/null
+++ b/test/mjsunit/compiler/regress-osr-debug.js
@@ -0,0 +1,49 @@
+// 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: --allow-natives-syntax --enable-inspector --turbofan
+
+let msgId = 1;
+function cmd(method, params) {
+  return JSON.stringify({id: msgId++, method: method, params: params || {}});
+}
+
+let arr = [1.1, 2.2, 3.3];
+
+let armModify = false;
+
+globalThis.handleInspectorMessage = function() {
+  if (armModify) {
+    arr.length = 1;
+  }
+  send(cmd("Debugger.resume"));
+};
+
+send(cmd("Debugger.enable"));
+const PAUSE = cmd("Debugger.pause");
+
+function hot(arr) {
+  let val;
+  for (let i = 0; i < 20; i++) {
+    if (i === 10) {
+      %OptimizeOsr();
+    }
+    if (i === 15) {
+      armModify = true;
+      send(PAUSE);
+    }
+    if (i > 15) {
+      // If deoptimized, arr[2] is undefined because arr.length was set to 1.
+      // If not deoptimized, arr[2] might be 3.3 because the bounds check is eliminated.
+      val = arr[2];
+    }
+  }
+  return val;
+}
+
+%PrepareFunctionForOptimization(hot);
+let result = hot(arr);
+if (result !== undefined) {
+  throw new Error("Result is " + result + " instead of undefined. OSR frame was not deoptimized!");
+}

Verification. Verified: yes, but PRE-EXISTING – it fails on the parent commit too, so this commit did not introduce it.

command:  out/x64.optdebug/d8 --allow-natives-syntax --enable-inspector --turbofan test/mjsunit/compiler/regress-osr-debug.js
expected: nonzero exit (crash/abort), output matching /OSR frame was not deoptimized!/
actual:   exit 1, signature matched
control:  on 73b4712d0fac^: exit 1, signature matched -- fails there too (pre-existing)

output (tail):

test/mjsunit/compiler/regress-osr-debug.js:48: Error: Result is 3.3 instead of undefined. OSR frame was not deoptimized!
  throw new Error("Result is " + result + " instead of undefined. OSR frame was not deoptimized!");
  ^
Error: Result is 3.3 instead of undefined. OSR frame was not deoptimized!
    at test/mjsunit/compiler/regress-osr-debug.js:48:9

Reviewer evidence. In src/debug/debug.cc:2914, Deoptimizer::DeoptimizeFunction is called with only two arguments. In src/deoptimizer/deoptimizer.cc, this falls back to function->code(isolate). ActivationsFinder::VisitThread checks if it.frame()->GcSafeLookupCode() is marked for deoptimization. Since the OSR code on the stack differs from function->code(isolate), it remains unmarked and is not replaced with a deoptimization trampoline.

filed by airc for v8-73b4712d0fac-src-debug-debug-cc-2914

View on issue tracker