← 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. Privileged process 안에 있는 네 개의 독립적인 handler가 sandbox 내부에서 전달받은 site identity를 permission 또는 platform-service 판단의 key로 사용했습니다. Identity spoofing이 실제 escalation으로 이어지는지는 각 system service가 해당 값을 어떻게 처리하는지에 달려 있습니다. 다만 geolocation 경로 하나만 보더라도, 사용자가 승인 여부를 판단해야 하는 프롬프트에 엉뚱한 사이트 이름이 표시되는 상황이 발생합니다.

WebKit은 브라우징 세션을 두 개의 process로 나눠 처리합니다. Site content를 파싱하고 실행하는 sandbox된 WebContent process가 있고, navigation state를 소유하며 permission prompt를 렌더링하고 AppSSO, MarketplaceKit, CoreLocation 같은 platform service의 client 역할을 수행하는 UI process가 있습니다. UI process는 각 frame에 대해 자체적인 사본을 유지합니다. 이것이 WebFrameProxy이며, document가 commit되는 시점에 url()securityOrigin()이 기록됩니다. 한편 WebContent는 자신이 보내는 여러 message에 직렬화 가능한 FrameInfoData frame 설명을 함께 첨부합니다. 원래 기대되는 동작은, UI process가 다른 곳으로 넘기는 site identity가 실제로 commit된 document를 정확히 반영하는 것입니다.

관전 포인트: 침해된 WebContent process가 geolocation prompt, AppSSO authorization, MarketplaceKit install에 피해 사이트의 이름을 올릴 수 있고, 자신이 지정하는 임의의 registrable domain으로 location grant를 redeem할 수 있습니다.

UI process의 IPC handler 네 곳은 DispatchedFrom=WebContent message에서 origin/domain 필드를 받아, 이를 UI-process-authoritative state와 비교하는 절차 없이 system service에 그대로 전달해 사이트별 authorization key로 사용했습니다. 침해된 WebContent process가 origin을 spoof하면, CoreLocation·AppSSO·MarketplaceKit이나 geolocation policy decider가 다른 사이트에 대한 permission 결정을 적용하게 만들 수 있습니다. 이번 변경으로 IPC로 전달된 필드를 그대로 신뢰하는 대신, 각 값을 UI-process-authoritative state로부터 다시 유도하도록 수정되었습니다.

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();
 
**Patch Details**
 
이번 변경은 forward되는 origin 값 세 가지를 소비 시점에 UI process 상태로부터 다시 계산하도록 하고, 별도로 geolocation authorization token에 redemption 시점에 검증 가능한 scope를 부여합니다.
 
Origin 재계산 클러스터에서는, `SOAuthorizationSession`이 `m_navigationAction->sourceFrame()->securityOrigin()`을 읽던 부분을 완전히 제거하고, 모든 initiating action에 대해 `initiatorOrigin`을 `SecurityOrigin::create(mainFrame->url())->toString()`으로 계산하도록 변경되었습니다. `SubFrame`이 아닌 경우에는 `!mainFrameOrigin->isOpaque()` 조건으로 보호되며, 이는 기존에 `InitiatingAction::SubFrame`에만 적용되던 로직을 일반화한 것입니다. `NavigationState.mm`의 `interceptMarketplaceKitNavigation`에서는 `action->data().requester->topOrigin`이 `SecurityOriginData::fromURL(page.mainFrame()->url())`로 교체되었고, gating null-check와 `requesterTopOriginURL` 계산 양쪽 모두에 적용됩니다. `WebPageProxy::requestGeolocationPermissionForFrame`은 기존의 무음 처리 `if (!frame) return;`을 `MESSAGE_CHECK(process, frame)`으로 바꾸고, `frame->url().host()`가 비어있지 않은 경우 `frameInfo.securityOrigin`을 `frame->securityOrigin()->data()`로 덮어씁니다.
 
