Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DevTools
DescriptionUse after free in DevTools
ComponentDevTools
Bug ClassUAF
Tracker537145191
Fix commit01c9ad7fe246 (chromium/src) +209/-170
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • PRESUBMIT.py
  • third_party/blink/renderer/core/exported/web_dev_tools_agent_impl.cc
  • third_party/blink/renderer/core/inspector/build.gni
  • third_party/blink/renderer/core/inspector/devtools_session.cc
  • third_party/blink/renderer/core/inspector/devtools_session.h
  • third_party/blink/renderer/core/inspector/inspector_animation_agent.cc
From 01c9ad7fe24638f468ec728736fd4cd74abcd9b4 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <caseq@chromium.org>
Date: Thu, 23 Jul 2026 13:14:35 -0700
Subject: [PATCH] Retain V8 session while building objects for event listeners

See also: https://chromium-review.git.corp.google.com/c/v8/v8/+/8135751
(either CL fixes the problem, but we want both for potential other problems)

Bug: 537145191
Change-Id: I3b5e56a94f8a32ee57025b7d832c633ec46ff797
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8126504
Reviewed-by: Alex Rudenko <alexrudenko@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Auto-Submit: Andrey Kosyakov <caseq@chromium.org>
Commit-Queue: Andrey Kosyakov <caseq@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1667363}
---

diff --git a/PRESUBMIT.py b/PRESUBMIT.py
index da9a1f26..0f42933 100644
--- a/PRESUBMIT.py
+++ b/PRESUBMIT.py
@@ -791,7 +791,7 @@
             # Needed for interop with third-party library.
             r'^third_party/blink/renderer/core/typed_arrays/array_buffer/' +
             r'array_buffer_contents\.(cc|h)',
-            r'^third_party/blink/renderer/core/inspector/devtools_session\.h',
+            r'^third_party/blink/renderer/core/inspector/v8_session_holder\.h',
             r'^third_party/blink/renderer/core/typed_arrays/dom_array_buffer\.cc',
             '^third_party/blink/renderer/bindings/core/v8/' +
             'v8_wasm_response_extensions.cc',
diff --git a/third_party/blink/renderer/core/exported/web_dev_tools_agent_impl.cc b/third_party/blink/renderer/core/exported/web_dev_tools_agent_impl.cc
index e65bcb0..450ed92 100644
--- a/third_party/blink/renderer/core/exported/web_dev_tools_agent_impl.cc
+++ b/third_party/blink/renderer/core/exported/web_dev_tools_agent_impl.cc
@@ -332,14 +332,14 @@
   session->ConnectToV8(main_thread_debugger->GetV8Inspector(),
                        context_group_id);
 
-  InspectorDOMAgent* dom_agent = session->CreateAndAppend<InspectorDOMAgent>(
-      isolate, inspected_frames, session->V8Session());
+  InspectorDOMAgent* dom_agent =
+      session->CreateAndAppend<InspectorDOMAgent>(isolate, inspected_frames);
 
   session->CreateAndAppend<InspectorLayerTreeAgent>(inspected_frames, this);
 
   InspectorNetworkAgent* network_agent =
-      session->CreateAndAppend<InspectorNetworkAgent>(inspected_frames, nullptr,
-                                                      session->V8Session());
+      session->CreateAndAppend<InspectorNetworkAgent>(inspected_frames,
+                                                      nullptr);
 
   session->CreateAndAppend<InspectorCrashReportContextAgent>(inspected_frames);
   auto* css_agent = session->CreateAndAppend<InspectorCSSAgent>(
@@ -347,41 +347,36 @@
       resource_content_loader_.Get(), resource_container_.Get());
 
   InspectorDOMDebuggerAgent* dom_debugger_agent =
-      session->CreateAndAppend<InspectorDOMDebuggerAgent>(isolate, dom_agent,
-                                                          session->V8Session());
+      session->CreateAndAppend<InspectorDOMDebuggerAgent>(isolate, dom_agent);
 
-  session->CreateAndAppend<InspectorEventBreakpointsAgent>(
-      session->V8Session());
+  session->CreateAndAppend<InspectorEventBreakpointsAgent>();
 
   session->CreateAndAppend<InspectorPerformanceAgent>(inspected_frames);
 
   session->CreateAndAppend<InspectorDOMSnapshotAgent>(inspected_frames,
                                                       dom_debugger_agent);
 
