← All reports

[4] WebPasteboardProxy accepted remote frame IDs outside the copied subtree

MediumWebKit UIProcessCrossOrigin

A frame ID is a name, not a permission — the pasteboard disagreed.

Severity: Medium | Component: WebKit UIProcess WebPasteboardProxy | 69aa562

Medium. Site Isolation is exactly the boundary this breaks: a compromised renderer gets another site's serialized DOM without needing a second memory-safety bug in the victim's process. It stays out of the High band because it requires that prior compromise and yields disclosure only, with no memory-corruption primitive.

Under Site Isolation, frames from different sites are hosted in separate content processes, so no single process holds the DOM for a whole page. Copying a page that spans that split therefore requires the UI process to act as an aggregator: the sending process submits archives for the frames it hosts locally and a list of identifiers for the out-of-process frames, and the UI process gathers the rest. FrameIdentifier is a process-global handle, resolvable in the UI process through a registry that spans every frame in the browser. The guarantee at stake is that a process can only cause that aggregator to reach frames it is entitled to — the subtree it is actually copying.

The angle: a compromised renderer can name any live frame in the browser as a remote subframe of its own copy operation and have the UI process serialize that frame's DOM into a pasteboard the attacker named.

WebPasteboardProxy currently does no subtree checks on the remote frame IDs it is given, which means it could cause content from an unrelated cross-site remote frame to be written to the pasteboard. Fix this by adding message checks to ensure that all remote frame IDs used within WebPasteboardProxy are descendants of the root frame ID provided.

Source/WebKit/UIProcess/Cocoa/WebPasteboardProxyCocoa.mm

+// Only allow exploring frames that descend from the provided root frame.
+static bool validateFrameIdentifiers(FrameIdentifier rootFrameIdentifier, const HashMap<FrameIdentifier, Ref<WebCore::LegacyWebArchive>>& localFrameArchives, const Vector<FrameIdentifier>& remoteFrameIdentifiers)
+{
+ auto isInSubtree = [&](WebFrameProxy& frame) {
+ for (RefPtr ancestor = &frame; ancestor; ancestor = ancestor->parentFrame()) {
+ if (ancestor->frameID() == rootFrameIdentifier)
+ return true;
+ }
+ return false;
+ };
+
+ auto isAllowed = [&](FrameIdentifier identifier) {
+ if (identifier == rootFrameIdentifier)
+ return true;
+ RefPtr frame = WebFrameProxy::webFrame(identifier);
+ return !frame || isInSubtree(*frame);
+ };
+
+ for (auto& [frameIdentifier, archive] : localFrameArchives) {
+ if (!isAllowed(frameIdentifier))
+ return false;
+ Ref protectedArchive = archive;
+ if (auto archiveFrameIdentifier = protectedArchive->frameIdentifier(); archiveFrameIdentifier && !isAllowed(*archiveFrameIdentifier))
+ return false;
+ for (auto subframeIdentifier : protectedArchive->subframeIdentifiers()) {
+ if (!isAllowed(subframeIdentifier))
+ return false;
+ }
+ }
+
+ for (auto identifier : remoteFrameIdentifiers) {
+ if (!isAllowed(identifier))
+ return false;
+ }
+
+ return true;
+}
...
// WebPasteboardProxy::writeWebContentToPasteboard
+ MESSAGE_CHECK(validateFrameIdentifiers(*rootFrameIdentifier, content.localFrameArchives, content.remoteFrameIdentifiers), connection);
 
