← WebKit Silent-Fix Report — 2026-W23

9d60567537546eba3b8e490334a06b2fb3ecb0ae  Fix use-after-free in ~WebProcessProxy() when replying to pending IPC messages

severity high class UAF confidence 0.90 WebKit UIProcess exploitable-grade
Zak Ridouh Tue Jun 2 21:22:21 2026 -0700 full: 9d60567537546eba3b8e490334a06b2fb3ecb0ae bug report ↗ view on GitHub ↗
Primitive: Use-after-free in ~WebProcessProxy() when replying to pending IPC messages
Triage note: Tearing down a cross-site iframe while its WebProcess is still launching left a WebPage::Close async-reply in AuxiliaryProcessProxy::m_pendingMessages; ~AuxiliaryProcessProxy cancelled it after ~WebProcessProxy freed m_pagesPendingClose, so the reply lambda's upgraded WeakPtr touched freed members. Fix calls replyToPendingMessages() while state is intact.
Contents

The bug at a glance

This is a use-after-free in the WebKit UIProcess (the privileged broker that owns every WebProcess), reachable from ordinary web content that creates and quickly removes a cross-site iframe under site isolation. The freed memory is read by an IPC reply lambda during process teardown, giving an attacker a timing-controlled UAF in the most privileged part of the browser; combined with the difficulty of winning the launch/teardown race this squares with the assigned High / CVSS 8.1 (network vector, high attack complexity, no privileges, high impact on confidentiality and integrity).

The bug lives entirely in C++ object destruction order, not in any logic anyone wrote deliberately. WebProcessProxy derives from AuxiliaryProcessProxy, so when the proxy dies the derived destructor guts its own members first, and only afterwards does the base destructor try to be a good citizen and reply to every still-pending async IPC message. The catch: the WeakPtrFactory those reply lambdas depend on lives in the base class, so it is still ‘alive’ during base destruction — the WeakPtr upgrades cleanly and hands the lambda a pointer to a WebProcessProxy whose members are already rubble. The fix is a one-line reordering: drain the reply queue at the very top of the derived destructor, while the object is still whole.

Root cause

A WebProcessProxy is an AuxiliaryProcessProxy. While the underlying process is still launching, outgoing IPC that expects a reply is not sent immediately; it is parked in AuxiliaryProcessProxy::m_pendingMessages, each entry carrying a completion handler that will run when the process finishes launching or when the queue is cancelled. WebPageProxy::close() / sendPageCloseMessage() enqueues exactly such a WebPage::Close async-reply, whose lambda captures a WeakPtr<WebProcessProxy> and, on invocation, upgrades it and reads WebProcessProxy state such as m_pagesPendingClose.

When a cross-site iframe is torn down before its provisional WebProcess has launched, the WebProcessProxy is destroyed with that reply still queued. C++ destroys the most-derived class first: ~WebProcessProxy() runs and destroys m_pagesPendingClose (and the rest of WebProcessProxy’s members). Only then does the base ~AuxiliaryProcessProxy() run, and it calls replyToPendingMessages() to cancel the queue. That invocation fires the parked Close reply lambda.

The lambda’s WeakPtr<WebProcessProxy> is still upgradeable at this point because the WeakPtrFactory (via CanMakeWeakPtr) is a member of a base class that has not yet been destroyed — object teardown has only reached the derived layer. So the upgrade succeeds and returns a pointer to storage whose derived-class members are already destroyed. The lambda then dereferences freed members like m_pagesPendingClose: a heap use-after-free, in the UIProcess address space, with the attacker controlling both the timing (microtask-scheduled iframe removal during launch) and the churn rate.

The fix in Source/WebKit/UIProcess/WebProcessProxy.cpp adds a single call to replyToPendingMessages() at the top of ~WebProcessProxy(), before any members are torn down. The pending reply lambdas now run while WebProcessProxy state is fully intact, so the upgraded WeakPtr points at a live object. When ~AuxiliaryProcessProxy() later calls replyToPendingMessages() again, the queue is already empty and the second call is a no-op. Nothing about the WeakPtr lifetime or the base-class factory changes — only the moment at which the queue is drained.

