[1] WebAuthenticatorCoordinatorProxy signal replies invoked off the main run loop
Three WebAuthn replies came back on a queue WebKit didn't choose.
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
WebAuthenticatorCoordinatorProxysignalUnknownCredential,signalAllAcceptedCredentials, andsignalCurrentUserDetailsinvoke their replyCompletionHandlers on the main run loop.Test:
ipc/web-authenticator-signal-main-thread.htmlCanonical link: https://commits.webkit.org/318615@main
Source/WebKit/UIProcess/WebAuthentication/Cocoa/WebAuthenticatorCoordinatorProxy.mm
LayoutTests/ipc/web-authenticator-signal-main-thread.html
Patch Details
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을 호출하는 패턴입니다.
Background
이 코드가 있는 위치.
UI process는 브라우저 UI 셸, WebPageProxy 객체, 그리고 모든 platform-framework integration을 담당합니다. WebContent와 달리 sandbox되어 있지 않으며, state의 대부분이 main run loop에 국한되어 있습니다. WebAuthenticatorCoordinatorProxy는 PublicKeyCredential signal API를 처리하는 privileged-side IPC receiver로, 이를 Cocoa AuthenticationServices / credential-updater stack과 연결하는 역할을 합니다. 영향을 받는 코드는 USE(APPLE_INTERNAL_SDK)로 gate되어 있고, 주변 선언에 따르면 HAVE(WEB_AUTHN_AS_MODERN)도 함께 요구됩니다.
WebAuthn Signal API.
PublicKeyCredential은 signalUnknownCredential(), 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의 CompletionHandler는 ThreadLikeAssertion 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에 직접 접근할 수 있는 이유입니다.
Analysis
이는 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가 줄어든다는 의미는 아닙니다.
Insight
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을 필요 이상으로 어렵게 만듭니다.
Audit directions
-
고정된 thread affinity를 third-party async API에 넘기는 continuation. 여기서 지켜야 할 invariant는, callback queue를 소유하는 쪽과 continuation의 thread contract를 소유하는 쪽이 서로 달라서는 안 된다는 점입니다. 그래서 경계에는 명시적인 hop이 필요합니다. Narrow:
Source/WebKit/UIProcess에서completionHandler(또는CompletionHandler)를 캡처하는makeBlockPtr([블록을 검색하고, 블록 본문이 중간에ensureOnMainRunLoop/RunLoop::main().dispatch를 거치지 않고 handler에 도달하는지 확인해야 합니다.WebAuthenticatorCoordinatorProxy.mm의 인접한 AuthenticationServices 경로들 —performRequest,performRequestLegacy, 그리고authorizationController:didCompleteWithAuthorization:/didCompleteWithError:안에서 이루어지는_WKASDelegate의m_completionHandler호출 — 이 가장 가까운 이웃입니다. Wider: 같은 유형의 패턴은 다른 전달 경로에서도 나타납니다. non-main queue로의dispatch_async,WorkQueue::dispatch,NSURLSession/NSXPCConnection의 reply block, 그리고WebKitSwiftSoftLink를 통해 다시 연결되는 Swift 구현 shim 등입니다. 코드 검색 결과에서 눈여겨봐야 할 형태는, reply handler를 자신이 threading을 제어하지 못하는 객체에 저장하거나 전달하는 IPC message receiver입니다. Widest: callback 타입이 thread affinity를 가지고 있다면, callback queue를 문서화하지 않은 API 경계를 넘는 모든 hand-off에는 소유 context로 돌아가는 명시적인 재진입이 필요합니다. 같은 점검 질문은 Chromium의base::BindOnce와SequencedTaskRunner, Rust의!Sendcapture가spawn_blocking으로 빠져나가는 경우, Android의Handler에 바인딩된 callback이 framework listener로 전달되는 경우에도 동일하게 적용됩니다. "이 코드가 실행될 thread를 누가 선택했고, 그것을 누가 확인했는가?"라는 질문을 어디에나 가져가면 됩니다. 코드 리뷰에서 눈에 띄는 신호는, Objective-CcompletionHandler:인자로 전달된 블록 안에 lexical하게 위치한completionHandler(...)호출이면서 그 위쪽에 hop이 하나도 없는 경우입니다. -
호출 context뿐 아니라 캡처된 completion handler의 소멸 context도 봐야 합니다. 이번 commit의
ensureOnMainRunLoophop은 호출되는 경로만 커버합니다. 따라서 handler가 그대로 버려지는 경로는 이 commit뿐 아니라 인접한 모든 사이트에서 여전히 열린 질문으로 남습니다. 이 패턴 클래스는, owning smart pointer나 handler의 마지막 reference가 해제되는 시점이 destructor 실행이 허용되지 않는 thread에서 발생하는 경우를 가리킵니다.CompletionHandler와 함께 캡처된 thread-safe하지 않은Ref/RefPtr/WeakPtr멤버는, 블록이 해제되는 어느 thread에서든 함께 해제됩니다. Narrow:Source/WebKit/UIProcess/**/Cocoa/*.mm에 있는 Objective-C 블록 중 WebKit refcounted 객체를 캡처하는 블록마다, framework가 그 블록을 한 번도 호출하지 않고 해제하는 경로에서 어떤 일이 벌어지는지 확인해야 합니다. Wider: 동일한 형태가WorkQueue/Timer/NativePromise체인에 캡처된 lambda에서도, main thread 밖에서 해제되는 객체의RetainPtr멤버에서도 나타납니다. 멤버는 main-thread에 종속되어 있는데 owning closure는 어디서든 소멸될 수 있는 클래스를 찾아봐야 합니다. Widest: 리소스의 thread contract는 사용 지점뿐 아니라 해제 지점까지 포괄합니다. 이는 RAII나 deterministic destruction을 사용하는 모든 언어에 적용되는 문제로, thread pool로 흘러 들어간!Send타입에 대한 RustDrop구현이나, confined ownership과 shared ownership이 섞인 코드베이스에서의 C++shared_ptrcontrol-block race도 마찬가지입니다. 핵심 질문은 "마지막 owner가 scope를 벗어나는 지점은 어디이고, 그 thread가 이 destructor를 실행해도 되는가?"입니다. 리뷰에서 눈에 띄는 신호는, framework에 전달된 블록 안에ThreadSafeRefCounted가 아닌 타입의RefPtr/Ref/WeakPtr가 캡처되어 있는 경우입니다. -
신뢰할 수 없는 입력 경계에서 debug-time assertion만으로 유지되는 invariant. UI-process IPC 어딘가에서 thread assertion의 release-build 동작이 안전성 보장 수단으로 사용되고 있는지 점검할 필요가 있습니다. Narrow:
DispatchedTo=UI메시지에 대해 생성된 IPC receiver의CompletionHandler생성부를 모두 나열하고, 그중 어떤 handler가 최종적으로 platform callback에서 호출되는지 확인해야 합니다.Source/WebKit/UIProcess/**에서 async reply를 선언하는.messages.in파일에서 시작해, 각 handler를 최종 호출 지점까지 따라가면 됩니다. Wider: 같은 질문이 신뢰할 수 없는 IPC가 도달할 수 있는 UI-process 코드 전반에 흩어진assertIsCurrent/ASSERT(isMainRunLoop())/ASSERT(isMainThread())에도 동일하게 적용됩니다. 이런 assertion들은 release build에서 강제되지 않는 contract를 표시할 뿐이며, 눈여겨봐야 할 형태는 async framework callback만이 유일한 호출자인 함수 안에 있는 contract 표시용 assertion입니다. Widest: assertion은 invariant를 문서화할 뿐 강제하지 않습니다. Trust boundary 위의 invariant는 release-mode check나 구조적 보장이 필요합니다. 같은 점검은 Mojo receiver의DCHECK기반 sequence checker나 FFI 경계의 Rustdebug_assert!에도 동일하게 적용됩니다. "이 입력과 invariant 사이를 막아주는 유일한 장치가, production에서는 컴파일 과정에서 사라지는 구문 하나뿐인가?"라는 질문을 가져가면 됩니다. 리뷰에서 눈에 띄는 신호는, main run loop의 제어 흐름을 벗어나는 handler — 멤버에 저장되거나, 블록에 캡처되거나, queue로 이동되는 경우 — 인데도 생성 시점에CompletionHandlerCallThread::AnyThread나MainThreadannotation이 명시적으로 붙어 있지 않은 경우입니다.