← 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이며, renderer가 이미 장악된 이후에만 의미가 있습니다. 메모리 손상은 발생하지 않습니다. 다만 compromised WebContent process가 피해 사이트의 origin을 신뢰된 시스템 동의 UI에 표시하도록 만들 가능성이 있습니다. 이것이 실제 credential misbinding으로 이어지는지는 플랫폼 chooser가 전달받은 origin을 어떻게 사용하는지에 달려 있습니다.

WebKit의 multi-process 모델에서 중개되는 동의 UI는 요청 주체를 privileged UI process가 직접 판단해야 안전합니다. sandbox된 renderer의 주장을 그대로 믿어서는 안 됩니다. Digital Credentials API의 요청은 WebPageProxy로 들어옵니다. WebPageProxy는 페이지의 IPC message를 수신하는 UI process 측 객체입니다. 이 API는 플랫폼 wallet에 모바일 운전면허증 같은 신분 문서를 제시하도록 요청합니다. 이때 각 요청에는 frame identifier와 함께 top-level origin, 요청 document의 origin이 포함됩니다. 시스템 credential chooser에 표시되는 신원은 원래 message를 보낸 connection에 묶인 UI process state에서 가져와야 합니다.

관전 포인트: WebContent process를 이미 장악한 attacker라면, 위조한 요청 origin으로 시스템 credential chooser를 띄우는 것이 이론적으로 가능합니다. 이 경우 chooser에는 신뢰할 수 있는 relying party가 표시됩니다. 결과적으로 사용자가 이를 믿고 신원 credential을 넘기도록 유도될 가능성이 있습니다.

Digital Credentials: 신뢰된 UI process state에서 요청 origin 도출. 기존 WebPageProxy::showDigitalCredentialsChooser는 requestData에 담긴 WebContent 제공 origin을 그대로 사용해 credential chooser를 표시했습니다. 문제는 이 값을 compromised WebContent process가 임의로 조작할 수 있다는 점입니다. 수정 후에는 IPC frame identifier로 요청 frame을 조회하고, 해당 frame이 이 페이지에 없으면 요청을 거부합니다. 또한 IPC가 그 frame의 process에서 왔는지 message check로 검증합니다. 이어서 UI process의 신뢰된 frame state에서 top-level origin과 요청 origin을 도출합니다. 이 값으로 requestData를 정제한 뒤에야 chooser를 표시합니다. 원래 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: UI process가 요청 frame을 확인할 수 없으므로 반드시 거부해야 합니다.
+ 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.", ...);

WebPageProxy::showDigitalCredentialsChooser의 HAVE(DIGITAL_CREDENTIALS_UI) 분기 안에서 세 가지가 변경되었습니다. 먼저 WebFrameProxy::webFrame(frameID)로 요청 frame을 조회하고 this->mainFrame()을 가져오도록 수정되었습니다. 둘 중 하나라도 null이거나 requestingFrame->page() != this이면 AbortError로 요청을 거부합니다. 다음으로 기존 MESSAGE_CHECK_COMPLETION_BASE 조건이 변경되었습니다. 기존 check는 renderer가 보낸 requestData.topOrigin을 SecurityOrigin::create(mainFrame()->url())과 isSameOriginDomain으로 비교했습니다. 새 조건은 &requestingFrame->process() == WebProcessProxy::fromConnection(connection).ptr()을 요구합니다. 실패 메시지도 일반적인 "Digital credentials request is not allowed."로 바뀌었습니다. 마지막으로 user activation이 사용된 이후 sanitizedRequestData를 생성합니다. 이 복사본의 topOrigin과 documentOrigin은 main frame과 요청 frame의 WebFrameProxy에서 얻은 documentSecurityOriginData()로 덮어쓰입니다. 이후 원본 데이터 대신 이 복사본이 PageClient::showDigitalCredentialsChooser로 전달됩니다. 한편 null frameID로 위조 message를 보내는 IPC layout test가 새로 추가되었고, glib TestExpectations 항목에서는 이 test를 skip합니다.

