← All reports

[1] WebContent-supplied origin forwarded to system services without UI-process re-derivation

HighWebKit UIProcess IPC surfaceAuthBypass

The UI process asked the sandbox which site it was talking to.

5ca4d87

High. Four independent handlers in the privileged process used a site identity that arrived from inside the sandbox as the key to a permission or platform-service decision. Escalation past identity spoofing depends on what each system service does with the value, but the geolocation path alone puts the wrong site's name on a prompt the user is asked to approve.

WebKit splits a browsing session between a sandboxed WebContent process that parses and executes site content and a UI process that owns navigation state, renders permission prompts, and acts as the client of platform services such as AppSSO, MarketplaceKit, and CoreLocation. The UI process keeps its own mirror of each frame — a WebFrameProxy, whose url() and securityOrigin() are recorded when the document commits — while WebContent attaches its own serializable FrameInfoData frame description to many of the messages it sends. The expectation is that any site identity the UI process hands onward reflects the document it actually committed.

The angle: a compromised WebContent process can put a victim site's name on a geolocation prompt, an AppSSO authorization, and a MarketplaceKit install, and can redeem a location grant under any registrable domain it names.

Four UI-process IPC handlers accepted an origin/domain field from a DispatchedFrom=WebContent message and forwarded it verbatim to a system service as a per-site authorization key, without comparing it against UI-process-authoritative state. A compromised WebContent process could spoof the origin and have CoreLocation, AppSSO, MarketplaceKit, or the geolocation policy decider apply another site's permission decision. The change re-derives each value from UI-process-authoritative state instead of trusting the IPC-supplied field.

Source/WebKit/UIProcess/WebPageProxy.cpp

