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 <[email protected]> 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 <[email protected]> Commit-Queue: Leszek Swirski <[email protected]> Reviewed-by: Leszek Swirski <[email protected]> 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);