57e51ecf9ceda6e175a561a14d42da2530b3adda [M155] Ensure sufficient lifetime of WebViewImpl::page_popup_.
Triage note: M155 backport; adds scoped_refptr owning_reference(page_popup_) in Cancel/Close/UpdatePagePopup plus a !widget_base_ null-check in WebPagePopupImpl::Update. Renderer-reachable via a <select> change listener closing the popup during Update.
The bug at a glance
Use-after-free of the ref-counted WebPagePopupImpl in the Blink renderer, reachable from web content that shows a popup and runs script during its lifecycle events. WebViewImpl::page_popup_ is the sole owning scoped_refptr, and a callback that clears it during Cancel/Close/Update drops the last reference and destroys the object while its own method is still running on a dangling this. Web-triggerable renderer UAF; M155 backport; High.
The owning reference to the popup lives in a single member, WebViewImpl::page_popup_, and the view calls straight through it: page_popup_->Update(). If anything inside Update()/Cancel()/ClosePopup() clears page_popup_ (closing the popup), the last ref drops mid-call and the object deletes itself while executing. The regression test drives the teardown via a test-only WidgetHostDisconnectedForTesting() hook invoked from a change listener (a simulated browser-side widget-host disconnect); a pure-script web path that resets page_popup_ mid-call is (inferred), not demonstrated by the diff.
Root cause
WebViewImpl::CancelPagePopup/ClosePagePopup/UpdatePagePopup invoked page_popup_->Cancel()/ClosePopup()/Update() directly, where page_popup_ is the only scoped_refptr owning the WebPagePopupImpl. During those calls a reentrant callback (popup-disconnect / widget-host-disconnected, or script run while updating a popup) can reset page_popup_, dropping the final reference and destroying the WebPagePopupImpl while its method is still on the stack, so this dangles. The fix takes a local ‘scoped_refptr owning_reference(page_popup_)’ in each of the three WebViewImpl entry points, extending the object’s lifetime across the call even if the member is cleared. Additionally WebPagePopupImpl::Update() now checks ‘if (!widget_base_) return;’ immediately after popup_client_->Update(forced_update), because that nested Update() can close the popup (tearing down widget_base_), and continuing to SetWindowRect()/touch members afterward would be a use-after-close.
Key code
Local owning ref in WebViewImpl plus a post-callback null-check in WebPagePopupImpl::Update.
void WebViewImpl::UpdatePagePopup() {
if (page_popup_) {
scoped_refptr<WebPagePopupImpl> owning_reference(page_popup_);
page_popup_->Update();
}
}
// WebPagePopupImpl::Update():
popup_client_->Update(forced_update);
if (!widget_base_) {
return;
}
Patch walkthrough
third_party/blink/renderer/core/exported/web_view_impl.cc— CancelPagePopup(), ClosePagePopup() and UpdatePagePopup() each add ‘scoped_refptr owning_reference(page_popup_);’ before calling Cancel()/ClosePopup()/Update(). This holds a second strong reference for the duration of the call, so a callback that nulls page_popup_ no longer drops the last reference.third_party/blink/renderer/core/exported/web_page_popup_impl.cc— WebPagePopupImpl::Update() gains an ‘if (!widget_base_) return;’ right after popup_client_->Update(forced_update). If that nested call closed the popup (widget_base_ torn down), the method stops instead of proceeding to SetWindowRect() on a half-destroyed object. Also adds WidgetHostDisconnectedForTesting().third_party/blink/renderer/core/exported/web_page_popup_impl.h— Declares the new WidgetHostDisconnectedForTesting() test hook.third_party/blink/renderer/core/html/forms/internal_popup_menu_test.cc— Adds DisconnectDuringPopupUpdate: shows a popup, registers a change-event listener that calls popup->WidgetHostDisconnectedForTesting() the first time it fires, provisionally changes the selection, moves the offscreen, updates lifecycle, then calls web_view->UpdatePagePopup().
Background
WebPagePopupImpl — The renderer-side widget implementing page popups such as the options list and date/color pickers. It is ref-counted (scoped_refptr), not Oilpan-garbage-collected.
WebViewImpl::page_popup_ — The scoped_refptr member on WebViewImpl that holds the single owning reference to the current WebPagePopupImpl. Clearing it (closing the popup) normally destroys the popup.
widget_base_ — The WidgetBase backing the popup’s compositor/IPC surface. Closing the popup tears it down, so a null widget_base_ after a nested Update() is the signal that the popup was closed underneath the current frame.
InternalPopupMenu / popup — The element’s dropdown is rendered through a WebPagePopupImpl; selection changes raise change events to page script, the web-facing hook that can run arbitrary JS during the popup’s update/close lifecycle.
Vulnerability window
- Setup — Web content shows a popup (WebPagePopupImpl owned solely by WebViewImpl::page_popup_) and has script observing the popup’s lifecycle (e.g. a change listener).
- Trigger — WebViewImpl::UpdatePagePopup() calls page_popup_->Update(); nested popup_client_->Update() runs application logic that fires an event into page script.
- Reentrant close — That script closes/disconnects the popup, resetting page_popup_ and dropping the last reference, so the WebPagePopupImpl is destroyed.
- Use-after-free — Control returns into the still-executing Update()/UpdatePagePopup(), which touches this / widget_base_ / SetWindowRect on the freed popup.
Proof of concept
The diff ships a concrete unit test. It shows a real popup, then installs a change-event listener that closes the popup (via the new WidgetHostDisconnectedForTesting hook) the first time it fires. Running the lifecycle makes the subsequent web_view->UpdatePagePopup() drive the popup’s Update(), which dispatches the pending selection change; the listener runs, disconnects the popup and drops page_popup_, deleting the WebPagePopupImpl mid-Update on an unpatched build. The web-facing equivalent is a dropdown whose change event handler closes the popup during an update. Converting the freed-popup reuse into controlled corruption is (inferred).
// TEST_F(InternalPopupMenuTest, DisconnectDuringPopupUpdate):
select->ShowPopup();
WebPagePopupImpl* popup = web_view->GetPagePopup();
select->ProvisionalSelectionChanged(1);
select->addEventListener(event_type_names::kChange,
MakeGarbageCollected<DisconnectOnChangeListener>(popup));
select->SetInlineStyleProperty(CSSPropertyID::kTop, "5000px");
document.View()->UpdateAllLifecyclePhasesForTest();
web_view->UpdatePagePopup(); // listener disconnects popup mid-Update
Exploitation
- Reach — Open a popup and register script on a popup-lifecycle event (e.g. change) so author JS runs while WebViewImpl is driving Update()/Cancel()/ClosePopup() through page_popup_.
- Free — From that handler, cause the popup to close/disconnect (resetting page_popup_), dropping the sole owning scoped_refptr and destroying the WebPagePopupImpl while its method is still executing.
- Use/groom — Execution returns into the running method, dereferencing the freed popup (this/widget_base_/SetWindowRect). Reclaiming the freed WebPagePopupImpl allocation with attacker-shaped data is (inferred).
Detection & hunting
For defenders and detection engineers:
- ASAN heap-use-after-free in WebPagePopupImpl — Free stack through popup close/WidgetHostDisconnected (page_popup_ reset) with use stack in WebPagePopupImpl::Update / WebViewImpl::UpdatePagePopup / CancelPagePopup / ClosePagePopup.
- Popup closed during its own update — Watch for widget_base_ becoming null across popup_client_->Update(), or page_popup_ being cleared while a page_popup_->* call is on the stack.
Audit directions
- scoped_refptr-member call-through — Find places that call member_refptr->Method() where the member is the sole owner and the method can reenter and clear that member; each needs a local owning scoped_refptr.
- Post-callback state revalidation — After any call that can run script or close a widget, re-check liveness (e.g. widget_base_) before touching members. Audit other WebPagePopupImpl methods (Cancel, ClosePopup, DidShowPopup) for the same missing revalidation.