-  session->CreateAndAppend<InspectorAnimationAgent>(inspected_frames, css_agent,
-                                                    session->V8Session());
+  session->CreateAndAppend<InspectorAnimationAgent>(inspected_frames,
+                                                    css_agent);
 
   session->CreateAndAppend<InspectorMemoryAgent>(inspected_frames);
   if (base::FeatureList::IsEnabled(features::kDevToolsWebMCPSupport)) {
-    session->CreateAndAppend<InspectorWebMCPAgent>(inspected_frames,
-                                                   session->V8Session());
+    session->CreateAndAppend<InspectorWebMCPAgent>(inspected_frames);
   }
 
   auto* page_agent = session->CreateAndAppend<InspectorPageAgent>(
       inspected_frames, this, resource_content_loader_.Get(),
-      session->V8Session(), session->script_to_evaluate_on_load(),
-      session->InjectedScriptManager());
+      session->script_to_evaluate_on_load(), session->InjectedScriptManager());
 
   session->CreateAndAppend<InspectorLogAgent>(
       &inspected_frames->Root()->GetPage()->GetConsoleMessageStorage(),
-      inspected_frames->Root()->GetPerformanceMonitor(), session->V8Session());
+      inspected_frames->Root()->GetPerformanceMonitor());
 
   InspectorOverlayAgent* overlay_agent =
       session->CreateAndAppend<InspectorOverlayAgent>(
-          web_local_frame_impl_.Get(), inspected_frames, session->V8Session(),
-          dom_agent);
+          web_local_frame_impl_.Get(), inspected_frames, dom_agent);
 
-  session->CreateAndAppend<InspectorIOAgent>(isolate, session->V8Session());
+  session->CreateAndAppend<InspectorIOAgent>(isolate);
 
   session->CreateAndAppend<InspectorAuditsAgent>(
       network_agent,
diff --git a/third_party/blink/renderer/core/inspector/build.gni b/third_party/blink/renderer/core/inspector/build.gni
index fb53f52..5c1dc2d 100644
--- a/third_party/blink/renderer/core/inspector/build.gni
+++ b/third_party/blink/renderer/core/inspector/build.gni
@@ -141,6 +141,7 @@
   "thread_debugger_common_impl.h",
   "v8_inspector_string.cc",
   "v8_inspector_string.h",
+  "v8_session_holder.h",
   "worker_devtools_params.h",
   "worker_inspector_controller.cc",
   "worker_inspector_controller.h",
diff --git a/third_party/blink/renderer/core/inspector/devtools_session.cc b/third_party/blink/renderer/core/inspector/devtools_session.cc
index fc7e306..8d47059 100644
--- a/third_party/blink/renderer/core/inspector/devtools_session.cc
+++ b/third_party/blink/renderer/core/inspector/devtools_session.cc
@@ -244,7 +244,7 @@
   const auto& cbor = v8_session_state_cbor_.Get();
   const auto* reattach_state = session_state_.ReattachState();
 
-  v8_session_ = inspector->connectShared(
+  v8_session_ = V8SessionHolder(inspector->connectShared(
       context_group_id, this,
       v8_inspector::StringView(cbor.data(), cbor.size()),
       client_is_trusted_ ? v8_inspector::V8Inspector::kFullyTrusted
@@ -255,7 +255,7 @@
       ConvertEmbedderState(
           reattach_state
               ? reattach_state->browser_originating_session_state.get()
-              : nullptr));
+              : nullptr)));
   injected_script_manager_->SetV8Session(v8_session_.get());
 }
 