Key code

The fix: drain pending IPC replies while WebProcessProxy members are still alive (WebProcessProxy.cpp)

WebProcessProxy::~WebProcessProxy()
{
    ASSERT(m_pageURLRetainCountMap.isEmpty());
    WEBPROCESSPROXY_RELEASE_LOG(Process, "destructor:");

    // ~AuxiliaryProcessProxy() replies to pending messages after our members are gone; a reply
    // handler that upgrades its still-live WeakPtr<WebProcessProxy> would then touch freed members
    // (e.g. m_pagesPendingClose). Cancel them now, while our state is intact.
    replyToPendingMessages();

    liveProcessesLRU().remove(*this);
    ...
}

Patch walkthrough

  • Source/WebKit/UIProcess/WebProcessProxy.cpp — Adds replyToPendingMessages(); as the first substantive statement of ~WebProcessProxy(), right after the release-log line and before liveProcessesLRU().remove(*this) and the member teardown. This drains AuxiliaryProcessProxy::m_pendingMessages while all WebProcessProxy members are still valid, so reply lambdas that upgrade their WeakPtr<WebProcessProxy> touch live state. A comment documents that the later base-class ~AuxiliaryProcessProxy() call would otherwise reply after members are gone. The base call remains but now finds an empty queue.
  • LayoutTests/http/tests/site-isolation/remove-iframe-while-process-launching-crash.html — Regression test that repeatedly appends a cross-site iframe (frame-with-text.html on localhost:8000) and removes it from a microtask via Promise.resolve().then(() => f.remove()), so removal lands while the provisional frame’s WebProcess is still launching. The five-iteration churn maximises the chance of hitting the teardown-during-launch window that leaves a WebPage::Close reply queued. It passes if the UIProcess does not crash.
  • LayoutTests/http/tests/site-isolation/remove-iframe-while-process-launching-crash-expected.txt — Expected output is the single line ‘PASS if no crash’, matching the text the test writes into the body once the churn completes. The test is a pure crash-regression check with no functional assertions.

Background

AuxiliaryProcessProxy::m_pendingMessages — The UIProcess queue of outgoing IPC messages that could not be sent yet because the target process is still launching. Each entry may carry an async-reply completion handler that is invoked when the process comes up or when the queue is cancelled via replyToPendingMessages().

Site isolation and provisional processes — Under SiteIsolationEnabled, a cross-site iframe is hosted in its own WebProcess. That process is provisioned and launched asynchronously, so there is a window in which the frame exists but its WebProcess has not finished launching.

WeakPtr / WeakPtrFactory lifetime across base classes — A WeakPtr becomes null only when its WeakPtrFactory is invalidated, which happens when the class holding the factory is destroyed. If the factory lives in a base class, a WeakPtr can still upgrade successfully after a derived class’s members have already been destroyed.

C++ destruction order — Destructors run most-derived first, then bases. So ~WebProcessProxy() completes (freeing its members) before ~AuxiliaryProcessProxy() runs, which is precisely why work done in the base destructor can observe freed derived-class state.

Vulnerability window

  1. Setup — Page creates a cross-site iframe under site isolation; WebKit begins provisioning and launching a dedicated WebProcess for it.
  2. Queue a reply — Because the process is still launching, a WebPage::Close (or similar) message with an async-reply handler capturing WeakPtr<WebProcessProxy> is parked in AuxiliaryProcessProxy::m_pendingMessages.
  3. Teardown races launch — The iframe is removed from a microtask before launch completes; WebPageProxy::didDestroyFrame drives destruction of the WebProcessProxy while the reply is still queued.
  4. Derived destructor frees members — ~WebProcessProxy() runs and destroys m_pagesPendingClose and other members; the base AuxiliaryProcessProxy (with the WeakPtrFactory) is not yet destroyed.
  5. Base destructor fires the lambda — ~AuxiliaryProcessProxy() calls replyToPendingMessages(); the parked lambda upgrades its still-valid WeakPtr<WebProcessProxy> and reads freed m_pagesPendingClose — heap use-after-free in the UIProcess.

