← All reports

[2] Digital Credentials chooser trusted renderer-supplied origins

MediumWebKit UIProcess Digital CredentialsAuthBypass

A compromised renderer could put any site's name on the system credential chooser.

26556dc

Medium, and only once the renderer is already owned. Nothing here corrupts memory, but a compromised WebContent process could put a victim site's origin on trusted system consent UI. Whether that becomes a real credential misbinding depends on how the platform chooser uses the origins it receives.

Consent UI that WebKit's multi-process model brokers depends on the privileged UI process deciding who is asking, without taking the sandboxed renderer's word for it. Requests from the Digital Credentials API arrive at WebPageProxy, the UI-process object that receives a page's IPC messages. That API asks a platform wallet to present an identity document such as a mobile driver's license, and each request carries a frame identifier plus the top-level origin and the requesting document's origin. The identity shown in the system credential chooser is supposed to come from UI-process state bound to the connection that sent the message.

The angle: an attacker who already controls a WebContent process could open the system credential chooser under a forged requesting origin. A user could then be led to release an identity credential while the chooser shows a trusted relying party.

Digital Credentials: derive request origins from trusted UI-process state. WebPageProxy::showDigitalCredentialsChooser presented the credential chooser using the WebContent-supplied origins in requestData, which a compromised WebContent process controls. The fix resolves the requesting frame from the IPC frame identifier, rejects the request if it names no frame in this page, and message-checks that the IPC came from that frame's process. It then derives the top-level and requesting origins from the UI process's trusted frame state and sanitizes requestData with them before presenting the chooser. Originally landed as 316606.155@safari-7625-branch.

Source/WebKit/UIProcess/WebPageProxy.cpp

#if HAVE(DIGITAL_CREDENTIALS_UI)
+ RefPtr requestingFrame = WebFrameProxy::webFrame(frameID);
+ RefPtr mainFrame = this->mainFrame();
+ if (!requestingFrame || !mainFrame || requestingFrame->page() != this) {
+ completionHandler(makeUnexpected(WebCore::ExceptionData { WebCore::ExceptionCode::AbortError, "Digital credentials request has no active frame."_s }));
+ return;
+ }
MESSAGE_CHECK_COMPLETION_BASE(
- requestData.topOrigin.securityOrigin()->isSameOriginDomain(SecurityOrigin::create(protect(mainFrame())->url())),
+ &requestingFrame->process() == WebProcessProxy::fromConnection(connection).ptr(),
connection,
- completionHandler(makeUnexpected(WebCore::ExceptionData { WebCore::ExceptionCode::SecurityError, "Digital credentials request is not same-origin with top-level navigable."_s }))
+ completionHandler(makeUnexpected(WebCore::ExceptionData { WebCore::ExceptionCode::SecurityError, "Digital credentials request is not allowed."_s }))
);
...
+ auto topLevelOrigin = mainFrame->documentSecurityOriginData();
+ auto requestingOrigin = requestingFrame->documentSecurityOriginData();
+
+ auto sanitizedRequestData = requestData;
+ sanitizedRequestData.topOrigin = WTF::move(topLevelOrigin);
+ sanitizedRequestData.documentOrigin = WTF::move(requestingOrigin);
+
- protect(pageClient())->showDigitalCredentialsChooser(requestData, WTF::move(completionHandler));
+ protect(pageClient())->showDigitalCredentialsChooser(sanitizedRequestData, WTF::move(completionHandler));

LayoutTests/ipc/digital-credentials-chooser-frame-check.html

+ // frameID = std::nullopt: the UI process cannot resolve a requesting frame, so it must reject.
+ const requestData = {
+ variantType: "WebCore::DigitalCredentialsMobileDocumentRequestData",
+ variant: { topOrigin: origin, documentOrigin: origin, requests: [] },
+ };
+ const serializedArguments = ArgumentSerializer.serializeArguments(
+ messageDefinition.arguments,
+ { frameID: {}, requestData },
+ );
+ const connection = IPC.connectionForProcessTarget("UI");
...
+ assert_equals(result.message, "Digital credentials request has no active frame.", ...);

Inside the HAVE(DIGITAL_CREDENTIALS_UI) branch of WebPageProxy::showDigitalCredentialsChooser, the patch makes three changes. First, it resolves the requesting frame with WebFrameProxy::webFrame(frameID) and takes this->mainFrame(). If either is null, or if requestingFrame->page() != this, it rejects the request with an AbortError. Second, it replaces the old MESSAGE_CHECK_COMPLETION_BASE condition. The old check compared the renderer-supplied requestData.topOrigin against SecurityOrigin::create(mainFrame()->url()) with isSameOriginDomain. The new one requires &requestingFrame->process() == WebProcessProxy::fromConnection(connection).ptr(), and its failure message becomes the generic "Digital credentials request is not allowed." Third, after user activation is consumed, it builds sanitizedRequestData, a copy whose topOrigin and documentOrigin are overwritten with documentSecurityOriginData() from the main and requesting WebFrameProxy. That copy goes to PageClient::showDigitalCredentialsChooser in place of the raw data. A new IPC layout test sends a forged message with a null frameID, and a glib TestExpectations entry skips it.

Confused deputy at a privilege boundary: a broker keys a consent decision on a principal the unprivileged client self-reports, not on broker-side state bound to the authenticated caller.

