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-componentDebug
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.
Tracker531297707
Fix commit429577a9437e (v8/v8) +217/-3
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-07-05 (per vendor advisory)
Fix landed2026-07-28 (public git)
Disclosed2026-08-25
Patch gap28 days — fix public in source before the advisory

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 Code and 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 Code for deopt; without an explicit Code argument it defaults to function->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.

Key insight
The single mistake was relying on the default 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

  1. Force OSR compilation Run a hot loop (e.g. via %OptimizeOsr() inside a long for loop) so V8 performs on-stack replacement, leaving the frame executing dedicated OSR machine code.
  2. Trigger a debugger break on the OSR frame Hit a debugger statement or set a breakpoint so debug.cc calls Deoptimizer::DeoptimizeFunction(*function, LazyDeoptimizeReason::kDebugger) on the optimized frame.
  3. Miss the real code Because no Code argument is passed, deoptimization targets function->code(isolate) (the default entrypoint) and never touches the OSR Code still live on the stack.
  4. Free the stale OSR code Restart or resume the frame so the OSR Code becomes unreferenced and is reclaimed while the frame is still associated with it, creating a dangling pointer.
  5. Exploit the dangling code Continued execution against the freed OSR Code produces a use-after-free that a crafted script can steer toward corruption and code execution.

Impact Assessment

An attacker who can run JavaScript in the renderer and drive the debugger (or an inspector-driven context) gains a use-after-free on freed OSR 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

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
for
test/mjsunit/debug-osr-step-on-throw.js
modified
if
test/mjsunit/debug-osr-step-out.js
modified
for
test/mjsunit/debug-osr-step-out.js
modified

Files Changed

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

Audit Directions

  • Default-`Code` deopt calls
    Audit every Deoptimizer::DeoptimizeFunction call site that omits the Code argument and relies on function->code(isolate), since any OSR or non-default frame there may be left running unfreed optimized code.
  • OSR frame assumptions in the debugger
    Review debugger and inspector paths (break, restart, step, exception unwind) that assume a frame’s Code equals its function’s entrypoint, as OSR frames break that assumption.
  • Frame-to-Code lookups
    Prefer frame->LookupCode() wherever code operating on a live frame is selected, and flag any logic that derives the frame’s code from the JSFunction instead of the stack.
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);
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

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.