CVE-2026-17807
Overview
Background
- `V8InspectorImpl::forEachSession`
- Iterates over the inspector sessions in a context group and invokes a caller-supplied
callbackon each matchingV8InspectorSessionImpl. - `V8InspectorSessionImpl::KeepSessionAliveScope`
- A stack-allocated RAII helper that promotes the session’s
m_weakThisstd::weak_ptrinto a strongstd::shared_ptr, keeping the session object alive for the scope’s lifetime. - `m_weakThis`
- A
std::weak_ptr<V8InspectorSessionImpl>the session holds to itself, used to obtain a strong reference on demand without creating an ownership cycle. - Use-After-Free (UAF)
- A memory-safety bug where code dereferences an object that has already been destroyed, here a
V8InspectorSessionImplfreed mid-callback.
Root Cause Analysis
When an agent callback runs inside V8InspectorImpl::forEachSession, it can re-enter JavaScript (for example while wrapping console arguments), and that JS can synchronously disconnect and destroy the very session being iterated. Before the patch, forEachSession passed the raw sessionIt->second pointer to callback without holding any strong reference, so when the session was torn down during the callback the remaining stack frames operated on freed memory. A KeepSessionAliveScope existed and protected dispatchProtocolMessage, but it did not cover the forEachSession iteration path, leaving that dispatch route exposed.
The fix hoists the KeepSessionAliveScope declaration into the shared public section of V8InspectorSessionImpl and instantiates one inside forEachSession immediately before invoking callback, locking m_weakThis into a strong shared_ptr on the C++ stack. This guarantees the session cannot be fully deconstructed until the callback and its nested run loops unwind, restoring the invariant that a session referenced by an in-flight call stays alive.
dispatchProtocolMessage — not the equally re-entrant forEachSession iteration — held a stack-strong reference to the session, so a synchronous disconnect during a callback freed a still-in-use object; the fix closes the gap by taking a KeepSessionAliveScope inside forEachSession itself.Attack Path
- Establish two sessions Attacker-controlled protocol clients connect two inspector sessions to the same context group so one session can outlive and observe the other.
- Plant a re-entrancy hook
JavaScript installs an
Error.prepareStackTracehandler that runs during error/argument wrapping, giving the attacker synchronous control mid-dispatch. - Trigger a notification
Logging an
Errorobject drives V8 to notify sessions viaforEachSession, entering an agent callback that wraps the argument and invokes theprepareStackTracehook. - Free the session mid-callback
Inside the hook the attacker synchronously disconnects and destroys the iterated session (
inspector.disconnectSession), releasing itsV8InspectorSessionImplwhileforEachSessionstill holds the raw pointer. - Dereference freed memory
When the callback returns,
forEachSessionand remaining frames continue using the freed session, producing the use-after-free that can be shaped for corruption.
Impact Assessment
V8InspectorSessionImpl inside the renderer/V8 process, potentially leading to memory corruption and code execution in that process. Exploitation requires the ability to open and control inspector sessions (for example via DevTools/CDP access or an embedder that exposes the inspector) and to run JavaScript that re-enters during session notification. The bug executes in the same process that hosts the targeted V8 isolate rather than a more privileged process.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
Audit Directions
- Raw session/object pointers across re-entrant callbacksAudit every path that hands a session or agent pointer to a
callbackthat can re-enter JavaScript and confirm aKeepSessionAliveScope(or equivalent strong reference) spans the call. - Synchronous detachment during iterationReview loops over
m_sessionsand similar containers for code that can destroy the current element mid-iteration, invalidating iterators or freeing referenced objects. - `weak_ptr` self-references not locked before useLook for
m_weakThis-style patterns where a strong reference is expected but only taken on some entry points, leaving other dispatch routes unprotected.
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().