Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Navigation
DescriptionUse after free in Navigation
ComponentNavigation
Sub-componentUI>Browser>Navigation
Bug ClassUAF
Tracker556899544
Fix commit4f44867a964f (chromium/src) +250/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
for
content/browser/web_contents/web_contents_impl.cc
modified
if
content/browser/web_contents/web_contents_impl.cc
modified
PopupLifecycleTestInterceptor
content/browser/renderer_host/render_widget_host_browsertest.cc
modified
defer_show_popup_
content/browser/renderer_host/render_widget_host_browsertest.cc
modified
if
content/browser/renderer_host/render_widget_host_browsertest.cc
modified
ASSERT_TRUE
content/browser/renderer_host/render_widget_host_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/render_widget_host_browsertest.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/web_contents/web_contents_impl.h
From 4f44867a964f834cf39a39ee160d99a0e72a0766 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
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 <rakina@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • content/browser/renderer_host/render_widget_host_impl.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/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

  1. Creation Gate: In RenderFrameHostImpl::CreateNewPopupWidget (content/browser/renderer_host/render_frame_host_impl.cc:11588), the browser checks IsActive() before creating the widget. It calls WebContentsImpl::CreateNewPopupWidget (content/browser/web_contents/web_contents_impl.cc:5911), which instantiates a self-owned RenderWidgetHostImpl and stores it in pending_widgets_ (web_contents_impl.cc:5943).

  2. Missing Lifecycle Gate on Show: When blink.mojom.PopupWidgetHost::ShowPopup is received, RenderWidgetHostImpl::ShowPopup (content/browser/renderer_host/render_widget_host_impl.cc:2905) forwards the call to WebContentsImpl::ShowCreatedWidget (content/browser/web_contents/web_contents_impl.cc:6060). ShowCreatedWidget verifies tab visibility, OS window focus, and permission prompt exclusion areas, but never checks if the creator document (tracked via popup_creator_frame_id_ in render_widget_host_impl.cc:439) is still active or attached to the current document.

  3. Absence of Lifecycle Cleanup: Self-owned RenderWidgetHostImpl instances are process-scoped (render_widget_host_impl.cc:458-464) and only destroy themselves when their RenderProcessHost exits. WebContentsImpl::RenderFrameHostStateChanged (web_contents_impl.cc:12846) does not clear or destroy pending popup widgets when an RFH leaves kActive.

  4. 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). Because NeedsInputGrab() returns true for popup widgets (render_widget_host_view_aura.cc:3183), RenderWidgetHostViewEventHandler::OnKeyEvent (render_widget_host_view_event_handler.cc:248-252) and RenderWidgetHostViewAura::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

  1. From a compromised renderer hosting an active document at attacker.com, invoke LocalFrameHost::CreateNewPopupWidget to create a popup widget. Retain the associated PopupWidgetHost Mojo remote and do not send ShowPopup immediately.
  2. The user navigates in the same tab to victim.com. The attacker’s RenderFrameHostImpl enters pending-deletion or BFCache, but the renderer process remains alive.
  3. The compromised renderer sends PopupWidgetHost::ShowPopup with attacker-chosen screen bounds.
  4. WebContentsImpl::ShowCreatedWidget retrieves the pending widget and initializes it over the now-active victim.com document.
  5. The attacker submits compositor frames for the popup to render deceptive UI and intercepts user keystrokes.

Suggested Fix

  1. In WebContentsImpl::ShowCreatedWidget, look up the RenderFrameHost associated with render_widget_host_impl->GetPopupCreatorFrameId(). If the creator frame no longer exists, is not in LifecycleState::kActive, or does not belong to the current WebContents, abort and destroy the widget via render_widget_host_impl->ShutdownAndDestroyWidget(true).
  2. In WebContentsImpl::RenderFrameHostStateChanged, destroy any pending and shown popup widgets whose creator RenderFrameHost is transitioning away from LifecycleState::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.

View on issue tracker