Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentInspector
Bug ClassUAF
Action tierwatch — corruption primitive but gated (local) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker516763884
Fix commitae4f685af9b9 (v8/v8) +73/-14
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-26 (per vendor advisory)
Fix landed2026-06-29 (public git)
Disclosed2026-07-29
Patch gap30 days — fix public in source before the advisory

Background

`V8InspectorImpl::forEachSession`
Iterates over the inspector sessions in a context group and invokes a caller-supplied callback on each matching V8InspectorSessionImpl.
`V8InspectorSessionImpl::KeepSessionAliveScope`
A stack-allocated RAII helper that promotes the session’s m_weakThis std::weak_ptr into a strong std::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 V8InspectorSessionImpl freed 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.

Key insight
The single mistake was that only 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

  1. 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.
  2. Plant a re-entrancy hook JavaScript installs an Error.prepareStackTrace handler that runs during error/argument wrapping, giving the attacker synchronous control mid-dispatch.
  3. Trigger a notification Logging an Error object drives V8 to notify sessions via forEachSession, entering an agent callback that wraps the argument and invokes the prepareStackTrace hook.
  4. Free the session mid-callback Inside the hook the attacker synchronously disconnects and destroys the iterated session (inspector.disconnectSession), releasing its V8InspectorSessionImpl while forEachSession still holds the raw pointer.
  5. Dereference freed memory When the callback returns, forEachSession and remaining frames continue using the freed session, producing the use-after-free that can be shaped for corruption.

Impact Assessment

An attacker who can drive the V8 inspector protocol gains a use-after-free on a 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

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

Audit Directions

  • Raw session/object pointers across re-entrant callbacks
    Audit every path that hands a session or agent pointer to a callback that can re-enter JavaScript and confirm a KeepSessionAliveScope (or equivalent strong reference) spans the call.
  • Synchronous detachment during iteration
    Review loops over m_sessions and 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 use
    Look for m_weakThis-style patterns where a strong reference is expected but only taken on some entry points, leaving other dispatch routes unprotected.
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.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)

  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 → ~V8InspectorSessionImpl → m_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:360 — kException 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