← All issues

[1] WebAuthenticatorCoordinatorProxy signal replies invoked off the main run loop

Three WebAuthn replies came back on a queue WebKit didn't choose.

Severity: Medium | Component: WebKit UI process, WebAuthentication | 3d1e0ca

Medium. Reply continuation의 legal thread는 UI process가 이를 생성할 때 고정됩니다. 하지만 실제 호출은 platform이 선택한 queue에서 이루어지며, 이 invariant를 지키는 유일한 장치는 debug-time assertion뿐입니다. High band에 속하지 않는 이유는, concurrent window가 좁고 handler 자체를 넘어서는 범위에서 충돌하는 state가 이 commit만으로는 확인되지 않기 때문입니다.

WebKit의 UI process는 브라우저의 multi-process 모델에서 sandbox되지 않은, 가장 높은 권한을 가진 process입니다. 브라우저 UI 셸과 page-proxy 객체, 그리고 platform framework와의 모든 integration을 담당하며, state의 대부분이 main run loop에 국한되어 있습니다. Web content는 오직 IPC를 통해서만 이 process에 접근할 수 있습니다. 메시지가 도착하면 생성된 receiver가 이를 main에서 dispatch하고, async reply를 선언한 메시지의 경우 CompletionHandler를 생성합니다. 이는 WTF의 one-shot continuation으로, 생성 시점에 어떤 thread에서 호출되어야 하는지를 기록해 둡니다. 이 설계 전체가 의존하는 전제는, main run loop에서 생성된 reply handler가 호출될 때도 같은 곳에서 호출되어야 한다는 것입니다. 그래야 reply encoding과 그 주변의 main-thread 국한 state가 serialize된 상태로 유지됩니다.

관전 포인트: web content가 세 가지 WebAuthn signal* 메시지를 UI process로 보내면, platform credential updater가 자신의 queue에서 reply handler를 실행시킬 수 있습니다. 그 결과 UI process의 reply encoding이 main run loop와 동기화되지 않은 상태로 실행됩니다.

WebAuthenticatorCoordinatorProxy signal methods must invoke reply CompletionHandler on the main run loop

Ensure WebAuthenticatorCoordinatorProxy signalUnknownCredential, signalAllAcceptedCredentials, and signalCurrentUserDetails invoke their reply CompletionHandlers on the main run loop.

Test: ipc/web-authenticator-signal-main-thread.html Canonical link: https://commits.webkit.org/318615@main

Source/WebKit/UIProcess/WebAuthentication/Cocoa/WebAuthenticatorCoordinatorProxy.mm

[getCredentialUpdaterShimClassSingleton() signalUnknownCredentialWithRelyingPartyIdentifier:options.rpId.createNSString().get() credentialID:WTF::toNSData(*decodedCredentialId).get() completionHandler:makeBlockPtr([completionHandler = WTF::move(completionHandler)](NSError *error) mutable {
 
- if (error) {
 
- RELEASE_LOG_ERROR(WebAuthn, "Error signaling unknown credential: %@.", error.localizedDescription);
 
- completionHandler(ExceptionData { ExceptionCode::UnknownError, "Error signaling unknown credential."_s });
 
- return;
 
- }
 
- completionHandler(std::nullopt);
+ ensureOnMainRunLoop([error = protect(error), completionHandler = WTF::move(completionHandler)] mutable {
+ if (error) {
+ RELEASE_LOG_ERROR(WebAuthn, "Error signaling unknown credential: %@.", error.get().localizedDescription);
+ completionHandler(ExceptionData { ExceptionCode::UnknownError, "Error signaling unknown credential."_s });
+ return;
+ }
+ completionHandler(std::nullopt);
+ });
}).get()];
// (identical restructuring applied to signalAllAcceptedCredentials and signalCurrentUserDetails,
// using error = retainPtr(error) instead of protect(error))

LayoutTests/ipc/web-authenticator-signal-main-thread.html