void WebPageProxy::requestGeolocationPermissionForFrame(IPC::Connection& connection, GeolocationIdentifier geolocationID, FrameInfoData&& frameInfo)
{
+ Ref process = WebProcessProxy::fromConnection(connection);
+
RefPtr frame = WebFrameProxy::webFrame(frameInfo.frameID);
- if (!frame)
- return;
+ MESSAGE_CHECK(process, frame);
+
+ if (!frame->url().host().isEmpty())
+ frameInfo.securityOrigin = frame->securityOrigin()->data();
+
+ WebCore::RegistrableDomain mainFrameDomain;
+ if (RefPtr mainFrame = m_mainFrame.get(); mainFrame && !mainFrame->url().host().isEmpty())
+ mainFrameDomain = WebCore::RegistrableDomain { mainFrame->url() };
 
- auto request = protect(internals().geolocationPermissionRequestManager)->createRequest(geolocationID, protect(frame->process()));
+ auto request = protect(internals().geolocationPermissionRequestManager)->createRequest(geolocationID, protect(frame->process()), WTF::move(mainFrameDomain));

Source/WebKit/UIProcess/WebGeolocationManagerProxy.cpp

void WebGeolocationManagerProxy::startUpdatingWithProxy(WebProcessProxy& proxy, ...)
{
- auto isValidAuthorizationToken = protect(page->geolocationPermissionRequestManager())->isValidAuthorizationToken(authorizationToken);
- MESSAGE_CHECK(proxy.connection(), isValidAuthorizationToken);
+ auto authorizedDomain = protect(page->geolocationPermissionRequestManager())->registrableDomainForAuthorizationToken(authorizationToken);
+ MESSAGE_CHECK(proxy.connection(), !!authorizedDomain);
+ MESSAGE_CHECK(proxy.connection(), authorizedDomain->isEmpty() || *authorizedDomain == registrableDomain);

Source/WebKit/UIProcess/Cocoa/SOAuthorization/SOAuthorizationSession.mm

auto initiatorOrigin = emptyString();
- if (RefPtr sourceOrigin = m_navigationAction->sourceFrame() ? m_navigationAction->sourceFrame()->securityOrigin().securityOrigin().ptr() : nullptr; sourceOrigin && !sourceOrigin->isOpaque())
- initiatorOrigin = sourceOrigin->toString();
String initiatingPath = emptyString();
- if (m_page->mainFrame()) {
- if (m_action == InitiatingAction::SubFrame)
- initiatorOrigin = WebCore::SecurityOrigin::create(m_page->mainFrame()->url())->toString();
- initiatingPath = m_page->mainFrame()->url().path().toString();
+ RefPtr page = m_page.get();
+ if (RefPtr mainFrame = page ? page->mainFrame() : nullptr) {
+ Ref mainFrameOrigin = WebCore::SecurityOrigin::create(mainFrame->url());
+ if (m_action == InitiatingAction::SubFrame || !mainFrameOrigin->isOpaque())
+ initiatorOrigin = mainFrameOrigin->toString();
+ initiatingPath = mainFrame->url().path().toString();
}

LayoutTests/ipc/forged-geolocation-permission-frame-origin.html

+ const msgName = IPC.messages.WebPageProxy_RequestGeolocationPermissionForFrame.name;
+ IPC.addOutgoingMessageListener("UI", function (desc) {
+ // Replay the captured message with the FrameInfoData::securityOrigin bytes mutated.
+ const mutated = new Uint8Array(new Uint8Array(desc.buffer));
+ // ... byte-replace "file" with "http" (same-length scheme swap) ...
+ IPC.sendMessage("UI", desc.destinationID, msgName, mutated.slice(16));
+ });
+ navigator.geolocation.getCurrentPosition(function () { }, function () { });

The change re-derives three forwarded origin values from UI-process state at their consuming call sites, and separately gives the geolocation authorization token a scope it can be checked against at redemption.

In the origin-re-derivation cluster: SOAuthorizationSession drops the read of m_navigationAction->sourceFrame()->securityOrigin() entirely and computes initiatorOrigin as SecurityOrigin::create(mainFrame->url())->toString() for all initiating actions, guarded by !mainFrameOrigin->isOpaque() for the non-SubFrame cases — generalizing what previously applied only to InitiatingAction::SubFrame. interceptMarketplaceKitNavigation in NavigationState.mm replaces action->data().requester->topOrigin with SecurityOriginData::fromURL(page.mainFrame()->url()) for both the gating null-check and the requesterTopOriginURL it computes. WebPageProxy::requestGeolocationPermissionForFrame turns the silent if (!frame) return; into MESSAGE_CHECK(process, frame) and overwrites frameInfo.securityOrigin with frame->securityOrigin()->data() whenever frame->url().host() is non-empty.

In the token-scoping cluster: GeolocationPermissionRequestProxy gains a RegistrableDomain m_registrableDomain member and accessor; GeolocationPermissionRequestManagerProxy::m_validAuthorizationTokens changes from HashSet<String> to HashMap<String, RegistrableDomain>, populated in didReceiveGeolocationPermissionDecision; and a registrableDomainForAuthorizationToken() lookup is added. WebGeolocationManagerProxy::startUpdatingWithProxy replaces the boolean isValidAuthorizationToken test with MESSAGE_CHECK(..., !!authorizedDomain) plus MESSAGE_CHECK(..., authorizedDomain->isEmpty() || *authorizedDomain == registrableDomain). Note that the two geolocation dimensions come from different frames: the prompt origin from the requesting frame, the token binding from the main frame's eTLD+1.

Trusting a security-decision key supplied by the very sandboxed component the decision is meant to constrain, instead of re-deriving it from state the privileged side authoritatively owns.

Where this lives. The UI process sits on the far side of the WebContent sandbox boundary. Messages marked DispatchedFrom=WebContent originate inside that sandbox and are, by design, treated as attacker-controlled once WebContent is compromised.

Frame identity on both sides. FrameInfoData is a serializable struct describing a frame — its frameID, request, securityOrigin — that WebContent attaches to many UI-process messages. WebFrameProxy is the UI process's own mirror of the same frame: WebFrameProxy::url() is the URL recorded at commit time and WebFrameProxy::securityOrigin() is the origin the UI process computed for the committed document. Both processes therefore hold a copy of the same fact, and only one of them is authoritative.

Origin vs. registrable domain. SecurityOrigin is the ref-counted origin object with isOpaque()/toString(); SecurityOriginData is its serializable scheme/host/port value type, obtainable via SecurityOriginData::fromURL(url). RegistrableDomain is the eTLD+1 — a coarser site key used for per-site bucketing. A subframe's registrable domain can differ from the page's main-frame registrable domain.

The geolocation grant flow. WebContent sends RequestGeolocationPermissionForFrame; the UI process creates a GeolocationPermissionRequestProxy and asks the embedder's UI delegate. On allow, didReceiveGeolocationPermissionDecision mints a UUID authorization token, stores it, and returns it to WebContent. WebContent later sends StartUpdating carrying that token plus a RegistrableDomain, and startUpdatingWithProxy starts CoreLocation updates bucketed per domain in m_perDomainData.

Platform-service consumers. AppSSO (SOAuthorization) receives an options dictionary including SOAuthorizationOptionInitiatorOrigin, the origin that initiated the authorization flow; InitiatingAction distinguishes Redirect, PopUp, and SubFrame triggers. MarketplaceKit receives alternative-marketplace navigations together with a referring top-origin URL.

MESSAGE_CHECK. A WebKit IPC-validation macro used in UI-process handlers: when the condition fails, the sending WebContent process is terminated rather than the message being processed.

IPC testing API. A test-only facility (IPCTestingAPIEnabled) exposing IPC.addOutgoingMessageListener / IPC.sendMessage to JavaScript, letting a test capture the raw serialized bytes of an outgoing message and replay a modified one — the standard way to simulate a compromised WebContent process in a regression test.

This is a confused-deputy / origin-spoofing flaw across the WebContent→UIProcess trust boundary — an authorization-logic bug, not memory corruption.

  WebContent (sandboxed)          |   UI process (privileged)
  ------------------------        |   ------------------------
  RequestGeolocationPermission -->|   frameInfo.securityOrigin  --> embedder prompt
    frameInfo.securityOrigin      |     (pre-fix: used verbatim)
                                  |
  DecidePolicyForNavigation ----->|   sourceFrame()->securityOrigin() --> AppSSO
    originatingFrameInfoData      |     SOAuthorizationOptionInitiatorOrigin
                                  |
  StartUpdating(token, domain) -->|   token in HashSet? yes --> CoreLocation
                                  |     m_perDomainData.ensure(domain)   <- unscoped
                                  |
        ^ trust boundary the forged value crosses

In each of the four cases the UI process deserializes a value that it also holds an authoritative copy of, and uses the deserialized copy. The SOAuthorization session read the source frame's origin and passed it to AppSSO as the initiator; interceptMarketplaceKitNavigation read requester->topOrigin from the navigation-action payload; requestGeolocationPermissionForFrame handed FrameInfoData::securityOrigin onward to the embedder's requestGeolocationPermissionForFrame: delegate, which is what a browser renders in the prompt. The regression test in SOAuthorizationTests.mm names the untrusted carrier in its comment as originatingFrameInfoData.securityOrigin inside DecidePolicyForNavigationActionAsync; that linkage is relayed from the test comment, since the supplied context does not include the path that populates API::NavigationAction::sourceFrame()'s origin from that message.

The geolocation path had a second, independent gap. startUpdatingWithProxy validated only that the token existed in a HashSet<String>; the RegistrableDomain under which CoreLocation updates were then bucketed came from the WebContent message and was never compared against anything recorded at grant time. A token minted for the loaded page could be spent to start updates under any domain the renderer named.

None of this is reachable from ordinary web content — a normal WebContent process fills these fields truthfully, so the attacker must already be able to emit arbitrary IPC. Given that primitive, the trigger sequences are concrete, and both regression tests demonstrate them by capturing a legitimate outgoing message and byte-replacing localhost with the same-length evil.host before replaying it:

  1. Prompt spoofing. Send RequestGeolocationPermissionForFrame with a valid frameInfo.frameID but frameInfo.securityOrigin set to a victim host; the embedder's delegate observes the forged origin. The new API test asserts the delegate now only ever observes the real localhost.
  2. Grant redemption outside its scope. Obtain a legitimate token via didReceiveGeolocationPermissionDecision, then send StartUpdating with that token and an arbitrary registrableDomain. Pre-fix, m_perDomainData.ensure(registrableDomain, ...) created or reused whatever bucket the renderer named.
  3. AppSSO initiator spoofing. Forge the origin carried in DecidePolicyForNavigationActionAsync; the SOAuthorizationTests.mm test asserts the observed initiator origin stays wcptest://localhost.
  4. MarketplaceKit attribution. Forge the requester's topOrigin so the referring top-origin URL names a victim site.

Escalation beyond identity spoofing depends on how each system service treats the value: if AppSSO extensions key credential issuance or flow selection on InitiatorOrigin, a forged value could steer an authorization flow under a victim site's name; if MarketplaceKit gates installs on the referring top origin, a forged value could satisfy that gate. Neither downstream behavior appears in the supplied context, so both remain conditional.

The fix is also deliberately partial. The geolocation override applies only when frame->url().host() / mainFrame->url().host() is non-empty; the in-code comment states that an empty RegistrableDomain means the UI process could not authoritatively determine the domain, and StartUpdating then skips the equality check and accepts the WebContent-supplied domain. Which concrete load types produce an empty frame-URL host is not established by the supplied context, but an attacker who could arrange such a load would still control those fields.

This vulnerability weakens the WebContent→UIProcess trust boundary and, through it, the per-site permission model. The model assumes that the origin a system service is told about reflects the document the UI process actually committed, and that a permission grant is redeemable only within the scope it was issued for. An attacker who already has code execution in WebContent could therefore have platform services and the embedding app apply a different site's identity — a spoofed origin in a prompt the user approves, a location grant redeemed under an unrelated domain bucket, an SSO or app-install flow driven under a victim site's name. This is post-compromise scope escalation: it grants no sandbox escape, but it converts renderer compromise into the cross-site privacy and identity-integrity failure the permission model was supposed to contain.

Insight: the same shape at four independent sites in one process is the signature of an audit sweep, not a bug report. It recurs because the frame-info and navigation-action structs are convenience carriers — WebContent already knows the origin, so serializing it saves the UI process a lookup — while the UI process holds an authoritative copy in WebFrameProxy anyway. Two details are worth carrying forward. The geolocation fix binds two different dimensions from two different frames, so the StartUpdating equality check pins the page's main-frame site, not the origin the user was actually prompted for; a cross-origin subframe grant is still redeemed under the embedder site's bucket. And the SOAuthorizationSession change trades precision for safety on Redirect and PopUp, which previously reported the source frame's origin and now report the main-frame origin — a real semantic downgrade for SSO flows started from a cross-origin subframe, and the kind of change that surfaces later as a functional regression report.