Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in DevTools
DescriptionInappropriate implementation in DevTools
ComponentDevTools
Bug ClassLogic Error
Tracker498281224
Fix commitb1cb06eaacb9 (chromium/src) +117/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
content/browser/devtools/render_frame_devtools_agent_host.cc
modified
RenderFrameDevToolsAgentHostRawHeadersBrowserTest
content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
modified
RenderFrameDevToolsAgentHostRawHeadersBrowserTest
content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
modified

Files Changed

  • content/browser/devtools/render_frame_devtools_agent_host.cc
  • content/browser/devtools/render_frame_devtools_agent_host.h
  • content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
From b1cb06eaacb982061f1e039f1ea16347af69050c Mon Sep 17 00:00:00 2001
From: Danil Somsikov <[email protected]>
Date: Wed, 29 Apr 2026 07:48:20 -0700
Subject: [PATCH] Fix raw headers access leak in DevTools.

RenderFrameDevToolsAgentHost::UpdateRawHeadersAccess had a logic flaw where it relied on IsAttached() to determine if an agent's origin should be granted raw header access.

During detachment (DevToolsAgentHostImpl::DetachInternal), the session is removed from the agent host *before* DetachSession is called. This caused IsAttached() to return false prematurely in UpdateRawHeadersAccess, leading the logic to incorrectly include the detaching host's origin in the grant list sent to the Network Service. This resulted in stale grants that could leak HttpOnly cookies (e.g., via WebSockets) even after DevTools was closed.

Bug: 498281224

Fixed: 498281224
Change-Id: I3243b8729301b8168cbd95c3d8aa3102a33ed2ec
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7722581
Reviewed-by: mmenke <[email protected]>
Reviewed-by: Mike West <[email protected]>
Reviewed-by: Andrey Kosyakov <[email protected]>
Auto-Submit: Danil Somsikov <[email protected]>
Commit-Queue: Mike West <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1622449}
---