+<!DOCTYPE html><!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+CoreIPC.UI.WebAuthenticatorCoordinatorProxy.SignalUnknownCredential(IPC.webPageProxyID, { origin, options: { rpId, credentialId } });
+CoreIPC.UI.WebAuthenticatorCoordinatorProxy.SignalAllAcceptedCredentials(IPC.webPageProxyID, { origin, options: { rpId, userId, allAcceptedCredentialIds: [credentialId] } });
+CoreIPC.UI.WebAuthenticatorCoordinatorProxy.SignalCurrentUserDetails(IPC.webPageProxyID, { origin, options: { rpId, userId, name: 'test', displayName: 'test' } });
+
+await asyncFlush('UI');
+// Give the platform credential-manager async callback time to fire on its background queue.
+await new Promise(resolve => setTimeout(resolve, 500));
+await asyncFlush('UI');

WebAuthenticatorCoordinatorProxy.mm의 세 async completion block이 동일한 방식으로 재구성되었습니다. signalUnknownCredential, signalAllAcceptedCredentials, signalCurrentUserDetails에서, getCredentialUpdaterShimClassSingleton()signalUnknownCredentialWithRelyingPartyIdentifier:..., signalAllAcceptedCredentialsWithRelyingPartyIdentifier:..., signalCurrentUserDetailsWithRelyingPartyIdentifier:... 메서드에 전달되는 makeBlockPtr callback은, 이전에는 platform credential updater가 선택한 queue에서 그대로 본체를 실행했습니다. NSError *error 인자를 검사하고 RELEASE_LOG_ERROR(WebAuthn, ...)를 출력한 뒤, ExceptionData { ExceptionCode::UnknownError, ... } 혹은 std::nullopt로 IPC reply completionHandler를 호출하는 방식이었습니다.

패치는 이 본체 전체를 ensureOnMainRunLoop([...] mutable { ... })로 감쌌습니다. CompletionHandler를 hop된 lambda 안으로 옮기고, 첫 번째 지점에서는 protect(error), 나머지 두 지점에서는 retainPtr(error)로 raw NSError *를 smart pointer에 담아 queue hop 이후까지 유지하도록 했습니다. 이렇게 해서 block이 실제로 실행되는 시점에도 error.get().localizedDescription이 여전히 유효한 값을 참조합니다. Guard나 bounds check, validation은 추가되지 않았습니다. 유일한 semantic 변경은 reply의 실행 context뿐입니다. 부수적으로, 세 개의 CoreIPC.UI.WebAuthenticatorCoordinatorProxy.Signal* 메시지를 모두 발생시킨 뒤 500ms 대기하여 background callback이 도착하도록 하는 IPC-testing-API layout test가 새로 추가되었고, glib TestExpectations에 skip 항목도 함께 추가되었습니다(해당 플랫폼에서는 WebAuthn이 활성화되어 있지 않기 때문입니다).

호출자가 선택한 queue에서 전달되는 비동기 platform-framework callback이, 생성 시점에 thread affinity가 고정된 continuation을 호출하는 패턴입니다.

이 코드가 있는 위치. UI process는 브라우저 UI 셸, WebPageProxy 객체, 그리고 모든 platform-framework integration을 담당합니다. WebContent와 달리 sandbox되어 있지 않으며, state의 대부분이 main run loop에 국한되어 있습니다. WebAuthenticatorCoordinatorProxyPublicKeyCredential signal API를 처리하는 privileged-side IPC receiver로, 이를 Cocoa AuthenticationServices / credential-updater stack과 연결하는 역할을 합니다. 영향을 받는 코드는 USE(APPLE_INTERNAL_SDK)로 gate되어 있고, 주변 선언에 따르면 HAVE(WEB_AUTHN_AS_MODERN)도 함께 요구됩니다.

WebAuthn Signal API. PublicKeyCredentialsignalUnknownCredential(), signalAllAcceptedCredentials(), signalCurrentUserDetails()를 제공합니다. 이를 통해 relying party는 특정 credential이 더 이상 유효하지 않다는 사실, 주어진 credential ID 집합만 유효하다는 사실, 혹은 user metadata가 변경되었다는 사실을 platform credential store에 알릴 수 있습니다. WebKit에서는 이 호출들이 WebAuthenticatorCoordinatorProxy.messages.in에 선언된 SignalUnknownCredential, SignalAllAcceptedCredentials, SignalCurrentUserDetails 메시지로서 WebContent에서 UI process로 전달됩니다. 각 메시지는 DispatchedFrom=WebContent, DispatchedTo=UI로 표시되며 EnabledBy=WebAuthenticationEnabled로 gate됩니다.

