CVE-2026-10928
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
HeadlessCommandHandlerInjectionBrowserTestheadless/test/headless_command_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fheadless/test/headless_command_browsertest.cc |
modified |
Files Changed
components/headless/command_handler/headless_command_handler.ccheadless/test/headless_command_browsertest.cc
Patch
From 880e6e43cfaa956c369374c67acddb03466f2b15 Mon Sep 17 00:00:00 2001
From: Mike West <mkwst@chromium.org>
Date: Tue, 07 Apr 2026 02:18:37 -0700
Subject: [PATCH] Harden `HeadlessCommandHandler::OnDevToolsProtocolExposed`.
This CL adjusts string concatenation to reduce the risk of accidental or
malicious injection via Headless' `--dump-dom` CLI flag.
Bug: 500124367
Change-Id: I321496820e82acf2226cbaaa11897abd90238918
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7734641
Commit-Queue: Mike West <mkwst@chromium.org>
Reviewed-by: Colin Blundell <blundell@chromium.org>
Owners-Override: Mike West <mkwst@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1610626}
---
diff --git a/components/headless/command_handler/headless_command_handler.cc b/components/headless/command_handler/headless_command_handler.cc
index 698d7d5b..b199b44c 100644
--- a/components/headless/command_handler/headless_command_handler.cc
+++ b/components/headless/command_handler/headless_command_handler.cc
@@ -22,6 +22,7 @@
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/json/json_writer.h"
+#include "base/json/string_escape.h"
#include "base/logging.h"
#include "base/no_destructor.h"
#include "base/path_service.h"
@@ -372,7 +373,8 @@
commands.Set("targetUrl", target_url_.spec());
std::string json_commands = base::WriteJson(commands).value_or("");
- std::string script = "executeCommands(JSON.parse('" + json_commands + "'))";
+ std::string script = "executeCommands(JSON.parse(" +
+ base::GetQuotedJSONString(json_commands) + "))";
base::DictValue params;
params.Set("expression", script);
diff --git a/headless/test/headless_command_browsertest.cc b/headless/test/headless_command_browsertest.cc
index f50c4318..05588f6b4 100644
--- a/headless/test/headless_command_browsertest.cc
+++ b/headless/test/headless_command_browsertest.cc
@@ -12,6 +12,7 @@
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/bind.h"
+#include "base/strings/strcat.h"
#include "base/strings/stringprintf.h"
#include "base/strings/to_string.h"
#include "base/task/single_thread_task_runner.h"
@@ -651,4 +652,72 @@
#endif // #if BUILDFLAG(IS_POSIX) || BUILDFLAG(IS_WIN)
+class HeadlessCommandHandlerInjectionBrowserTest
+ : public HeadlessCommandBrowserTest {
+ public:
+ HeadlessCommandHandlerInjectionBrowserTest() = default;
+
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ HeadlessCommandBrowserTest::SetUpCommandLine(command_line);
+ command_line->AppendSwitch(switches::kDumpDom);
+ }
+
+ void SetDelimeter(char c) { delimeter_ = c; }
+
+ GURL GetTargetUrl() override {
+ // This URL contains a payload that attempts to hijack window.dumpDOM.
+ // If the injection is successful, dumpDOM will return "INJECTED".
+ // If the fix works, the payload will be safely escaped and dumpDOM
+ // will return the actual page content.
+ std::string payload =
+ "(window.dumpDOM=function(dp){return(String.fromCharCode(73,78,74,69,"
+ "67,"
+ "84,69,68))},'')";
+
+ std::string url_spec =
+ base::StrCat({embedded_test_server()->GetURL("/hello.html").spec(), "#",
+ delimeter_, "+", payload, "+", delimeter_});
+ return GURL(url_spec);
+ }
+
+ void RunInjectionTest() {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+
+ CaptureStdOut capture_stdout;
+ capture_stdout.StartCapture();
+ RunTest();
+ capture_stdout.StopCapture();
+
+ ASSERT_THAT(result(),
+ testing::Eq(HeadlessCommandHandler::Result::kSuccess));
+
+ std::string captured_stdout = capture_stdout.TakeCapturedData();
+
+ // The captured output contains the content from the page, and does not
+ // contain the injection.
+ EXPECT_THAT(captured_stdout, testing::HasSubstr("Hello headless world!"));
+ EXPECT_THAT(captured_stdout, testing::Not(testing::HasSubstr("INJECTED")));
+ }
+
+ private:
+ std::string delimeter_ = "\'";
+};
+
+IN_PROC_BROWSER_TEST_F(HeadlessCommandHandlerInjectionBrowserTest,
+ SingleQuote) {
+ SetDelimeter('\'');
+ RunInjectionTest();
+}
+
+IN_PROC_BROWSER_TEST_F(HeadlessCommandHandlerInjectionBrowserTest,
+ DoubleQuote) {
+ SetDelimeter('"');
+ RunInjectionTest();
+}
+
+IN_PROC_BROWSER_TEST_F(HeadlessCommandHandlerInjectionBrowserTest, Backtic) {
+ SetDelimeter('`');
+ RunInjectionTest();
+}
+
} // namespace headless
Regression Test / PoC
diff --git a/headless/test/headless_command_browsertest.cc b/headless/test/headless_command_browsertest.cc
index f50c4318..05588f6b4 100644
--- a/headless/test/headless_command_browsertest.cc
+++ b/headless/test/headless_command_browsertest.cc
@@ -12,6 +12,7 @@
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/bind.h"
+#include "base/strings/strcat.h"
#include "base/strings/stringprintf.h"
#include "base/strings/to_string.h"
#include "base/task/single_thread_task_runner.h"
@@ -651,4 +652,72 @@
#endif // #if BUILDFLAG(IS_POSIX) || BUILDFLAG(IS_WIN)
+class HeadlessCommandHandlerInjectionBrowserTest
+ : public HeadlessCommandBrowserTest {
+ public:
+ HeadlessCommandHandlerInjectionBrowserTest() = default;
+
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ HeadlessCommandBrowserTest::SetUpCommandLine(command_line);
+ command_line->AppendSwitch(switches::kDumpDom);
+ }
+
+ void SetDelimeter(char c) { delimeter_ = c; }
+
+ GURL GetTargetUrl() override {
+ // This URL contains a payload that attempts to hijack window.dumpDOM.
+ // If the injection is successful, dumpDOM will return "INJECTED".
+ // If the fix works, the payload will be safely escaped and dumpDOM
+ // will return the actual page content.
+ std::string payload =
+ "(window.dumpDOM=function(dp){return(String.fromCharCode(73,78,74,69,"
+ "67,"
+ "84,69,68))},'')";
+
+ std::string url_spec =
+ base::StrCat({embedded_test_server()->GetURL("/hello.html").spec(), "#",
+ delimeter_, "+", payload, "+", delimeter_});
+ return GURL(url_spec);
+ }
+
+ void RunInjectionTest() {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+
+ CaptureStdOut capture_stdout;
+ capture_stdout.StartCapture();
+ RunTest();
+ capture_stdout.StopCapture();
+
+ ASSERT_THAT(result(),
+ testing::Eq(HeadlessCommandHandler::Result::kSuccess));
+
+ std::string captured_stdout = capture_stdout.TakeCapturedData();
+
+ // The captured output contains the content from the page, and does not
+ // contain the injection.
+ EXPECT_THAT(captured_stdout, testing::HasSubstr("Hello headless world!"));
+ EXPECT_THAT(captured_stdout, testing::Not(testing::HasSubstr("INJECTED")));
+ }
+
+ private:
+ std::string delimeter_ = "\'";
+};
+
+IN_PROC_BROWSER_TEST_F(HeadlessCommandHandlerInjectionBrowserTest,
+ SingleQuote) {
+ SetDelimeter('\'');
+ RunInjectionTest();
+}
+
+IN_PROC_BROWSER_TEST_F(HeadlessCommandHandlerInjectionBrowserTest,
+ DoubleQuote) {
+ SetDelimeter('"');
+ RunInjectionTest();
+}
+
+IN_PROC_BROWSER_TEST_F(HeadlessCommandHandlerInjectionBrowserTest, Backtic) {
+ SetDelimeter('`');
+ RunInjectionTest();
+}
+
} // namespace headless
Original Bug Report
Single-quote JS injection in HeadlessCommandHandler grants browser-level CDP access
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 without the security team.
Overview: When Chrome is run in headless mode with certain flags (e.g., --dump-dom), the provided URL is concatenated into a JavaScript expression using single quotes. Because base::WriteJson does not escape single quotes and GURL preserves them in URL fragments, an attacker can inject arbitrary JavaScript. This script executes in the chrome://headless WebUI with browser-level DevTools Protocol (CDP) privileges, potentially allowing arbitrary file read/write and RCE.
Affected files:
components/headless/command_handler/headless_command_handler.cc
Estimated timestamp from git blame: 2025-09-10
Summary
A potential vulnerability exists in HeadlessCommandHandler where an attacker-supplied URL is unsafely concatenated into a JavaScript string literal evaluated in the chrome://headless/ WebUI context. Because this context is granted browser-level DevTools Protocol (CDP) access via Target.exposeDevToolsProtocol, an attacker can leverage this injection to achieve full browser compromise, including arbitrary local file read and write.
Technical Details
When Chrome is invoked in headless mode with a command-processing flag (such as --dump-dom), it routes the request to headless::ProcessHeadlessCommands (chrome/browser/headless/headless_command_processor.cc). This creates a new WebContents navigated to chrome://headless/ and instantiates a HeadlessCommandHandler.
The vulnerability occurs during the execution of the headless commands. In components/headless/command_handler/headless_command_handler.cc, the OnDevToolsProtocolExposed method builds a JavaScript expression to be executed via Runtime.evaluate:
std::string json_commands = base::WriteJson(commands).value_or("");
std::string script = "executeCommands(JSON.parse('" + json_commands + "'))";
The commands dictionary contains targetUrl, which is the attacker-supplied URL from the command line. The injection is possible due to the interaction of three factors:
- GURL Canonicalization: When parsing the command-line URL,
GURLstrictly follows RFCs and does not percent-encode single quotes (') within the URL fragment (url/url_canon_etc.cc:262). - JSON Serialization:
base::WriteJsonfollows the JSON standard and escapes double quotes ("), but it explicitly does not escape single quotes (') (base/json/string_escape.cc:83). - Unsafe Concatenation: The resulting JSON string is blindly concatenated into a single-quoted JavaScript string literal.
An attacker providing a URL like https://example.com/#'+PAYLOAD+' causes the generated expression to break out of the string literal:
executeCommands(JSON.parse('{"targetUrl":"https://example.com/#'+PAYLOAD+'"}'))
The PAYLOAD is then executed as raw JavaScript in the chrome://headless/ renderer. Crucially, because the expression is executed via the CDP command Runtime.evaluate, it bypasses the WebUI Content Security Policy (CSP) that would normally block inline scripts.
Impact and Escalation
Prior to evaluating the script, HeadlessCommandHandler calls Target.exposeDevToolsProtocol. Because the handler’s DevTools client is trusted, this successfully injects a window.cdp.send function into the chrome://headless/ page, backed by a BrowserDevToolsAgentHost.
While this initial host is created in “discovery-only” mode (which restricts domains like Browser), the injected session has AccessMode::kBrowser privileges. The attacker’s JavaScript payload can simply call Target.attachToBrowserTarget via window.cdp.send to obtain a session ID for a brand-new, unrestricted browser-level DevTools connection.
With this full connection, the attacker can:
- Read arbitrary local files by sending
Target.createTargetwith afile:///URL and evaluating scripts within it. - Write arbitrary files to the filesystem by sending
Browser.setDownloadBehaviorto override the download path, followed byPage.download.
Potential Reproduction Steps
(Note: These are suggested steps based on code analysis; we do not currently have a working proof of concept.)
- Run headless Chrome with a specially crafted URL designed to break out of the single-quoted string and invoke a CDP command. For example, to read
/etc/passwd:chrome --headless --dump-dom "https://example.com/x#'+window.cdp.send(JSON.stringify({id:1,method:'Target.createTarget',params:{url:'file:///etc/passwd'}}))+'" - To achieve file write/RCE, the payload would first call
Target.attachToBrowserTarget, extract thesessionId, and then send flattened protocol commands (e.g.,Browser.setDownloadBehaviorandPage.download) using that session ID.
Suggested Fix
Do not use string concatenation to build the JavaScript expression. Instead, pass the configuration via standard DevTools Protocol arguments or properly escape the json_commands string specifically for inclusion within a single-quoted JavaScript literal (e.g., by replacing ' with \' before concatenation).
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.