Where this lives. WebKit runs web content in sandboxed WebContent processes. GPU and Networking processes handle graphics and network I/O, and the UI process, which is the app embedding WebKit, holds trusted state such as the frame tree, committed origins and user-activation timestamps. WebContent talks to the UI process over IPC, and classes like WebPageProxy receive those messages.

IPC validation macros. MESSAGE_CHECK and its variant MESSAGE_CHECK_COMPLETION_BASE are WebKit's standard defense against a compromised renderer. When the condition fails, the message is treated as coming from a misbehaving process: it is marked invalid, which typically terminates that WebContent process, and the supplied completion runs.

WebFrameProxy and site isolation. WebFrameProxy is the UI process's representation of a frame. WebFrameProxy::webFrame(frameID) looks a frame up in a UI-process map, page() returns the owning WebPageProxy, and process() returns the WebProcessProxy that hosts the frame. Under site isolation, frames of one page can live in different WebContent processes, so knowing the page does not tell you which process sent a message.

Committed origin vs. URL-derived origin. documentSecurityOriginData() is the origin of the document committed in a frame. SecurityOrigin::create(url) computes an origin from a URL string. The two can differ for documents whose origin is inherited or opaque, such as about:blank or sandboxed frames.

Digital Credentials. navigator.credentials.get with a digital member lets a site ask a platform wallet for an identity document, for example an ISO mdoc mobile driver's license. The request data (DigitalCredentialsMobileDocumentRequestData) carries topOrigin and documentOrigin. The browser shows a system chooser where the user consents to sharing the credential with that requester.

This is a logic error, specifically missing IPC validation: the UI process treated the serialized request data as authoritative about who was asking.

  WebContent (compromised)            UI process (WebPageProxy)
  ┌───────────────────────┐          ┌──────────────────────────────────────┐
  │ ShowDigitalCredentials│   IPC    │ Before: topOrigin ~ create(mainURL)? │
  │  Chooser(frameID,     │ ───────► │   documentOrigin: unchecked          │
  │   topOrigin,          │          │   frameID: not bound to sender       │
  │   documentOrigin)     │          │   requestData ──► system chooser     │
  └───────────────────────┘          │ After: frame in page? sender hosts   │
                                     │   it? origins overwritten from       │
                                     │   WebFrameProxy ──► system chooser   │
                                     └──────────────────────────────────────┘

The "Before" box in the diagram has three gaps. (a) documentOrigin, the origin of the frame actually making the request, was never validated and was forwarded unchanged to the chooser. (b) Within the HAVE(DIGITAL_CREDENTIALS_UI) branch, the IPC frameID was never resolved or bound to the sending connection. This covers a null frameID, a frame belonging to another page, or a frame hosted by a different WebContent process. The last case matters under site isolation, where several processes talk to one WebPageProxy. Whether any earlier part of showDigitalCredentialsChooser constrained frameID is an open question; if nothing did, all three cases reached the chooser. (c) Even the topOrigin check built its expected value from the main frame's URL and not its committed document origin, so the two could disagree for URL-less or inherited-origin documents.

The regression test runs the compromised-renderer threat model directly through the IPC testing API:

  1. Get IPC.connectionForProcessTarget("UI").
  2. Serialize WebPageProxy_ShowDigitalCredentialsChooser arguments with frameID: {} (null) and a DigitalCredentialsMobileDocumentRequestData whose origins and empty requests are chosen by the test.
  3. Send it and assert the rejection message "Digital credentials request has no active frame."

The test checks post-fix rejection only. The replaced branch did no frame resolution, so a null frameID would have reached the chooser unless an earlier part of showDigitalCredentialsChooser rejected it.

Ordinary web content cannot reach this. The renderer-side coordinator fills both origins honestly, and forging the message needs either IPCTestingAPIEnabled or a compromised WebContent process. With such a compromise, the pre-fix handler would accept forged origins and unbound frame identifiers and forward them to the platform chooser. That is a second-stage abuse of a brokered capability, not memory corruption. User consent and the consumption of lastActivationTimestamp, which still gates each request, bound how far it goes. The escalation question is how the chooser behind PageClient::showDigitalCredentialsChooser uses topOrigin and documentOrigin. If they are only display strings, the ceiling would be UI spoofing that relies on deceiving the user. If they feed origin-bound response construction, such as an origin-scoped session transcript or encryption context, a forged origin could let a compromised renderer obtain a credential presentation scoped to a victim relying party.

This vulnerability weakens the isolation boundary between a WebContent process and the UI process. The broken invariant is that identity claims shown in, or bound into, trusted system UI must come from UI-process state tied to the sending connection and never from data the renderer serialized. Before the fix, a compromised renderer could make the credential chooser display and act on a forged requesting origin, or on a topOrigin that passed the URL-derived check but did not match the committed document.

Insight: the pre-fix code did have a MESSAGE_CHECK, but it validated the wrong thing in the wrong way: one of two renderer-supplied origins, compared against a URL-derived origin, and no binding to the sending process. A validator that compares renderer data to broker state is weaker than one that throws the renderer data away and substitutes broker state, which is what the fix does. The requestingFrame->process() == WebProcessProxy::fromConnection(connection) comparison is the site-isolation-era form of this defense. Once several WebContent processes share one WebPageProxy, trusting the page is not enough, and every frameID carried over IPC has to be bound to its sender.