CVE-2026-79197
Overview
Background
- OSR (On-Stack Replacement)
- a V8 optimization that swaps a long-running function’s executing stack frame from unoptimized bytecode to freshly compiled optimized machine code mid-execution.
- Deoptimization
- the process of discarding optimized
Codeand rewriting an active frame to resume in unoptimized bytecode, used when assumptions are invalidated or a debugger attaches. - `Deoptimizer::DeoptimizeFunction`
- the entry point that marks a function’s optimized
Codefor deopt; without an explicitCodeargument it defaults tofunction->code(isolate), the function’s default entrypoint rather than the code on the frame. - `LazyDeoptimizeReason::kDebugger`
- the deopt reason recorded when the debugger forces a frame off optimized code so it can be inspected or restarted.
Root Cause Analysis
When the debugger hit a break (debug.cc:2911) or a restart-frame request (debug.cc:3434) on an optimized frame, it called Deoptimizer::DeoptimizeFunction(*function, LazyDeoptimizeReason::kDebugger) without passing the target frame’s Code object. Because the argument was omitted, the deoptimizer defaulted to function->code(isolate), which points to the function’s default entrypoint (unoptimized bytecode, baseline code, or non-OSR optimized code) rather than the OSR machine code actually executing on the stack. As a result an OSR-optimized frame was left running its stale OSR Code while the debugger believed it had been deoptimized, violating the invariant that a frame observed or restarted by the debugger is backed by live, non-optimized state. The OSR Code could then be freed while the frame still executed from it, yielding a use-after-free.
The fix threads frame->LookupCode() — the exact Code object on the stack — into DeoptimizeFunction, so the OSR code executing on that frame is the code that is actually deoptimized.
function->code(isolate) target, which never matches the transient OSR Code on the stack; passing frame->LookupCode() makes the deoptimizer act on the actual executing code rather than the function’s default entrypoint.Attack Path
- Force OSR compilation
Run a hot loop (e.g. via
%OptimizeOsr()inside a longforloop) so V8 performs on-stack replacement, leaving the frame executing dedicated OSR machine code. - Trigger a debugger break on the OSR frame
Hit a
debuggerstatement or set a breakpoint sodebug.cccallsDeoptimizer::DeoptimizeFunction(*function, LazyDeoptimizeReason::kDebugger)on the optimized frame. - Miss the real code
Because no
Codeargument is passed, deoptimization targetsfunction->code(isolate)(the default entrypoint) and never touches the OSRCodestill live on the stack. - Free the stale OSR code
Restart or resume the frame so the OSR
Codebecomes unreferenced and is reclaimed while the frame is still associated with it, creating a dangling pointer. - Exploit the dangling code
Continued execution against the freed OSR
Codeproduces a use-after-free that a crafted script can steer toward corruption and code execution.
Impact Assessment
Code, which can be leveraged into memory corruption and potentially renderer-process code execution. Exploitation requires triggering OSR on a function and then forcing a debugger break or frame restart on that optimized frame, so the inspector/debugger path must be reachable in the target context.Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/inspector/debugger/restart-frame/restart-osr-frame-expected.txt |
modified | |
fortest/inspector/debugger/restart-frame/restart-osr-frame.js |
modified | |
iftest/mjsunit/debug-osr-debug-break.js |
modified | |
fortest/mjsunit/debug-osr-debug-break.js |
modified | |
iftest/mjsunit/debug-osr-step-on-throw.js |
modified | |
fortest/mjsunit/debug-osr-step-on-throw.js |
modified | |
iftest/mjsunit/debug-osr-step-out.js |
modified | |
fortest/mjsunit/debug-osr-step-out.js |
modified |
Files Changed
test/mjsunit/debug-osr-debug-break.jstest/inspector/debugger/restart-frame/restart-osr-frame-expected.txttest/mjsunit/debug-osr-step-out.jstest/mjsunit/debug-osr-step-on-throw.jstest/inspector/debugger/restart-frame/restart-osr-frame.jssrc/debug/debug.cc
Audit Directions
- Default-`Code` deopt callsAudit every
Deoptimizer::DeoptimizeFunctioncall site that omits theCodeargument and relies onfunction->code(isolate), since any OSR or non-default frame there may be left running unfreed optimized code. - OSR frame assumptions in the debuggerReview debugger and inspector paths (break, restart, step, exception unwind) that assume a frame’s
Codeequals its function’s entrypoint, as OSR frames break that assumption. - Frame-to-Code lookupsPrefer
frame->LookupCode()wherever code operating on a live frame is selected, and flag any logic that derives the frame’s code from theJSFunctioninstead of the stack.
Patch
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"));
+ }
+ }
+}
+
+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);
Regression Test / PoC
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);