Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Input
DescriptionUse after free in Input
ComponentInput
Sub-componentInput
Bug ClassUAF
Tracker506152584
Fix commita64fc96ef2ff (chromium/src) +106/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/exported/web_view_impl.cc
modified

Files Changed

  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/renderer/core/html/forms/html_input_element.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • third_party/blink/renderer/core/html/forms/color_input_type.cc
  • third_party/blink/renderer/core/html/forms/html_select_element.cc
  • third_party/blink/renderer/core/exported/web_view_impl.cc
  • content/browser/security_exploit_browsertest.cc
From a64fc96ef2ffe58da607cecc59c2257d73ffae4f Mon Sep 17 00:00:00 2001
From: David Liu <lcdavid@google.com>
Date: Thu, 24 Sep 2026 07:22:54 -0700
Subject: [PATCH] [input] Make popup widget consume user activation in browser

Require and consume transient user activation in the browser process
in RenderFrameHostImpl::CreateNewPopupWidget, guarded by the
PopupWidgetRequiresUserActivation kill switch. In the renderer, move
LocalFrame::ConsumeTransientUserActivation to after opening the
picker (under the same flag) so browser-side activation is still
active when the popup widget IPC arrives, and guard
WebViewImpl::OpenPagePopup against re-entrant popup creation if
CancelPagePopup fires a synchronous change event that opens another
popup.

Fixed: 506152584
Change-Id: I5cf34ee08f237a14004f342e7f90879142c91797
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8378165
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Commit-Queue: David Liu <lcdavid@google.com>
Reviewed-by: Mustaq Ahmed <mustaq@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1704274}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 8783584..41e031a 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -11186,6 +11186,17 @@
     return;
   }
 
+  // Form-control popups require and consume transient user activation, which
+  // we enforce on the browser side to guard against compromised renderers.
+  CHECK(owner_);
+  if (base::FeatureList::IsEnabled(
+          blink::features::kPopupWidgetRequiresUserActivation) &&
+      !owner_->UpdateUserActivationState(
+          blink::mojom::UserActivationUpdateType::kConsumeTransientActivation,
+          blink::mojom::UserActivationNotificationType::kNone)) {
+    return;
+  }
+
   // We still need to allocate a widget routing id. Even though the renderer
   // doesn't receive it, the browser side still uses routing ids to track
   // widgets in various global tables.
diff --git a/third_party/blink/renderer/core/exported/web_view_impl.cc b/third_party/blink/renderer/core/exported/web_view_impl.cc
index 726f53d..94d7922 100644
--- a/third_party/blink/renderer/core/exported/web_view_impl.cc
+++ b/third_party/blink/renderer/core/exported/web_view_impl.cc
@@ -1079,13 +1079,16 @@
 WebPagePopupImpl* WebViewImpl::OpenPagePopup(PagePopupClient* client) {
   CHECK(client);
 
-  // This guarantees there is never more than 1 PagePopup active at a time.
-  // CancelPagePopup may fire a synchronous change event when a select element
-  // is closed, but that event handler shouldn't be able to open another picker
-  // because it would require multiple user activations to be created and
-  // consumed within the same task.
+  // Make sure there is never more than one page popup active at a time.
   CancelPagePopup();
-  CHECK(!page_popup_);
+  // CancelPagePopup may fire a synchronous change event when a select element
+  // is closed, which may then try to open another page popup. We prevent this
+  // through this early return. Note that `WebPagePopupImpl::Create` call below
+  // asynchronously prevents multiple popups with a single user activation
+  // anyway.
+  if (page_popup_) {
+    return nullptr;
+  }
 
   LocalFrame* opener_frame = client->OwnerElement().GetDocument().GetFrame();
   WebLocalFrameImpl* web_opener_frame =
diff --git a/third_party/blink/renderer/core/html/forms/color_input_type.cc b/third_party/blink/renderer/core/html/forms/color_input_type.cc
index 4e818a1b..2613dc7f 100644
--- a/third_party/blink/renderer/core/html/forms/color_input_type.cc
+++ b/third_party/blink/renderer/core/html/forms/color_input_type.cc
@@ -254,10 +254,12 @@
     document.AddConsoleMessage(MakeGarbageCollected<ConsoleMessage>(
         mojom::blink::ConsoleMessageSource::kJavaScript,
         mojom::blink::ConsoleMessageLevel::kWarning,
-        "A user gesture is required to show the color picker."));
+        "User activation is required to show the color picker."));
     return;
   }