@@ -266,7 +266,7 @@
 void DevToolsSession::Append(InspectorAgent* agent) {
   agents_.push_back(agent);
   agent->Init(agent_->probe_sink_.Get(), inspector_backend_dispatcher_.get(),
-              &session_state_);
+              &session_state_, v8_session_);
 }
 
 void DevToolsSession::Detach() {
diff --git a/third_party/blink/renderer/core/inspector/devtools_session.h b/third_party/blink/renderer/core/inspector/devtools_session.h
index fcf0fa1b..28d1007 100644
--- a/third_party/blink/renderer/core/inspector/devtools_session.h
+++ b/third_party/blink/renderer/core/inspector/devtools_session.h
@@ -7,6 +7,7 @@
 
 #include <memory>
 #include <type_traits>
+
 #include "base/functional/callback.h"
 #include "base/task/sequenced_task_runner.h"
 #include "mojo/public/cpp/bindings/pending_associated_receiver.h"
@@ -16,6 +17,7 @@
 #include "third_party/blink/renderer/core/core_export.h"
 #include "third_party/blink/renderer/core/inspector/inspector_session_state.h"
 #include "third_party/blink/renderer/core/inspector/protocol/forward.h"
+#include "third_party/blink/renderer/core/inspector/v8_session_holder.h"
 #include "third_party/blink/renderer/platform/heap/collection_support/heap_vector.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/mojo/heap_mojo_associated_receiver.h"
@@ -75,7 +77,7 @@
   ~DevToolsSession() override;
 
   void ConnectToV8(v8_inspector::V8Inspector*, int context_group_id);
-  v8_inspector::V8InspectorSession* V8Session() { return v8_session_.get(); }
+  V8SessionHolder V8Session() { return v8_session_; }
 
   template <typename Agent, typename... Args>
   Agent* CreateAndAppend(Args&&... args) {
@@ -176,7 +178,7 @@
   HeapMojoAssociatedRemote<mojom::blink::DevToolsSessionHost> host_remote_{
       nullptr};
   IOSession* io_session_;
-  std::shared_ptr<v8_inspector::V8InspectorSession> v8_session_;
+  V8SessionHolder v8_session_;
   std::unique_ptr<protocol::UberDispatcher> inspector_backend_dispatcher_;
   InspectorSessionState session_state_;
   HeapVector<Member<InspectorAgent>> agents_;
diff --git a/third_party/blink/renderer/core/inspector/inspector_animation_agent.cc b/third_party/blink/renderer/core/inspector/inspector_animation_agent.cc
index 3266b43..5485949 100644
--- a/third_party/blink/renderer/core/inspector/inspector_animation_agent.cc
+++ b/third_party/blink/renderer/core/inspector/inspector_animation_agent.cc
@@ -86,11 +86,9 @@
 
 InspectorAnimationAgent::InspectorAnimationAgent(
     InspectedFrames* inspected_frames,
-    InspectorCSSAgent* css_agent,
-    v8_inspector::V8InspectorSession* v8_session)
+    InspectorCSSAgent* css_agent)
     : inspected_frames_(inspected_frames),
       css_agent_(css_agent),
-      v8_session_(v8_session),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/inspector/inspector_css_agent_test.cc b/third_party/blink/renderer/core/inspector/inspector_css_agent_test.cc
index b9985b08..98b839d 100644
--- a/third_party/blink/renderer/core/inspector/inspector_css_agent_test.cc
+++ b/third_party/blink/renderer/core/inspector/inspector_css_agent_test.cc
@@ -67,11 +67,10 @@
         MakeGarbageCollected<InspectedFrames>(frame);
     InspectorCSSAgent* agent = MakeGarbageCollected<InspectorCSSAgent>(
         MakeGarbageCollected<InspectorDOMAgent>(
-            GetDocument().GetExecutionContext()->GetIsolate(), inspected_frames,
-            nullptr),
+            GetDocument().GetExecutionContext()->GetIsolate(),
+            inspected_frames),
         inspected_frames,
-        MakeGarbageCollected<InspectorNetworkAgent>(inspected_frames, nullptr,
-                                                    nullptr),
+        MakeGarbageCollected<InspectorNetworkAgent>(inspected_frames, nullptr),
         MakeGarbageCollected<InspectorResourceContentLoader>(
             GetDocument().GetFrame()),
         MakeGarbageCollected<InspectorResourceContainer>(inspected_frames));
diff --git a/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc b/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
index 8d190fe..929c4d3 100644
--- a/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
+++ b/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
@@ -99,7 +99,8 @@
 
   auto* agent_a = MakeGarbageCollected<InspectorEmulationAgent>(
       web_frame, *virtual_time_controller);
-  agent_a->Init(frame->GetProbeSink(), &dispatcher_a, &session_state_a);
+  agent_a->Init(frame->GetProbeSink(), &dispatcher_a, &session_state_a,
+                V8SessionHolder());
 
   DummyFrontendChannel channel_b;
   protocol::UberDispatcher dispatcher_b(&channel_b);
@@ -108,7 +109,8 @@
 
   auto* agent_b = MakeGarbageCollected<InspectorEmulationAgent>(
       web_frame, *virtual_time_controller);
