CVE-2026-87533
Overview
Background
- DevTools Target domain
- the CDP surface that lets a debugging client attach to, create, and detach from “targets” (pages, workers, hidden targets), managed browser-side by
TargetHandler. - `attached_sessions_`
- a member map (keyed by session id) owned by the handler that holds the
Sessionobjects representing every DevTools session currently attached through this handler. - Reentrancy during container clear
- the failure mode where destroying an element of a container synchronously triggers code that mutates (e.g.
erase()s from) that same container while it is still being iterated/cleared. - Hidden target close path
- when a hidden target’s renderer is already gone, its close runs synchronously, so agent hosts can call
AgentHostClosed()back into sibling sessions during teardown rather than deferring it.
Root Cause Analysis
The changed code clears the handler’s session state, and the removed line attached_sessions_.clear(); destroyed each Session while the map member attached_sessions_ was still the live, in-use container. Per the commit message, destroying a Session detaches its child DevTools session, and the embedder side of that child may own hidden targets that get closed at that moment; if their renderers are gone the close runs synchronously and the hidden targets’ agent hosts invoke AgentHostClosed() on sibling Sessions still held by the map being cleared, which in turn call attached_sessions_.erase(id_) on it. That reentrant erase() mutates the very container clear() is walking, invalidating its internal traversal and causing a use-after-free.
The fix moves the map into a local before releasing it — auto attached_sessions = std::move(attached_sessions_); followed by attached_sessions.clear(); — so the member attached_sessions_ is already empty when the reentrant erase(id_) fires, and the destruction of sessions now walks the detached local copy that nothing else references.
The fix therefore works by ensuring the reentrant mutation targets the now-empty member instead of the container being torn down.
attached_sessions_ in place, so a synchronously reentrant attached_sessions_.erase(id_) mutates the same container mid-clear; the fix neutralizes the reentrancy by std::move-ing the map into a local first, leaving the member empty for any reentrant erase to operate on harmlessly.Attack Path
- Attach a browser-level client
A DevTools client attaches to the browser target and creates a page target, then attaches to it with
flattento obtain a child session, as the regression test does viaAttachToBrowserTarget()andTarget.attachToTarget. - Create attached hidden targets
Through the page session the client creates multiple hidden targets (
Target.createTargetwithhidden: true) and attaches to each, so theirSessions and agent hosts are owned alongside siblings inattached_sessions_. - Force the synchronous close path
The client causes the hidden targets’ renderers to be gone (the test kills each
RenderProcessHost), so that when the targets are later closed the close path runs synchronously rather than being deferred. - Trigger the reentrant clear
Detaching the browser client releases the handler’s sessions; destroying a
Sessionsynchronously closes the now-rendererless hidden targets, whose agent hosts callAgentHostClosed()into siblingSessions and reachattached_sessions_.erase(id_)while the pre-patchattached_sessions_.clear();is still walking the map, yielding a use-after-free(inferred)as the specific freed-then-used object, since the diff shows only the container-mutation-during-clear pattern and not a concrete dereference sink.
Impact Assessment
ScopedAllowRendererCrashes and set_agent_host_can_close() to reproduce). Any escalation from the UAF to controlled read/write or code execution is (inferred) and not demonstrated by the diff, which shows only the reentrant-erase-during-clear fix; the advisory states neither exploited-in-the-wild status nor a severity rating.Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fcontent/browser/devtools/protocol/devtools_protocol_browsertest.cc |
modified | |
forcontent/browser/devtools/protocol/devtools_protocol_browsertest.cc |
modified | |
ifcontent/browser/devtools/protocol/devtools_protocol_browsertest.cc |
modified |
Files Changed
content/browser/devtools/protocol/devtools_protocol_browsertest.cccontent/browser/devtools/protocol/target_handler.cc
Audit Directions
- Destroy-in-place of owning containersLook for
.clear(),.erase(), or destructor-driven teardown of a member container whose element destructors can synchronously call back and mutate that same member; prefer moving into a local first. - Synchronous callbacks on teardownAudit paths like
AgentHostClosed()and other detach/close notifications that can run synchronously when a renderer or process is already dead, since deferred assumptions break under that path. - DevTools session and target lifetimeReview other handlers that bulk-clear
attached_sessions_,auto_attached_sessions_, or hidden-target maps for the same reentrancy class, especially where a parent session’s destruction can cascade into closing embedder-owned child targets.
Patch
From d5186c8dddab74ef4874b27aba84f942c16efd16 Mon Sep 17 00:00:00 2001 From: Alex Rudenko <[email protected]> Date: Mon, 03 Aug 2026 03:00:48 -0700 Subject: [PATCH] DevTools: avoid reentrant erase during TargetHandler::Disable TargetHandler::Disable() bulk-clears `attached_sessions_`, but destroying a Session detaches its child DevTools session from the target's agent host. The embedder side of that child session may own hidden targets which get closed at this point; if their renderers are gone the close path runs synchronously and the hidden targets' agent hosts call AgentHostClosed() on sibling Sessions still owned by the map being cleared, which then call `attached_sessions_.erase(id_)` on it. Move the map into a local before releasing it so the reentrant erase operates on the now-empty member instead of the container being walked. Fixed: 521620916 Change-Id: Iaa54071504b73d54ac1f18cfc67514f42e1c3d31 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8182568 Commit-Queue: Alex Rudenko <[email protected]> Reviewed-by: Andrey Kosyakov <[email protected]> Cr-Commit-Position: refs/heads/main@{#1672509} --- diff --git a/content/browser/devtools/protocol/target_handler.cc b/content/browser/devtools/protocol/target_handler.cc index f051aaf1..f20a1a8 100644 --- a/content/browser/devtools/protocol/target_handler.cc +++ b/content/browser/devtools/protocol/target_handler.cc @@ -792,7 +792,10 @@ SetDiscoverTargets(false, {}); hidden_target_manager_.Clear(); auto_attached_sessions_.clear(); - attached_sessions_.clear(); + // Destroying a session may synchronously close attached targets which can + // try to detach sibling sessions, so move the map aside before releasing. + auto attached_sessions = std::move(attached_sessions_); + attached_sessions.clear(); DevToolsManagerDelegate* delegate = DevToolsManager::GetInstance()->delegate(); diff --git a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc index 1174671f..382d5c3c 100644 --- a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc +++ b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc @@ -5374,4 +5374,108 @@ EXPECT_EQ("Internal error", *error_message); } +// Regression test for crbug.com/521620916: detaching the browser client when +// hidden targets with crashed renderers are attached should not cause +// reentrancy UAF when sessions are cleared. +IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, DetachWithCrashedHiddenTargets) { + content::ScopedAllowRendererCrashes scoped_allow_renderer_crashes; + set_agent_host_can_close(); + AttachToBrowserTarget(); + + base::DictValue create_page_params; + create_page_params.Set("url", "about:blank"); + const base::DictValue* result = + SendCommandSync("Target.createTarget", std::move(create_page_params)); + ASSERT_TRUE(result); + const std::string* page_target_id_ptr = result->FindString("targetId"); + ASSERT_TRUE(page_target_id_ptr); + std::string page_target_id = *page_target_id_ptr; + + scoped_refptr<DevToolsAgentHost> page_agent_host = + DevToolsAgentHost::GetForId(page_target_id); + ASSERT_TRUE(page_agent_host); + EXPECT_TRUE(WaitForLoadStop(page_agent_host->GetWebContents())); + + base::DictValue attach_params; + attach_params.Set("targetId", page_target_id); + attach_params.Set("flatten", true); + result = SendCommandSync("Target.attachToTarget", std::move(attach_params)); + ASSERT_TRUE(result); + const std::string* page_session_id_ptr = result->FindString("sessionId"); + ASSERT_TRUE(page_session_id_ptr); + std::string page_session_id = *page_session_id_ptr; + + // Use 12 targets to ensure sufficient depth and branching in libc++'s + // std::map Red-Black tree so that reentrant erase() calls during clearance + // reliably collide with post-order traversal regardless of key ordering. + constexpr size_t kHiddenTargetCount = 12; + std::vector<std::string> hidden_target_ids; + for (size_t i = 0; i < kHiddenTargetCount; ++i) { + base::DictValue params; + params.Set("url", "about:blank"); + params.Set("hidden", true); + const base::DictValue* create_result = SendSessionCommand( + "Target.createTarget", std::move(params), page_session_id, true); + ASSERT_TRUE(create_result); + const std::string* target_id = create_result->FindString("targetId"); + ASSERT_TRUE(target_id); + hidden_target_ids.push_back(*target_id); + } + + for (const std::string& target_id : hidden_target_ids) { + scoped_refptr<DevToolsAgentHost> agent_host = + DevToolsAgentHost::GetForId(target_id); + ASSERT_TRUE(agent_host); + WebContents* web_contents = agent_host->GetWebContents(); + ASSERT_TRUE(web_contents); + EXPECT_TRUE(WaitForLoadStop(web_contents)); + } + + for (const std::string& target_id : hidden_target_ids) { + base::DictValue params; + params.Set("targetId", target_id); + params.Set("flatten", true); + SendCommandSync("Target.attachToTarget", std::move(params)); + } + + // Terminate each hidden target's renderer so that closing the page on + // detach takes the synchronous path. The browser session is then detached + // during fixture tear down, which closes the hidden targets while the + // owning child session is being released. + std::set<RenderProcessHost*> rphs; + for (const std::string& target_id : hidden_target_ids) { + scoped_refptr<DevToolsAgentHost> agent_host = + DevToolsAgentHost::GetForId(target_id); + ASSERT_TRUE(agent_host); + WebContents* web_contents = agent_host->GetWebContents(); + ASSERT_TRUE(web_contents); + RenderProcessHost* rph = web_contents->GetPrimaryMainFrame()->GetProcess(); + if (rph) { + rphs.insert(rph); + } + } + + for (RenderProcessHost* rph : rphs) { + if (rph && rph->IsInitializedAndNotDead()) { + RenderProcessHostWatcher watcher( + rph, RenderProcessHostWatcher::WATCH_FOR_PROCESS_EXIT); + rph->Shutdown(RESULT_CODE_KILLED); + watcher.Wait(); + } + } + + for (const std::string& target_id : hidden_target_ids) { + scoped_refptr<DevToolsAgentHost> agent_host = + DevToolsAgentHost::GetForId(target_id); + ASSERT_TRUE(agent_host); + WebContents* web_contents = agent_host->GetWebContents(); + ASSERT_TRUE(web_contents); + ASSERT_FALSE(web_contents->GetPrimaryMainFrame()->IsRenderFrameLive()); + } + + // Detaching the browser client closes the page session and hidden targets + // synchronously; should not crash or trigger reentrancy UAF. + Detach(); +} + } // namespace content
Regression Test / PoC
diff --git a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
index 1174671f..382d5c3c 100644
--- a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
+++ b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
@@ -5374,4 +5374,108 @@
EXPECT_EQ("Internal error", *error_message);
}
+// Regression test for crbug.com/521620916: detaching the browser client when
+// hidden targets with crashed renderers are attached should not cause
+// reentrancy UAF when sessions are cleared.
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, DetachWithCrashedHiddenTargets) {
+ content::ScopedAllowRendererCrashes scoped_allow_renderer_crashes;
+ set_agent_host_can_close();
+ AttachToBrowserTarget();
+
+ base::DictValue create_page_params;
+ create_page_params.Set("url", "about:blank");
+ const base::DictValue* result =
+ SendCommandSync("Target.createTarget", std::move(create_page_params));
+ ASSERT_TRUE(result);
+ const std::string* page_target_id_ptr = result->FindString("targetId");
+ ASSERT_TRUE(page_target_id_ptr);
+ std::string page_target_id = *page_target_id_ptr;
+
+ scoped_refptr<DevToolsAgentHost> page_agent_host =
+ DevToolsAgentHost::GetForId(page_target_id);
+ ASSERT_TRUE(page_agent_host);
+ EXPECT_TRUE(WaitForLoadStop(page_agent_host->GetWebContents()));
+
+ base::DictValue attach_params;
+ attach_params.Set("targetId", page_target_id);
+ attach_params.Set("flatten", true);
+ result = SendCommandSync("Target.attachToTarget", std::move(attach_params));
+ ASSERT_TRUE(result);
+ const std::string* page_session_id_ptr = result->FindString("sessionId");
+ ASSERT_TRUE(page_session_id_ptr);
+ std::string page_session_id = *page_session_id_ptr;
+
+ // Use 12 targets to ensure sufficient depth and branching in libc++'s
+ // std::map Red-Black tree so that reentrant erase() calls during clearance
+ // reliably collide with post-order traversal regardless of key ordering.
+ constexpr size_t kHiddenTargetCount = 12;
+ std::vector<std::string> hidden_target_ids;
+ for (size_t i = 0; i < kHiddenTargetCount; ++i) {
+ base::DictValue params;
+ params.Set("url", "about:blank");
+ params.Set("hidden", true);
+ const base::DictValue* create_result = SendSessionCommand(
+ "Target.createTarget", std::move(params), page_session_id, true);
+ ASSERT_TRUE(create_result);
+ const std::string* target_id = create_result->FindString("targetId");
+ ASSERT_TRUE(target_id);
+ hidden_target_ids.push_back(*target_id);
+ }
+
+ for (const std::string& target_id : hidden_target_ids) {
+ scoped_refptr<DevToolsAgentHost> agent_host =
+ DevToolsAgentHost::GetForId(target_id);
+ ASSERT_TRUE(agent_host);
+ WebContents* web_contents = agent_host->GetWebContents();
+ ASSERT_TRUE(web_contents);
+ EXPECT_TRUE(WaitForLoadStop(web_contents));
+ }
+
+ for (const std::string& target_id : hidden_target_ids) {
+ base::DictValue params;
+ params.Set("targetId", target_id);
+ params.Set("flatten", true);
+ SendCommandSync("Target.attachToTarget", std::move(params));
+ }
+
+ // Terminate each hidden target's renderer so that closing the page on
+ // detach takes the synchronous path. The browser session is then detached
+ // during fixture tear down, which closes the hidden targets while the
+ // owning child session is being released.
+ std::set<RenderProcessHost*> rphs;
+ for (const std::string& target_id : hidden_target_ids) {
+ scoped_refptr<DevToolsAgentHost> agent_host =
+ DevToolsAgentHost::GetForId(target_id);
+ ASSERT_TRUE(agent_host);
+ WebContents* web_contents = agent_host->GetWebContents();
+ ASSERT_TRUE(web_contents);
+ RenderProcessHost* rph = web_contents->GetPrimaryMainFrame()->GetProcess();
+ if (rph) {
+ rphs.insert(rph);
+ }
+ }
+
+ for (RenderProcessHost* rph : rphs) {
+ if (rph && rph->IsInitializedAndNotDead()) {
+ RenderProcessHostWatcher watcher(
+ rph, RenderProcessHostWatcher::WATCH_FOR_PROCESS_EXIT);
+ rph->Shutdown(RESULT_CODE_KILLED);
+ watcher.Wait();
+ }
+ }
+
+ for (const std::string& target_id : hidden_target_ids) {
+ scoped_refptr<DevToolsAgentHost> agent_host =
+ DevToolsAgentHost::GetForId(target_id);
+ ASSERT_TRUE(agent_host);
+ WebContents* web_contents = agent_host->GetWebContents();
+ ASSERT_TRUE(web_contents);
+ ASSERT_FALSE(web_contents->GetPrimaryMainFrame()->IsRenderFrameLive());
+ }
+
+ // Detaching the browser client closes the page session and hidden targets
+ // synchronously; should not crash or trigger reentrancy UAF.
+ Detach();
+}
+
} // namespace content
Original Bug Report
Browser-process Use-After-Free/Double-Free in content::TargetHandler::Disable
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 high-severity heap use-after-free and double-free vulnerability exists in the browser process due to reentrancy in content::TargetHandler::Disable(). Synchronous destruction of target sessions during map clearance can trigger a recursive deletion cascade that modifies the same map mid-operation. This corrupts the underlying libc++ tree structure, leading to memory corruption without MiraclePtr protection.
Affected files:
content/browser/devtools/protocol/target_handler.cccontent/browser/devtools/protocol/target_handler.h
Estimated timestamp from git blame: 2017-07-20
Summary
There is a potential reentrancy-driven heap use-after-free (UAF) and double-free vulnerability in the browser process within content::TargetHandler::Disable(). When clearing attached_sessions_ (a std::map<std::string, std::unique_ptr<Session>>), the value destructor (~Session()) can run synchronously. Under specific teardown conditions (such as when a renderer process has crashed), this destruction triggers a recursive cascade that synchronously unlinks and deallocates sibling entries from the same map via S_Bi->AgentHostClosed() -> Detach(true).
Because libc++ __tree::clear() is not reentrancy-safe (it performs a recursive post-order traversal and caches the right-hand child node pointer before invoking the value destructor), the reentrant erase() call executes a search starting from the tree root. This results in Use-After-Free reads on already-freed tree node pointers and keys. Furthermore, if a sibling entry resides in the cached right subtree, the inner erase() deallocates it immediately, causing a Use-After-Free dereference, double-destruction of its unique_ptr payload, and a double-free of the node structure when the outer clear() loop subsequently attempts to visit it.
Affected Locations
content/browser/devtools/protocol/target_handler.cc(specifically withinTargetHandler::Disable(),~Session(), andSession::Detach()).content/browser/devtools/protocol/target_handler.h
Potential Trigger Mechanism
Note: The following are potential steps to trigger the issue, as our automated tooling does not currently have the capability to execute code or run a working Proof-of-Concept.
- Establish a trusted Chrome DevTools Protocol (CDP) client session
S1connected to the root browser session (e.g., via--remote-debugging-pipe). - Attach a child session
S_XviaTarget.attachToTargetwith theflatten: trueoption. This populatesT1->attached_sessions_and instantiates a headless sessionHDS_Xand headless handlerHT_X. - Through child session
sX, create several hidden targets $B_1 \dots B_n$ viaTarget.createTargetwithhidden: true. This places the hidden target IDs intoHT_X->hidden_web_contents_. - Attach all hidden targets $B_1 \dots B_n$ to the root session
S1usingTarget.attachToTarget(Bi). This stores sibling sessionsS_BialongsideS_XinT1->attached_sessions_. - Crash the renderer processes associated with the hidden targets $B_1 \dots B_n$, so that their respective main frames report
IsRenderFrameLive() == false. - Close the root pipe connection
S1. This triggersBrowserDevToolsAgentHost::DetachInternal(S1)on the UI thread, invokingT1->Disable()and callingattached_sessions_.clear(). - When the outer
__tree::clear()deletes nodeS_X, its destructor synchronously detaches the agent host. Because the renderers are dead, this triggers a synchronous fallback branch inRenderFrameHostImpl::ClosePagethat destroys the headless WebContents synchronously. - The WebContents destruction fires frame deletion notifications synchronously, reaching the other attached sessions $S_{Bi}$ on
T1viaAgentHostClosed() -> Detach(true). - This triggers
handler_->attached_sessions_.erase(id_SBi)reentrantly onT1, corrupting the__treeduring the outerclear()operation.
Suggested Fix
To eliminate this reentrancy vulnerability, attached_sessions_ should be swapped to a local map before clearance, or cleared in a reentrancy-safe loop. Swapping the map ensures that any reentrant deletion calls operate on an empty map rather than mutating a container currently undergoing destruction.
Response TargetHandler::Disable() {
SetAutoAttachInternal(false, false, false, base::DoNothing());
SetDiscoverTargets(false, {});
hidden_target_manager_.Clear();
auto_attached_sessions_.clear();
// Swap to local map to prevent reentrancy issues during clearance
std::map<std::string, std::unique_ptr<Session>> attached_sessions;
attached_sessions.swap(attached_sessions_);
attached_sessions.clear();
...
Evaluated with Chrome root at commit: 3947e01999a53d4e2382e39736cb79d79c7dffcf
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.