Token scoping 클러스터에서는, `GeolocationPermissionRequestProxy`에 `RegistrableDomain m_registrableDomain` 멤버와 accessor가 추가되었습니다. `GeolocationPermissionRequestManagerProxy::m_validAuthorizationTokens`는 `HashSet<String>`에서 `HashMap<String, RegistrableDomain>`으로 변경되었으며, `didReceiveGeolocationPermissionDecision`에서 채워집니다. 또한 `registrableDomainForAuthorizationToken()` lookup이 새로 추가되었습니다. `WebGeolocationManagerProxy::startUpdatingWithProxy`에서는 boolean 형태의 `isValidAuthorizationToken` 검사를 `MESSAGE_CHECK(..., !!authorizedDomain)`과 `MESSAGE_CHECK(..., authorizedDomain->isEmpty() || *authorizedDomain == registrableDomain)`로 대체합니다. 다만 두 geolocation 관련 값은 서로 다른 frame에서 온다는 점에 유의해야 합니다. Prompt에 표시되는 origin은 *요청한* frame에서, token binding은 *main* frame의 eTLD+1에서 나옵니다.
 
<mark>보안 결정을 제약해야 할 대상인 sandbox 컴포넌트 자신이 제공한 값을, 그 결정의 key로 신뢰하는 패턴입니다. Privileged 측이 authoritative하게 보유한 상태로부터 다시 계산하는 대신 그렇게 처리한 것이 문제입니다.</mark>
 
**Background**
 
**이 코드가 있는 위치.** UI process는 WebContent sandbox 경계의 반대편에 위치합니다. `DispatchedFrom=WebContent`로 표시된 메시지는 그 sandbox 내부에서 발생하며, 설계상 WebContent가 compromise된 경우 attacker-controlled로 간주됩니다.
 
**양쪽의 frame identity.** `FrameInfoData`는 frame을 설명하는 직렬화 가능한 struct로, `frameID`, request, `securityOrigin`을 담고 있으며 WebContent가 다수의 UI-process 메시지에 첨부합니다. `WebFrameProxy`는 UI process가 자체적으로 보유한 동일 frame의 mirror입니다. `WebFrameProxy::url()`은 commit 시점에 기록된 URL이고, `WebFrameProxy::securityOrigin()`은 UI process가 commit된 document에 대해 계산한 origin입니다. 즉 양쪽 process 모두 같은 사실의 복사본을 보유하고 있지만, 그중 하나만이 authoritative합니다.
 
**Origin과 registrable domain의 차이.** `SecurityOrigin`은 `isOpaque()`/`toString()`을 갖는 ref-counted origin 객체이며, `SecurityOriginData`는 그 직렬화 가능한 scheme/host/port 값 타입으로 `SecurityOriginData::fromURL(url)`로 얻을 수 있습니다. `RegistrableDomain`은 eTLD+1로, site 단위 bucketing에 쓰이는 더 거친 site key입니다. Subframe의 registrable domain은 페이지의 main-frame registrable domain과 다를 수 있습니다.
 
**Geolocation grant 흐름.** WebContent가 `RequestGeolocationPermissionForFrame`을 전송하면, UI process는 `GeolocationPermissionRequestProxy`를 생성하고 embedder의 UI delegate에 확인을 요청합니다. 허용되면 `didReceiveGeolocationPermissionDecision`이 UUID authorization token을 생성해 저장하고 WebContent에 반환합니다. 이후 WebContent는 `StartUpdating`을 통해 해당 token과 `RegistrableDomain`을 함께 전송하고, `startUpdatingWithProxy`가 `m_perDomainData`에서 domain별로 bucketing된 CoreLocation update를 시작합니다.
 
**Platform-service consumer들.** AppSSO(`SOAuthorization`)는 `SOAuthorizationOptionInitiatorOrigin`을 포함한 options dictionary를 전달받으며, 이는 authorization flow를 시작한 origin입니다. `InitiatingAction`은 `Redirect`, `PopUp`, `SubFrame` 트리거를 구분합니다. MarketplaceKit은 alternative-marketplace navigation과 함께 referring top-origin URL을 전달받습니다.
 