diff --git a/content/browser/devtools/render_frame_devtools_agent_host.cc b/content/browser/devtools/render_frame_devtools_agent_host.cc
index f3324b7f..0d546e9 100644
--- a/content/browser/devtools/render_frame_devtools_agent_host.cc
+++ b/content/browser/devtools/render_frame_devtools_agent_host.cc
@@ -264,7 +264,8 @@
 
 // static
 void RenderFrameDevToolsAgentHost::UpdateRawHeadersAccess(
-    RenderFrameHostImpl* rfh) {
+    RenderFrameHostImpl* rfh,
+    RenderFrameDevToolsAgentHost* force_include_host) {
   if (!rfh) {
     return;
   }
@@ -272,10 +273,10 @@
   std::set<url::Origin> process_origins;
   for (const auto& entry : GetAgentHostInstances()) {
     RenderFrameHostImpl* frame_host = entry.second->frame_host_;
-    if (!frame_host)
+    if (!frame_host) {
       continue;
-    // Do not skip the nodes if they're about to get attached.
-    if (!entry.second->IsAttached() && entry.first != rfh->frame_tree_node()) {
+    }
+    if (!entry.second->IsAttached() && entry.second != force_include_host) {
       continue;
     }
     RenderProcessHost* process_host = frame_host->GetProcess();
@@ -438,7 +439,7 @@
 #endif  // !BUILDFLAG(IS_ANDROID)
 
   if (sessions().empty()) {
-    UpdateRawHeadersAccess(frame_host_);
+    UpdateRawHeadersAccess(frame_host_, this);
 #if BUILDFLAG(IS_ANDROID)
     GetWakeLock()->RequestWakeLock();
 #endif
@@ -449,7 +450,7 @@
 void RenderFrameDevToolsAgentHost::DetachSession(DevToolsSession* session) {
   // Destroying session automatically detaches in renderer.
   if (sessions().empty()) {
-    UpdateRawHeadersAccess(frame_host_);
+    UpdateRawHeadersAccess(frame_host_, nullptr);
 #if BUILDFLAG(IS_ANDROID)
     GetWakeLock()->CancelWakeLock();
 #endif
@@ -528,7 +529,7 @@
       NotifyNavigated();
 
     if (IsAttached()) {
-      UpdateRawHeadersAccess(frame_tree_node_->current_frame_host());
+      UpdateRawHeadersAccess(frame_tree_node_->current_frame_host(), nullptr);
     }
 
     // Same-document navigations don't get a new RFH, so there isn't really
@@ -567,7 +568,7 @@
   RenderFrameHostImpl* old_host = frame_host_;
   ChangeFrameHostAndObservedProcess(frame_host);
   if (IsAttached())
-    UpdateRawHeadersAccess(old_host);
+    UpdateRawHeadersAccess(old_host, nullptr);
 
   std::vector<DevToolsSession*> restricted_sessions;
   for (DevToolsSession* session : sessions()) {
@@ -638,7 +639,7 @@
   scoped_refptr<DevToolsAgentHost> retain_this;
   if (IsAttached()) {
     retain_this = ForceDetachAllSessionsImpl();
-    UpdateRawHeadersAccess(frame_host_);
+    UpdateRawHeadersAccess(frame_host_, nullptr);
   }
   WebContentsObserver::Observe(nullptr);
   ChangeFrameHostAndObservedProcess(nullptr);
diff --git a/content/browser/devtools/render_frame_devtools_agent_host.h b/content/browser/devtools/render_frame_devtools_agent_host.h
index e9876b5c..8750034ee 100644
--- a/content/browser/devtools/render_frame_devtools_agent_host.h
+++ b/content/browser/devtools/render_frame_devtools_agent_host.h
@@ -136,7 +136,9 @@
   friend class DevToolsAgentHost;
   friend class RenderFrameDevToolsAgentHostFencedFrameBrowserTest;
 
-  static void UpdateRawHeadersAccess(RenderFrameHostImpl* rfh);
+  static void UpdateRawHeadersAccess(
+      RenderFrameHostImpl* rfh,
+      RenderFrameDevToolsAgentHost* force_include_host);
 
   RenderFrameDevToolsAgentHost(FrameTreeNode*, RenderFrameHostImpl*);
   ~RenderFrameDevToolsAgentHost() override;
diff --git a/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc b/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
index a631227..052c185 100644
--- a/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
+++ b/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
@@ -7,12 +7,16 @@
 
 #include <string_view>
 
+#include "base/run_loop.h"
+#include "base/test/bind.h"
 #include "build/build_config.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/common/content_navigation_policy.h"
 #include "content/public/browser/devtools_agent_host.h"
 #include "content/public/browser/devtools_agent_host_client.h"
+#include "content/public/browser/network_service_instance.h"
+#include "content/public/browser/network_service_util.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/common/result_codes.h"
@@ -27,6 +31,7 @@
 #include "content/shell/browser/shell.h"
 #include "net/dns/mock_host_resolver.h"
 #include "net/test/embedded_test_server/controllable_http_response.h"
+#include "services/network/public/mojom/network_service_test.mojom.h"
 
 namespace content {
 
@@ -305,4 +310,86 @@
   EXPECT_TRUE(IsCrashed(ff_rfh_devtools_agent));
 }
 
+class RenderFrameDevToolsAgentHostRawHeadersBrowserTest
+    : public RenderFrameDevToolsAgentHostBrowserTest {
+ public:
+  RenderFrameDevToolsAgentHostRawHeadersBrowserTest() {
+#if BUILDFLAG(IS_ANDROID)
+    // Network service is in-process by default on Android. Force it
+    // out-of-process so we can use the BindTestInterfaceForTesting which
+    // requires a registry that is only present in the utility process or when
+    // forced OOP.
+    ForceOutOfProcessNetworkService();
+#endif
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(RenderFrameDevToolsAgentHostRawHeadersBrowserTest,
+                       RawHeadersAccess) {
+  EXPECT_TRUE(embedded_test_server()->Start());
+
+  // 1) Loads a document.
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  WebContentsImpl* web_contents_impl =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* rfh = web_contents_impl->GetPrimaryMainFrame();
+  uint32_t process_id = rfh->GetProcess()->GetID().GetUnsafeValue();
+
+  mojo::Remote<network::mojom::NetworkServiceTest>& network_service_test_remote =
+      network_service_test();
+  if (!network_service_test_remote.is_bound()) {
+    GetNetworkService()->BindTestInterfaceForTesting(
+        network_service_test_remote.BindNewPipeAndPassReceiver());
+  }
+
+  // 2) Verify raw headers access is NOT granted initially.
+  {
+    base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
+    bool has_access = true;
+    network_service_test_remote->HasRawHeadersAccess(
+        process_id, url_a, base::BindLambdaForTesting([&](bool result) {
+          has_access = result;
+          run_loop.Quit();
+        }));
+    run_loop.Run();
+    EXPECT_FALSE(has_access);
+  }
+
+  // 3) Attach DevTools.
+  StubDevToolsAgentHostClient client;
+  scoped_refptr<DevToolsAgentHost> devtools_agent =
+      DevToolsAgentHost::GetOrCreateFor(web_contents_impl);
+  devtools_agent->AttachClient(&client);
+
+  // 4) Verify raw headers access IS granted.
+  {
+    base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
+    bool has_access = false;
+    network_service_test_remote->HasRawHeadersAccess(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc b/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
index a631227..052c185 100644
--- a/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
+++ b/content/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
@@ -7,12 +7,16 @@
 
 #include <string_view>
 
+#include "base/run_loop.h"
+#include "base/test/bind.h"
 #include "build/build_config.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/common/content_navigation_policy.h"
 #include "content/public/browser/devtools_agent_host.h"
 #include "content/public/browser/devtools_agent_host_client.h"
+#include "content/public/browser/network_service_instance.h"
+#include "content/public/browser/network_service_util.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/common/result_codes.h"
@@ -27,6 +31,7 @@
 #include "content/shell/browser/shell.h"
 #include "net/dns/mock_host_resolver.h"
 #include "net/test/embedded_test_server/controllable_http_response.h"
+#include "services/network/public/mojom/network_service_test.mojom.h"
 
 namespace content {
 
@@ -305,4 +310,86 @@
   EXPECT_TRUE(IsCrashed(ff_rfh_devtools_agent));
 }
 
+class RenderFrameDevToolsAgentHostRawHeadersBrowserTest
+    : public RenderFrameDevToolsAgentHostBrowserTest {
+ public:
+  RenderFrameDevToolsAgentHostRawHeadersBrowserTest() {
+#if BUILDFLAG(IS_ANDROID)
+    // Network service is in-process by default on Android. Force it
+    // out-of-process so we can use the BindTestInterfaceForTesting which
+    // requires a registry that is only present in the utility process or when
+    // forced OOP.
+    ForceOutOfProcessNetworkService();
+#endif
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(RenderFrameDevToolsAgentHostRawHeadersBrowserTest,
+                       RawHeadersAccess) {
+  EXPECT_TRUE(embedded_test_server()->Start());
+
+  // 1) Loads a document.
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  WebContentsImpl* web_contents_impl =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* rfh = web_contents_impl->GetPrimaryMainFrame();
+  uint32_t process_id = rfh->GetProcess()->GetID().GetUnsafeValue();
+
+  mojo::Remote<network::mojom::NetworkServiceTest>& network_service_test_remote =
+      network_service_test();
+  if (!network_service_test_remote.is_bound()) {
+    GetNetworkService()->BindTestInterfaceForTesting(
+        network_service_test_remote.BindNewPipeAndPassReceiver());
+  }
+
+  // 2) Verify raw headers access is NOT granted initially.
+  {
+    base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
+    bool has_access = true;
+    network_service_test_remote->HasRawHeadersAccess(
+        process_id, url_a, base::BindLambdaForTesting([&](bool result) {
+          has_access = result;
+          run_loop.Quit();
+        }));
+    run_loop.Run();
+    EXPECT_FALSE(has_access);
+  }
+
+  // 3) Attach DevTools.
+  StubDevToolsAgentHostClient client;
+  scoped_refptr<DevToolsAgentHost> devtools_agent =
+      DevToolsAgentHost::GetOrCreateFor(web_contents_impl);
+  devtools_agent->AttachClient(&client);
+
+  // 4) Verify raw headers access IS granted.
+  {
+    base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
+    bool has_access = false;
+    network_service_test_remote->HasRawHeadersAccess(
+        process_id, url_a, base::BindLambdaForTesting([&](bool result) {
+          has_access = result;
+          run_loop.Quit();
+        }));
+    run_loop.Run();
+    EXPECT_TRUE(has_access);
+  }
+
+  // 5) Detach DevTools.
+  devtools_agent->DetachClient(&client);
+
+  // 6) Verify raw headers access is NOT granted.
+  {
+    base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
+    bool has_access = true;
+    network_service_test_remote->HasRawHeadersAccess(
+        process_id, url_a, base::BindLambdaForTesting([&](bool result) {
+          has_access = result;
+          run_loop.Quit();
+        }));
+    run_loop.Run();
+    EXPECT_FALSE(has_access);
+  }
+}
+
 }  // namespace content
diff --git a/content/public/test/network_service_test_helper.cc b/content/public/test/network_service_test_helper.cc
index 971d0b2..afe41efe 100644
--- a/content/public/test/network_service_test_helper.cc
+++ b/content/public/test/network_service_test_helper.cc
@@ -59,6 +59,7 @@
 #include "services/network/network_service.h"
 #include "services/network/public/cpp/features.h"
 #include "services/network/public/cpp/network_service_buildflags.h"
+#include "services/network/public/cpp/originating_process_id.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/network_change_manager.mojom.h"
 #include "services/network/public/mojom/network_service.mojom.h"
@@ -815,6 +816,16 @@
   }
 #endif
 
+  void HasRawHeadersAccess(uint32_t process_id,
+                           const GURL& url,
+                           HasRawHeadersAccessCallback callback) override {
+    std::move(callback).Run(
+        network::NetworkService::GetNetworkServiceForTesting()
+            ->HasRawHeadersAccess(
+                network::OriginatingProcessId::FromUnsafeValue(process_id),
+                url));
+  }
+
  private:
   void OnMemoryPressure(
       base::MemoryPressureLevel memory_pressure_level) override {
Loading diff…

Original Bug Report

reported by [email protected]

Stale DevTools raw headers grant leaks HttpOnly cookies via WebSocket

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

Overview: A logic error in RenderFrameDevToolsAgentHost::UpdateRawHeadersAccess causes a stale raw headers grant to persist in the Network Service after DevTools is closed. A compromised renderer can exploit this stale grant by initiating a WebSocket connection, allowing it to read HttpOnly Set-Cookie headers. This bypasses the Network Service cookie isolation boundary.

Affected files:

  • content/browser/devtools/render_frame_devtools_agent_host.cc
  • services/network/websocket.cc
  • services/network/network_service.cc
  • content/browser/devtools/devtools_agent_host_impl.cc

Estimated timestamp from git blame: 2021-02-04

Summary

There is a potential defense-in-depth bypass where a compromised renderer process can leak HttpOnly cookies. This occurs due to a logic flaw in the DevTools detachment path, which fails to revoke raw header access for the renderer process in the Network Service. The compromised renderer can then use this stale permission to initiate a WebSocket connection and read Set-Cookie headers from the handshake response, which are normally stripped by the Network Service.

Technical Details

  1. The Logic Flaw: In content/browser/devtools/render_frame_devtools_agent_host.cc, the method UpdateRawHeadersAccess updates the list of origins with raw header access. It iterates over all active DevToolsAgentHost instances and uses the following condition to skip inactive ones:

    // Do not skip the nodes if they're about to get attached.
    if (!entry.second->IsAttached() && entry.first != rfh->frame_tree_node()) {
      continue;
    }
    

    During the detach path, DevToolsAgentHostImpl::DetachInternal removes the session from its internal list before calling the virtual DetachSession method. Thus, when UpdateRawHeadersAccess runs for the detaching host:

    • entry.second->IsAttached() evaluates to false.
    • entry.first != rfh->frame_tree_node() evaluates to false (because it’s the same frame).
    • The combined condition is false, meaning the detaching host is not skipped.
    • Its origin is incorrectly added to the process_origins set and sent to the Network Service.
  2. Persistence in Network Service: NetworkService::SetRawHeadersAccess receives the Mojo call. Because the origin list is not empty, it updates raw_headers_access_origins_by_pid_ with the stale origin instead of erasing the entry.

  3. WebSocket Leak: When a WebSocket connection is created, WebSocketFactory::CreateWebSocket checks HasRawHeadersAccess. Because of the stale grant, this returns true, and the WebSocket is instantiated with the has_raw_headers_access_ flag set to true. During the handshake response processing in services/network/websocket.cc, the function ToMojo checks this flag:

    if (has_raw_headers_access || !net::HttpResponseHeaders::IsCookieResponseHeader(name)) {
        // ... include header
    }
    

    The HttpOnly Set-Cookie header bypasses the filter and is sent directly to the renderer process via the mojom::WebSocketHandshakeClient interface.

Potential Attack Steps

Note: These are suggested steps; our tooling agent does not currently have the ability to run a live proof of concept.

  1. An attacker compromises a renderer process (e.g., via a v8 vulnerability) and gains arbitrary code execution within the sandbox.
  2. The user opens and subsequently closes Chrome DevTools for a tab hosted in the compromised renderer.
  3. Upon DevTools closure, the logic bug leaves a stale raw headers grant in the Network Service.
  4. The compromised renderer manually constructs a Mojo call to blink::mojom::WebSocketConnector::Connect for the affected origin, passing a malicious WebSocketHandshakeClient.
  5. The Network Service establishes the WebSocket connection and sends the handshake response back to the attacker’s client.
  6. The attacker parses the mojom::WebSocketHandshakeResponsePtr to extract the HttpOnly Set-Cookie headers.

Suggested Fix

Refactor the skip condition in RenderFrameDevToolsAgentHost::UpdateRawHeadersAccess to correctly handle the detachment phase. Specifically, it must differentiate between a host that is about to attach (where sessions_ might be empty but attachment is pending) and a host that has just detached (where sessions_ is empty and access should be revoked). Alternatively, check the DevToolsAgentHost instances directly against the one currently being detached.

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