-  LocalFrame::ConsumeTransientUserActivation(document.GetFrame());
+  if (!RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+    LocalFrame::ConsumeTransientUserActivation(document.GetFrame());
+  }
 
   ChromeClient* chrome_client = GetChromeClient();
   if (chrome_client && !HasOpenedPopup()) {
@@ -268,6 +270,10 @@
     OpenPopupView();
   }
 
+  if (RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+    LocalFrame::ConsumeTransientUserActivation(document.GetFrame());
+  }
+
   event.SetDefaultHandled();
 }
 
diff --git a/third_party/blink/renderer/core/html/forms/html_input_element.cc b/third_party/blink/renderer/core/html/forms/html_input_element.cc
index eb55bd5..97429e3 100644
--- a/third_party/blink/renderer/core/html/forms/html_input_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_input_element.cc
@@ -2625,12 +2625,17 @@
   if (!LocalFrame::HasTransientUserActivation(frame)) {
     exception_state.ThrowDOMException(
         DOMExceptionCode::kNotAllowedError,
-        "HTMLInputElement::showPicker() requires a user gesture.");
+        "HTMLInputElement::showPicker() requires user activation.");
     return;
   }
-  LocalFrame::ConsumeTransientUserActivation(frame);
 
+  if (!RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+    LocalFrame::ConsumeTransientUserActivation(frame);
+  }
   input_type_view_->OpenPopupView();
+  if (RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+    LocalFrame::ConsumeTransientUserActivation(frame);
+  }
 }
 
 bool HTMLInputElement::IsPickerVisible() const {
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element.cc b/third_party/blink/renderer/core/html/forms/html_select_element.cc
index ec67bfe..980eb65 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element.cc
@@ -1832,7 +1832,7 @@
 
   if (!LocalFrame::HasTransientUserActivation(frame)) {
     exception_state.ThrowDOMException(DOMExceptionCode::kNotAllowedError,
-                                      "showPicker() requires a user gesture.");
+                                      "showPicker() requires user activation.");
     return;
   }
 
@@ -1845,9 +1845,13 @@
     return;
   }
 
-  LocalFrame::ConsumeTransientUserActivation(frame);
-
+  if (!RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+    LocalFrame::ConsumeTransientUserActivation(frame);
+  }
   select_type_->ShowPicker();
+  if (RuntimeEnabledFeatures::PopupWidgetRequiresUserActivationEnabled()) {
+    LocalFrame::ConsumeTransientUserActivation(frame);
+  }
 }
 
 bool HTMLSelectElement::IsValidBuiltinCommand(HTMLElement& invoker,
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 91508ef..d0e06909 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -4855,6 +4855,15 @@
       status: "stable",
     },
     {
+      // Kill switch for requiring and consuming transient user activation in
+      // the browser process when creating a form-control popup widget,
+      // guarding against compromised renderers opening popups without a user
+      // gesture.
+      // See https://crbug.com/506152584.
+      name: "PopupWidgetRequiresUserActivation",
+      status: "stable",
+    },
+    {
       name: "PositionOutsideTabSpanCheckSiblingNode",
       status: "stable",
     },
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 091c9b8..46ccfd3 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -59,6 +59,8 @@
 #include "content/public/browser/content_browser_client.h"
 #include "content/public/browser/file_select_listener.h"
 #include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_widget_host.h"
+#include "content/public/browser/render_widget_host_iterator.h"
 #include "content/public/browser/site_isolation_policy.h"
 #include "content/public/browser/storage_partition.h"
 #include "content/public/common/bindings_policy.h"
@@ -136,7 +138,9 @@
 #include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
 #include "third_party/blink/public/mojom/frame/remote_frame.mojom-test-utils.h"
 #include "third_party/blink/public/mojom/loader/mixed_content.mojom.h"
+#include "third_party/blink/public/mojom/page/widget.mojom.h"
 #include "third_party/blink/public/mojom/permissions/permission.mojom.h"
+#include "third_party/blink/public/mojom/widget/platform_widget.mojom.h"
 
 using ::testing::HasSubstr;
 using ::testing::Optional;
@@ -4586,6 +4590,57 @@
             kill_waiter.Wait());
 }
 