**`MESSAGE_CHECK`.** UI-process handler에서 사용되는 WebKit IPC-validation macro입니다. 조건이 실패하면 메시지를 처리하는 대신 해당 메시지를 보낸 WebContent process를 종료시킵니다.
 
**IPC testing API.** JavaScript에 `IPC.addOutgoingMessageListener` / `IPC.sendMessage`를 노출하는 test-only 기능(`IPCTestingAPIEnabled`)입니다. 이를 통해 테스트가 나가는 메시지의 raw serialized byte를 캡처하고, 수정된 버전을 replay할 수 있습니다. Regression test에서 compromise된 WebContent process를 시뮬레이션하는 표준적인 방법입니다.
 
**Analysis**
 
이번 취약점은 WebContent→UIProcess trust boundary를 가로지르는 confused-deputy / origin-spoofing 결함입니다. Memory corruption이 아니라 authorization-logic 버그에 해당합니다.
 
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
```

네 가지 case 모두에서, UI process는 자신이 authoritative한 사본을 이미 보유하고 있는 값을 역직렬화해서 그 역직렬화된 사본을 사용합니다. SOAuthorization session은 source frame의 origin을 읽어 AppSSO에 initiator로 전달했습니다. interceptMarketplaceKitNavigation은 navigation-action payload에서 requester->topOrigin을 읽었습니다. requestGeolocationPermissionForFrameFrameInfoData::securityOrigin을 embedder의 requestGeolocationPermissionForFrame: delegate로 그대로 전달했는데, 이는 브라우저가 prompt에 렌더링하는 값입니다. SOAuthorizationTests.mm의 regression test는 comment에서 신뢰할 수 없는 carrier를 DecidePolicyForNavigationActionAsync 내부의 originatingFrameInfoData.securityOrigin으로 명시하고 있습니다. 이 연결 관계는 test comment에서 그대로 인용한 것으로, 해당 메시지로부터 API::NavigationAction::sourceFrame()의 origin을 채우는 경로 자체는 제공된 context에 포함되어 있지 않습니다.

Geolocation 경로에는 별개의 gap이 하나 더 있었습니다. startUpdatingWithProxy는 token이 HashSet<String> 안에 존재하는지만 검증했을 뿐, CoreLocation update가 bucketing되는 기준인 RegistrableDomain은 WebContent 메시지에서 그대로 가져와서 grant 시점에 기록된 어떤 값과도 비교하지 않았습니다. 즉 로드된 페이지에 대해 발급된 token을, renderer가 지정한 임의의 domain으로 update를 시작하는 데 사용할 수 있었습니다.

이 문제들은 일반적인 web content에서는 도달할 수 없습니다. 정상적인 WebContent process는 이런 필드를 진실하게 채우기 때문에, attacker는 이미 임의의 IPC를 emit할 수 있는 능력을 갖추고 있어야 합니다. 다만 그 primitive가 주어졌을 때 trigger sequence는 구체적이며, 두 regression test 모두 정상적인 outgoing message를 캡처해 localhost를 같은 길이의 evil.host로 byte-replace한 뒤 replay하는 방식으로 이를 증명합니다.

  1. Prompt spoofing. 유효한 frameInfo.frameID와 함께 frameInfo.securityOrigin을 victim host로 설정해 RequestGeolocationPermissionForFrame을 전송합니다. Embedder의 delegate는 위조된 origin을 관찰하게 됩니다. 새로 추가된 API test는 이제 delegate가 오직 실제 localhost만 관찰한다는 것을 검증합니다.
  2. Grant redemption이 scope 밖에서 이루어지는 경우. didReceiveGeolocationPermissionDecision을 통해 정상적인 token을 획득한 뒤, 그 token과 임의의 registrableDomain으로 StartUpdating을 전송합니다. 패치 이전에는 m_perDomainData.ensure(registrableDomain, ...)가 renderer가 지정한 임의의 bucket을 생성하거나 재사용했습니다.
  3. AppSSO initiator spoofing. DecidePolicyForNavigationActionAsync가 전달하는 origin을 위조합니다. SOAuthorizationTests.mm test는 관찰된 initiator origin이 wcptest://localhost로 유지되는지를 검증합니다.
  4. MarketplaceKit attribution. Requester의 topOrigin을 위조해, referring top-origin URL이 victim site를 가리키도록 만듭니다.

Identity spoofing을 넘어서는 확장 가능성은 각 system service가 해당 값을 어떻게 처리하는지에 달려 있습니다. AppSSO extension이 credential 발급이나 flow 선택을 InitiatorOrigin에 근거해 결정한다면, 위조된 값이 victim site 명의로 authorization flow를 조작하는 데로 이어질 가능성이 있습니다. 마찬가지로 MarketplaceKit이 referring top origin을 기준으로 install을 gating한다면, 위조된 값이 그 gate를 통과시킬 가능성도 있습니다. 다만 이러한 downstream 동작은 제공된 context에 나타나지 않으므로, 두 시나리오 모두 조건부로 남아 있습니다.

이번 fix 역시 의도적으로 부분적입니다. Geolocation override는 frame->url().host() / mainFrame->url().host()가 비어있지 않은 경우에만 적용됩니다. 코드 내 comment에 따르면, RegistrableDomain이 비어있다는 것은 UI process가 domain을 authoritative하게 판별할 수 없었다는 의미이며, 이 경우 StartUpdating은 equality check를 건너뛰고 WebContent가 제공한 domain을 그대로 받아들입니다. 어떤 구체적인 로드 유형이 비어있는 frame-URL host를 만들어내는지는 제공된 context만으로는 확정되지 않지만, 그러한 로드를 유도할 수 있는 attacker라면 여전히 이 필드들을 제어할 수 있게 됩니다.

이 취약점은 WebContent→UIProcess trust boundary를 약화시키고, 이를 통해 site별 permission model 전체를 약화시킵니다. 이 model은 system service에 전달되는 origin이 UI process가 실제로 commit한 document를 반영한다는 것, 그리고 permission grant가 발급된 scope 내에서만 redeem 가능하다는 것을 전제로 합니다. 이미 WebContent에서 code execution을 확보한 attacker라면, platform service와 embedding app이 다른 site의 identity로 동작하게 만들 수 있습니다. 사용자가 승인하는 prompt에 위조된 origin이 표시되거나, location grant가 관련 없는 domain bucket 하에서 redeem되거나, SSO나 app-install flow가 victim site 명의로 진행될 수 있습니다. 이는 post-compromise scope escalation에 해당합니다. Sandbox escape를 제공하지는 않지만, renderer compromise를 permission model이 막고자 했던 cross-site privacy 및 identity-integrity 실패로 전환시킵니다.

Insight: 같은 형태의 문제가 한 process 안에서 서로 독립된 네 곳에 나타난다는 것은 개별 bug report가 아니라 audit sweep의 흔적입니다. 이런 패턴이 반복되는 이유는 frame-info와 navigation-action struct가 본질적으로 편의용 carrier이기 때문입니다. WebContent는 이미 origin을 알고 있으므로 이를 직렬화해 전달하면 UI process의 lookup을 절약할 수 있지만, UI process는 어차피 WebFrameProxy에 authoritative한 사본을 보유하고 있습니다. 여기서 두 가지 세부 사항은 눈여겨볼 만합니다. Geolocation fix는 서로 다른 두 frame에서 나온 두 dimension을 binding합니다. 즉 StartUpdating equality check는 사용자가 실제로 prompt받은 origin이 아니라 페이지의 main-frame site를 고정합니다. 따라서 cross-origin subframe에 대한 grant는 여전히 embedder site의 bucket 하에서 redeem됩니다. 또한 SOAuthorizationSession 변경은 RedirectPopUp에 대해 정밀도를 안전성과 맞바꿉니다. 기존에는 source frame의 origin을 보고했지만 이제는 main-frame origin을 보고하는데, 이는 cross-origin subframe에서 시작된 SSO flow에 실질적인 semantic downgrade이며, 이후 기능적 regression report로 다시 표면화될 수 있는 성격의 변경입니다.