Proof of concept

Faithful reduction of the shipped regression test. Rapidly appending and microtask-removing a cross-site iframe maximises the chance that a WebProcessProxy is destroyed while its process is still launching and a Close reply is still queued. The crash is in the UIProcess, not the page, so a successful trigger shows up as a UIProcess ASan use-after-free rather than a JS-observable error; it is inherently racy and may need many iterations.

<!-- webkit-test-runner [ SiteIsolationEnabled=true ] -->
<!DOCTYPE html>
<body>
<script>
let count = 0;
function churn() {
    let f = document.createElement("iframe");
    // cross-site relative to the top document -> dedicated provisional WebProcess
    f.src = "http://localhost:8000/site-isolation/resources/frame-with-text.html";
    document.body.appendChild(f);
    // Remove on a microtask: the provisional frame's WebProcess is still
    // launching when didDestroyFrame tears down its WebProcessProxy, leaving
    // a WebPage::Close async-reply parked in m_pendingMessages.
    Promise.resolve().then(() => f.remove());
    if (++count < 5)
        setTimeout(churn, 0);
}
onload = churn;
</script>
</body>

Exploitation

  1. Trigger the free-then-read — Winning the race is the entire difficulty: the attacker must remove the iframe inside the narrow window where the provisional WebProcess is launching and a reply is queued. Microtask-scheduled removal plus high churn is a reliable way to hit it repeatedly, but it remains probabilistic.
  2. Groom the freed WebProcessProxy — Turning the read of freed m_pagesPendingClose into a controlled primitive requires reclaiming the just-freed WebProcessProxy allocation with attacker-influenced UIProcess heap content before the base destructor fires — a same-thread reclamation with essentially no window, making this hard to steer in practice.
  3. Escalate within the UIProcess — Because the corruption is in the privileged UIProcess, a reliably controlled UAF here would be high value (it is above the sandbox), but the tight, single-threaded free/use window and the indirection through an upgraded WeakPtr make weaponisation substantially harder than the crash itself.

Detection & hunting

For defenders and SOC / detection engineers:

  • UIProcess crash in replyToPendingMessages / async-reply teardown — ASan/MallocScribble UIProcess crashes whose backtrace runs through ~AuxiliaryProcessProxy -> replyToPendingMessages into a WebPageProxy Close reply lambda, especially reading WebProcessProxy members, indicate this class of teardown UAF.
  • Iframe churn during process launch — Fuzz site-isolated pages that add and immediately remove cross-site iframes on microtasks/timers; correlate crashes with the provisional-process-launching state to reproduce the window.
  • WeakPtr upgrade succeeding during destruction — Instrument or assert that reply handlers do not run against partially-destroyed proxies; a WeakPtr<WebProcessProxy> that upgrades while a derived destructor has already run is the tell.

Audit directions

  • Base-class destructor callbacks touching derived state — Audit every AuxiliaryProcessProxy subclass and any pattern where a base destructor invokes queued callbacks (replyToPendingMessages, invalidate handlers) that upgrade a WeakPtr whose factory is in a base class — the same free-then-use ordering can recur.
  • Async-reply handlers capturing WeakPtr to the proxy — Review other messages enqueued into m_pendingMessages (beyond WebPage::Close) whose completion lambdas read proxy members; each is a candidate for firing after derived teardown.
  • Provisional/launching-process teardown paths — Trace WebPageProxy::didDestroyFrame and process-swap/provisional-process cleanup for other objects destroyed while a process is mid-launch with work still parked pending launch completion.

Before / after

Loading diff…