+// CreateNewPopupWidget should be ignored when the requesting frame has no
+// transient user activation, since opening a form-control popup is gated on
+// user activation in the renderer and the browser-side state mirrors that.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       CreateNewPopupWidgetRequiresUserActivation) {
+  GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  RenderFrameHostImpl* rfh = root->current_frame_host();
+
+  ASSERT_FALSE(rfh->HasTransientUserActivation());
+
+  auto count_popup_widgets = [&]() {
+    size_t count = 0;
+    std::unique_ptr<RenderWidgetHostIterator> widgets =
+        RenderWidgetHost::GetRenderWidgetHosts();
+    while (RenderWidgetHost* widget = widgets->GetNextHost()) {
+      if (widget->GetPopupCreatorFrameId() == rfh->GetGlobalId()) {
+        ++count;
+      }
+    }
+    return count;
+  };
+
+  auto request_popup_widget = [&]() {
+    mojo::AssociatedRemote<blink::mojom::Widget> widget;
+    std::ignore = widget.BindNewEndpointAndPassDedicatedReceiver();
+    static_cast<blink::mojom::LocalFrameHost*>(rfh)->CreateNewPopupWidget(
+        mojo::AssociatedRemote<blink::mojom::PopupWidgetHost>()
+            .BindNewEndpointAndPassDedicatedReceiver(),
+        mojo::AssociatedRemote<blink::mojom::WidgetHost>()
+            .BindNewEndpointAndPassDedicatedReceiver(),
+        widget.Unbind());
+  };
+
+  // The frame has never received a user gesture, so the request should be
+  // dropped without creating a popup widget.
+  request_popup_widget();
+  EXPECT_EQ(0u, count_popup_widgets());
+
+  // With transient user activation the request should be honoured.
+  EXPECT_TRUE(root->UpdateUserActivationState(
+      blink::mojom::UserActivationUpdateType::kNotifyActivation,
+      blink::mojom::UserActivationNotificationType::kTest));
+  ASSERT_TRUE(rfh->HasTransientUserActivation());
+  request_popup_widget();
+  EXPECT_EQ(1u, count_popup_widgets());
+}
+
 namespace {
 
 // Interceptor that replaces the origin in the DidCommitProvisionalLoadParams
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 091c9b8..46ccfd3 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -59,6 +59,8 @@
 #include "content/public/browser/content_browser_client.h"
 #include "content/public/browser/file_select_listener.h"
 #include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_widget_host.h"
+#include "content/public/browser/render_widget_host_iterator.h"
 #include "content/public/browser/site_isolation_policy.h"
 #include "content/public/browser/storage_partition.h"
 #include "content/public/common/bindings_policy.h"
@@ -136,7 +138,9 @@
 #include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
 #include "third_party/blink/public/mojom/frame/remote_frame.mojom-test-utils.h"
 #include "third_party/blink/public/mojom/loader/mixed_content.mojom.h"
+#include "third_party/blink/public/mojom/page/widget.mojom.h"
 #include "third_party/blink/public/mojom/permissions/permission.mojom.h"
+#include "third_party/blink/public/mojom/widget/platform_widget.mojom.h"
 
 using ::testing::HasSubstr;
 using ::testing::Optional;
@@ -4586,6 +4590,57 @@
             kill_waiter.Wait());
 }
 