Privilege boundary에서의 confused deputy: broker가 인증된 호출자에 묶인 broker 측 state가 아니라, 비권한 client가 스스로 보고한 principal을 기준으로 동의 결정을 내리는 패턴.

이 코드가 있는 위치. WebKit은 web content를 sandbox된 WebContent process에서 실행합니다. GPU process와 Networking process는 그래픽과 네트워크 I/O를 담당합니다. UI process는 WebKit을 내장한 앱 자체로, frame tree, commit된 origin, user activation timestamp 같은 신뢰된 state를 보관합니다. WebContent는 IPC로 UI process와 통신하며, WebPageProxy 같은 클래스가 이 message를 수신합니다.

IPC 검증 매크로. MESSAGE_CHECK와 그 변형인 MESSAGE_CHECK_COMPLETION_BASE는 compromised renderer에 대한 WebKit의 표준 방어 수단입니다. 조건이 실패하면 해당 message는 비정상 process가 보낸 것으로 간주됩니다. 이때 message는 invalid로 표시되고, 대개 해당 WebContent process가 종료됩니다. 전달된 completion도 함께 실행됩니다.

WebFrameProxy와 site isolation. WebFrameProxy는 UI process가 frame을 표현하는 객체입니다. WebFrameProxy::webFrame(frameID)는 UI process의 map에서 frame을 조회합니다. page()는 소유 WebPageProxy를, process()는 해당 frame을 호스팅하는 WebProcessProxy를 반환합니다. Site isolation 환경에서는 한 페이지의 frame들이 서로 다른 WebContent process에 있을 수 있습니다. 즉 페이지를 안다고 해서 어떤 process가 message를 보냈는지까지 알 수는 없습니다.

Commit된 origin과 URL 기반 origin. documentSecurityOriginData()는 frame에 commit된 document의 origin입니다. 반면 SecurityOrigin::create(url)은 URL 문자열에서 origin을 계산합니다. about:blank나 sandbox된 frame처럼 origin을 상속받거나 opaque origin을 갖는 document에서는 두 값이 달라질 수 있습니다.

Digital Credentials. navigator.credentials.get에 digital 멤버를 지정하면, 사이트가 플랫폼 wallet에 신분 문서를 요청할 수 있습니다. ISO mdoc 형식의 모바일 운전면허증이 대표적인 예입니다. 요청 데이터(DigitalCredentialsMobileDocumentRequestData)에는 topOrigin과 documentOrigin이 들어 있습니다. 브라우저는 시스템 chooser를 띄우고, 사용자는 여기서 해당 요청자에게 credential을 공유할지 동의합니다.

UI process는 serialize된 요청 데이터를 그대로 믿고 요청 주체를 판단했습니다. 즉 IPC validation이 누락된 logic error에 해당합니다.

  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   │
                                     └──────────────────────────────────────┘

다이어그램의 "Before" 상자에는 세 가지 빈틈이 있습니다. (a) 실제로 요청을 보내는 frame의 origin인 documentOrigin은 전혀 검증되지 않았고, 그대로 chooser에 전달되었습니다. (b) HAVE(DIGITAL_CREDENTIALS_UI) 분기 안에서는 IPC frameID를 조회하지도, 송신 connection에 묶지도 않았습니다. null frameID, 다른 페이지에 속한 frame, 다른 WebContent process가 호스팅하는 frame이 모두 여기에 해당합니다. 마지막 경우는 여러 process가 하나의 WebPageProxy와 통신하는 site isolation 환경에서 특히 중요합니다. 다만 showDigitalCredentialsChooser의 앞부분에서 frameID를 제약했는지는 아직 열린 질문입니다. 아무 제약도 없었다면 세 경우 모두 chooser까지 도달했을 것입니다. (c) topOrigin check조차 기대값을 main frame의 commit된 document origin이 아니라 URL에서 만들었습니다. 그래서 URL이 없거나 origin을 상속받는 document에서는 두 값이 어긋날 수 있었습니다.

