CVE-2026-113216
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/web_contents/web_contents_impl.cc |
modified | |
ifcontent/browser/web_contents/web_contents_impl.cc |
modified | |
PopupLifecycleTestInterceptorcontent/browser/renderer_host/render_widget_host_browsertest.cc |
modified | |
defer_show_popup_content/browser/renderer_host/render_widget_host_browsertest.cc |
modified | |
ifcontent/browser/renderer_host/render_widget_host_browsertest.cc |
modified | |
ASSERT_TRUEcontent/browser/renderer_host/render_widget_host_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_widget_host_browsertest.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/web_contents/web_contents_impl.h
Patch
From 4f44867a964f834cf39a39ee160d99a0e72a0766 Mon Sep 17 00:00:00 2001 From: Alex Moshchuk <[email protected]> Date: Fri, 18 Sep 2026 08:40:02 -0700 Subject: [PATCH] Close and reject popup widgets when creator RenderFrameHost is inactive Currently, RenderFrameHostImpl::CreateNewPopupWidget verifies that the creator frame is active at widget creation time, creating a self-owned RenderWidgetHostImpl stored in WebContentsImpl::pending_widgets_ and created_widgets_. However, WebContentsImpl::ShowCreatedWidget does not re-verify the creator frame's lifecycle state when showing the widget, nor does WebContentsImpl clean up popup widgets when their creator frame leaves LifecycleState::kActive (e.g., entering BackForwardCache on navigation) or is deleted. A compromised renderer could defer ShowPopup or suppress RequestClosePopup across a cross-origin navigation to show spoofed popup UI over another site. This CL fixes the issue by: 1. Adding a check in WebContentsImpl::ShowCreatedWidget to verify that the popup widget's creator RenderFrameHost exists, is active, and belongs to the current WebContents. Otherwise, the widget is immediately destroyed via ShutdownAndDestroyWidget(true). 2. Removing the DCHECK(false) in WebContentsImpl::GetCreatedWidget when a routing ID is not found in pending_widgets_, allowing late or duplicate ShowPopup requests after cleanup to fail gracefully. 3. Adding WebContentsImpl::ClosePopupWidgetsForFrame to destroy any pending or shown popup widgets created by a frame when it leaves LifecycleState::kActive (in RenderFrameHostStateChanged) or is deleted (in RenderFrameDeleted). TAG=agy CONV=baaf02fe-2569-434c-a573-4803011aef64 Bug: 556899544 Change-Id: I4acababf68adda964184afcba24b1922921a02c0 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8424342 Reviewed-by: Rakina Zata Amni <[email protected]> Commit-Queue: Alex Moshchuk <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700803} --- diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc index 0689e63e..b2ec492 100644 --- a/content/browser/web_contents/web_contents_impl.cc +++ b/content/browser/web_contents/web_contents_impl.cc @@ -6117,13 +6117,28 @@ return; } + RenderWidgetHostImpl* render_widget_host_impl = widget_host_view->host(); + + // Do not show a popup widget whose creator document is no longer active + // (e.g., pending deletion, in BFCache, prerendering, or already destroyed). + RenderFrameHostImpl* creator_rfh = + render_widget_host_impl->GetPopupCreatorFrameId().has_value() + ? RenderFrameHostImpl::FromID( + *render_widget_host_impl->GetPopupCreatorFrameId()) + : nullptr; + if (!creator_rfh || !creator_rfh->IsActive() || + WebContents::FromRenderFrameHost(creator_rfh) != this) { + render_widget_host_impl->ShutdownAndDestroyWidget(true); + return; + } + RenderWidgetHostImpl* rwh = GetPrimaryMainFrame()->GetRenderWidgetHost(); if (base::FeatureList::IsEnabled( blink::features::kBlockSelectPopupUnfocusedWindow) && !rwh->is_active()) { // If the OS window isn't focused, then don't open select element popups for // it: https://issues.chromium.org/issues/365089001 - widget_host_view->host()->ShutdownAndDestroyWidget(true); + render_widget_host_impl->ShutdownAndDestroyWidget(true); return; } @@ -6169,8 +6184,6 @@ transformed_rect = ConstrainPopupBounds(transformed_rect); - RenderWidgetHostImpl* render_widget_host_impl = widget_host_view->host(); - // A background tab cannot show a popup over the active tab. if (GetVisibility() != Visibility::VISIBLE) { render_widget_host_impl->ShutdownAndDestroyWidget(true); @@ -6239,7 +6252,6 @@ auto iter = pending_widgets_.find(GlobalRoutingID(process_id, route_id)); if (iter == pending_widgets_.end()) { - DCHECK(false); return nullptr; } @@ -6255,6 +6267,22 @@ return widget_host->GetView(); } +void WebContentsImpl::ClosePopupWidgetsForFrame( + RenderFrameHost* render_frame_host) { + GlobalRenderFrameHostId creator_frame_id = render_frame_host->GetGlobalId(); + std::vector<base::WeakPtr<RenderWidgetHostImpl>> widgets_to_destroy; + for (const auto& [_, host] : created_widgets_) { + if (host->GetPopupCreatorFrameId() == creator_frame_id) { + widgets_to_destroy.push_back(host->GetWeakPtr()); + } + } + for (base::WeakPtr<RenderWidgetHostImpl>& widget : widgets_to_destroy) { + if (widget) { + widget->ShutdownAndDestroyWidget(true); + } + } +} + void WebContentsImpl::CreateMediaPlayerHostForRenderFrameHost( RenderFrameHostImpl* frame_host, mojo::PendingAssociatedReceiver<media::mojom::MediaPlayerHost> receiver) { @@ -9494,6 +9522,7 @@ RenderFrameHostImpl* render_frame_host) { TRACE_EVENT1("content", "WebContentsImpl::RenderFrameDeleted", "render_frame_host", render_frame_host); + ClosePopupWidgetsForFrame(render_frame_host); { SCOPED_UMA_HISTOGRAM_TIMER("WebContentsObserver.RenderFrameDeleted"); observers_.NotifyObservers(&WebContentsObserver::RenderFrameDeleted, @@ -12964,16 +12993,20 @@ "render_frame_host", render_frame_host, "old_state", old_state, "new_state", new_state); + if (old_state == LifecycleState::kActive) { #if BUILDFLAG(IS_ANDROID) - if (old_state == LifecycleState::kActive && !render_frame_host->GetParent()) { - // TODO(sreejakshetty): Remove this reset when ColorChooserHolder becomes - // per-frame. - // Close the color chooser popup when RenderFrameHost changes state from - // kActive. - color_chooser_holder_.reset(); - } + if (!render_frame_host->GetParent()) { + // TODO(sreejakshetty): Remove this reset when ColorChooserHolder becomes + // per-frame. + // Close the color chooser popup when RenderFrameHost changes state from + // kActive. + color_chooser_holder_.reset(); + } #endif // BUILDFLAG(IS_ANDROID) + ClosePopupWidgetsForFrame(render_frame_host); + } + observers_.NotifyObservers(&WebContentsObserver::RenderFrameHostStateChanged, render_frame_host, old_state, new_state); } diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h index 6f21585f..a2186506 100644 --- a/content/browser/web_contents/web_contents_impl.h +++ b/content/browser/web_contents/web_contents_impl.h @@ -2092,6 +2092,10 @@ RenderWidgetHostView* GetCreatedWidget(ChildProcessId process_id, int route_id); + // Closes and destroys any pending or shown popup widgets created by + // `render_frame_host`. + void ClosePopupWidgetsForFrame(RenderFrameHost* render_frame_host); + // Finds the new CreatedWindow by |main_frame_widget_route_id|, initializes // it for renderer-initiated creation, and returns it. Note that this can only // be called once as this call also removes it from the internal map. diff --git a/content/browser/renderer_host/render_widget_host_browsertest.cc b/content/browser/renderer_host/render_widget_host_browsertest.cc index a4b536b..9efa1915 100644 --- a/content/browser/renderer_host/render_widget_host_browsertest.cc +++ b/content/browser/renderer_host/render_widget_host_browsertest.cc @@ -12,6 +12,7 @@ #include "base/strings/string_number_conversions.h" #include "base/task/single_thread_task_runner.h" #include "base/test/bind.h" +#include "base/test/run_until.h" #include "base/test/scoped_feature_list.h" #include "base/test/test_timeouts.h" #include "base/time/time.h" @@ -700,6 +701,207 @@ root_frame_host->GetGlobalId()); } +namespace { + +// Helper to test browser-side popup widget lifecycle cleanup when a creator +// RenderFrameHost becomes inactive or is deleted. Intercepts +// CreateNewPopupWidget and PopupWidgetHost IPCs to simulate compromised +// renderer behaviors: +// - Always suppresses RequestClosePopup() so the renderer does not close its +// own popup during navigation or frame detachment, ensuring browser-side +// cleanup is responsible for destroying the widget. +// - Optionally intercepts ShowPopup() (`defer_show_popup = true`) without +// forwarding it to the browser, leaving the popup in the pending state so +// tests can verify delayed ShowCreatedWidget() calls after frame changes. +class PopupLifecycleTestInterceptor + : public blink::mojom::PopupWidgetHostInterceptorForTesting { + public: + PopupLifecycleTestInterceptor(RenderFrameHostImpl* frame_host, + bool defer_show_popup) + : frame_host_(frame_host), + create_new_popup_widget_interceptor_( + std::in_place, + frame_host, + base::BindOnce(&PopupLifecycleTestInterceptor::DidCreatePopupWidget, + base::Unretained(this))), + defer_show_popup_(defer_show_popup) {} + + PopupLifecycleTestInterceptor(const PopupLifecycleTestInterceptor&) = delete; + PopupLifecycleTestInterceptor& operator=( + const PopupLifecycleTestInterceptor&) = delete; + + ~PopupLifecycleTestInterceptor() override { + if (popup_widget_) { + std::ignore = popup_widget_->popup_widget_host_receiver_for_testing() + .SwapImplForTesting(popup_widget_.get()); + } + } + + void OpenSelectPopupAndWait() { + // Ensure that `frame_host_` (especially when it is an out-of-process + // subframe) has received its non-empty viewport intersection from the + // embedder and completed a lifecycle update; otherwise + // MenuListSelectType::ShowPopup() sees an empty VisibleBoundsInLocalRoot() + // and silently aborts opening the popup. + ASSERT_EQ(true, EvalJs(frame_host_, R"( + new Promise(resolve => { + const select = document.querySelector('select'); + const observer = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && + entries[0].intersectionRect.width > 0) { + observer.disconnect(); + resolve(true); + } + }); + observer.observe(select); + }) + )")); + + input::NativeWebKeyboardEvent event( + blink::WebInputEvent::Type::kChar, blink::WebInputEvent::kNoModifiers, + blink::WebInputEvent::GetStaticTimeStampForTests()); + event.text[0] = ' '; + + EXPECT_TRUE(ExecJs(frame_host_, "focusSelectMenu();")); + frame_host_->GetRenderWidgetHost()->ForwardKeyboardEvent(event); + ASSERT_TRUE(base::test::RunUntil([&]() { return show_popup_called_; })); + create_new_popup_widget_interceptor_.reset(); + frame_host_ = nullptr; + } + + void SimulateDelayedShowPopup(WebContentsImpl* contents) { + contents->ShowCreatedWidget(process_id_, routing_id_, initial_rect_, + initial_anchor_rect_); + } + + // blink::mojom::PopupWidgetHostInterceptorForTesting: + blink::mojom::PopupWidgetHost* GetForwardingInterface() override { + return popup_widget_.get(); + } + + void ShowPopup(const gfx::Rect& initial_rect, + const gfx::Rect& initial_anchor_rect, + ShowPopupCallback callback) override { + initial_rect_ = initial_rect; + initial_anchor_rect_ = initial_anchor_rect; + if (defer_show_popup_) { + std::move(callback).Run(); + } else if (auto* forwarding = GetForwardingInterface()) { + forwarding->ShowPopup(initial_rect, initial_anchor_rect, + std::move(callback)); + } + show_popup_called_ = true; + } + + void RequestClosePopup() override { + // Suppress renderer-initiated close requests to simulate a compromised + // renderer that keeps its popup open across navigation or frame deletion. + } + + void DidCreatePopupWidget(RenderWidgetHost* render_widget_host) { + process_id_ = render_widget_host->GetProcess()->GetID(); + routing_id_ = render_widget_host->GetRoutingID(); + auto* rwhi = static_cast<RenderWidgetHostImpl*>(render_widget_host); + popup_widget_ = rwhi->GetWeakPtr(); + std::ignore = + rwhi->popup_widget_host_receiver_for_testing().SwapImplForTesting(this); + } + + RenderWidgetHostImpl* popup_widget() const { return popup_widget_.get(); } + int32_t process_id() const { return process_id_.value(); } + int32_t routing_id() const { return routing_id_; } + + private: + raw_ptr<RenderFrameHostImpl> frame_host_; + std::optional<CreateNewPopupWidgetInterceptor> + create_new_popup_widget_interceptor_; + const bool defer_show_popup_; + bool show_popup_called_ = false; + gfx::Rect initial_rect_; + gfx::Rect initial_anchor_rect_; + base::WeakPtr<RenderWidgetHostImpl> popup_widget_; + int32_t routing_id_ = IPC::mojom::kRoutingIdNone; + ChildProcessId process_id_; +}; + +} // namespace + +// Regression test for https://crbug.com/556899544: +// Verify that a popup widget created while a document is active is destroyed +// when the document leaves the active state (e.g. navigates away / enters +// BFCache), and cannot be shown over the successor document. +IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest, + DeferredShowPopupAfterCreatorFrameInactive) { + GURL main_url(embedded_test_server()->GetURL( + "a.com", "/site_isolation/page-with-select.html")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents()); + + auto* contents = static_cast<WebContentsImpl*>(shell()->web_contents()); + RenderFrameHostImpl* root_frame_host = + contents->GetPrimaryFrameTree().root()->current_frame_host(); + + PopupLifecycleTestInterceptor interceptor(root_frame_host, + /*defer_show_popup=*/true); + interceptor.OpenSelectPopupAndWait(); + + ASSERT_TRUE(interceptor.popup_widget()); + EXPECT_EQ(1u, contents->GetPopupWidgets().size()); + EXPECT_FALSE(contents->GetPopupWidgets()[0]->IsShowing()); + + // Navigate the tab cross-origin so the creator document leaves kActive. + GURL next_url(embedded_test_server()->GetURL("b.com", "/title1.html")); + EXPECT_TRUE(NavigateToURL(shell(), next_url)); + + // The pending popup widget should have been destroyed when the creator + // RenderFrameHost left LifecycleState::kActive. + EXPECT_FALSE(interceptor.popup_widget()); + EXPECT_FALSE(RenderWidgetHost::FromID(interceptor.process_id(), + interceptor.routing_id())); + EXPECT_TRUE(contents->GetPopupWidgets().empty()); + + // Attempting to show the widget after its creator frame became inactive + // should safely no-op. + interceptor.SimulateDelayedShowPopup(contents); + EXPECT_TRUE(contents->GetPopupWidgets().empty()); +} + +// Verify that an already-shown popup widget created by an out-of-process +// subframe is destroyed when the subframe is removed from the DOM, even if a +// compromised renderer suppresses RequestClosePopup(). +IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest, + ShownSubframePopupDestroyedWhenSubframeRemoved) { + GURL main_url(embedded_test_server()->GetURL( + "a.com", "/cross_site_iframe_factory.html?a(b)")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + auto* contents = static_cast<WebContentsImpl*>(shell()->web_contents()); + FrameTreeNode* root = contents->GetPrimaryFrameTree().root(); + ASSERT_EQ(1u, root->child_count()); + FrameTreeNode* child_node = root->child_at(0); + + GURL subframe_select_url(embedded_test_server()->GetURL( + "b.com", "/site_isolation/page-with-select.html")); + EXPECT_TRUE(NavigateToURLFromRenderer(child_node, subframe_select_url)); + SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents()); + + RenderFrameHostImpl* child_rfh = child_node->current_frame_host(); + PopupLifecycleTestInterceptor interceptor(child_rfh, + /*defer_show_popup=*/false); + interceptor.OpenSelectPopupAndWait(); + + ASSERT_TRUE(interceptor.popup_widget()); + ASSERT_EQ(1u, contents->GetPopupWidgets().size()); + EXPECT_TRUE(contents->GetPopupWidgets()[0]->IsShowing()); + + // Remove the iframe from the main document. + EXPECT_TRUE(ExecJs(root->current_frame_host(), + "document.querySelector('iframe').remove();")); + + EXPECT_FALSE(interceptor.popup_widget()); + EXPECT_TRUE(contents->GetPopupWidgets().empty()); +} + #endif #if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
Regression Test / PoC
diff --git a/content/browser/renderer_host/render_widget_host_browsertest.cc b/content/browser/renderer_host/render_widget_host_browsertest.cc
index a4b536b..9efa1915 100644
--- a/content/browser/renderer_host/render_widget_host_browsertest.cc
+++ b/content/browser/renderer_host/render_widget_host_browsertest.cc
@@ -12,6 +12,7 @@
#include "base/strings/string_number_conversions.h"
#include "base/task/single_thread_task_runner.h"
#include "base/test/bind.h"
+#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_timeouts.h"
#include "base/time/time.h"
@@ -700,6 +701,207 @@
root_frame_host->GetGlobalId());
}
+namespace {
+
+// Helper to test browser-side popup widget lifecycle cleanup when a creator
+// RenderFrameHost becomes inactive or is deleted. Intercepts
+// CreateNewPopupWidget and PopupWidgetHost IPCs to simulate compromised
+// renderer behaviors:
+// - Always suppresses RequestClosePopup() so the renderer does not close its
+// own popup during navigation or frame detachment, ensuring browser-side
+// cleanup is responsible for destroying the widget.
+// - Optionally intercepts ShowPopup() (`defer_show_popup = true`) without
+// forwarding it to the browser, leaving the popup in the pending state so
+// tests can verify delayed ShowCreatedWidget() calls after frame changes.
+class PopupLifecycleTestInterceptor
+ : public blink::mojom::PopupWidgetHostInterceptorForTesting {
+ public:
+ PopupLifecycleTestInterceptor(RenderFrameHostImpl* frame_host,
+ bool defer_show_popup)
+ : frame_host_(frame_host),
+ create_new_popup_widget_interceptor_(
+ std::in_place,
+ frame_host,
+ base::BindOnce(&PopupLifecycleTestInterceptor::DidCreatePopupWidget,
+ base::Unretained(this))),
+ defer_show_popup_(defer_show_popup) {}
+
+ PopupLifecycleTestInterceptor(const PopupLifecycleTestInterceptor&) = delete;
+ PopupLifecycleTestInterceptor& operator=(
+ const PopupLifecycleTestInterceptor&) = delete;
+
+ ~PopupLifecycleTestInterceptor() override {
+ if (popup_widget_) {
+ std::ignore = popup_widget_->popup_widget_host_receiver_for_testing()
+ .SwapImplForTesting(popup_widget_.get());
+ }
+ }
+
+ void OpenSelectPopupAndWait() {
+ // Ensure that `frame_host_` (especially when it is an out-of-process
+ // subframe) has received its non-empty viewport intersection from the
+ // embedder and completed a lifecycle update; otherwise
+ // MenuListSelectType::ShowPopup() sees an empty VisibleBoundsInLocalRoot()
+ // and silently aborts opening the popup.
+ ASSERT_EQ(true, EvalJs(frame_host_, R"(
+ new Promise(resolve => {
+ const select = document.querySelector('select');
+ const observer = new IntersectionObserver(entries => {
+ if (entries[0].isIntersecting &&
+ entries[0].intersectionRect.width > 0) {
+ observer.disconnect();
+ resolve(true);
+ }
+ });
+ observer.observe(select);
+ })
+ )"));
+
+ input::NativeWebKeyboardEvent event(
+ blink::WebInputEvent::Type::kChar, blink::WebInputEvent::kNoModifiers,
+ blink::WebInputEvent::GetStaticTimeStampForTests());
+ event.text[0] = ' ';
+
+ EXPECT_TRUE(ExecJs(frame_host_, "focusSelectMenu();"));
+ frame_host_->GetRenderWidgetHost()->ForwardKeyboardEvent(event);
+ ASSERT_TRUE(base::test::RunUntil([&]() { return show_popup_called_; }));
+ create_new_popup_widget_interceptor_.reset();
+ frame_host_ = nullptr;
+ }
+
+ void SimulateDelayedShowPopup(WebContentsImpl* contents) {
+ contents->ShowCreatedWidget(process_id_, routing_id_, initial_rect_,
+ initial_anchor_rect_);
+ }
+
+ // blink::mojom::PopupWidgetHostInterceptorForTesting:
+ blink::mojom::PopupWidgetHost* GetForwardingInterface() override {
+ return popup_widget_.get();
+ }
+
+ void ShowPopup(const gfx::Rect& initial_rect,
+ const gfx::Rect& initial_anchor_rect,
+ ShowPopupCallback callback) override {
+ initial_rect_ = initial_rect;
+ initial_anchor_rect_ = initial_anchor_rect;
+ if (defer_show_popup_) {
+ std::move(callback).Run();
+ } else if (auto* forwarding = GetForwardingInterface()) {
+ forwarding->ShowPopup(initial_rect, initial_anchor_rect,
+ std::move(callback));
+ }
+ show_popup_called_ = true;
+ }
+
+ void RequestClosePopup() override {
+ // Suppress renderer-initiated close requests to simulate a compromised
+ // renderer that keeps its popup open across navigation or frame deletion.
+ }
+
+ void DidCreatePopupWidget(RenderWidgetHost* render_widget_host) {
+ process_id_ = render_widget_host->GetProcess()->GetID();
+ routing_id_ = render_widget_host->GetRoutingID();
+ auto* rwhi = static_cast<RenderWidgetHostImpl*>(render_widget_host);
+ popup_widget_ = rwhi->GetWeakPtr();
+ std::ignore =
+ rwhi->popup_widget_host_receiver_for_testing().SwapImplForTesting(this);
+ }
+
+ RenderWidgetHostImpl* popup_widget() const { return popup_widget_.get(); }
+ int32_t process_id() const { return process_id_.value(); }
+ int32_t routing_id() const { return routing_id_; }
+
+ private:
+ raw_ptr<RenderFrameHostImpl> frame_host_;
+ std::optional<CreateNewPopupWidgetInterceptor>
+ create_new_popup_widget_interceptor_;
+ const bool defer_show_popup_;
+ bool show_popup_called_ = false;
+ gfx::Rect initial_rect_;
+ gfx::Rect initial_anchor_rect_;
+ base::WeakPtr<RenderWidgetHostImpl> popup_widget_;
+ int32_t routing_id_ = IPC::mojom::kRoutingIdNone;
+ ChildProcessId process_id_;
+};
+
+} // namespace
+
+// Regression test for https://crbug.com/556899544:
+// Verify that a popup widget created while a document is active is destroyed
+// when the document leaves the active state (e.g. navigates away / enters
+// BFCache), and cannot be shown over the successor document.
+IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest,
+ DeferredShowPopupAfterCreatorFrameInactive) {
+ GURL main_url(embedded_test_server()->GetURL(
+ "a.com", "/site_isolation/page-with-select.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents());
+
+ auto* contents = static_cast<WebContentsImpl*>(shell()->web_contents());
+ RenderFrameHostImpl* root_frame_host =
+ contents->GetPrimaryFrameTree().root()->current_frame_host();
+
+ PopupLifecycleTestInterceptor interceptor(root_frame_host,
+ /*defer_show_popup=*/true);
+ interceptor.OpenSelectPopupAndWait();
+
+ ASSERT_TRUE(interceptor.popup_widget());
+ EXPECT_EQ(1u, contents->GetPopupWidgets().size());
+ EXPECT_FALSE(contents->GetPopupWidgets()[0]->IsShowing());
+
+ // Navigate the tab cross-origin so the creator document leaves kActive.
+ GURL next_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), next_url));
+
+ // The pending popup widget should have been destroyed when the creator
+ // RenderFrameHost left LifecycleState::kActive.
+ EXPECT_FALSE(interceptor.popup_widget());
+ EXPECT_FALSE(RenderWidgetHost::FromID(interceptor.process_id(),
+ interceptor.routing_id()));
+ EXPECT_TRUE(contents->GetPopupWidgets().empty());
+
+ // Attempting to show the widget after its creator frame became inactive
+ // should safely no-op.
+ interceptor.SimulateDelayedShowPopup(contents);
+ EXPECT_TRUE(contents->GetPopupWidgets().empty());
+}
+
+// Verify that an already-shown popup widget created by an out-of-process
+// subframe is destroyed when the subframe is removed from the DOM, even if a
+// compromised renderer suppresses RequestClosePopup().
+IN_PROC_BROWSER_TEST_F(RenderWidgetHostSitePerProcessTest,
+ ShownSubframePopupDestroyedWhenSubframeRemoved) {
+ GURL main_url(embedded_test_server()->GetURL(
+ "a.com", "/cross_site_iframe_factory.html?a(b)"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ auto* contents = static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = contents->GetPrimaryFrameTree().root();
+ ASSERT_EQ(1u, root->child_count());
+ FrameTreeNode* child_node = root->child_at(0);
+
+ GURL subframe_select_url(embedded_test_server()->GetURL(
+ "b.com", "/site_isolation/page-with-select.html"));
+ EXPECT_TRUE(NavigateToURLFromRenderer(child_node, subframe_select_url));
+ SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents());
+
+ RenderFrameHostImpl* child_rfh = child_node->current_frame_host();
+ PopupLifecycleTestInterceptor interceptor(child_rfh,
+ /*defer_show_popup=*/false);
+ interceptor.OpenSelectPopupAndWait();
+
+ ASSERT_TRUE(interceptor.popup_widget());
+ ASSERT_EQ(1u, contents->GetPopupWidgets().size());
+ EXPECT_TRUE(contents->GetPopupWidgets()[0]->IsShowing());
+
+ // Remove the iframe from the main document.
+ EXPECT_TRUE(ExecJs(root->current_frame_host(),
+ "document.querySelector('iframe').remove();"));
+
+ EXPECT_FALSE(interceptor.popup_widget());
+ EXPECT_TRUE(contents->GetPopupWidgets().empty());
+}
+
#endif
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
Original Bug Report
UI overlay and input capture via missing creator lifecycle check in ShowCreatedWidget
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: While popup widget creation verifies that the creator RenderFrameHost is active, showing the widget does not check whether the creator document remains active. A compromised renderer can create a popup widget while active and defer displaying it until the tab navigates to a cross-origin page. This potentially allows an attacker to overlay spoofed UI and capture keystrokes over the successor document.
Affected files:
content/browser/web_contents/web_contents_impl.cccontent/browser/renderer_host/render_widget_host_impl.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_widget_host_view_aura.cc
Estimated timestamp from git blame: 2021-08-12
Summary
There is a potential time-of-check to time-of-use (TOCTOU) lifecycle validation gap in popup widget management. The browser checks that the creator RenderFrameHost is active when CreateNewPopupWidget is invoked, but does not re-verify the creator document’s lifecycle state when ShowPopup is called later. Additionally, pending popup widgets are not destroyed when the creating frame navigates, enters BFCache, or is deleted. As a result, a compromised renderer can arm a popup widget and defer showing it until the user has navigated to another site, displaying attacker-controlled UI and intercepting user input over the victim page.
Technical Details
-
Creation Gate: In
RenderFrameHostImpl::CreateNewPopupWidget(content/browser/renderer_host/render_frame_host_impl.cc:11588), the browser checksIsActive()before creating the widget. It callsWebContentsImpl::CreateNewPopupWidget(content/browser/web_contents/web_contents_impl.cc:5911), which instantiates a self-ownedRenderWidgetHostImpland stores it inpending_widgets_(web_contents_impl.cc:5943). -
Missing Lifecycle Gate on Show: When
blink.mojom.PopupWidgetHost::ShowPopupis received,RenderWidgetHostImpl::ShowPopup(content/browser/renderer_host/render_widget_host_impl.cc:2905) forwards the call toWebContentsImpl::ShowCreatedWidget(content/browser/web_contents/web_contents_impl.cc:6060).ShowCreatedWidgetverifies tab visibility, OS window focus, and permission prompt exclusion areas, but never checks if the creator document (tracked viapopup_creator_frame_id_inrender_widget_host_impl.cc:439) is still active or attached to the current document. -
Absence of Lifecycle Cleanup: Self-owned
RenderWidgetHostImplinstances are process-scoped (render_widget_host_impl.cc:458-464) and only destroy themselves when theirRenderProcessHostexits.WebContentsImpl::RenderFrameHostStateChanged(web_contents_impl.cc:12846) does not clear or destroy pending popup widgets when an RFH leaveskActive. -
Input Interception: When
widget_host_view->InitAsPopup(content/browser/renderer_host/render_widget_host_view_aura.cc:452) runs, the popup is parented to the current root window (which now belongs to the successor page). BecauseNeedsInputGrab()returnstruefor popup widgets (render_widget_host_view_aura.cc:3183),RenderWidgetHostViewEventHandler::OnKeyEvent(render_widget_host_view_event_handler.cc:248-252) andRenderWidgetHostViewAura::InsertChar(render_widget_host_view_aura.cc:1621-1625) route victim window key events into the popup first.
Note: These findings are based on static code analysis; our tooling agent does not currently have the environment to run live exploits.
Potential Attack Steps
- From a compromised renderer hosting an active document at
attacker.com, invokeLocalFrameHost::CreateNewPopupWidgetto create a popup widget. Retain the associatedPopupWidgetHostMojo remote and do not sendShowPopupimmediately. - The user navigates in the same tab to
victim.com. The attacker’sRenderFrameHostImplenters pending-deletion or BFCache, but the renderer process remains alive. - The compromised renderer sends
PopupWidgetHost::ShowPopupwith attacker-chosen screen bounds. WebContentsImpl::ShowCreatedWidgetretrieves the pending widget and initializes it over the now-activevictim.comdocument.- The attacker submits compositor frames for the popup to render deceptive UI and intercepts user keystrokes.
Suggested Fix
- In
WebContentsImpl::ShowCreatedWidget, look up theRenderFrameHostassociated withrender_widget_host_impl->GetPopupCreatorFrameId(). If the creator frame no longer exists, is not inLifecycleState::kActive, or does not belong to the currentWebContents, abort and destroy the widget viarender_widget_host_impl->ShutdownAndDestroyWidget(true). - In
WebContentsImpl::RenderFrameHostStateChanged, destroy any pending and shown popup widgets whose creatorRenderFrameHostis transitioning away fromLifecycleState::kActive.
Evaluated with Chrome root at commit: 53c437ac4ea52cc89d0265eb9e6d68666fdb303e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.