CVE-2026-11250
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/devtools/render_frame_devtools_agent_host.cc |
modified | |
RenderFrameDevToolsAgentHostRawHeadersBrowserTestcontent/browser/devtools/render_frame_devtools_agent_host_browsertest.cc |
modified | |
RenderFrameDevToolsAgentHostRawHeadersBrowserTestcontent/browser/devtools/render_frame_devtools_agent_host_browsertest.cc |
modified |
Files Changed
content/browser/devtools/render_frame_devtools_agent_host.cccontent/browser/devtools/render_frame_devtools_agent_host.hcontent/browser/devtools/render_frame_devtools_agent_host_browsertest.cc
Patch
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(
Regression Test / PoC
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 {
Original Bug Report
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.ccservices/network/websocket.ccservices/network/network_service.cccontent/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
-
The Logic Flaw: In
content/browser/devtools/render_frame_devtools_agent_host.cc, the methodUpdateRawHeadersAccessupdates the list of origins with raw header access. It iterates over all activeDevToolsAgentHostinstances 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::DetachInternalremoves the session from its internal list before calling the virtualDetachSessionmethod. Thus, whenUpdateRawHeadersAccessruns for the detaching host:entry.second->IsAttached()evaluates tofalse.entry.first != rfh->frame_tree_node()evaluates tofalse(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_originsset and sent to the Network Service.
-
Persistence in Network Service:
NetworkService::SetRawHeadersAccessreceives the Mojo call. Because the origin list is not empty, it updatesraw_headers_access_origins_by_pid_with the stale origin instead of erasing the entry. -
WebSocket Leak: When a WebSocket connection is created,
WebSocketFactory::CreateWebSocketchecksHasRawHeadersAccess. Because of the stale grant, this returnstrue, and theWebSocketis instantiated with thehas_raw_headers_access_flag set totrue. During the handshake response processing inservices/network/websocket.cc, the functionToMojochecks this flag:if (has_raw_headers_access || !net::HttpResponseHeaders::IsCookieResponseHeader(name)) { // ... include header }The
HttpOnlySet-Cookieheader bypasses the filter and is sent directly to the renderer process via themojom::WebSocketHandshakeClientinterface.
Potential Attack Steps
Note: These are suggested steps; our tooling agent does not currently have the ability to run a live proof of concept.
- An attacker compromises a renderer process (e.g., via a v8 vulnerability) and gains arbitrary code execution within the sandbox.
- The user opens and subsequently closes Chrome DevTools for a tab hosted in the compromised renderer.
- Upon DevTools closure, the logic bug leaves a stale raw headers grant in the Network Service.
- The compromised renderer manually constructs a Mojo call to
blink::mojom::WebSocketConnector::Connectfor the affected origin, passing a maliciousWebSocketHandshakeClient. - The Network Service establishes the WebSocket connection and sends the handshake response back to the attacker’s client.
- The attacker parses the
mojom::WebSocketHandshakeResponsePtrto extract theHttpOnlySet-Cookieheaders.
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.