Regression test는 IPC testing API를 통해 compromised renderer threat model을 직접 재현합니다.

  1. IPC.connectionForProcessTarget("UI")를 획득합니다.
  2. WebPageProxy_ShowDigitalCredentialsChooser 인자를 serialize합니다. 이때 frameID: {}(null)와 함께, origin과 빈 requests를 test가 임의로 정한 DigitalCredentialsMobileDocumentRequestData를 사용합니다.
  3. message를 전송하고, 거부 메시지 "Digital credentials request has no active frame."을 assert합니다.

이 test는 패치 이후의 거부 동작만 확인합니다. 기존 분기는 frame 조회를 전혀 수행하지 않았습니다. 따라서 showDigitalCredentialsChooser의 앞부분에서 거부하지 않는 한, null frameID가 chooser까지 도달했을 것입니다.

일반 web content로는 이 경로에 도달할 수 없습니다. Renderer 측 coordinator는 두 origin을 정직하게 채우기 때문입니다. message를 위조하려면 IPCTestingAPIEnabled가 켜져 있거나 WebContent process가 compromise된 상태여야 합니다. 이런 compromise를 전제하면, 패치 이전 handler는 위조된 origin과 sender에 묶이지 않은 frame identifier를 받아들였을 것입니다. 그리고 이를 그대로 플랫폼 chooser로 전달했을 것입니다. 메모리 손상이 아니라, 중개된 capability를 2단계로 악용하는 형태입니다. 피해 범위는 사용자 동의와 lastActivationTimestamp 사용 여부로 제한되며, 이 timestamp는 여전히 매 요청을 gate합니다.

확장 여부를 가르는 질문은 PageClient::showDigitalCredentialsChooser 뒤의 chooser가 topOrigin과 documentOrigin을 어떻게 사용하는지입니다. 단순한 표시용 문자열이라면, 영향의 상한은 사용자 기만에 의존하는 UI spoofing에 그칠 것으로 보입니다. 반대로 origin 범위의 session transcript나 암호화 context처럼, origin에 묶인 응답 생성에 쓰인다면 상황이 달라집니다. 이 경우 compromised renderer가 위조된 origin을 이용해, 피해 relying party 범위로 한정된 credential presentation을 얻을 가능성이 있습니다.

이 vulnerability는 WebContent process와 UI process 사이의 isolation 경계를 약화시킵니다. 깨진 invariant는 다음과 같습니다. 신뢰된 시스템 UI에 표시되거나 그 안에 결합되는 신원 주장은, 송신 connection에 묶인 UI process state에서 나와야 합니다. Renderer가 serialize한 데이터에서 나와서는 안 됩니다. 패치 이전에는 compromised renderer가 위조된 요청 origin을 chooser에 표시하고, 그 값으로 동작하게 만들 수 있었습니다. URL 기반 check는 통과하지만 commit된 document와는 일치하지 않는 topOrigin도 마찬가지였습니다.

Insight: 패치 이전 코드에도 MESSAGE_CHECK는 있었습니다. 문제는 검증 대상과 방식이 모두 잘못되었다는 점입니다. renderer가 보낸 두 origin 중 하나만 검사했고, 비교 기준도 URL에서 만든 origin이었습니다. 송신 process와의 결합도 없었습니다. Renderer 데이터를 broker state와 비교하는 validator는, renderer 데이터를 버리고 broker state로 치환하는 방식보다 약합니다. 이번 fix가 택한 것이 바로 후자입니다. requestingFrame->process() == WebProcessProxy::fromConnection(connection) 비교는 이 방어를 site isolation 시대에 맞게 다듬은 형태입니다. 여러 WebContent process가 하나의 WebPageProxy를 공유하게 되면, 페이지를 신뢰하는 것만으로는 충분하지 않습니다. IPC로 전달되는 모든 frameID를 송신자에 묶어야 합니다.