-  agent_b->Init(frame->GetProbeSink(), &dispatcher_b, &session_state_b);
+  agent_b->Init(frame->GetProbeSink(), &dispatcher_b, &session_state_b,
+                V8SessionHolder());
 
   // Initially, throttling thread should not exist (no throttling active).
   EXPECT_DOUBLE_EQ(blink::scheduler::ThreadCPUThrottler::GetInstance()
@@ -209,7 +211,8 @@
 
   auto* agent = MakeGarbageCollected<InspectorEmulationAgent>(
       web_frame, *virtual_time_controller);
-  agent->Init(frame->GetProbeSink(), &dispatcher, &session_state);
+  agent->Init(frame->GetProbeSink(), &dispatcher, &session_state,
+              V8SessionHolder());
 
   double base_ms = 0;
   agent->setVirtualTimePolicy(protocol::Emulation::VirtualTimePolicyEnum::Pause,
diff --git a/third_party/blink/web_tests/inspector-protocol/debugger/domdebugger-getEventListeners-custom-formatter-disconnect-expected.txt b/third_party/blink/web_tests/inspector-protocol/debugger/domdebugger-getEventListeners-custom-formatter-disconnect-expected.txt
new file mode 100644
index 0000000..36c659e
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/debugger/domdebugger-getEventListeners-custom-formatter-disconnect-expected.txt
@@ -0,0 +1,2 @@
+Tests disconnect inside custom devtoolsFormatter called from DOMDebugger.getEventListeners.
+
diff --git a/third_party/blink/web_tests/inspector-protocol/debugger/domdebugger-getEventListeners-custom-formatter-disconnect.js b/third_party/blink/web_tests/inspector-protocol/debugger/domdebugger-getEventListeners-custom-formatter-disconnect.js
new file mode 100644
index 0000000..8684ef2
--- /dev/null
+++ b/third_party/blink/web_tests/inspector-protocol/debugger/domdebugger-getEventListeners-custom-formatter-disconnect.js
@@ -0,0 +1,50 @@
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+  const {page, session, dp} = await testRunner.startHTML(
+      `
+    <div id='target'></div>
+  `,
+      `Tests disconnect inside custom devtoolsFormatter called from DOMDebugger.getEventListeners.`);
+
+  const session2 = await page.createSession();
+  const dp2 = session2.protocol;
+
+  await dp.Debugger.enable();
+  await dp2.Runtime.enable();
+
+  await session.evaluate(`
+    window.devtoolsFormatters = [{
+      header() {
+        debugger;
+        return null;
+      }
+    }];
+    const target = document.getElementById('target');
+    target.addEventListener('click', () => {}, false);
+  `);
+
+  const {result} = await dp2.Runtime.evaluate({
+    expression: `document.getElementById('target')`,
+    objectGroup: 'my-group'
+  });
+  const objectId = result.result.objectId;
+
+  await dp2.Runtime.setCustomObjectFormatterEnabled({enabled: true});
+
+  const completionPromise = Promise.race([
+    testRunner.browserP().Target.onceDetachedFromTarget().then(
+        () => new Promise(resolve => setTimeout(resolve, 500))),
+    dp2.DOMDebugger.getEventListeners({objectId})
+  ]);
+
+  dp.Debugger.oncePaused().then(async () => {
+    await session2.disconnect();
+    dp.Debugger.disable();
+  });
+
+  // If this resolves without hitting a breakpoint, that's fine.
+  // However, if it hits the breakpoint, it should not crash.
+  await completionPromise;
+  // Do a round-trip to renderer to assure it hasn't crashed.
+  await session.evaluate('');
+  testRunner.completeTest();
+})
Loading diff…

Original Bug Report

reported by vm...@google.com

Renderer Use-After-Free of V8InspectorSession in InspectorDOMDebuggerAgent

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential Use-After-Free (UAF) vulnerability exists in the renderer process within InspectorDOMDebuggerAgent due to the use of a bare pointer to a V8InspectorSession that can be destroyed during a nested message loop. If the debugger session is detached while inside this nested loop, the session is freed, potentially leading to a virtual call on the freed memory upon resuming. This occurs because Blink-domain Chrome DevTools Protocol (CDP) dispatches lack a session-pinning scope.

Affected files:

  • third_party/blink/renderer/core/inspector/inspector_dom_debugger_agent.cc
  • third_party/blink/renderer/core/inspector/inspector_dom_debugger_agent.h
  • third_party/blink/renderer/core/inspector/devtools_session.cc
  • third_party/blink/renderer/core/inspector/devtools_session.h

