CVE-2026-12027
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
HeadlessCreatedTargetIsUsedProcessTestheadless/test/headless_devtooled_browsertest.cc |
modified |
Files Changed
headless/lib/browser/protocol/target_handler.ccheadless/test/headless_devtooled_browsertest.cc
Patch
From 1001ffc71823bcfadb3d0a9cab6582f84128d2ff Mon Sep 17 00:00:00 2001
From: Peter Kvitek <kvitekp@chromium.org>
Date: Thu, 04 Jun 2026 15:16:04 -0700
Subject: [PATCH] [headless] Mark created hidden target process as used
This mirrors the regular Chrome behavior and assures that the associated
process is used as expected.
Bug: 517517155
Change-Id: I77182a26e29198ed08036176b56f28bc28013dd1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7903461
Auto-Submit: Peter Kvitek <kvitekp@chromium.org>
Reviewed-by: Andrey Kosyakov <caseq@chromium.org>
Commit-Queue: Peter Kvitek <kvitekp@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641974}
---
diff --git a/headless/lib/browser/protocol/target_handler.cc b/headless/lib/browser/protocol/target_handler.cc
index 548be82..096d7ec 100644
--- a/headless/lib/browser/protocol/target_handler.cc
+++ b/headless/lib/browser/protocol/target_handler.cc
@@ -8,6 +8,9 @@
#include <string_view>
#include "build/build_config.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/render_process_host.h"
+#include "content/public/browser/web_contents.h"
#include "headless/lib/browser/headless_browser_context_impl.h"
#include "headless/lib/browser/headless_browser_impl.h"
#include "headless/lib/browser/headless_web_contents_impl.h"
@@ -110,6 +113,15 @@
HeadlessWebContentsImpl* web_contents_impl = HeadlessWebContentsImpl::From(
context->CreateWebContentsBuilder().SetInitialURL(gurl).Build());
+ // Mark the process used so IsSuitableHost() rejects it for sites that
+ // require a dedicated process. (Mirrors content::HiddenTargetManager, which
+ // the headless embedder layer bypasses by handling Target.createTarget
+ // itself.)
+ web_contents_impl->web_contents()
+ ->GetPrimaryMainFrame()
+ ->GetProcess()
+ ->SetIsUsed();
+
*out_target_id = content::DevToolsAgentHost::GetOrCreateFor(
web_contents_impl->web_contents())
->GetId();
diff --git a/headless/test/headless_devtooled_browsertest.cc b/headless/test/headless_devtooled_browsertest.cc
index efe69468..598e265b 100644
--- a/headless/test/headless_devtooled_browsertest.cc
+++ b/headless/test/headless_devtooled_browsertest.cc
@@ -11,6 +11,8 @@
#include "base/strings/stringprintf.h"
#include "base/values.h"
#include "build/build_config.h"
+#include "content/public/browser/devtools_agent_host.h"
+#include "content/public/browser/render_process_host.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_test.h"
@@ -170,4 +172,52 @@
#endif // #if !BUILDFLAG(IS_FUCHSIA)
+class HeadlessCreatedTargetIsUsedProcessTest
+ : public HeadlessDevTooledBrowserTest,
+ public testing::WithParamInterface<bool> {
+ public:
+ HeadlessCreatedTargetIsUsedProcessTest() = default;
+
+ bool IsHiddenTarget() const { return GetParam(); }
+
+ private:
+ void RunDevTooledTest() override {
+ base::DictValue params;
+ params.Set("url", "");
+ params.Set("hidden", IsHiddenTarget());
+ browser_devtools_client_.SendCommand(
+ "Target.createTarget", std::move(params),
+ base::BindOnce(&HeadlessCreatedTargetIsUsedProcessTest::OnTargetCreated,
+ base::Unretained(this)));
+ }
+
+ void OnTargetCreated(base::DictValue result) {
+ std::string target_id = DictString(result, "result.targetId");
+ ASSERT_FALSE(target_id.empty());
+
+ scoped_refptr<content::DevToolsAgentHost> agent_host =
+ content::DevToolsAgentHost::GetForId(target_id);
+ ASSERT_TRUE(agent_host);
+
+ content::WebContents* web_contents = agent_host->GetWebContents();
+ ASSERT_TRUE(web_contents);
+
+ bool is_used =
+ !web_contents->GetPrimaryMainFrame()->GetProcess()->IsUnused();
+ EXPECT_EQ(is_used, IsHiddenTarget());
+
+ FinishAsynchronousTest();
+ }
+};
+
+INSTANTIATE_TEST_SUITE_P(
+ /* no prefix */,
+ HeadlessCreatedTargetIsUsedProcessTest,
+ testing::Bool(),
+ [](const testing::TestParamInfo<bool>& info) {
+ return info.param ? "hidden" : "normal";
+ });
+
+HEADLESS_DEVTOOLED_TEST_P(HeadlessCreatedTargetIsUsedProcessTest);
+
} // namespace headless
Regression Test / PoC
diff --git a/headless/test/headless_devtooled_browsertest.cc b/headless/test/headless_devtooled_browsertest.cc
index efe69468..598e265b 100644
--- a/headless/test/headless_devtooled_browsertest.cc
+++ b/headless/test/headless_devtooled_browsertest.cc
@@ -11,6 +11,8 @@
#include "base/strings/stringprintf.h"
#include "base/values.h"
#include "build/build_config.h"
+#include "content/public/browser/devtools_agent_host.h"
+#include "content/public/browser/render_process_host.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_test.h"
@@ -170,4 +172,52 @@
#endif // #if !BUILDFLAG(IS_FUCHSIA)
+class HeadlessCreatedTargetIsUsedProcessTest
+ : public HeadlessDevTooledBrowserTest,
+ public testing::WithParamInterface<bool> {
+ public:
+ HeadlessCreatedTargetIsUsedProcessTest() = default;
+
+ bool IsHiddenTarget() const { return GetParam(); }
+
+ private:
+ void RunDevTooledTest() override {
+ base::DictValue params;
+ params.Set("url", "");
+ params.Set("hidden", IsHiddenTarget());
+ browser_devtools_client_.SendCommand(
+ "Target.createTarget", std::move(params),
+ base::BindOnce(&HeadlessCreatedTargetIsUsedProcessTest::OnTargetCreated,
+ base::Unretained(this)));
+ }
+
+ void OnTargetCreated(base::DictValue result) {
+ std::string target_id = DictString(result, "result.targetId");
+ ASSERT_FALSE(target_id.empty());
+
+ scoped_refptr<content::DevToolsAgentHost> agent_host =
+ content::DevToolsAgentHost::GetForId(target_id);
+ ASSERT_TRUE(agent_host);
+
+ content::WebContents* web_contents = agent_host->GetWebContents();
+ ASSERT_TRUE(web_contents);
+
+ bool is_used =
+ !web_contents->GetPrimaryMainFrame()->GetProcess()->IsUnused();
+ EXPECT_EQ(is_used, IsHiddenTarget());
+
+ FinishAsynchronousTest();
+ }
+};
+
+INSTANTIATE_TEST_SUITE_P(
+ /* no prefix */,
+ HeadlessCreatedTargetIsUsedProcessTest,
+ testing::Bool(),
+ [](const testing::TestParamInfo<bool>& info) {
+ return info.param ? "hidden" : "normal";
+ });
+
+HEADLESS_DEVTOOLED_TEST_P(HeadlessCreatedTargetIsUsedProcessTest);
+
} // namespace headless
Original Bug Report
chrome-headless-shell Target.createTarget(hidden) bypasses SetIsUsed process isolation
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: In chrome-headless-shell, creating a hidden target bypasses content-layer hidden target management and fails to mark the backing RenderProcessHost as used. Under process-limit conditions, this allows process reuse for untrusted web content, potentially exposing browser-level DevTools bindings to a compromised renderer process.
Affected files:
headless/lib/browser/protocol/target_handler.ccheadless/lib/browser/headless_web_contents_impl.cc
Estimated timestamp from git blame: 2025-04-02
Root Cause
In Chrome desktop, Target.createTarget requests with hidden=true fall through to the content layer (content::protocol::HiddenTargetManager::CreateHiddenTarget), which creates a hidden WebContents and explicitly marks its backing RenderProcessHost as used:
// content/browser/devtools/protocol/hidden_target_manager.cc
web_contents->GetPrimaryMainFrame()->GetProcess()->SetIsUsed();
This prevents the process from being reused for other origins under process-limit conditions.
However, in chrome-headless-shell, the embedder-layer target handler (headless::protocol::TargetHandler::CreateTarget in headless/lib/browser/protocol/target_handler.cc) intercepts the hidden=true case and returns success directly without falling through or notifying the content layer:
if (hidden.value_or(false)) {
...
HeadlessWebContentsImpl* web_contents_impl = HeadlessWebContentsImpl::From(
context->CreateWebContentsBuilder().SetInitialURL(gurl).Build());
*out_target_id = content::DevToolsAgentHost::GetOrCreateFor(
web_contents_impl->web_contents())->GetId();
hidden_web_contents_.insert(*out_target_id);
return Response::Success();
}
Because of this bypass, SetIsUsed() is never called on the backing RenderProcessHost. The host remains marked as is_unused_ = true while keeping an allows-any-site process lock due to its initial unsited (about:blank) state.
Potential Attack Path
- A trusted client (e.g., WebDriver-BiDi automation) initiates a hidden target page via
Target.createTarget({url: "about:blank", hidden: true})followed byTarget.exposeDevToolsProtocolwithbinding_name = "cdp"to expose browser-level DevTools bindings on the page. - The backing
RenderProcessHostfor the hidden page is created but is never marked as used, maintainingIsUnused() == true. - The automation script or a user navigates a normal tab to an untrusted site under the control of the attacker (e.g.,
https://attacker.example). - Under process-limit constraints (such as running inside Linux containers or with
--renderer-process-limitconfigured),IsSuitableHostis evaluated for the attacker’s site. Because the hidden target’s process hasIsUnused() == true, the reuse checks inIsSuitableHostare bypassed, allowing the attacker’s site to be co-scheduled in the same renderer process as the hidden frame/BiDi-mapper. - Upon compromising this shared renderer process via a separate renderer bug, the attacker can directly construct and send a forged
Runtime.bindingCallednotification for thecdpbinding over the IPC/Mojo channel. - The browser process’s
BrowserToPageConnectorreceives this forged notification and pumps the payload into the trustedBrowserDevToolsAgentHostsession, granting the compromised renderer full browser-level privileges (such as local file access or remote code execution outside the sandbox).
(Note: These are suggested/potential steps modeled from static analysis; our tooling agent does not have the ability to run code and verify runtime execution.)
Suggested Fix
Modify headless::protocol::TargetHandler::CreateTarget in headless/lib/browser/protocol/target_handler.cc to return protocol::Response::FallThrough() when hidden is true, aligning its behavior with the Chrome desktop implementation and ensuring that content::protocol::HiddenTargetManager handles the creation and properly marks the process as used.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.