CVE-2026-87511
Overview
Background
- `V8Inspector` client trust level
- a per-session classification where a session is either
kFullyTrusted(e.g. local DevTools) or untrusted (e.g. a remote/renderer-exposed client), gating which inspector capabilities the session may use. - `V8ProfilerAgentImpl`
- the inspector backend that implements the
Profilerprotocol domain (CPU profiling, precise coverage), created per session and wired into the session’s command dispatcher. - `V8InspectorSessionImpl`
- the object representing one inspector session; its constructor instantiates the per-domain agents and
Profiler::Dispatcher::wireregisters theProfilermethods so protocol commands can reach them. - Missing authorization
- a capability being reachable by a client that should not be entitled to it, here the
Profilerdomain being exposed to sessions that are notkFullyTrusted.
Root Cause Analysis
In V8InspectorSessionImpl’s constructor, m_profilerAgent was created with new V8ProfilerAgentImpl(...) and wired via protocol::Profiler::Dispatcher::wire unconditionally, for every session regardless of m_clientTrustLevel, while the adjacent V8HeapProfilerAgentImpl was already constructed only inside an if (m_clientTrustLevel == V8Inspector::kFullyTrusted) block. This violated the invariant that the Profiler domain must be restricted to fully trusted clients: an untrusted session could invoke Profiler commands (e.g. Profiler.enable) that a less-privileged client should not be able to reach.
The fix moves the m_profilerAgent construction and Profiler::Dispatcher::wire call inside the same kFullyTrusted guard, so untrusted sessions never get a wired Profiler agent — the new test confirms Profiler.enable then returns Called non-existent method ... code: -32601. Because m_profilerAgent is now potentially null, every previously unconditional dereference (restore, disable, triggerPreciseCoverageDeltaUpdate, and the console.profile/console.profileEnd paths in v8-console.cc via session->profilerAgent()) is wrapped in a null check to avoid a null-pointer crash. The supportedDomainsImpl schema list likewise only advertises the Profiler, HeapProfiler, and Schema domains for fully trusted clients now.
The patch only adds an authorization gate plus the null guards that gate makes necessary; it does not change profiling logic itself.
Profiler agent was instantiated and dispatcher-wired unconditionally while its sibling HeapProfiler agent was correctly trust-gated, leaving the Profiler domain reachable by untrusted sessions; the fix simply relocates the Profiler creation into the existing kFullyTrusted branch and adds null-guards for the now-optional agent.Attack Path
- Obtain an untrusted inspector session
Connect to a
V8InspectorSessionImplwhosem_clientTrustLevelis notkFullyTrusted(the new test does exactly this withcontextGroup.connect(/* isFullyTrusted */ false)). - Reach the wired `Profiler` domain
Before the fix the constructor always ran
protocol::Profiler::Dispatcher::wire(&m_dispatcher, m_profilerAgent.get()), so the untrusted session could issueProfiler-domain protocol commands such asProfiler.enablethat should have been unavailable. - Exercise profiler functionality from the untrusted context
With the domain wired, the untrusted client can drive CPU-profiling / precise-coverage operations (and the
console.profile/console.profileEndpaths reachsession->profilerAgent()), gaining access to profiling capability it was not authorized for. - Downstream escalation (inferred)
Any further impact from exercising the
Profilerdomain as an untrusted client is not demonstrated by this diff; the patch shows only the missing authorization, not a memory-safety sink or a concrete escalation primitive.
Impact Assessment
Profiler protocol domain that should be reserved for kFullyTrusted clients, in the process hosting the V8 inspector (e.g. the renderer or the context being debugged). The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation past unauthorized access to profiling capability is (inferred) and not established by the patch. The precondition is the ability to open an inspector session that is not kFullyTrusted.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/inspector/v8-inspector-session-impl.cc |
modified |
Files Changed
test/inspector/cpu-profiler/untrusted-session-expected.txttest/inspector/cpu-profiler/untrusted-session.jssrc/inspector/v8-console.ccsrc/inspector/v8-inspector-session-impl.cc
Audit Directions
- Trust-gated sibling agentsAudit every per-domain agent created in
V8InspectorSessionImpl(and other session constructors) to confirm each privileged domain sits inside thekFullyTrustedguard, since hereProfilerwas left ungated whileHeapProfilerbeside it was correctly gated. - Unconditional agent dereferencesSearch for
m_*Agent->andsession->*Agent()->call sites that assume an agent always exists; once an agent becomes trust-conditional, each dereference (including inv8-console.ccand coverage/restore paths) needs a null guard. - Domain advertisement vs. dispatch consistencyCross-check
supportedDomainsImplandDispatcher::wireregistrations so a domain that is not advertised is also not reachable via the dispatcher (and vice versa), catching authorization gaps between what a client is told it can use and what commands actually resolve.
Patch
From 01087f5f8f921c5f55f8f6bda4132d48f5eba0e7 Mon Sep 17 00:00:00 2001
From: Philip Pfaffe <pfaffe@chromium.org>
Date: Wed, 05 Aug 2026 13:55:02 +0000
Subject: [PATCH] Require fully trusted clients for the inspector profiler
Fixed: 513395384
Change-Id: Icc180358092f354a7e719ae3aad807249b2a95c9
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8203445
Reviewed-by: Simon Zünd <szuend@chromium.org>
Commit-Queue: Philip Pfaffe <pfaffe@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109069}
---
diff --git a/src/inspector/v8-console.cc b/src/inspector/v8-console.cc
index 88d0da1..0fa7caf 100644
--- a/src/inspector/v8-console.cc
+++ b/src/inspector/v8-console.cc
@@ -389,7 +389,9 @@
String16 title =
toProtocolString(m_inspector->isolate(), helper.firstArgToString());
helper.forEachSession([&title](V8InspectorSessionImpl* session) {
- session->profilerAgent()->consoleProfile(title);
+ if (session->profilerAgent()) {
+ session->profilerAgent()->consoleProfile(title);
+ }
});
TRACE_EVENT_END(TRACE_DISABLED_BY_DEFAULT("v8.inspector"), "title",
title.utf8().c_str());
@@ -403,7 +405,9 @@
String16 title =
toProtocolString(m_inspector->isolate(), helper.firstArgToString());
helper.forEachSession([&title](V8InspectorSessionImpl* session) {
- session->profilerAgent()->consoleProfileEnd(title);
+ if (session->profilerAgent()) {
+ session->profilerAgent()->consoleProfileEnd(title);
+ }
});
TRACE_EVENT_END(TRACE_DISABLED_BY_DEFAULT("v8.inspector"), "title",
title.utf8().c_str());
diff --git a/src/inspector/v8-inspector-session-impl.cc b/src/inspector/v8-inspector-session-impl.cc
index e7d788d..99271ac 100644
--- a/src/inspector/v8-inspector-session-impl.cc
+++ b/src/inspector/v8-inspector-session-impl.cc
@@ -138,11 +138,11 @@
this, this, agentState(protocol::Console::Metainfo::domainName)));
protocol::Console::Dispatcher::wire(&m_dispatcher, m_consoleAgent.get());
- m_profilerAgent.reset(new V8ProfilerAgentImpl(
- this, this, agentState(protocol::Profiler::Metainfo::domainName)));
- protocol::Profiler::Dispatcher::wire(&m_dispatcher, m_profilerAgent.get());
-
if (m_clientTrustLevel == V8Inspector::kFullyTrusted) {
+ m_profilerAgent.reset(new V8ProfilerAgentImpl(
+ this, this, agentState(protocol::Profiler::Metainfo::domainName)));
+ protocol::Profiler::Dispatcher::wire(&m_dispatcher, m_profilerAgent.get());
+
m_heapProfilerAgent.reset(new V8HeapProfilerAgentImpl(
this, this, agentState(protocol::HeapProfiler::Metainfo::domainName)));
protocol::HeapProfiler::Dispatcher::wire(&m_dispatcher,
@@ -156,7 +156,7 @@
m_runtimeAgent->restore();
m_debuggerAgent->restore();
if (m_heapProfilerAgent) m_heapProfilerAgent->restore();
- m_profilerAgent->restore();
+ if (m_profilerAgent) m_profilerAgent->restore();
m_consoleAgent->restore();
}
}
@@ -165,7 +165,7 @@
v8::Isolate::Scope scope(m_inspector->isolate());
discardInjectedScripts();
m_consoleAgent->disable();
- m_profilerAgent->disable();
+ if (m_profilerAgent) m_profilerAgent->disable();
if (m_heapProfilerAgent) m_heapProfilerAgent->disable();
m_debuggerAgent->disable();
m_runtimeAgent->disable();
@@ -442,18 +442,20 @@
.setName(protocol::Debugger::Metainfo::domainName)
.setVersion(protocol::Debugger::Metainfo::version)
.build());
- result.push_back(protocol::Schema::Domain::create()
- .setName(protocol::Profiler::Metainfo::domainName)
- .setVersion(protocol::Profiler::Metainfo::version)
- .build());
- result.push_back(protocol::Schema::Domain::create()
- .setName(protocol::HeapProfiler::Metainfo::domainName)
- .setVersion(protocol::HeapProfiler::Metainfo::version)
- .build());
- result.push_back(protocol::Schema::Domain::create()
- .setName(protocol::Schema::Metainfo::domainName)
- .setVersion(protocol::Schema::Metainfo::version)
- .build());
+ if (m_clientTrustLevel == V8Inspector::kFullyTrusted) {
+ result.push_back(protocol::Schema::Domain::create()
+ .setName(protocol::Profiler::Metainfo::domainName)
+ .setVersion(protocol::Profiler::Metainfo::version)
+ .build());
+ result.push_back(protocol::Schema::Domain::create()
+ .setName(protocol::HeapProfiler::Metainfo::domainName)
+ .setVersion(protocol::HeapProfiler::Metainfo::version)
+ .build());
+ result.push_back(protocol::Schema::Domain::create()
+ .setName(protocol::Schema::Metainfo::domainName)
+ .setVersion(protocol::Schema::Metainfo::version)
+ .build());
+ }
return result;
}
@@ -521,7 +523,9 @@
void V8InspectorSessionImpl::triggerPreciseCoverageDeltaUpdate(
StringView occasion) {
- m_profilerAgent->triggerPreciseCoverageDeltaUpdate(toString16(occasion));
+ if (m_profilerAgent) {
+ m_profilerAgent->triggerPreciseCoverageDeltaUpdate(toString16(occasion));
+ }
}
V8InspectorSession::EvaluateResult V8InspectorSessionImpl::evaluate(
diff --git a/test/inspector/cpu-profiler/untrusted-session-expected.txt b/test/inspector/cpu-profiler/untrusted-session-expected.txt
new file mode 100644
index 0000000..eb29fc0
--- /dev/null
+++ b/test/inspector/cpu-profiler/untrusted-session-expected.txt
@@ -0,0 +1,3 @@
+Tests that Profiler domain is not available in untrusted session.
+console.profile in untrusted session did not crash.
+Error: Called non-existent method. 'Profiler.enable' wasn't found code: -32601
diff --git a/test/inspector/cpu-profiler/untrusted-session.js b/test/inspector/cpu-profiler/untrusted-session.js
new file mode 100644
index 0000000..47c1b2c
--- /dev/null
+++ b/test/inspector/cpu-profiler/untrusted-session.js
@@ -0,0 +1,20 @@
+// 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.
+
+InspectorTest.start(
+ 'Tests that Profiler domain is not available in untrusted session.');
+
+(async () => {
+ const contextGroup = new InspectorTest.ContextGroup();
+ const session = contextGroup.connect(/* isFullyTrusted */ false);
+ const {Protocol} = session;
+
+ await Protocol.Runtime.evaluate({
+ expression: 'console.profile("test"); console.profileEnd("test");'
+ });
+ InspectorTest.log('console.profile in untrusted session did not crash.');
+ await Protocol.Profiler.enable();
+
+ InspectorTest.completeTest();
+})();