Estimated timestamp from git blame: 2016-04-27

Detailed Description

A potential Use-After-Free (UAF) vulnerability has been identified in InspectorDOMDebuggerAgent. The agent holds a bare (non-raw_ptr) pointer to a v8_inspector::V8InspectorSession object. During the dispatch of certain Blink-domain Chrome DevTools Protocol (CDP) commands, virtual methods are called on this session inside loops that can re-enter page JavaScript via custom object formatters. If the DevTools session is detached while JavaScript execution is paused in a nested message loop, the underlying V8InspectorSession is deleted, leaving the agent with a dangling pointer when execution resumes.

Root Cause

  1. Lack of Pinning Scope on Blink Dispatch Path: When a CDP command is executed, DevToolsSession::DispatchProtocolCommandImpl checks whether the command can be handled by V8. If so, it uses V8’s dispatch path which implicitly instantiates a KeepSessionAliveScope (keeping the session’s reference count > 0). If it is a Blink-domain command (such as DOMDebugger.getEventListeners), it is dispatched directly via inspector_backend_dispatcher_->Dispatch(dispatchable) without any pinning scope on the stack.

  2. Bare Pointer Usage: InspectorDOMDebuggerAgent stores v8_session_ as a bare raw pointer:

    // third_party/blink/renderer/core/inspector/inspector_dom_debugger_agent.h
    v8_inspector::V8InspectorSession* v8_session_;
    

    Unlike other agents (e.g., InspectorDOMAgent), InspectorDOMDebuggerAgent does not override Dispose() to nullify v8_session_ upon detachment.

  3. Nested Pause and Teardown: In InspectorDOMDebuggerAgent::BuildObjectForEventListener, v8_session_->wrapObject is called on the event listener’s handler function. If custom formatters are enabled, this triggers V8’s generateCustomPreview which runs user-supplied formatter JavaScript. If a debugger; statement is encountered during formatting, the debugger pauses and enters a nested message loop (base::RunLoop(kNestableTasksAllowed).Run()).

    While paused in this nested loop, a detach request (such as closing DevTools or calling chrome.debugger.detach()) will trigger DevToolsSession::Detach(). This resets the strong std::shared_ptr<v8_inspector::V8InspectorSession> reference to 0, freeing the object. When the pause loop terminates and the stack unwinds, InspectorDOMDebuggerAgent attempts to execute the next statement:

    value->setOriginalHandler(v8_session_->wrapObject(context, info.handler, object_group_id, false));
    

    This results in a virtual method call on the freed v8_session_ pointer.

Suggested Potential Steps to Reproduce

Note: These steps are suggested/potential based on static code analysis; our automated tooling does not have the capability to execute code.

  1. Load a target page containing multiple event listeners registered on document.body.
  2. Configure a custom object formatter on the page’s global scope:
    window.devtoolsFormatters = [{ 
      header() { 
        debugger; 
        return null; 
      } 
    }];
    
  3. Attach a chrome.debugger extension to the page and enable custom formatters via Runtime.setCustomObjectFormatterEnabled(true).
  4. Request the event listeners for document.body using the DOMDebugger.getEventListeners command, passing a non-empty objectGroup ID.
  5. Once V8 hits the programmatic breakpoint inside the custom formatter, it enters a nested message loop on pause.
  6. Have the extension call chrome.debugger.detach() to disconnect the session.
  7. Resume/quit the pause loop. The agent resumes execution in BuildObjectForEventListener and attempts to perform a virtual call to v8_session_->wrapObject using the freed pointer.

Suggested Fix

To prevent this issue, InspectorDOMDebuggerAgent should align with the design pattern of other agents (such as InspectorDOMAgent and InspectorPageAgent) by implementing a Dispose() override to clear the v8_session_ pointer upon session teardown, and adding null-checks before referencing it:

  1. In InspectorDOMDebuggerAgent:
    void InspectorDOMDebuggerAgent::Dispose() override {
      v8_session_ = nullptr;
      InspectorBaseAgent::Dispose();
    }
    
  2. Add checks to verify that v8_session_ is non-null before invoking any methods on it within getEventListeners and BuildObjectForEventListener.

Evaluated with Chrome root at commit: bf775e5d75cb9e1767e2cd02cc93efa0077d14a5


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker