CVE-2026-87492
Overview
Background
- DevTools UI bindings
- the privileged
DevToolsUIBindingsobject that adevtools://frontendWebContentsreceives (tracked by itsfrontend_host_member), granting the frontend host access to the DevTools embedder API. - `frontend_host_`
- the member whose non-null state means active DevTools frontend bindings were installed for a
WebContents; the patched code decides whether to install it. - Live opener vs. original opener chain
WebContents::GetOpener()returns the current live opener and becomes null oncewindow.openeris severed (e.g.window.opener = nullorrel="noopener"), whereasHasLiveOriginalOpenerChain()/GetFirstWebContentsInLiveOriginalOpenerChain()walk the original opener chain that persists even after severing.- Incorrect authorization
- granting a privilege (here, DevTools frontend bindings) to a context that does not satisfy the intended precondition (a legitimate DevTools opener).
Root Cause Analysis
When a devtools:// navigation commits in a window that was opened by another window, the code (enclosing function DevToolsUIBindings::ReadyToCommitNavigation (inferred) — not named in the @@ hunk header) must verify that the opener is itself a valid DevTools frontend before installing frontend_host_. The pre-patch code consulted only the live immediate opener via content::WebContents* opener = web_contents_->GetOpener(); if that opener was null the entire validation block was skipped, and the navigation proceeded to install DevTools bindings as though it were a legitimate browser-initiated frontend. An attacker who opened the window and then severed the link (window.opener = null or rel="noopener") made GetOpener() return null, bypassing the !opener_bindings || !opener_bindings->frontend_host_ rejection check entirely.
The fix replaces the live-opener lookup with web_contents_->HasLiveOriginalOpenerChain() as the guard and web_contents_->GetFirstWebContentsInLiveOriginalOpenerChain() as the source, so the root of the original opener chain is validated even after window.opener is cleared. This works because the original opener chain records the true opener regardless of runtime severing, so the same !opener_bindings || !opener_bindings->frontend_host_ rejection now fires for the severed case, as the added OriginalOpenerWithoutDevToolsBindingsRejectedEvenIfOpenerSevered test confirms.
GetOpener()), which a page can null out, instead of the original opener chain; the fix authorizes DevTools bindings against GetFirstWebContentsInLiveOriginalOpenerChain() guarded by HasLiveOriginalOpenerChain(), closing the severed-opener bypass.Attack Path
- Open a window from attacker content
An attacker-controlled page opens a new window/tab whose navigation targets a
devtools://frontend URL such asdevtools://devtools/bundled/devtools_app.html, so the openedWebContentshas an opener that is not a DevTools frontend(inferred)— the exact web-facing trigger for reaching adevtools://navigation is not shown in the diff. - Sever the opener link
Before the navigation commits, the relationship is broken via
window.opener = nullorrel="noopener", soweb_contents_->GetOpener()returns null while the original opener chain still records the non-DevTools opener. - Bypass the opener check
In pre-patch
ReadyToCommitNavigation(inferred), theif (content::RenderFrameHost* opener = web_contents_->GetOpener())block is skipped because the live opener is null, so the!opener_bindings || !opener_bindings->frontend_host_rejection never runs. - Obtain DevTools bindings
Because the validation was skipped, execution falls through and
frontend_host_is installed for a window whose opener never had DevTools frontend bindings, granting it the privileged DevTools UI bindings. -
Escalate via the DevTools embedder API
(inferred)— With DevTools frontend bindings, the context can reach embedder capabilities exposed to legitimate frontends (e.g. DevTools protocol / local resource access); the concrete downstream capability and any RCE/sandbox impact are not demonstrated by this diff.
Impact Assessment
frontend_host_) for a window they opened, i.e. incorrect authorization in DevTools, by opening a window to a devtools:// frontend and severing window.opener before commit. The preconditions are the ability to open such a window and control over the opener relationship. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6. The advisory states only “Incorrect authorization in DevTools” and does not report exploitation in the wild or a severity rating in the provided metadata.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/devtools/devtools_ui_bindings.cc |
modified | |
Delegatechrome/browser/devtools/devtools_ui_bindings.h |
modified | |
DevToolsUIBindingsNavigationTestchrome/browser/devtools/devtools_ui_bindings_unittest.cc |
modified | |
DevToolsUIBindingsSyncInfoTestchrome/browser/devtools/devtools_ui_bindings_unittest.cc |
modified |
Files Changed
chrome/browser/devtools/devtools_ui_bindings_unittest.ccchrome/browser/devtools/devtools_ui_bindings.hchrome/browser/devtools/devtools_ui_bindings.cc
Audit Directions
- Live-opener vs. original-opener checksAudit any authorization or origin decision keyed on
GetOpener()/ livewindow.opener; a page can null it out, so security-relevant opener checks should useHasLiveOriginalOpenerChain()/GetFirstWebContentsInLiveOriginalOpenerChain(). - Skipped-branch fall-through on privilege grantsLook for guards where a null/absent precondition causes the validation block to be skipped and execution to fall through to granting a privilege (installing bindings, enabling APIs) rather than to an explicit denial default.
- `devtools://` and WebUI binding installationReview other paths that install privileged frontend/WebUI bindings for
devtools://orchrome://WebContents to confirm they validate the true opener/initiator chain and cannot be reached by attacker-opened, opener-severed windows.
Patch
From b781be4b5981905b69c69643709baa64781a6bc8 Mon Sep 17 00:00:00 2001 From: Alex Rudenko <[email protected]> Date: Wed, 02 Sep 2026 03:10:37 -0700 Subject: [PATCH] Improve origin validation in devtools_ui_bindings Fixed: 529123409 Change-Id: Ie16a65f099e66db08b5bd271f5af547e411b7cd1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8303113 Reviewed-by: Danil Somsikov <[email protected]> Commit-Queue: Alex Rudenko <[email protected]> Cr-Commit-Position: refs/heads/main@{#1690574} --- diff --git a/chrome/browser/devtools/devtools_ui_bindings.cc b/chrome/browser/devtools/devtools_ui_bindings.cc index 6a47474d..157d0cff 100644 --- a/chrome/browser/devtools/devtools_ui_bindings.cc +++ b/chrome/browser/devtools/devtools_ui_bindings.cc @@ -3211,9 +3211,13 @@ if (frontend_host_) { return; } - if (content::RenderFrameHost* opener = web_contents_->GetOpener()) { + // If the window was opened by another window, ensure the root opener in + // the live opener chain is a DevTools WebContents with active DevTools + // frontend bindings. This also covers cases where `window.opener` was + // severed (e.g. via `window.opener = null` or `rel="noopener"`). + if (web_contents_->HasLiveOriginalOpenerChain()) { content::WebContents* opener_wc = - content::WebContents::FromRenderFrameHost(opener); + web_contents_->GetFirstWebContentsInLiveOriginalOpenerChain(); DevToolsUIBindings* opener_bindings = opener_wc ? DevToolsUIBindings::ForWebContents(opener_wc) : nullptr; if (!opener_bindings || !opener_bindings->frontend_host_) { diff --git a/chrome/browser/devtools/devtools_ui_bindings.h b/chrome/browser/devtools/devtools_ui_bindings.h index 260add27..0b6b6d3 100644 --- a/chrome/browser/devtools/devtools_ui_bindings.h +++ b/chrome/browser/devtools/devtools_ui_bindings.h @@ -81,6 +81,7 @@ friend class DevToolsUIBindingsDispatchHttpRequestTest; friend class DevToolsUIBindingsDispatchHttpRequestStreamingTest; friend class DevToolsUIBindingsLoadNetworkResourceTest; + friend class DevToolsUIBindingsNavigationTest; public: class Delegate { @@ -168,6 +169,13 @@ const std::string& script) { RegisterExtensionsAPI(origin, script); } + bool has_frontend_host_for_testing() const { + return frontend_host_ != nullptr; + } + void ReadyToCommitNavigationForTesting( + content::NavigationHandle* navigation_handle) { + ReadyToCommitNavigation(navigation_handle); + } void ShowDevToolsInfoBarForTesting( const std::u16string& message, diff --git a/chrome/browser/devtools/devtools_ui_bindings_unittest.cc b/chrome/browser/devtools/devtools_ui_bindings_unittest.cc index 716d446..2949666 100644 --- a/chrome/browser/devtools/devtools_ui_bindings_unittest.cc +++ b/chrome/browser/devtools/devtools_ui_bindings_unittest.cc @@ -26,9 +26,11 @@ #include "components/sync/test/test_sync_service.h" #include "content/public/browser/web_contents.h" #include "content/public/test/browser_task_environment.h" +#include "content/public/test/mock_navigation_handle.h" #include "content/public/test/navigation_simulator.h" #include "content/public/test/test_web_contents_factory.h" #include "content/public/test/url_loader_interceptor.h" +#include "content/public/test/web_contents_tester.h" #include "google_apis/gaia/google_service_auth_error.h" #include "net/base/net_errors.h" #include "net/cookies/site_for_cookies.h" @@ -393,6 +395,109 @@ } } +class DevToolsUIBindingsNavigationTest : public testing::Test { + public: + content::WebContents* CreateWebContents() { + return web_contents_factory_.CreateWebContents(&profile_); + } + + protected: + content::BrowserTaskEnvironment task_environment_; + TestingProfile profile_; + content::TestWebContentsFactory web_contents_factory_; +}; + +TEST_F(DevToolsUIBindingsNavigationTest, + BrowserInitiatedNavigationCreatesFrontendHost) { + content::WebContents* web_contents = CreateWebContents(); + auto bindings = std::make_unique<DevToolsUIBindings>(web_contents); + EXPECT_FALSE(bindings->has_frontend_host_for_testing()); + + content::MockNavigationHandle handle( + GURL("devtools://devtools/bundled/devtools_app.html"), + web_contents->GetPrimaryMainFrame()); + handle.set_is_in_primary_main_frame(true); + handle.set_is_renderer_initiated(false); + + bindings->ReadyToCommitNavigationForTesting(&handle); + + EXPECT_TRUE(bindings->has_frontend_host_for_testing()); +} + +TEST_F(DevToolsUIBindingsNavigationTest, + OpenerWithoutDevToolsBindingsRejected) { + content::WebContents* opener_contents = CreateWebContents(); + content::WebContents* web_contents = CreateWebContents(); + content::WebContentsTester::For(web_contents)->SetOpener(opener_contents); + content::WebContentsTester::For(web_contents) + ->SetOriginalOpener(opener_contents); + + auto bindings = std::make_unique<DevToolsUIBindings>(web_contents); + + content::MockNavigationHandle handle( + GURL("devtools://devtools/bundled/devtools_app.html"), + web_contents->GetPrimaryMainFrame()); + handle.set_is_in_primary_main_frame(true); + handle.set_is_renderer_initiated(false); + + bindings->ReadyToCommitNavigationForTesting(&handle); + + EXPECT_FALSE(bindings->has_frontend_host_for_testing()); +} + +TEST_F(DevToolsUIBindingsNavigationTest, + OriginalOpenerWithoutDevToolsBindingsRejectedEvenIfOpenerSevered) { + content::WebContents* original_opener_contents = CreateWebContents(); + content::WebContents* web_contents = CreateWebContents(); + content::WebContentsTester::For(web_contents) + ->SetOriginalOpener(original_opener_contents); + // Ensure the live opener is null (simulating `window.opener = null`). + EXPECT_EQ(nullptr, web_contents->GetOpener()); + EXPECT_TRUE(web_contents->HasLiveOriginalOpenerChain()); + + auto bindings = std::make_unique<DevToolsUIBindings>(web_contents); + + content::MockNavigationHandle handle( + GURL("devtools://devtools/bundled/devtools_app.html"), + web_contents->GetPrimaryMainFrame()); + handle.set_is_in_primary_main_frame(true); + handle.set_is_renderer_initiated(false); + + bindings->ReadyToCommitNavigationForTesting(&handle); + + EXPECT_FALSE(bindings->has_frontend_host_for_testing()); +} + +TEST_F(DevToolsUIBindingsNavigationTest, + OpenerWithValidDevToolsBindingsAccepted) { + content::WebContents* opener_contents = CreateWebContents(); + auto opener_bindings = std::make_unique<DevToolsUIBindings>(opener_contents); + content::MockNavigationHandle opener_handle( + GURL("devtools://devtools/bundled/devtools_app.html"), + opener_contents->GetPrimaryMainFrame()); + opener_handle.set_is_in_primary_main_frame(true); + opener_handle.set_is_renderer_initiated(false); + opener_bindings->ReadyToCommitNavigationForTesting(&opener_handle); + ASSERT_TRUE(opener_bindings->has_frontend_host_for_testing()); + + content::WebContents* web_contents = CreateWebContents(); + content::WebContentsTester::For(web_contents)->SetOpener(opener_contents); + content::WebContentsTester::For(web_contents) + ->SetOriginalOpener(opener_contents); + + auto bindings = std::make_unique<DevToolsUIBindings>(web_contents); + + content::MockNavigationHandle handle( + GURL("devtools://devtools/bundled/devtools_app.html"), + web_contents->GetPrimaryMainFrame()); + handle.set_is_in_primary_main_frame(true); + handle.set_is_renderer_initiated(false); + + bindings->ReadyToCommitNavigationForTesting(&handle); + + EXPECT_TRUE(bindings->has_frontend_host_for_testing()); +} + class DevToolsUIBindingsSyncInfoTest : public testing::Test { public: void SetUp() override {
Regression Test / PoC
diff --git a/chrome/browser/devtools/devtools_ui_bindings_unittest.cc b/chrome/browser/devtools/devtools_ui_bindings_unittest.cc
index 716d446..2949666 100644
--- a/chrome/browser/devtools/devtools_ui_bindings_unittest.cc
+++ b/chrome/browser/devtools/devtools_ui_bindings_unittest.cc
@@ -26,9 +26,11 @@
#include "components/sync/test/test_sync_service.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_task_environment.h"
+#include "content/public/test/mock_navigation_handle.h"
#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_web_contents_factory.h"
#include "content/public/test/url_loader_interceptor.h"
+#include "content/public/test/web_contents_tester.h"
#include "google_apis/gaia/google_service_auth_error.h"
#include "net/base/net_errors.h"
#include "net/cookies/site_for_cookies.h"
@@ -393,6 +395,109 @@
}
}
+class DevToolsUIBindingsNavigationTest : public testing::Test {
+ public:
+ content::WebContents* CreateWebContents() {
+ return web_contents_factory_.CreateWebContents(&profile_);
+ }
+
+ protected:
+ content::BrowserTaskEnvironment task_environment_;
+ TestingProfile profile_;
+ content::TestWebContentsFactory web_contents_factory_;
+};
+
+TEST_F(DevToolsUIBindingsNavigationTest,
+ BrowserInitiatedNavigationCreatesFrontendHost) {
+ content::WebContents* web_contents = CreateWebContents();
+ auto bindings = std::make_unique<DevToolsUIBindings>(web_contents);
+ EXPECT_FALSE(bindings->has_frontend_host_for_testing());
+
+ content::MockNavigationHandle handle(
+ GURL("devtools://devtools/bundled/devtools_app.html"),
+ web_contents->GetPrimaryMainFrame());
+ handle.set_is_in_primary_main_frame(true);
+ handle.set_is_renderer_initiated(false);
+
+ bindings->ReadyToCommitNavigationForTesting(&handle);
+
+ EXPECT_TRUE(bindings->has_frontend_host_for_testing());
+}
+
+TEST_F(DevToolsUIBindingsNavigationTest,
+ OpenerWithoutDevToolsBindingsRejected) {
+ content::WebContents* opener_contents = CreateWebContents();
+ content::WebContents* web_contents = CreateWebContents();
+ content::WebContentsTester::For(web_contents)->SetOpener(opener_contents);
+ content::WebContentsTester::For(web_contents)
+ ->SetOriginalOpener(opener_contents);
+
+ auto bindings = std::make_unique<DevToolsUIBindings>(web_contents);
+
+ content::MockNavigationHandle handle(
+ GURL("devtools://devtools/bundled/devtools_app.html"),
+ web_contents->GetPrimaryMainFrame());
+ handle.set_is_in_primary_main_frame(true);
+ handle.set_is_renderer_initiated(false);
+
+ bindings->ReadyToCommitNavigationForTesting(&handle);
+
+ EXPECT_FALSE(bindings->has_frontend_host_for_testing());
+}
+
+TEST_F(DevToolsUIBindingsNavigationTest,
+ OriginalOpenerWithoutDevToolsBindingsRejectedEvenIfOpenerSevered) {
+ content::WebContents* original_opener_contents = CreateWebContents();
+ content::WebContents* web_contents = CreateWebContents();
+ content::WebContentsTester::For(web_contents)
+ ->SetOriginalOpener(original_opener_contents);
+ // Ensure the live opener is null (simulating `window.opener = null`).
+ EXPECT_EQ(nullptr, web_contents->GetOpener());
+ EXPECT_TRUE(web_contents->HasLiveOriginalOpenerChain());
+
+ auto bindings = std::make_unique<DevToolsUIBindings>(web_contents);
+
+ content::MockNavigationHandle handle(
+ GURL("devtools://devtools/bundled/devtools_app.html"),
+ web_contents->GetPrimaryMainFrame());
+ handle.set_is_in_primary_main_frame(true);
+ handle.set_is_renderer_initiated(false);
+
+ bindings->ReadyToCommitNavigationForTesting(&handle);
+
+ EXPECT_FALSE(bindings->has_frontend_host_for_testing());
+}
+
+TEST_F(DevToolsUIBindingsNavigationTest,
+ OpenerWithValidDevToolsBindingsAccepted) {
+ content::WebContents* opener_contents = CreateWebContents();
+ auto opener_bindings = std::make_unique<DevToolsUIBindings>(opener_contents);
+ content::MockNavigationHandle opener_handle(
+ GURL("devtools://devtools/bundled/devtools_app.html"),
+ opener_contents->GetPrimaryMainFrame());
+ opener_handle.set_is_in_primary_main_frame(true);
+ opener_handle.set_is_renderer_initiated(false);
+ opener_bindings->ReadyToCommitNavigationForTesting(&opener_handle);
+ ASSERT_TRUE(opener_bindings->has_frontend_host_for_testing());
+
+ content::WebContents* web_contents = CreateWebContents();
+ content::WebContentsTester::For(web_contents)->SetOpener(opener_contents);
+ content::WebContentsTester::For(web_contents)
+ ->SetOriginalOpener(opener_contents);
+
+ auto bindings = std::make_unique<DevToolsUIBindings>(web_contents);
+
+ content::MockNavigationHandle handle(
+ GURL("devtools://devtools/bundled/devtools_app.html"),
+ web_contents->GetPrimaryMainFrame());
+ handle.set_is_in_primary_main_frame(true);
+ handle.set_is_renderer_initiated(false);
+
+ bindings->ReadyToCommitNavigationForTesting(&handle);
+
+ EXPECT_TRUE(bindings->has_frontend_host_for_testing());
+}
+
class DevToolsUIBindingsSyncInfoTest : public testing::Test {
public:
void SetUp() override {