← All reports

[6] Cross-Process Page Identity Confusion in didPostMessage

MediumWebKit UIProcess IPC surfaceAuthBypass

The UIProcess looked up the page, then never asked who was asking.

1ad0d2a

Medium. A renderer that already has code execution can steer UIProcess-mediated script-message dispatch onto a page belonging to a different renderer, which is exactly the confinement site isolation exists to provide. It is not higher because it grants no memory-safety primitive and yields nothing to ordinary web content.

WebKit splits the browser into a privileged UIProcess and multiple sandboxed WebContent processes, with the UIProcess acting as reference monitor for everything the renderers ask it to do. Each WebContent process is represented in the UIProcess by a WebProcessProxy — the object that terminates that process's IPC connection and receives its messages as ordinary C++ method calls — while a WebPageProxy is the UIProcess-side object for one web view, named on the wire by a WebPageProxyIdentifier. The invariant a per-connection handler must uphold is an ownership one: a WebProcess may only name pages it actually hosts.

The angle: a WebProcess already under attacker control can name a page hosted by a different renderer and have the UIProcess deliver script-message dispatch under that page's identity, plus receive whatever the async reply carries back.

From the commit message:

WebProcessProxy::didPostMessage() may look up a WebPageProxy belonging to another web process if given a bad WebPageProxyIdentifier from a compromised WebProcess.

Address the issue by adding a MESSAGE_CHECK that checks that the page is associated with the current WebProcess, using the pre-existing WebProcessProxy::isAssociatedWithPage() utility function. Note that I had to tweak isAssociatedWithPage() to also check m_remotePages to keep site isolation tests working.

Source/WebKit/UIProcess/WebProcessProxy.cpp