Async IPC replies. .messages.in 파일에서 -> (...) 형태로 선언된 메시지는, reply용 CompletionHandler를 생성해 C++ handler에 전달하는 receiver를 만들어냅니다. 이 handler를 호출하면 reply가 encode되어 IPC::Connection을 통해 다시 전송됩니다.

CompletionHandler와 thread contract. WTF의 CompletionHandlerThreadLikeAssertion m_callThread를 저장하며, 기본값은 CompletionHandlerCallThread::ConstructionThread입니다. Out operator()(In... in)는 시작 시 assertIsCurrent(m_callThread)를 호출합니다. 의도적으로 다른 곳에서 호출되어야 하는 handler를 위해 MainThread, AnyThread라는 대안도 존재합니다. 이 contract가 branch가 아니라 assertion으로 표현된 이유는, 매 reply마다 check 비용을 지불하기보다 개발 단계에서 contract 위반을 잡아내려는 의도이기 때문입니다.

ensureOnMainRunLoop, makeBlockPtr, retainPtr. ensureOnMainRunLoop(lambda)는 이미 main run loop에 있다면 lambda를 즉시 실행하고, 그렇지 않으면 main run loop로 dispatch합니다. makeBlockPtr는 C++ lambda를 framework callback으로 쓸 수 있도록 heap에 복사된 Objective-C block으로 감쌉니다. retainPtr(obj)는 Objective-C 객체에 대해 +1 reference를 소유하는 RetainPtr를 생성하여, 현재 autorelease scope보다 더 오래 살아남게 합니다. 이 덕분에 queue hop 이후에도 NSError *를 안전하게 읽을 수 있습니다.

IPC Testing API. IPCTestingAPIEnabled=true로 표시된 layout test는 JavaScript에서 CoreIPC.<destination>.<Receiver>.<Message>(...) 형태로 raw IPC 메시지를 직접 구성하고 전송할 수 있습니다. 이 regression test가 실제 authenticator flow 없이도 세 handler에 직접 접근할 수 있는 이유입니다.

이는 memory-safety 버그가 아니라 thread-affinity 위반, 즉 race condition에 해당합니다. Reply path — std::exchange를 통해 CompletionHandler 내부의 Function을 소비하는 과정, ExceptionData를 생성하고 encode하는 과정, 그리고 생성된 reply lambda가 참조하는 main-thread 국한 state 전반 — 가 main run loop와 동시에, 동기화 없이 실행되었습니다.

  WebContent            UI process (main run loop)        Credential updater queue
  ──────────            ──────────────────────────        ────────────────────────
  SignalUnknown ──IPC──► dispatch on main
                         construct CompletionHandler
                           (callThread = main)
                         hand makeBlockPtr to shim ─────► async work
                         ... other main-loop work ...
                                                          block fires HERE
                         reply encode  ◄── concurrent ──  completionHandler(...)
                                                          assertIsCurrent(main) ✗

위 다이어그램에서, reply handler를 capture하는 block은 main에서 생성되지만 실제 호출은 updater의 queue에서 일어납니다. 패치 이전 코드에는 NSError나 handler를 건드리기 전에 main-run-loop affinity를 다시 확립하는 로직이 없었습니다. Shim의 구현 자체는 제공된 context에 포함되어 있지 않으므로, off-main delivery라는 사실은 새로 추가된 test의 주석("Give the platform credential-manager async callback time to fire on its background queue")과 fix의 형태에서 유추한 것입니다. Hop이 의미를 가지려면 callback이 다른 곳에서 도착할 수 있어야 하기 때문입니다. 마찬가지로, reply handler가 main run loop에서 생성된다는 사실은 .messages.in 선언의 DispatchedTo=UI에 별도의 receive-queue attribute가 없다는 점에서 따라 나옵니다.

Assertion이 활성화된 build에서는 assertIsCurrent(m_callThread)가 즉시 실패하여 위반 사실이 바로 드러납니다. 반면 해당 assertion이 비활성 상태인 build — 표준 WTF release 구성에 해당하지만, ThreadAssertions.h는 제공된 context에 포함되어 있지 않습니다 — 에서는, reply-encode path와 그것이 참조하는 state가 lock 없이 잘못된 thread에서 실행되는 것을 막을 방법이 없습니다.

