Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker516763884
Fix commitae4f685af9b9 (v8/v8) +73/-14
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
KeepSessionAliveScope
src/inspector/v8-inspector-session-impl.h
modified
if
test/inspector/runtime/session-disconnect-uaf.js
modified
setTimeout
test/inspector/runtime/session-disconnect-uaf.js
modified

Files Changed

  • src/inspector/v8-inspector-impl.cc
  • src/inspector/v8-inspector-session-impl.h
  • test/inspector/inspector-test.cc
  • test/inspector/runtime/session-disconnect-uaf-expected.txt
  • test/inspector/runtime/session-disconnect-uaf.js
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);
+  `
+});
Loading diff…

Original Bug Report

reported by [email protected]

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.logreportToFrontend 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::addMessageV8InspectorImpl::forEachSessionsession->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)

  1. DevTools attached, Runtime.enable issued (default DevTools UI state).
  2. Page does console.log(obj) where obj makes wrapArguments run page JS:
    • obj is a NativeError subclass with a prototype name/message/stack getter — getErrorProperty falls through to object->Get (value-mirror.cc:281); or
    • Runtime.setCustomObjectFormatterEnabled(true)devtoolsFormatters[i].header() (custom-preview.cc:312); or
    • doesAttributeHaveObservableSideEffectOnGet @@hasInstance callout (value-mirror.cc:1473-1508).
  3. The re-entrant JS enters a nested message loop (alert()/print()). During it, the host_remote_ Mojo disconnect handler fires (devtools_session.cc:188-190) — user closes DevTools, or a colluding extension calls chrome.debugger.detach. Detach() does v8_session_.reset() (devtools_session.cc:258) → shared_ptr refcount 0 → ~V8InspectorSessionImplm_runtimeAgent destroyed.
  4. wrapArguments returns. Storage-identity check at :370 passes (storage not erased). frontend->consoleAPICalled(...) reads frontend->frontend_channel_ from the freed V8RuntimeAgentImpl and makes a virtual SendProtocolNotification call.
  5. Back in reportMessage: m_frontend.flush() and m_inspector->hasConsoleMessageStorage(m_session->contextGroupId()) deref freed this.

(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 — second wrapArguments-loop iteration calls session->wrapObject(...) on freed session (loop only re-checks inspectedContext).
  • v8-console-message.cc:360kException branch frontend->exceptionThrown(...) after wrapException ran 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 an alert() nested loop (standard renderer nested-message-loop pattern; not traced to the exact SequencedTaskRunner).
  • Confirmed in source: KeepSessionAliveScope only at :368; ~V8InspectorSessionImpl does not erase console storage; forEachSession passes raw ptr without pin; Blink uses connectShared and Detach() does v8_session_.reset().
View on issue tracker