Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement Headless
DescriptionInsufficient policy enforcement Headless
ComponentChromium
Bug ClassLogic Error
Tracker517517155
Fix commit1001ffc71823 (chromium/src) +62/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-11

Changed Functions

FunctionChangeNotes
HeadlessCreatedTargetIsUsedProcessTest
headless/test/headless_devtooled_browsertest.cc
modified

Files Changed

  • headless/lib/browser/protocol/target_handler.cc
  • headless/test/headless_devtooled_browsertest.cc
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • headless/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

  1. A trusted client (e.g., WebDriver-BiDi automation) initiates a hidden target page via Target.createTarget({url: "about:blank", hidden: true}) followed by Target.exposeDevToolsProtocol with binding_name = "cdp" to expose browser-level DevTools bindings on the page.
  2. The backing RenderProcessHost for the hidden page is created but is never marked as used, maintaining IsUnused() == true.
  3. 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).
  4. Under process-limit constraints (such as running inside Linux containers or with --renderer-process-limit configured), IsSuitableHost is evaluated for the attacker’s site. Because the hidden target’s process has IsUnused() == true, the reuse checks in IsSuitableHost are bypassed, allowing the attacker’s site to be co-scheduled in the same renderer process as the hidden frame/BiDi-mapper.
  5. Upon compromising this shared renderer process via a separate renderer bug, the attacker can directly construct and send a forged Runtime.bindingCalled notification for the cdp binding over the IPC/Mojo channel.
  6. The browser process’s BrowserToPageConnector receives this forged notification and pumps the payload into the trusted BrowserDevToolsAgentHost session, 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.

View on issue tracker