diff --git a/third_party/blink/renderer/core/html/forms/internal_popup_menu_test.cc b/third_party/blink/renderer/core/html/forms/internal_popup_menu_test.cc
index 2350e2634b02f3..faac1dcf87b20e 100644
--- a/third_party/blink/renderer/core/html/forms/internal_popup_menu_test.cc
+++ b/third_party/blink/renderer/core/html/forms/internal_popup_menu_test.cc
@@ -4,6 +4,7 @@
#include "third_party/blink/renderer/core/html/forms/internal_popup_menu.h"
+#include "base/run_loop.h"
#include "build/build_config.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/web/web_document.h"
@@ -12,14 +13,18 @@
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/dom/element.h"
#include "third_party/blink/renderer/core/exported/web_page_popup_impl.h"
+#include "third_party/blink/renderer/core/exported/web_view_impl.h"
#include "third_party/blink/renderer/core/frame/frame_test_helpers.h"
#include "third_party/blink/renderer/core/frame/local_frame_view.h"
#include "third_party/blink/renderer/core/html/forms/html_select_element.h"
#include "third_party/blink/renderer/core/loader/empty_clients.h"
+#include "third_party/blink/renderer/core/page/page.h"
+#include "third_party/blink/renderer/core/page/page_popup_controller.h"
#include "third_party/blink/renderer/core/testing/dummy_page_holder.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
+#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/url_test_helpers.h"
#include "third_party/blink/renderer/platform/wtf/shared_buffer.h"
@@ -108,6 +113,90 @@ TEST_F(InternalPopupMenuTest, MediaFeatureOverridesPropagation) {
popup2->ClosePopup();
}
+// Demonstrates that PagePopupController::popup_ (a bare PagePopup& reference,
+// page_popup_controller.h:92) dangles after the orphaned-popup destruction
+// path frees its WebPagePopupImpl referent without DestroyPage().
+//
+// In production this is reached when a re-entrant OpenPagePopup overwrites
+// WebViewImpl::page_popup_ (so ClosePagePopup early-returns at
+// web_view_impl.cc:1104) and the browser later disconnects the orphaned
+// popup's PopupWidgetHost. We model that here by dropping the WebView's
+// scoped_refptr via CleanupPagePopup() and letting the test PopupWidgetHost
+// receiver (which the stub LocalFrameHost drops on the floor) disconnect.
+//
+// The picker JS in the orphaned popup's still-attached LocalFrame can then
+// call window.pagePopupController.setWindowRect(), which performs a virtual
+// call through the freed PagePopup vtable.
+TEST_F(InternalPopupMenuTest, PagePopupControllerUAFAfterOrphanedFree) {
+ if (!RuntimeEnabledFeatures::PagePopupEnabled()) {
+ return;
+ }
+
+ frame_test_helpers::WebViewHelper web_view_helper;
+ WebViewImpl* web_view = web_view_helper.Initialize();
+ WebURL base_url = url_test_helpers::ToKURL("http://example.com/");
+ frame_test_helpers::LoadHTMLString(web_view->MainFrameImpl(), R"HTML(
+
+ )HTML",
+ base_url);
+ Document& document =
+ *web_view->MainFrameImpl()->GetDocument().Unwrap();
+ document.View()->UpdateAllLifecyclePhasesForTest();
+
+ auto* sel =
+ To(document.getElementById(AtomicString("sel")));
+ ASSERT_TRUE(sel);
+ auto* menu = MakeGarbageCollected(
+ MakeGarbageCollected(), *sel);
+
+ // Open popup_B. WebViewImpl::page_popup_ = B; B refcount == 2.
+ WebPagePopupImpl* popup = web_view->OpenPagePopup(menu);
+ popup->DidShowPopup();
+
+ // Grab the PagePopupController bound to B's internal Page. Its `popup_`
+ // member is a bare C++ reference to B's PagePopup subobject.
+ Page* popup_page = popup->GetDocument().Unwrap()->GetPage();
+ ASSERT_TRUE(popup_page);
+ Persistent controller =
+ PagePopupController::From(*popup_page);
+ ASSERT_TRUE(controller);
+
+ // Model the re-entrant-OpenPagePopup orphaning step: drop the WebView's
+ // owning reference *without* running ClosePopup()/DestroyPage() on B.
+ // (In the wild, this happens when page_popup_ is overwritten by popup_C
+ // while popup_B was created inside a synchronous change-event handler.)
+ // After this, B has only its self-reference (refcount == 1) and page_ is
+ // still non-null.
+ web_view->CleanupPagePopup();
+
+ // The PopupWidgetHost receiver was passed to LocalFrameHost::
+ // CreateNewPopupWidget; the test stub drops it. Pump tasks so the
+ // associated remote observes the disconnection and runs
+ // WebPagePopupImpl::WidgetHostDisconnected() -> Close().
+ //
+ // Inside Close(): page_ non-null -> Cancel() -> InternalPopupMenu::
+ // CancelPopup() -> ClosePagePopup(B) goes nowhere (page_popup_ != B), so
+ // ClosePopup()/DestroyPage()/ClearPagePopupClient() are *bypassed*. Then
+ // widget_base_->Shutdown(); Release() drops refcount 1->0.
+ // ~WebPagePopupImpl runs with page_ non-null (release: DCHECK stripped)
+ // and PartitionAlloc frees B.
+ base::RunLoop().RunUntilIdle();
+ test::RunPendingTasks();
+
+ // The popup's LocalFrame was never detached (DestroyPage skipped), so in
+ // production its picker JS DOMTimers can still fire and reach
+ // setWindowRect() on the very next main-thread turn. We model that JS
+ // call directly:
+ //
+ // page_popup_controller.cc:120 -> popup_.SetWindowRect(...) is a *virtual*
+ // call (PagePopup::SetWindowRect is pure virtual, page_popup.h:50) through
+ // the freed PartitionAlloc allocation. ASAN: heap-use-after-free.
+ controller->setWindowRect(0, 0, 100, 100);
+}
+
#endif // !BUILDFLAG(IS_ANDROID)
} // namespace blink