Ref senderProcess = WebProcessProxy::fromConnection(connection);
auto localFrameArchives = content.localFrameArchives;
createOneWebArchiveFromFrames(senderProcess.get(), *rootFrameIdentifier, WTF::move(localFrameArchives), content.remoteFrameIdentifiers, ...);
...
-void WebPasteboardProxy::writeWebArchiveToPasteBoard(..., CompletionHandler<void(int64_t)>&& completionHandler)
+void WebPasteboardProxy::writeWebArchiveToPasteBoard(..., CompletionHandler<void(WriteWebArchiveToPasteBoardResult, int64_t)>&& completionHandler)
{
- MESSAGE_CHECK_COMPLETION(!pasteboardName.isEmpty(), connection, completionHandler(0));
+ MESSAGE_CHECK_COMPLETION(!pasteboardName.isEmpty(), connection, completionHandler(WriteWebArchiveToPasteBoardResult::FailureOther, 0));
+ MESSAGE_CHECK_COMPLETION(validateFrameIdentifiers(rootFrameIdentifier, localFrameArchives, remoteFrameIdentifiers), connection, completionHandler(WriteWebArchiveToPasteBoardResult::FailureDueToInvalidFrameIdentifiers, 0));

LayoutTests/http/tests/ipc/resources/write-web-archive-frame-ancestry-check-iframe.html

+ let myFrameID = IPC.frameID[0];
+ let parentFrameID = await new Promise((resolve) => { ... parent.postMessage({ getParentFrameID: true }, '*'); });
+
+ // Forge a WriteWebArchiveToPasteBoard IPC that names an out-of-process frame ID (the parent
+ // frame) as a remote subframe. This should fail with the FailureDueToInvalidFrameIdentifiers
+ // error to prevent writing a cross-process DOM to the pasteboard.
+ let r = IPC.sendSyncMessage('UI', 0, IPC.messages.WebPasteboardProxy_WriteWebArchiveToPasteBoard.name, 1000, [
+ S(pbName),
+ FrameID(myFrameID),
+ u64(0n),
+ u64(1n), FrameID(parentFrameID),
+ ]);

The change adds an ancestry validator and wires it into both pasteboard-write entry points, then reshapes the sync reply so a rejection is observable to tests.

The new file-static validateFrameIdentifiers() defines an isInSubtree lambda that walks WebFrameProxy::parentFrame() upward looking for the root, and an isAllowed lambda that accepts an identifier if it equals the root, if WebFrameProxy::webFrame(identifier) resolves to nothing, or if the resolved frame is in the root's subtree. Every identifier reaching the pasteboard path runs through it: each key of localFrameArchives, each archive's own frameIdentifier(), each entry of the archive's subframeIdentifiers(), and every entry of remoteFrameIdentifiers. writeWebContentToPasteboard gains a MESSAGE_CHECK before createOneWebArchiveFromFrames; writeWebArchiveToPasteBoard gains a MESSAGE_CHECK_COMPLETION.

To make the rejection distinguishable, the sync reply of WriteWebArchiveToPasteBoard changes from (int64_t changeCount) to (enum:uint8_t WriteWebArchiveToPasteBoardResult result, int64_t changeCount), with a new Shared/WriteWebArchiveToPasteBoardResult.h plus .serialization.in defining Success / FailureDueToInvalidFrameIdentifiers / FailureOther; every early return in the handler and its nested completion lambdas now carries a result code, and WebPlatformStrategies::writeWebArchive destructures the two-value reply while still returning only newChangeCount. Collateral: build-system registration of the new serialization file, and a test-only hook in RemotePageProxy::RemotePageProxy calling setIgnoreInvalidMessageForTesting() under ENABLE(IPC_TESTING_API) when both the ipcTestingAPIEnabled() and ignoreInvalidMessageWhenIPCTestingAPIEnabled() preferences are set, so the layout test can send a deliberately invalid message from a site-isolated subframe process without that process being torn down.

Trusting a caller-supplied global identifier as proof of entitlement, without checking that the named object lies within the subtree the caller is authorized over.

Site Isolation. Frames from different sites are hosted in separate WebContent processes; a given process holds the DOM only for its own frames, and cross-site subframes appear locally as placeholder "remote frames".

FrameIdentifier. A process-global identifier for a frame. In the UI process, WebFrameProxy::webFrame(identifier) resolves any identifier to its WebFrameProxy, and WebFrameProxy::parentFrame() walks the authoritative frame tree the UI process maintains across all WebContent processes.

LegacyWebArchive. WebKit's serialized page format — main resource plus subresources plus nested subframe archives. It records the identifier of the frame it came from (frameIdentifier()) and the identifiers of its child frames (subframeIdentifiers()), so an archive submitted over IPC carries frame identifiers inside it as well as in the message's top-level vectors.

Multi-process archive assembly. When a copy spans a site-isolated frame tree, the sending process submits archives for its own local frames plus a list of FrameIdentifiers for out-of-process frames; WebPasteboardProxy::createOneWebArchiveFromFrames gathers the remaining pieces. LegacyWebArchiveCallbackAggregator collects them via addResult() and, on destruction, calls completeFrameArchive(rootFrameIdentifier) to splice subframe archives into their parents before invoking the completion handler.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit's IPC-validation macros. A failed check treats the message as malformed and terminates the sending connection; the _COMPLETION variant first invokes the supplied completion expression so the sync reply is not dropped.

ENABLE(IPC_TESTING_API). A build- and preference-gated facility exposing an IPC object to JavaScript in layout tests, standing in for a compromised WebContent process; setIgnoreInvalidMessageForTesting() prevents a failed MESSAGE_CHECK from killing the test's process.

Pasteboard naming. Pasteboard writes are keyed by a caller-supplied pasteboardName string, and WebPasteboardProxy also serves the read side for WebContent processes.

This is missing authorization on IPC-supplied identifiers — a confused-deputy / site-isolation bypass leading to cross-origin information disclosure, not a memory-safety bug.

  WebContent A (127.0.0.1)          WebContent B (localhost)
  ------------------------          ------------------------
  main frame  [F1]                  iframe  [F2]  (child of F1)
                                       |
                                       | WriteWebArchiveToPasteBoard
                                       |   root = F2, remote = [F1]
                                       v
                        UI process: WebPasteboardProxy
                          pre-fix : webFrame(F1) resolves -> archive F1 -> pasteboard
                          post-fix: isInSubtree(F1, root=F2) == false -> reject

The only validation applied before this change was a non-empty pasteboard name plus a webFrame(rootFrameIdentifier) null check. Because FrameIdentifier resolves through a global registry, any identifier the sender writes into the message resolves to whatever frame owns it anywhere in the browser, regardless of which process sent the message or where that frame sits in the tree. As the diagram shows, the identifier the attacker names can be an ancestor of its declared root, a sibling, or — since nothing restricted the named frame to the same page — a frame in an entirely different tab. createOneWebArchiveFromFrames would then resolve it and merge its serialized DOM into the archive written to the caller-named pasteboard.

The path is not reachable from ordinary web content: JavaScript has no way to choose the rootFrameIdentifier or the remoteFrameIdentifiers vector — the legitimate producer, WebPlatformStrategies::writeWebArchive, derives both from the frame tree actually being copied. Reaching the vulnerable state requires the ability to emit arbitrary IPC on the WebContent→UIProcess connection; the layout test substitutes IPCTestingAPIEnabled=true for that capability.

Given that capability, the attack needs no memory grooming: send WriteWebArchiveToPasteBoard with an attacker-chosen pasteboardName, rootFrameIdentifier set to a frame the compromised process legitimately owns, an empty localFrameArchives map, and remoteFrameIdentifiers containing the identifier of a cross-site victim frame. The regression test walks exactly this shape: the top-level document at 127.0.0.1:8000 embeds a localhost:8000 iframe, which under Site Isolation lands in a different process; the iframe asks its parent for IPC.frameID[0] via postMessage, then hand-encodes the sync message as [String pbName, FrameID(myFrameID), u64(0), u64(1), FrameID(parentFrameID)]. The named remote frame is the ancestor of the declared root, so isInSubtree walks upward from the parent and never encounters the child's frameID(); isAllowed returns false and the handler now replies FailureDueToInvalidFrameIdentifiers instead of assembling a cross-process archive.

Reading the result back is gated separately — WebPasteboardProxy::accessType()/canAccessPasteboardData() depend on prior grantAccess() calls or on a page in that process having domPasteAllowed() plus javaScriptCanAccessClipboard() — so recovering the archive in-process requires satisfying that gate; the disclosure to the system pasteboard, and thence to whatever the user pastes into next, does not. Escalation beyond disclosure is not indicated: no length, offset, or buffer-size field is under attacker control, so this would not convert into a memory-corruption primitive.

This vulnerability weakens the Site Isolation trust boundary the UI process is responsible for enforcing. The assumption at stake is that a WebContent process can only cause the UI process to act on frames it legitimately hosts or that descend from the frame it is copying; before the fix, a raw FrameIdentifier from an untrusted sender was treated as sufficient entitlement to reach any frame in the browser's global registry. An attacker with code execution in one WebContent process could have the UI process serialize an unrelated cross-site frame's DOM — cross-origin content and any secrets rendered into it — without needing a second memory-safety bug in the victim's process.

Insight: this is the classic shape of a Site Isolation retrofit bug. An IPC message that predates isolation carried FrameIdentifiers as pure data; when it was extended to describe a multi-process frame tree, the identifiers silently became capabilities without acquiring the matching entitlement check. Two details of the fix are instructive. It validates identifiers nested inside the serialized payload, not just the ones spelled out in the message signature — necessary because completeFrameArchive recursively expands subframeIdentifiers() to pull in more frames. And isAllowed fails open for identifiers the UI process cannot resolve (return !frame || isInSubtree(*frame)), so the guarantee delivered is "no live, resolvable, out-of-subtree frame can be pulled in", not "only subtree frames can be named" — a weaker post-condition than the commit message's phrasing suggests, and worth keeping in mind when reasoning about teardown races.