CVE-2026-17807
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
KeepSessionAliveScopesrc/inspector/v8-inspector-session-impl.h |
modified | |
iftest/inspector/runtime/session-disconnect-uaf.js |
modified | |
setTimeouttest/inspector/runtime/session-disconnect-uaf.js |
modified |
Files Changed
src/inspector/v8-inspector-impl.ccsrc/inspector/v8-inspector-session-impl.htest/inspector/inspector-test.cctest/inspector/runtime/session-disconnect-uaf-expected.txttest/inspector/runtime/session-disconnect-uaf.js
Patch
From ae4f685af9b9030f101a4deab88fb453485a5c10 Mon Sep 17 00:00:00 2001
From: Kim-Anh Tran <kimanh@chromium.org>
Date: Mon, 08 Jun 2026 11:00:19 +0200
Subject: [PATCH] [inspector] Fix Session UAF by hoisting KeepSessionAliveScope into forEachSession
This fixes a Use-After-Free (UAF) vulnerability where the V8 inspector
session is synchronously disconnected/destroyed mid-execution.
By instantiating KeepSessionAliveScope inside
V8InspectorImpl::forEachSession, we ensure that a strong reference to
any active session is kept on the C++ stack during agent callbacks.
Bug: 516763884
Change-Id: Ibe04e6a000a93633fdf8052a0f453a02c55af74f
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7878304
Reviewed-by: Simon Zünd <szuend@chromium.org>
Commit-Queue: Kim-Anh Tran <kimanh@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108306}
---
diff --git a/src/inspector/v8-inspector-impl.cc b/src/inspector/v8-inspector-impl.cc
index dbca2be..4fd23c1 100644
--- a/src/inspector/v8-inspector-impl.cc
+++ b/src/inspector/v8-inspector-impl.cc
@@ -519,7 +519,11 @@
it = m_sessions.find(contextGroupId);
if (it == m_sessions.end()) continue;
auto sessionIt = it->second.find(sessionId);
- if (sessionIt != it->second.end()) callback(sessionIt->second);
+ if (sessionIt != it->second.end()) {
+ V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(
+ *sessionIt->second);
+ callback(sessionIt->second);
+ }
}
}
diff --git a/src/inspector/v8-inspector-session-impl.h b/src/inspector/v8-inspector-session-impl.h
index cd80878..15bf420 100644
--- a/src/inspector/v8-inspector-session-impl.h
+++ b/src/inspector/v8-inspector-session-impl.h
@@ -77,6 +77,21 @@
v8::Local<v8::Context>*, String16* objectGroup);
void releaseObjectGroup(const String16& objectGroup);
+ // Turns the weakThis reference into a strong one so nested run loops or
+ // synchronous session detachment (e.g., during JS re-entrancy) cannot fully
+ // deconstruct the V8 session until any active call (such as
+ // dispatchProtocolMessage or forEachSession) fully unwinds from the stack.
+ class KeepSessionAliveScope {
+ CPPGC_STACK_ALLOCATED();
+
+ public:
+ explicit KeepSessionAliveScope(const V8InspectorSessionImpl& session)
+ : m_this(session.m_weakThis.lock()) {}
+
+ private:
+ std::shared_ptr<V8InspectorSessionImpl> m_this;
+ };
+
// V8InspectorSession implementation.
void dispatchProtocolMessage(StringView message,
StringView associated_data) override;
@@ -155,20 +170,7 @@
bool use_binary_protocol_ = false;
V8Inspector::ClientTrustLevel m_clientTrustLevel = V8Inspector::kUntrusted;
- // On each call to "dispatchProtocolMessage", the session turns the weakThis
- // reference into a strong one, so nested run loops are not able to fully
- // deconstruct the V8 session until we return from the
- // "dispatchProtocolMessage" call (i.e. no freed "this" remains on the stack).
- class KeepSessionAliveScope {
- CPPGC_STACK_ALLOCATED();
- public:
- explicit KeepSessionAliveScope(const V8InspectorSessionImpl& session)
- : m_this(session.m_weakThis.lock()) {}
-
- private:
- std::shared_ptr<V8InspectorSessionImpl> m_this;
- };
std::weak_ptr<V8InspectorSessionImpl> m_weakThis;
};
diff --git a/test/inspector/inspector-test.cc b/test/inspector/inspector-test.cc
index 1c033e4..dde746a 100644
--- a/test/inspector/inspector-test.cc
+++ b/test/inspector/inspector-test.cc
@@ -673,6 +673,9 @@
inspector->Set(isolate, "runNestedMessageLoop",
v8::FunctionTemplate::New(
isolate, &InspectorExtension::RunNestedMessageLoop));
+ inspector->Set(isolate, "disconnectSession",
+ v8::FunctionTemplate::New(
+ isolate, &InspectorExtension::DisconnectSession));
global->Set(isolate, "inspector", inspector);
}
@@ -961,6 +964,17 @@
data->task_runner()->RunMessageLoop(true);
}
+
+ static void DisconnectSession(
+ const v8::FunctionCallbackInfo<v8::Value>& info) {
+ if (info.Length() != 1 || !info[0]->IsInt32()) {
+ FATAL("Internal error: disconnectSession(session_id).");
+ }
+ v8::Local<v8::Context> context = info.GetIsolate()->GetCurrentContext();
+ InspectorIsolateData* data = InspectorIsolateData::FromContext(context);
+ data->DisconnectSession(info[0].As<v8::Int32>()->Value(),
+ data->task_runner());
+ }
};
int InspectorTestMain(int argc, char* argv[]) {
diff --git a/test/inspector/runtime/session-disconnect-uaf-expected.txt b/test/inspector/runtime/session-disconnect-uaf-expected.txt
new file mode 100644
index 0000000..6521d63
--- /dev/null
+++ b/test/inspector/runtime/session-disconnect-uaf-expected.txt
@@ -0,0 +1,3 @@
+Tests that disconnecting session synchronously during argument wrapping does not cause UAF
+prepareStackTrace called
+Did not crash
diff --git a/test/inspector/runtime/session-disconnect-uaf.js b/test/inspector/runtime/session-disconnect-uaf.js
new file mode 100644
index 0000000..ada930d
--- /dev/null
+++ b/test/inspector/runtime/session-disconnect-uaf.js
@@ -0,0 +1,36 @@
+// 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.
+
+let {session, contextGroup, Protocol} = InspectorTest.start(
+ 'Tests that disconnecting session synchronously during argument wrapping does not cause UAF');
+
+let session2 = contextGroup.connect();
+
+session2.Protocol.Runtime.enable();
+session2.Protocol.Runtime.onConsoleAPICalled(function(result) {
+ if (result.params.args[0].value == "prepareStackTrace called") {
+ InspectorTest.log("prepareStackTrace called");
+ session2.Protocol.Runtime.evaluate({ expression: 'console.log("End of test")' });
+ }
+ if (result.params.args[0].value == "End of test") {
+ InspectorTest.log("Did not crash");
+ InspectorTest._sessions.delete(session);
+ InspectorTest.completeTest();
+ }
+});
+
+Protocol.Runtime.enable();
+Protocol.Runtime.evaluate({
+ expression: `
+ Error.prepareStackTrace = function(error, stack) {
+ console.log("prepareStackTrace called");
+ inspector.disconnectSession(${session.id});
+ return stack;
+ };
+ setTimeout(function() {
+ const err = new Error("Trigger UAF");
+ console.log(err);
+ }, 0);
+ `
+});
Original Bug Report
V8RuntimeAgentImpl UAF via session destruction during console.log→reportToFrontend re-entrancy
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
(Note from awillia: Our system dupes this bug to bug 511255112, but it’s a separate issue. Raising this manually to bypass the duping).
Title: V8 Inspector: V8RuntimeAgentImpl UAF via session destruction during console.log→reportToFrontend re-entrancy (KeepSessionAliveScope missing on addMessage path)
Component: Blink>JavaScript>Inspector (or V8>Inspector)
Severity: S2 (renderer C++-heap UAF → vtable call on attacker-groomable freed chunk; DevTools-attached gating)
Summary
V8RuntimeAgentImpl::reportMessage (v8-runtime-agent-impl.cc:1222-1226)
passes &m_frontend and m_session into reportToFrontend, which can run
page JS via wrapArguments (custom formatters / Error-prototype accessors /
@@hasInstance). If that JS destroys the session (not the storage), the
agent — owned by unique_ptr<V8RuntimeAgentImpl> m_runtimeAgent inside the
session — is freed mid-call. On return, frontend->consoleAPICalled(...)
(v8-console-message.cc) makes a virtual call through a FrontendChannel*
read from the freed agent, then reportMessage does m_frontend.flush() and
m_session->contextGroupId() on freed this.
The KeepSessionAliveScope (a shared_ptr lock on the session,
v8-inspector-session-impl.h:163-172) that prevents this is only applied at
dispatchProtocolMessage:368 (CDP entry). The console.log path enters via
V8ConsoleMessageStorage::addMessage → V8InspectorImpl::forEachSession →
session->runtimeAgent()->messageAdded(), which never constructs the scope.
forEachSession re-looks-up the session by ID between iterations but hands
the lambda a raw V8InspectorSessionImpl* with no pin during one.
Why the bug 511255112 fix does not cover this
1dba9760bf9 + the addMessage:591 follow-up replaced
hasConsoleMessageStorage(gid) with consoleMessageStorage(gid) != storage
identity checks. But ~V8InspectorSessionImpl
(v8-inspector-session-impl.cc:161-170) does not erase
m_consoleStorageMap[gid] — only resetContextGroup does. So when the
session is destroyed and the storage survives, the storage-identity check
at v8-console-message.cc:370 returns false (unchanged) and execution
continues into frontend->consoleAPICalled() on the freed agent.
Destruction path (Chrome)
- DevTools attached,
Runtime.enableissued (default DevTools UI state). - Page does
console.log(obj)whereobjmakeswrapArgumentsrun page JS:objis a NativeError subclass with a prototypename/message/stackgetter —getErrorPropertyfalls through toobject->Get(value-mirror.cc:281); orRuntime.setCustomObjectFormatterEnabled(true)→devtoolsFormatters[i].header()(custom-preview.cc:312); ordoesAttributeHaveObservableSideEffectOnGet@@hasInstancecallout (value-mirror.cc:1473-1508).
- The re-entrant JS enters a nested message loop (
alert()/print()). During it, thehost_remote_Mojo disconnect handler fires (devtools_session.cc:188-190) — user closes DevTools, or a colluding extension callschrome.debugger.detach.Detach()doesv8_session_.reset()(devtools_session.cc:258) → shared_ptr refcount 0 →~V8InspectorSessionImpl→m_runtimeAgentdestroyed. wrapArgumentsreturns. Storage-identity check at:370passes (storage not erased).frontend->consoleAPICalled(...)readsfrontend->frontend_channel_from the freedV8RuntimeAgentImpland makes a virtualSendProtocolNotificationcall.- Back in
reportMessage:m_frontend.flush()andm_inspector->hasConsoleMessageStorage(m_session->contextGroupId())deref freedthis.
(Same-process iframe.remove() does not trigger this in Chrome — only
local-root frames own a WebDevToolsAgentImpl. Non-Chrome embedders using
V8Inspector::connect() (unique_ptr, no m_weakThis) have no keep-alive at
all; any synchronous embedder teardown from the JS callout suffices.)
Additional sinks (same root cause)
v8-console-message.cc:302— secondwrapArguments-loop iteration callssession->wrapObject(...)on freedsession(loop only re-checksinspectedContext).v8-console-message.cc:360—kExceptionbranchfrontend->exceptionThrown(...)afterwrapExceptionran page JS, with no post-JS check at all.
Suggested fix
Pin the session across messageAdded. Either in forEachSession’s callback
wrapper, or in addMessage:
// v8-console-message.cc, V8ConsoleMessageStorage::addMessage:
inspector->forEachSession(contextGroupId, [&message](V8InspectorSessionImpl* session) {
V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(*session);
if (message->origin() == V8MessageOrigin::kConsole)
session->consoleAgent()->messageAdded(message.get());
session->runtimeAgent()->messageAdded(message.get());
});
(or hoist into V8InspectorImpl::forEachSession so every callback gets it).
Assumptions
- Unconfirmed:
host_remote_disconnect handler dispatch inside analert()nested loop (standard renderer nested-message-loop pattern; not traced to the exactSequencedTaskRunner). - Confirmed in source:
KeepSessionAliveScopeonly at:368;~V8InspectorSessionImpldoes not erase console storage;forEachSessionpasses raw ptr without pin; Blink usesconnectSharedandDetach()doesv8_session_.reset().