+// CreateNewPopupWidget should be ignored when the requesting frame has no
+// transient user activation, since opening a form-control popup is gated on
+// user activation in the renderer and the browser-side state mirrors that.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       CreateNewPopupWidgetRequiresUserActivation) {
+  GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  RenderFrameHostImpl* rfh = root->current_frame_host();
+
+  ASSERT_FALSE(rfh->HasTransientUserActivation());
+
+  auto count_popup_widgets = [&]() {
+    size_t count = 0;
+    std::unique_ptr<RenderWidgetHostIterator> widgets =
+        RenderWidgetHost::GetRenderWidgetHosts();
+    while (RenderWidgetHost* widget = widgets->GetNextHost()) {
+      if (widget->GetPopupCreatorFrameId() == rfh->GetGlobalId()) {
+        ++count;
+      }
+    }
+    return count;
+  };
+
+  auto request_popup_widget = [&]() {
+    mojo::AssociatedRemote<blink::mojom::Widget> widget;
+    std::ignore = widget.BindNewEndpointAndPassDedicatedReceiver();
+    static_cast<blink::mojom::LocalFrameHost*>(rfh)->CreateNewPopupWidget(
+        mojo::AssociatedRemote<blink::mojom::PopupWidgetHost>()
+            .BindNewEndpointAndPassDedicatedReceiver(),
+        mojo::AssociatedRemote<blink::mojom::WidgetHost>()
+            .BindNewEndpointAndPassDedicatedReceiver(),
+        widget.Unbind());
+  };
+
+  // The frame has never received a user gesture, so the request should be
+  // dropped without creating a popup widget.
+  request_popup_widget();
+  EXPECT_EQ(0u, count_popup_widgets());
+
+  // With transient user activation the request should be honoured.
+  EXPECT_TRUE(root->UpdateUserActivationState(
+      blink::mojom::UserActivationUpdateType::kNotifyActivation,
+      blink::mojom::UserActivationNotificationType::kTest));
+  ASSERT_TRUE(rfh->HasTransientUserActivation());
+  request_popup_widget();
+  EXPECT_EQ(1u, count_popup_widgets());
+}
+
 namespace {
 
 // Interceptor that replaces the origin in the DidCommitProvisionalLoadParams
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-origin keystroke interception via OOPIF popup parenting on Aura

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A compromised cross-origin subframe (OOPIF) can potentially intercept keystrokes intended for the top-level main frame on Aura platforms. This occurs because the browser unconditionally parents popup widgets created by subframes to the outermost main frame’s view without origin checks. Consequently, the main frame’s input handler forwards all keyboard events to the attacker-controlled popup, bypassing Site Isolation.

Affected files:

  • content/browser/renderer_host/render_widget_host_view_event_handler.cc
  • content/browser/renderer_host/render_widget_host_view_aura.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/renderer_host/render_frame_host_impl.cc

Estimated timestamp from git blame: 2024-04-26

Description

A logic flaw in how the browser process handles popup widget creation on Aura platforms (Windows, ChromeOS, Linux) can lead to a Site Isolation bypass. A compromised subframe renderer can create an invisible popup widget that intercepts all keystrokes typed into the cross-origin top-level parent page.

The vulnerability stems from the following chain of events:

  1. Missing Origin and Activation Checks: When a renderer sends the blink.mojom.LocalFrameHost::CreateNewPopupWidget IPC, RenderFrameHostImpl::CreateNewPopupWidget only verifies if the requesting frame is active. It does not enforce user activation (transient or sticky), nor does it check if the frame is a top-level main frame.
  2. Cross-Origin Parenting: When the attacker subsequently calls ShowPopup via IPC, WebContentsImpl::ShowCreatedWidget identifies the parent view by calling GetOutermostWebContents()->GetRenderWidgetHostView(). This unconditionally retrieves the top-level main frame’s view. InitAsPopup is then called, parenting the attacker’s popup to the main frame without any SiteInstance or origin boundary validation.
  3. Bypassed Dismissal: The popup dismissal logic in RenderWidgetHostViewAura::ApplyEventObserverForPopupExit explicitly ignores clicks targeting the parent window (target != popup_parent_host_view_->window_ evaluates to false). If the user clicks an input field in the main frame, the invisible popup remains active.
  4. Unconditional Input Forwarding: When the user types into the main frame, RenderWidgetHostViewEventHandler::OnKeyEvent checks NeedsInputGrab(popup_child_host_view_), which returns true for kPopup widgets. The browser unconditionally forwards the ui::KeyEvent to the popup’s event handler, routing the keystrokes to the attacker-controlled renderer process instead of the main frame.

Potential Reproduction Steps

Note: Our tooling agent does not have the ability to run code, so these are suggested steps based on static code analysis.

  1. An attacker compromises the renderer process of a cross-origin Site Isolated subframe (e.g., evil.com embedded in victim.com).
  2. The compromised renderer uses the blink.mojom.LocalFrameHost interface to send a CreateNewPopupWidget IPC to the browser process.
  3. The attacker sends the blink.mojom.PopupWidgetHost::ShowPopup IPC, specifying a 1x1 invisible geometry placed anywhere on the screen.
  4. The victim clicks on a sensitive input field (e.g., a password field) in the top-level victim.com frame. The click does not dismiss the popup because the dismissal logic ignores clicks on the parent window.
  5. The victim types into the field. Every keystroke is routed by the browser’s input handler directly to the evil.com renderer process, allowing the attacker to exfiltrate the data.

Suggested Fix

There are a few potential avenues to address this:

  1. Restrict Popup Creation: Modify RenderFrameHostImpl::CreateNewPopupWidget to enforce strict user activation checks, or restrict it so that only top-level frames (or frames with specific permissions) can create kPopup widgets.
  2. Validate Origin During Parenting: In WebContentsImpl::ShowCreatedWidget, verify that the SiteInstance or origin of the frame requesting the popup matches the SiteInstance of the view it is being parented to. If they do not match, the creation or parenting should be blocked.
  3. Parent to the Requesting Frame: Instead of unconditionally using GetOutermostWebContents()->GetRenderWidgetHostView(), parent the popup to the specific RenderWidgetHostView of the frame that initiated the request, ensuring events are routed within the correct process boundaries.

Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker