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 <[email protected]> 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 <[email protected]> Commit-Queue: Kim-Anh Tran <[email protected]> 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().