패치는 error와 handler를 건드리기 전에 ensureOnMainRunLoop로 hop함으로써 invariant를 복원합니다. 다만 그 적용 범위는 정확히 짚어볼 필요가 있습니다. 변경되는 것은 오직 호출되는 경로의 thread뿐입니다. 만약 platform이 block을 한 번도 호출하지 않은 채 해제한다면, 바깥쪽 makeBlockPtr lambda가 여전히 capture하고 있는 CompletionHandler는 패치 이전과 마찬가지로 그 해제를 수행하는 thread에서 소멸됩니다. 이 abandonment 경로는 이번 commit으로 변경되지 않았습니다.

이번 discovery의 형태는 단발성 crash report보다는 표적화된 audit sweep에 가까워 보입니다. 함께 추가된 regression test는 IPC-testing-API test로서 세 Signal* 메시지를 모두 직접 발생시킨 뒤 platform queue가 fire할 시간을 기다리는 형태를 취하고 있습니다. 이는 UI process IPC receiver들을 대상으로, 생성 thread 밖에서 호출되는 reply handler를 찾는 체계적인 점검의 형태입니다. Internal-SDK build에서 WebAuthn signal flow를 실행하던 중 assertIsCurrent(m_callThread)에서 발생한 assertion failure가 이 점검의 출발점이었을 가능성이 있습니다. 관련된 callback 종류는 하나뿐임에도 세 지점 모두가 함께 수정되었다는 점은, 단일 재현 사례보다는 파일 전체에 대한 패턴 기반 variant analysis에 가까운 모습입니다.

Exploitability 측면에서, 이 unsynchronized window를 안정적으로 유발할 수 있는 공격자라면 최소한 UI process를 공격자가 원하는 시점에 crash시킬 수 있습니다. 이는 단순히 tab 하나가 아니라 브라우저 세션 전체를 종료시키는 결과로 이어집니다. 더 강한 결과로 이어질지는 생성된 reply lambda가 동시에 무엇을 건드리는지에 달려 있는데, 제공된 context에는 이를 판단할 수 있는 생성된 receiver 코드가 포함되어 있지 않습니다.

이 vulnerability는 web content가 IPC 경계를 넘어 유발할 수 있는 경로를 통해 UI process의 thread-confinement invariant를 약화시킵니다. 여기서 걸려 있는 security-model 전제는, UI process의 IPC reply 처리와 그것이 건드리는 state가 main run loop에서 serialize되어야 한다는 것입니다. 패치 이전에는 세 WebAuthn signal* 메시지의 reply가 platform credential updater 소유의 queue에서 실행되고 있었습니다. Build coverage 측면에서는, 영향을 받는 block이 USE(APPLE_INTERNAL_SDK)에서만 compile되므로 open-source WebKit build에는 해당 경로가 포함되지 않습니다. 다만 Apple이 실제로 배포하는 WebKit/Safari binary는 internal SDK를 기반으로 빌드되므로, 이는 어떤 build에 이 경로가 포함되는지의 차이일 뿐, 대다수 사용자가 실행하는 build에서의 impact가 줄어든다는 의미는 아닙니다.

WTF의 CompletionHandler는 thread contract를 생성 시점에 고정하고, 이를 release-mode guard가 아니라 assertIsCurrent라는 assertion으로 강제합니다. 이 때문에 UI process의 IPC reply handler를 platform framework의 completion block에 넘기는 모든 지점이 이 버그의 잠재적 사례가 됩니다. Callback이 실행될 queue를 선택하는 주체가 WebKit이 아니라 framework이기 때문입니다. 안전한 패턴은 정확히 두 가지뿐입니다. 이번 패치처럼 ensureOnMainRunLoop로 hop하거나, handler를 CompletionHandlerCallThread::AnyThread로 선언하고 reply path 전체를 실제로 thread-safe하게 만드는 것입니다. 패치 자체에서 눈에 띄는 사소한 지점도 있습니다. 첫 번째 hunk는 retain을 protect(error)로 표기한 반면, 나머지 두 hunk는 retainPtr(error)를 사용합니다. error.get() 사용 방식으로 미루어 보면 동일한 smart-pointer capture를 두 가지 방식으로 표기한 셈인데, 하나의 commit 안에 이런 불일치가 있다는 점은 이 패턴에 대한 grep 기반 audit을 필요 이상으로 어렵게 만듭니다.