void WebProcessProxy::didPostMessage(WebPageProxyIdentifier pageID, UserContentC...
RefPtr page = WebPageProxy::fromIdentifier(pageID);
if (!page)
return completionHandler(makeUnexpected(String()));
+ MESSAGE_CHECK_COMPLETION(isAssociatedWithPage(pageID), completionHandler(makeUnexpected(String())));
RefPtr controller = WebUserContentControllerProxy::get(identifier);
if (!controller)
return completionHandler(makeUnexpected(String()));
...
bool WebProcessProxy::isAssociatedWithPage(WebPageProxyIdentifier pageID) const
{
if (m_pageMap.contains(pageID))
return true;
- for (auto& provisionalPage : m_provisionalPages) {
- if (provisionalPage.page() && provisionalPage.page()->identifier() == pageID)
+
+ for (Ref remotePage : m_remotePages) {
+ if (remotePage->page() && remotePage->page()->identifier() == pageID)
+ return true;
+ }
+ for (Ref provisionalPage : m_provisionalPages) {
+ if (provisionalPage->page() && provisionalPage->page()->identifier() == pageID)
return true;
}
for (auto& suspendedPage : m_suspendedPages) {
if (suspendedPage.page() && suspendedPage.page()->identifier() == pageID)
return true;
}
+ if (m_pagesPendingClose.contains(pageID))
+ return true;
return false;
}
...
+void WebProcessProxy::sendPageCloseMessage(std::optional<WebPageProxyIdentifier> pageProxyID, WebCore::PageIdentifier pageID, CompletionHandler<void()>&& completionHandler)
+{
+ if (pageProxyID)
+ m_pagesPendingClose.add(*pageProxyID);
+ sendWithAsyncReply(Messages::WebPage::Close(), [weakThis = WeakPtr { *this }, pageProxyID, completionHandler = WTF::move(completionHandler)]() mutable {
+ if (RefPtr protectedThis = weakThis; protectedThis && pageProxyID) {
+ protectedThis->m_pagesPendingClose.remove(*pageProxyID);
+ protectedThis->reportProcessDisassociatedWithPageIfNecessary(*pageProxyID);
+ }
+ if (completionHandler)
+ completionHandler();
+ }, pageID);
+}

Source/WebKit/UIProcess/RemotePageProxy.cpp

void RemotePageProxy::disconnect()
{
- if (RefPtr page = m_page.get())
+ RefPtr page = m_page;
+ if (page)
page->isNoLongerAssociatedWithRemotePage(*this);
if (m_drawingArea)
- m_process->sendWithAsyncReply(Messages::WebPage::Close(), [] { }, m_webPageID);
+ m_process->sendPageCloseMessage(page ? std::optional { page->identifier() } : std::nullopt, m_webPageID);

The change lands as two coordinated pieces in Source/WebKit/UIProcess: one adds the missing ownership check on the message handler, the other broadens the ownership predicate so legitimate traffic still passes, which in turn requires new lifetime bookkeeping around the asynchronous page-close handshake.

WebProcessProxy::didPostMessage() — the UIProcess-side IPC handler that receives user-content script messages from a WebProcess — previously resolved the caller-supplied WebPageProxyIdentifier through the global WebPageProxy::fromIdentifier() registry and proceeded if any page existed. The patch inserts MESSAGE_CHECK_COMPLETION(isAssociatedWithPage(pageID), completionHandler(makeUnexpected(String()))) immediately after the lookup, so a page that does not belong to the sending process now fails the message check — which tears down the connection — instead of being used.

WebProcessProxy::isAssociatedWithPage() is broadened so legitimate traffic still passes: it now also iterates m_remotePages (site-isolation subframe hosting, previously not consulted) and returns true for identifiers in a new HashCountedSet<WebPageProxyIdentifier> m_pagesPendingClose member. The NODELETE annotation on the declaration is dropped, since the function now has a production call site. To populate that set, a new helper WebProcessProxy::sendPageCloseMessage(std::optional<WebPageProxyIdentifier>, WebCore::PageIdentifier, CompletionHandler<void()>&&) adds the page-proxy ID to m_pagesPendingClose before sendWithAsyncReply(Messages::WebPage::Close(), ...) and removes it — plus calls reportProcessDisassociatedWithPageIfNecessary — in the async reply lambda, which captures WeakPtr { *this }. Every previous direct sender of Messages::WebPage::Close() is rewritten to route through the helper: ProvisionalPageProxy::~ProvisionalPageProxy, RemotePageProxy::disconnect, SuspendedPageProxy::close, WebPageProxy::close's deferred runloop dispatch, and WebPageProxy::commitProvisionalPage. RemotePageProxy::disconnect and SuspendedPageProxy::close hoist a RefPtr page local so they can pass std::nullopt when the page is already gone.

Trusting a caller-supplied identifier resolved through a global object registry without verifying the caller owns the object it resolves to.

Where this lives. WebKit splits the browser into a privileged UIProcess and multiple sandboxed WebContent processes. Each WebContent process is represented in the UIProcess by a WebProcessProxy, which owns the IPC connection to it and receives messages from it as C++ method calls. Because the proxy is per-connection, this inside any of its message handlers identifies the sending process.

WebPageProxy and its identifier. A WebPageProxy is the UIProcess-side object for one web view. WebPageProxyIdentifier is its process-wide identifier, and WebPageProxy::fromIdentifier() looks one up in a registry spanning every page in the UIProcess, regardless of which WebProcess hosts them.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit's IPC-validation macros. When the asserted condition is false they treat the message as malformed and terminate the offending connection; the _COMPLETION variant additionally runs a supplied expression first, so an outstanding async reply is not dropped on the floor.

Page/process association states. A page can relate to a WebProcess in several ways, and this multiplicity is the reason the ownership predicate is nontrivial. m_pageMap holds pages the process currently hosts. ProvisionalPageProxy represents a page being loaded in a new process during a cross-site process swap. SuspendedPageProxy represents a page kept alive in the back/forward cache. Under site isolation, RemotePageProxy represents a page whose frames are partly hosted in another process — the subframe's process holds a RemotePageProxy rather than an entry in m_pageMap.

User content messages. Page script can post messages to the embedding application through the user-content controller. The WebProcess forwards them to the UIProcess, where WebProcessProxy::didPostMessage resolves the target page and controller and delivers the message, with an async reply carrying a result back to the WebProcess.

sendWithAsyncReply. Sends an IPC message and registers a completion handler that runs when the peer replies. Pending handlers are queued on the sender-side process proxy and are cancelled — invoked with no result — if the connection or the proxy goes away. The cancellation path matters here because it runs during teardown, on a different schedule from the success path.

This is an authorization flaw: missing ownership validation on an IPC-supplied identifier, in the confused-deputy shape. No memory-safety primitive is involved — the resolved WebPageProxy is a valid live object, just the wrong one.

  WebContent A (compromised)         UIProcess                     WebContent B
  ──────────────────────────         ─────────                     ────────────
  DidPostMessage(pageID_of_B) ─────► WebProcessProxy(A)::didPostMessage
                                       │
                                       ├─► WebPageProxy::fromIdentifier(pageID)
                                       │      global registry: ALL pages   ◄── hosts B's page
                                       │      only check: if (!page) return
                                       ▼
                                     WebUserContentControllerProxy::get(id)
                                       │
                                       └─► dispatch to embedding app, bound to B's page
                                             async reply ──────────► back to A

As the diagram shows, didPostMessage() is a per-connection handler — this is the proxy for the sending WebProcess — yet it resolved the attacker-supplied identifier through WebPageProxy::fromIdentifier(), a process-agnostic global registry covering every WebPageProxy in the UIProcess. The only validation was a null check on the lookup result; nothing tied the resolved page back to the connection the message arrived on.

WebPageProxyIdentifier values carry no per-connection namespacing. A WebProcess under attacker control emits a DidPostMessage IPC carrying the identifier of a page hosted by a different WebProcess; the UIProcess resolves that foreign page, fetches the WebUserContentControllerProxy by identifier, and continues the user-content message dispatch bound to the victim page rather than to any page the sender hosts.

The fix restores the ownership invariant by consulting isAssociatedWithPage(), which enumerates the process's own m_pageMap, m_remotePages, m_provisionalPages, m_suspendedPages, and m_pagesPendingClose. The m_remotePages and m_pagesPendingClose additions exist because the pre-existing predicate under-approximated legitimate association: under site isolation a subframe process holds only a RemotePageProxy for the page, and during the async WebPage::Close handshake the UIProcess has already dropped the page from its maps while the WebProcess is still legitimately sending messages from inside its own close handling.

This is not reachable from web content on its own: page script cannot choose the WebPageProxyIdentifier that the WebProcess stamps on a DidPostMessage IPC, and pages that legitimately share a WebProcess all appear in that process's m_pageMap, so they would pass the new check anyway. The realistic path is second-stage, matching the commit message's compromised-WebProcess threat model:

  1. Obtain code execution in a WebContent process via a separate renderer bug.
  2. Learn or brute-force a WebPageProxyIdentifier belonging to a page in another process. These are sequentially generated, so the search space would be small, and before the fix guessing wrong only caused the handler to bail on the null check rather than terminate the connection. The generation scheme — monotonic ObjectIdentifier::generate() versus a randomized identifier — is not established by the supplied context, so the cost of this step is a projection.
  3. Emit a hand-crafted WebProcessProxy::DidPostMessage naming that foreign identifier together with a UserContentControllerIdentifier.

Two consequences could follow. First, the embedding application's script-message handling could observe a message attributed to a page the attacker does not host; if the application authorizes privileged operations based on which web view or frame a message arrived from — a WKScriptMessageHandler gating native capabilities on the originating web view is the interesting target — that decision could be subverted. Second, didPostMessage's completion handler returns a value back to the sending process, so any result the victim page's handler produces might be delivered to the attacker's process, amounting to a cross-page information-disclosure channel. Both consequences are projections from the handler's visible shape (fromIdentifier lookup, controller lookup, Expected<..., String> reply); the body of didPostMessage past the patched lines is not in the supplied source context.

The vulnerable handler runs in the UIProcess, the most privileged process in the WebKit stack, but the fix does not prevent code execution there — it prevents one WebProcess from steering UIProcess-mediated dispatch toward another WebProcess's page. This is not itself a sandbox escape; the attacker still needs a separate escape for that. Its value in a chain is horizontal: it would let a compromise confined to one sandboxed renderer reach state belonging to a different renderer.

This change also expands the attack surface of the very check it installs. The m_pagesPendingClose set is a deliberate relaxation of the new validator: isAssociatedWithPage() now returns true for page identifiers the process no longer appears in any live page map for, from the moment sendPageCloseMessage() is called until the WebPage::Close async reply arrives or is cancelled. The assumption — not enforced — is that this window is bounded by the WebProcess's own responsiveness; a WebProcess that simply never replies to WebPage::Close would keep the identifier associated indefinitely, so any handler gated on isAssociatedWithPage() remains reachable for that page after teardown from the UIProcess's point of view. A second new coupling is lifetime-shaped: the reply lambda captures WeakPtr { *this } and mutates m_pagesPendingClose, a WebProcessProxy member, while the pending-reply queue lives in the base AuxiliaryProcessProxy. The class of bug the new state introduces is therefore destructor-ordering use-after-free in async-reply cancellation, distinct from the authorization bug being fixed.

Discovery most likely came from systematic pattern auditing of UIProcess IPC handlers rather than fuzzing or a crash report: the bug produces no crash, and the fix uses a utility that already existed in WebProcessProxy but had no production caller. The audit signature is "find every message handler on a per-connection proxy that resolves a WebPageProxyIdentifier through the global registry and check whether the page is validated against the sending process". The rdar reference and two-reviewer sign-off are consistent with an internal hardening sweep over the compromised-WebProcess threat model, and the fact that isAssociatedWithPage() needed extending for m_remotePages to keep site-isolation tests passing indicates the check was written first and its false-positive fallout discovered by the existing test suite.

This vulnerability weakens the cross-process isolation boundary between WebContent processes as mediated by the UIProcess. The security model assumes the UIProcess acts as a reference monitor: an identifier arriving on one process's connection may only designate objects that process legitimately hosts, so that a compromise of one renderer stays confined to the sites that renderer is permitted to host. Before the fix, that assumption was not enforced on this handler, so an attacker holding code execution in one WebProcess could act on a WebPageProxy belonging to an unrelated process — reaching the embedding application's script-message handling under the identity of a page the attacker never controlled. At the model level the consequence is horizontal privilege escalation across the process/page partition, and application-level authorization decisions keyed on which page or web view a script message came from could be subverted.

The interesting part of this commit is not the one-line MESSAGE_CHECK — it is how hard it turned out to be to define "this process owns this page". The pre-existing isAssociatedWithPage() was carrying a NODELETE annotation and covered only three of the five ways a page can relate to a process; the moment it was promoted from a telemetry helper to a security predicate, both site isolation (m_remotePages) and the asynchronous close handshake (m_pagesPendingClose) turned out to be legitimate association states it did not model. An association predicate that under-approximates a lifecycle with many transient states is a systemic risk in a UIProcess that juggles process swaps, back/forward cache, site isolation and deferred teardown simultaneously: too narrow and you break working IPC, too wide and the check stops being a check. A security hardening check whose correctness depends on new mutable lifetime-tracking state pays for itself twice.

Note: The commit message's account of the compromised-WebProcess threat model, and the tree's regression tests for the close-handshake fail-closed case and for a m_pagesPendingClose cancellation use-after-free, are relayed as described; those test sources are not included in the supplied context.