2b7262df1a Occasional UI process crash under DisplayLink callbacks for RemoteLayerTree DisplayLink clients
Triage note: Reworks destructor to stop DisplayLink clients through a WeakPtr-held process pool, fixing a lifetime/UAF under async callbacks.
Contents
The bug at a glance
A UI-process use-after-free: two RemoteLayerTree DisplayLink clients deregistered themselves via existingDisplayLink(), which depends on page()/*m_displayID still being valid at destruction time; when that lookup returned null the client’s entry stayed in the DisplayLinkCollection, and a later DisplayLink callback invoked a CheckedRef pointing at zero’d/freed memory. It is a real lifetime bug in the trusted UIProcess made more deterministic by prior heap-allocation and dead-store-resilience changes, but it is timing-dependent (requires a DisplayLink callback racing teardown) and not directly content-scriptable to a clean primitive, so medium.
A DisplayLink::Client must remove itself from the DisplayLinkCollection before it dies, unconditionally. The buggy clients gated removal on existingDisplayLink(), which itself needs a live page() and *m_displayID – but during destruction those may already be gone, so the removal was skipped and a dangling client stayed registered. The fix caches a WeakPtr<WebProcessPool> at construction and routes deregistration through DisplayLinkCollection::stopDisplayLinks, which walks every DisplayLink and removes the client no matter what.
Root cause
DisplayLink drives per-frame callbacks to registered DisplayLink::Client objects held in a DisplayLinkCollection (owned by the WebProcessPool). Each client is responsible for removing its own entry before it is destroyed; if it does not, the next DisplayLink tick calls into a CheckedRef/CheckedPtr that now references freed (zero’d) memory – a use-after-free.
Two clients had a fragile deregistration path. RemoteLayerTreeDrawingAreaProxyMac::~RemoteLayerTreeDrawingAreaProxyMac() and RemoteLayerTreeEventDispatcher::removeDisplayLinkClient() both began by calling existingDisplayLink() to find the specific DisplayLink to remove their observers/client from. existingDisplayLink() resolves through page()/*m_displayID; during teardown (or before a display ID is assigned) that lookup can return null, so the destructor did nothing and left the client registered in the collection. A subsequent DisplayLink callback then dereferenced the dangling client.
The commit notes this was a latent issue made more deterministic by 302438@main (forcing DisplayLink::Client to be heap-allocated) and 310357@main (making the CheckedPtr zombie path resilient to dead-store elimination), and follows 313698@main which fixed the same pattern for SwipeProgressTracker.
The fix, applied to both classes, is twofold: (1) cache a WebProcessPool reference at construction as a const WeakPtr<WebProcessPool> m_processPool, taken from pageProxy.configuration().processPool() / scrollingCoordinator.webPageProxy().configuration().processPool(); and (2) in the destructor / removeDisplayLinkClient, if the WeakPtr still resolves, call processPool->displayLinks().stopDisplayLinks(m_displayLinkClient). stopDisplayLinks walks every DisplayLink in the collection and removes the client unconditionally, so deregistration no longer depends on page()/m_displayID being valid. Using a WeakPtr avoids resurrecting a dead pool and simply skips the (already-torn-down) work when the pool is gone. RemoteLayerTreeEventDispatcher additionally imports APIPageConfiguration.h and WebProcessPool.h and still protects m_displayLinkClient via *protect(…) during the removal.
Key code
Route deregistration through the cached WebProcessPool’s DisplayLinkCollection instead of existingDisplayLink() (RemoteLayerTreeDrawingAreaProxyMac.mm)
RemoteLayerTreeDrawingAreaProxyMac::RemoteLayerTreeDrawingAreaProxyMac(WebPageProxy& pageProxy, WebProcessProxy& webProcessProxy)
: RemoteLayerTreeDrawingAreaProxy(pageProxy, webProcessProxy)
, m_displayLinkClient(makeUniqueRef<RemoteLayerTreeDisplayLinkClient>(pageProxy.identifier()))
, m_processPool(pageProxy.configuration().processPool())
{
}
RemoteLayerTreeDrawingAreaProxyMac::~RemoteLayerTreeDrawingAreaProxyMac()
{
if (RefPtr processPool = m_processPool.get())
processPool->displayLinks().stopDisplayLinks(m_displayLinkClient);
}
Patch walkthrough
Source/WebKit/UIProcess/RemoteLayerTree/mac/RemoteLayerTreeDrawingAreaProxyMac.h— Forward-declares WebProcessPool and adds aconst WeakPtr<WebProcessPool> m_processPool;member so the drawing-area proxy can reach the DisplayLinkCollection at destruction without relying on page()/m_displayID.Source/WebKit/UIProcess/RemoteLayerTree/mac/RemoteLayerTreeDrawingAreaProxyMac.mm— The constructor initializes m_processPool from pageProxy.configuration().processPool(). The destructor is rewritten from the fragile existingDisplayLink()-then-removeObserver sequence to:if (RefPtr processPool = m_processPool.get()) processPool->displayLinks().stopDisplayLinks(m_displayLinkClient);– unconditional removal of the client from every DisplayLink.Source/WebKit/UIProcess/RemoteLayerTree/mac/RemoteLayerTreeEventDispatcher.h— Forward-declares WebProcessPool and addsconst WeakPtr<WebProcessPool> m_processPool;to the event dispatcher.Source/WebKit/UIProcess/RemoteLayerTree/mac/RemoteLayerTreeEventDispatcher.mm— Adds #import of APIPageConfiguration.h and WebProcessPool.h; the constructor caches m_processPool from scrollingCoordinator.webPageProxy().configuration().processPool(). removeDisplayLinkClient() no longer early-returns on a null existingDisplayLink(); instead, if m_processPool resolves it calls processPool->displayLinks().stopDisplayLinks(*protect(m_displayLinkClient)) and clears m_displayRefreshObserverID.
Background
DisplayLink::Client / DisplayLinkCollection — DisplayLink fires per-refresh callbacks to registered Client objects tracked in a DisplayLinkCollection owned by the WebProcessPool. A client that is destroyed without removing itself leaves a dangling registration that the next callback dereferences.
existingDisplayLink() — A helper that looks up the DisplayLink for a client via page()/*m_displayID. During teardown or before a display ID is assigned it can return null, so any cleanup gated on it silently no-ops – the root cause here.
stopDisplayLinks() — A DisplayLinkCollection method that iterates every DisplayLink and removes the given client unconditionally, independent of page()/display-ID state – the robust deregistration primitive the fix adopts.
CheckedRef / CheckedPtr zombie path — WebKit’s checked-pointer machinery aborts on use-after-free; 310357@main made the zombie (zero’d) path resilient to dead-store elimination, which changed how a dangling client callback manifested and helped surface this latent bug.
WeakPtr<WebProcessPool> — A non-owning reference cached at construction so the client can reach the collection at destruction time without extending the pool’s lifetime; .get() returns null (skipping work) if the pool is already gone.
Vulnerability window
- Registration — A RemoteLayerTreeDrawingAreaProxyMac or RemoteLayerTreeEventDispatcher registers its DisplayLink::Client with the DisplayLinkCollection to receive per-frame callbacks.
- Teardown starts — The proxy/dispatcher is being destroyed (or its display ID cleared); page()/*m_displayID is no longer resolvable.
- Skipped removal (bug) — The destructor calls existingDisplayLink(), gets null, and returns without removing the client, leaving a dangling registration in the collection.
- Dangling callback — The next DisplayLink tick invokes the freed client through a CheckedRef pointing at zero’d memory – UAF, made more deterministic after 302438@main/310357@main.
- Fix — Both classes cache WeakPtr<WebProcessPool> at construction and, at destruction, call processPool->displayLinks().stopDisplayLinks(client) to remove it from every DisplayLink unconditionally.
- Precedent — Mirrors 313698@main’s SwipeProgressTracker fix and WebProcessProxy’s earlier cached-pool precedent.
Triggering
No new test; the commit states existing harnesses don’t register a DisplayLink::Client deterministically and test support must land in a follow-up. Conceptual trigger: on macOS site-isolation/RemoteLayerTree, cause a RemoteLayerTreeDrawingAreaProxyMac or RemoteLayerTreeEventDispatcher to be destroyed while a DisplayLink is active but page()/display-ID has been torn down, so the client is not removed and a subsequent DisplayLink callback dereferences the freed client.
Exploitation
- Register — Get a RemoteLayerTree DisplayLink client registered (active scrolling/compositing on a Mac window drives DisplayLink observers).
- Force fragile teardown — Destroy the drawing-area proxy / event dispatcher under conditions where existingDisplayLink() returns null (display ID cleared, page detaching), leaving the client registered.
- Reclaim — Allocate to reoccupy the freed client object before the next DisplayLink tick; the collection still holds a CheckedRef to that slot.
- Callback UAF — The DisplayLink callback invokes into the reclaimed memory; controlling the client’s vtable/fields could steer the call, though the timing (per-refresh callback vs teardown) constrains reliability and the checked-pointer machinery tends to abort.
Detection & hunting
For defenders and SOC / detection engineers:
- UI-process crash in a DisplayLink callback into a destroyed RemoteLayerTree client — Backtraces showing DisplayLinkCollection invoking a RemoteLayerTreeDisplayLinkClient / RemoteLayerTreeEventDispatcherDisplayLinkClient whose memory is zero’d/freed are the signature.
- Client destroyed with a still-registered DisplayLink entry — Instrument DisplayLinkCollection to assert no live registration references a destructing client; a hit indicates the skipped-removal path.
- existingDisplayLink() returning null during client teardown — Log when a DisplayLink client destructor cannot resolve its DisplayLink; historically this meant cleanup was silently skipped.
Audit directions
- All DisplayLink::Client implementations — Enumerate every DisplayLink::Client and verify each deregisters via stopDisplayLinks (or equivalent unconditional path) rather than gating removal on existingDisplayLink()/page()/display-ID validity; SwipeProgressTracker, these two, and WebProcessProxy are done – confirm no others remain.
- Destructors depending on page()/identifier lookups — Audit UIProcess destructors that perform cleanup via lookups requiring live page/frame/display state; teardown ordering can invalidate those lookups and skip essential deregistration.
- Cached WeakPtr<WebProcessPool> pattern — Confirm the cached-pool pattern is applied consistently and that .get() null-skip semantics are correct (no leak when the pool outlives the client but the display link is not the pool’s).
- Interplay with 302438@main/310357@main — Re-examine other latent lifetime bugs that heap-allocation of DisplayLink::Client and dead-store-resilient CheckedPtr zombies may have made newly reachable/deterministic.