CVE-2026-6919
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TestAgentHostcontent/browser/devtools/devtools_refcount_unittest.cc |
modified | |
DevToolsRefcountTestcontent/browser/devtools/devtools_refcount_unittest.cc |
modified | |
TEST_Fcontent/browser/devtools/devtools_refcount_unittest.cc |
modified |
Files Changed
content/browser/bad_message.hcontent/browser/devtools/dedicated_worker_devtools_agent_host.hcontent/browser/devtools/devtools_refcount_unittest.cccontent/browser/devtools/devtools_renderer_channel.cc
Patch
From 5b7fd82e6c332f92840e8762cdaf218f009ddc3c Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <yyanagisawa@chromium.org>
Date: Fri, 10 Apr 2026 05:59:37 -0700
Subject: [PATCH] Prevent double initialization and Release() in DedicatedWorkerDevToolsAgentHost
A compromised renderer could trigger multiple ChildTargetCreated calls
for the same dedicated worker via a shared worker's DevTools interface.
Each call could establish a new mojo pipe, leading to multiple
SetRenderer() calls and subsequent multiple Release() calls on the host
object when the pipes were closed. This could underflow the reference
count and cause a Use-After-Free (UAF) in the browser process.
This CL fixes the issue in two ways:
1. It prevents multiple initializations of a
DedicatedWorkerDevToolsAgentHost in
DevToolsRendererChannel::ChildTargetCreated(). If the agent host
already has a renderer set, further requests for the same token are
ignored.
2. It introduces a flag (kWorkerOrWorkletAgentDoubleReleaseFix)
to ensure that the self-reference (AddRef() in constructor) is
only released once during Disconnected() in the base class
WorkerOrWorkletDevToolsAgentHost.
Additionally, a DCHECK in ChildTargetCreated is promoted to CHECK to
ensure that the token must correspond to an existing agent host.
The refcount fix is gated behind a feature flag to serve as a kill
switch for easier merging into older release branches.
Bug: 500136078, 493652473
Change-Id: Ib1a58b9a7f3f63f0362d40476f290645ee865c88
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7736861
Auto-Submit: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Reviewed-by: Alex Rudenko <alexrudenko@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Andrey Kosyakov <caseq@chromium.org>
Commit-Queue: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Steven Holte <holte@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1612797}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index 45c44d0..77067be7 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -364,6 +364,7 @@
RFH_MODAL_DIALOG_FROM_SANDBOXED_FRAME = 336,
RFH_OPEN_URL_INVALID_DISPOSITION = 337,
RFH_ENTER_FULLSCREEN_PERMISSION_DENIED = 338,
+ DT_DUPLICATE_CHILD_TARGET_CREATED = 339,
// Please add new elements here. The naming convention is abbreviated class
// name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/devtools/dedicated_worker_devtools_agent_host.h b/content/browser/devtools/dedicated_worker_devtools_agent_host.h
index 314625a..eab2a740 100644
--- a/content/browser/devtools/dedicated_worker_devtools_agent_host.h
+++ b/content/browser/devtools/dedicated_worker_devtools_agent_host.h
@@ -48,6 +48,8 @@
const std::string& name,
base::OnceCallback<void(DevToolsAgentHostImpl*)> callback);
+ bool child_worker_created() const { return child_worker_created_; }
+
private:
~DedicatedWorkerDevToolsAgentHost() override;
diff --git a/content/browser/devtools/devtools_refcount_unittest.cc b/content/browser/devtools/devtools_refcount_unittest.cc
new file mode 100644
index 0000000..05da47f
--- /dev/null
+++ b/content/browser/devtools/devtools_refcount_unittest.cc
@@ -0,0 +1,93 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "base/test/bind.h"
+#include "content/browser/devtools/worker_or_worklet_devtools_agent_host.h"
+#include "content/public/test/browser_task_environment.h"
+#include "content/public/test/mock_render_process_host.h"
+#include "content/public/test/test_browser_context.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace content {
+
+namespace {
+
+class TestAgentHost : public WorkerOrWorkletDevToolsAgentHost {
+ public:
+ TestAgentHost(
+ int process_id,
+ const GURL& url,
+ const std::string& name,
+ const base::UnguessableToken& devtools_worker_token,
+ const std::string& parent_id,
+ base::OnceCallback<void(DevToolsAgentHostImpl*)> destroyed_callback)
+ : WorkerOrWorkletDevToolsAgentHost(process_id,
+ url,
+ name,
+ devtools_worker_token,
+ parent_id,
+ std::move(destroyed_callback)) {
+ NotifyCreated();
+ }
+
+ using WorkerOrWorkletDevToolsAgentHost::Disconnected;
+
+ // DevToolsAgentHost implementation:
+ std::string GetType() override { return "test"; }
+ bool AttachSession(DevToolsSession* session) override { return true; }
+ void DetachSession(DevToolsSession* session) override {}
+
+ private:
+ ~TestAgentHost() override = default;
+};
+
+} // namespace
+
+class DevToolsRefcountTest : public testing::Test {
+ public:
+ DevToolsRefcountTest()
+ : task_environment_(content::BrowserTaskEnvironment::IO_MAINLOOP) {}
+
+ protected:
+ content::BrowserTaskEnvironment task_environment_;
+ TestBrowserContext browser_context_;
+};
+
+// Tests that WorkerOrWorkletDevToolsAgentHost doesn't underflow refcount
+// when Disconnected() is called multiple times.
+TEST_F(DevToolsRefcountTest, PreventDoubleRelease) {
+ MockRenderProcessHost renderer_host(&browser_context_);
+ const int process_id = renderer_host.GetDeprecatedID();
+ base::UnguessableToken token = base::UnguessableToken::Create();
+
+ bool destroyed_called = false;
+ auto destroyed_callback = base::BindLambdaForTesting(
+ [&](DevToolsAgentHostImpl* host) { destroyed_called = true; });
+
+ // 1. Create a TestAgentHost.
+ // It starts with refcount 1 (from our scoped_refptr) + 1 (from
+ // self-keep-alive in constructor).
+ scoped_refptr<TestAgentHost> agent_host = base::MakeRefCounted<TestAgentHost>(
+ process_id, GURL("http://example.com"), "worker", token, "parent_id",
+ std::move(destroyed_callback));
+
+ // 2. Call Disconnected() first time.
+ // This should trigger the first Release() and destroyed_callback.
+ agent_host->Disconnected();
+ EXPECT_TRUE(destroyed_called);
+
+ // 3. Call Disconnected() multiple times.
+ // If the fix is working, these subsequent calls won't cause double-release.
+ // We reset destroyed_called to ensure it's not called again.
+ destroyed_called = false;
+ agent_host->Disconnected();
+ agent_host->Disconnected();
+ EXPECT_FALSE(destroyed_called);
+
+ // 4. Verification: If we can still access agent_host, it hasn't been
+ // over-released. The refcount should be 1 (only our scoped_refptr).
+ EXPECT_TRUE(agent_host->HasOneRef());
+}
+
+} // namespace content
diff --git a/content/browser/devtools/devtools_renderer_channel.cc b/content/browser/devtools/devtools_renderer_channel.cc
index 466d7fc..62413dc7 100644
--- a/content/browser/devtools/devtools_renderer_channel.cc
+++ b/content/browser/devtools/devtools_renderer_channel.cc
@@ -7,6 +7,7 @@
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/memory/safety_checks.h"
+#include "content/browser/bad_message.h"
#include "content/browser/devtools/dedicated_worker_devtools_agent_host.h"
#include "content/browser/devtools/devtools_agent_host_impl.h"
#include "content/browser/devtools/devtools_manager.h"
@@ -14,6 +15,7 @@
#include "content/browser/devtools/protocol/devtools_domain_handler.h"
#include "content/browser/devtools/worker_devtools_manager.h"
#include "content/browser/devtools/worklet_devtools_agent_host.h"
+#include "content/common/features.h"
#include "content/public/browser/child_process_host.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/browser/web_contents.h"
@@ -185,7 +187,7 @@
case blink::mojom::DevToolsExecutionContextType::kDedicatedWorker: {
// WorkerDevToolsAgentHost for dedicated workers is already created in the
// browser process.
- DCHECK(content::DevToolsAgentHost::GetForId(
+ CHECK(content::DevToolsAgentHost::GetForId(
devtools_worker_token.ToString()));
DedicatedWorkerDevToolsAgentHost* dedicated_worker_agent_host =
WorkerDevToolsManager::GetInstance().GetDevToolsHostFromToken(
@@ -197,6 +199,15 @@
// case.
return;
}
+ if (base::FeatureList::IsEnabled(
+ ::features::kWorkerOrWorkletAgentDoubleReleaseFix) &&
Regression Test / PoC
diff --git a/content/browser/devtools/devtools_refcount_unittest.cc b/content/browser/devtools/devtools_refcount_unittest.cc
new file mode 100644
index 0000000..05da47f
--- /dev/null
+++ b/content/browser/devtools/devtools_refcount_unittest.cc
@@ -0,0 +1,93 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "base/test/bind.h"
+#include "content/browser/devtools/worker_or_worklet_devtools_agent_host.h"
+#include "content/public/test/browser_task_environment.h"
+#include "content/public/test/mock_render_process_host.h"
+#include "content/public/test/test_browser_context.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace content {
+
+namespace {
+
+class TestAgentHost : public WorkerOrWorkletDevToolsAgentHost {
+ public:
+ TestAgentHost(
+ int process_id,
+ const GURL& url,
+ const std::string& name,
+ const base::UnguessableToken& devtools_worker_token,
+ const std::string& parent_id,
+ base::OnceCallback<void(DevToolsAgentHostImpl*)> destroyed_callback)
+ : WorkerOrWorkletDevToolsAgentHost(process_id,
+ url,
+ name,
+ devtools_worker_token,
+ parent_id,
+ std::move(destroyed_callback)) {
+ NotifyCreated();
+ }
+
+ using WorkerOrWorkletDevToolsAgentHost::Disconnected;
+
+ // DevToolsAgentHost implementation:
+ std::string GetType() override { return "test"; }
+ bool AttachSession(DevToolsSession* session) override { return true; }
+ void DetachSession(DevToolsSession* session) override {}
+
+ private:
+ ~TestAgentHost() override = default;
+};
+
+} // namespace
+
+class DevToolsRefcountTest : public testing::Test {
+ public:
+ DevToolsRefcountTest()
+ : task_environment_(content::BrowserTaskEnvironment::IO_MAINLOOP) {}
+
+ protected:
+ content::BrowserTaskEnvironment task_environment_;
+ TestBrowserContext browser_context_;
+};
+
+// Tests that WorkerOrWorkletDevToolsAgentHost doesn't underflow refcount
+// when Disconnected() is called multiple times.
+TEST_F(DevToolsRefcountTest, PreventDoubleRelease) {
+ MockRenderProcessHost renderer_host(&browser_context_);
+ const int process_id = renderer_host.GetDeprecatedID();
+ base::UnguessableToken token = base::UnguessableToken::Create();
+
+ bool destroyed_called = false;
+ auto destroyed_callback = base::BindLambdaForTesting(
+ [&](DevToolsAgentHostImpl* host) { destroyed_called = true; });
+
+ // 1. Create a TestAgentHost.
+ // It starts with refcount 1 (from our scoped_refptr) + 1 (from
+ // self-keep-alive in constructor).
+ scoped_refptr<TestAgentHost> agent_host = base::MakeRefCounted<TestAgentHost>(
+ process_id, GURL("http://example.com"), "worker", token, "parent_id",
+ std::move(destroyed_callback));
+
+ // 2. Call Disconnected() first time.
+ // This should trigger the first Release() and destroyed_callback.
+ agent_host->Disconnected();
+ EXPECT_TRUE(destroyed_called);
+
+ // 3. Call Disconnected() multiple times.
+ // If the fix is working, these subsequent calls won't cause double-release.
+ // We reset destroyed_called to ensure it's not called again.
+ destroyed_called = false;
+ agent_host->Disconnected();
+ agent_host->Disconnected();
+ EXPECT_FALSE(destroyed_called);
+
+ // 4. Verification: If we can still access agent_host, it hasn't been
+ // over-released. The refcount should be 1 (only our scoped_refptr).
+ EXPECT_TRUE(agent_host->HasOneRef());
+}
+
+} // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 12eea20b..9c8b29e 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -3677,6 +3677,7 @@
# Non-Android.
sources += [
"../browser/devtools/devtools_manager_unittest.cc",
+ "../browser/devtools/devtools_refcount_unittest.cc",
"../browser/devtools/protocol/webauthn_handler_unittest.cc",
"../browser/host_zoom_map_impl_unittest.cc",
"../browser/picture_in_picture/document_picture_in_picture_navigation_throttle_unittest.cc",
Original Bug Report
Use-after-free in DevToolsRendererChannel::ForceDetachWorkerSessions via duplicate ChildTargetCreated for dedicated workers
Use-after-free in DevToolsRendererChannel::ForceDetachWorkerSessions via duplicate ChildTargetCreated for dedicated workers
Summary
A compromised renderer can trigger a use-after-free in the browser process by sending duplicate blink.mojom.DevToolsAgentHost.ChildTargetCreated messages for the same dedicated worker token through different DevTools channels. The dedicated worker branch in ChildTargetCreated lacks the duplicate-token validation that the worklet branch enforces, allowing a single DedicatedWorkerDevToolsAgentHost to be inserted into multiple child_targets_ sets while its destroyed_callback_ is silently overwritten. When the host object is freed, one channel retains a dangling pointer. A subsequent frame navigation calls ForceDetachWorkerSessions, which dereferences this pointer in the browser process. The vulnerability affects all desktop platforms (Linux, Windows, macOS, ChromeOS) and requires only that the user opens DevTools (F12) on the compromised tab. Threat model: compromised renderer / sandbox escape.
Bisect
Introducing Commit: 1598b6392def1a0d33fb485d78e463875e414655
- Date: 2024-01-12
- Author: Andrey Kosyakov <caseq@chromium.org>
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/5185611
Root Cause
When a dedicated worker is created and DevTools is attached, the renderer sends a ChildTargetCreated IPC to the browser. The browser-side handler in DevToolsRendererChannel::ChildTargetCreated looks up the corresponding DedicatedWorkerDevToolsAgentHost by token, calls ChildWorkerCreated to register a destruction callback, rebinds the host’s Mojo pipes via SetRenderer, and inserts the host’s raw pointer into the channel’s child_targets_ set:
// content/browser/devtools/devtools_renderer_channel.cc:185-225
case blink::mojom::DevToolsExecutionContextType::kDedicatedWorker: {
DedicatedWorkerDevToolsAgentHost* dedicated_worker_agent_host =
WorkerDevToolsManager::GetInstance().GetDevToolsHostFromToken(
devtools_worker_token);
if (!dedicated_worker_agent_host)
return;
dedicated_worker_agent_host->ChildWorkerCreated(
url, name,
base::BindOnce(&DevToolsRendererChannel::ChildTargetDestroyed,
weak_factory_.GetWeakPtr()));
agent_host = dedicated_worker_agent_host;
break;
}
agent_host->SetRenderer(process_id_, std::move(worker_devtools_agent),
std::move(host_receiver));
child_targets_.insert(agent_host.get());
The worklet branch guards against duplicate tokens with an explicit check that calls ReportBadMessage and returns early. The dedicated worker branch has no such guard. GetDevToolsHostFromToken performs a global lookup by token with no validation of the caller’s identity, so any channel in the same process can retrieve the same host object. ChildWorkerCreated unconditionally overwrites the single destroyed_callback_ member:
// content/browser/devtools/worker_or_worklet_devtools_agent_host.cc:55-62
void WorkerOrWorkletDevToolsAgentHost::ChildWorkerCreated(
const GURL& url, const std::string& name,
base::OnceCallback<void(DevToolsAgentHostImpl*)> callback) {
url_ = url;
name_ = name;
destroyed_callback_ = std::move(callback);
}
The child_targets_ container uses raw_ptr<WorkerOrWorkletDevToolsAgentHost, CtnExperimental>, which maps to kMayDangle and disables Chromium’s internal dangling-pointer detection.
The attack proceeds as follows. The compromised renderer creates a dedicated worker, causing the parent frame’s DevTools channel to send the normal ChildTargetCreated with the worker’s token. The browser inserts the host pointer into the frame channel’s child_targets_ and sets destroyed_callback_ to point back at that channel. Meanwhile, the worker’s own DevToolsAgent receives a host_remote_ Mojo endpoint connected to the host’s own DevToolsRendererChannel. Through this endpoint, the compromised renderer sends a second ChildTargetCreated carrying the same token but with throwaway Mojo pipe endpoints. The browser processes this on the host’s channel, overwriting destroyed_callback_ and rebinding the host’s Mojo connection to the throwaway pipes. When the throwaway pipes are immediately destroyed on the renderer side, the browser’s agent_remote_ disconnect handler fires Disconnected, which invokes the overwritten callback, removing the host only from its own channel’s child_targets_. The frame channel’s child_targets_ still holds the pointer. After the renderer terminates the worker, WorkerDevToolsManager::WorkerDestroyed erases the host from its hosts_ map, dropping the last reference and freeing the object. On the next same-origin navigation of the frame, ReadyToCommitNavigation calls ForceDetachWorkerSessions, which iterates the frame channel’s child_targets_ and dereferences the freed host:
// content/browser/devtools/devtools_renderer_channel.cc:76-80
void DevToolsRendererChannel::ForceDetachWorkerSessions() {
for (WorkerOrWorkletDevToolsAgentHost* host : child_targets_) {
host->ForceDetachAllSessions();
}
}
This virtual call reads the vtable pointer from freed memory, producing a heap-use-after-free in the browser process.
Reproduce
Tested at commit d0f83d769eeed on macOS arm64 (also confirmed on Linux x86_64) with an ASAN build:
is_asan = true
is_debug = false
is_component_build = true
dcheck_always_on = true
The PoC requires a renderer-side source modification (patch.diff) that replays the ChildTargetCreated IPC through the worker’s own DevTools pipe, simulating a compromised renderer.
1. Apply patch and build
cd ~/chromium/src
git apply patch.diff
autoninja -C out/asan-release chrome
2. Start HTTP server
cd ~/issue_devtools_childtarget_uaf
python3 -m http.server 18035 &
3. Launch Chrome
- macOS:
ASAN_OPTIONS=detect_odr_violation=0 \
~/chromium/src/out/asan-release/Chromium.app/Contents/MacOS/Chromium \
--enable-logging=stderr \
http://localhost:18035/poc.html
- Linux:
ASAN_OPTIONS=detect_odr_violation=0 \
~/chromium/src/out/asan-release/chrome \
--enable-logging=stderr \
http://localhost:18035/poc.html
4. Trigger
Open DevTools on the tab (F12 or Cmd+Option+I). The PoC page automatically creates a worker, triggers the duplicate IPC, terminates the worker, and navigates. The browser process crashes within 15 seconds.
=================================================================
==4176353==ERROR: AddressSanitizer: heap-use-after-free on address 0x7cfbb38b7980 at pc 0x7f8c16791b0a bp 0x7ffd8c1de3b0 sp 0x7ffd8c1de3a8
READ of size 8 at 0x7cfbb38b7980 thread T0 (chrome)
#0 0x7f8c16791b09 in content::DevToolsRendererChannel::ForceDetachWorkerSessions() content/browser/devtools/devtools_renderer_channel.cc:81:11
#1 0x7f8c169ecd9a in content::RenderFrameDevToolsAgentHost::ReadyToCommitNavigation(content::NavigationHandle*) content/browser/devtools/render_frame_devtools_agent_host.cc:510:25
#2 0x7f8c1825a7b3 in void content::WebContentsImpl::WebContentsObserverList::NotifyObservers<void (content::WebContentsObserver::*)(content::NavigationHandle*), content::NavigationHandle*&>(void (content::WebContentsObserver::*)(content::NavigationHandle*), content::NavigationHandle*&) content/browser/web_contents/web_contents_impl.h:1833:9
#3 0x7f8c1825b75d in content::WebContentsImpl::ReadyToCommitNavigation(content::NavigationHandle*) content/browser/web_contents/web_contents_impl.cc:7431:14
#4 0x7f8c178bcc95 in content::NavigationRequest::ReadyToCommitNavigation(bool) content/browser/renderer_host/navigation_request.cc:8990:20
#5 0x7f8c178b3e03 in content::NavigationRequest::CommitNavigation() content/browser/renderer_host/navigation_request.cc:6716:3
0x7cfbb38b7980 is located 0 bytes inside of 648-byte region [0x7cfbb38b7980,0x7cfbb38b7c08)
freed by thread T0 (chrome) here:
#0 0x55ace64339e2 in operator delete(void*, unsigned long)
#1 0x7f8c16a2dcd5 in std::__Cr::__tree<...>::erase(...) base/memory/ref_counted.h:375:5
#2 0x7f8c184c4614 in content::DedicatedWorkerHost::~DedicatedWorkerHost() content/browser/worker_host/dedicated_worker_host.cc:190:40
#3 0x7f8c184c5652 in content::DedicatedWorkerHost::OnMojoDisconnect() content/browser/worker_host/dedicated_worker_host.cc:245:3
previously allocated by thread T0 (chrome) here:
#0 0x55ace6432ddd in operator new(unsigned long)
#1 0x7f8c16a2caac in base::MakeRefCounted<content::DedicatedWorkerDevToolsAgentHost, ...>(...) base/memory/scoped_refptr.h:151:12
#2 0x7f8c16a2c689 in content::WorkerDevToolsManager::WorkerCreated(...) content/browser/devtools/worker_devtools_manager.cc:54:18
SUMMARY: AddressSanitizer: heap-use-after-free content/browser/devtools/devtools_renderer_channel.cc:81:11 in content::DevToolsRendererChannel::ForceDetachWorkerSessions()
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.