[1] WebAuthenticatorCoordinatorProxy signal replies invoked off the main run loop
Three WebAuthn replies came back on a queue WebKit didn't choose.
Medium. A reply continuation whose legal thread was fixed when the UI process constructed it gets called from a queue the platform picked, and the only thing standing between that and the invariant is a debug-time assertion. What keeps this out of the High band is that the concurrent window is narrow and the state it collides with beyond the handler itself isn't established here.
WebKit's UI process is the unsandboxed, most privileged process in the browser's multi-process model — it hosts the chrome, the page-proxy objects, and every integration with platform frameworks, and nearly all of its state is confined to the main run loop. Web content reaches it only through IPC: a message arrives, the generated receiver dispatches it on main, and for messages that declare an async reply it constructs a CompletionHandler — a WTF one-shot continuation that records at construction which thread is allowed to invoke it. The expectation the whole design rests on is that a reply handler built on the main run loop is also called there, so reply encoding and the main-thread-confined state around it stay serialized.
The angle: web content can drive three WebAuthn signal* messages into the UI process and have the platform credential updater fire their reply handlers from its own queue, running UI-process reply encoding unsynchronized against the 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
Three async completion blocks in WebAuthenticatorCoordinatorProxy.mm are restructured identically. In signalUnknownCredential, signalAllAcceptedCredentials, and signalCurrentUserDetails, the makeBlockPtr callback handed to getCredentialUpdaterShimClassSingleton()'s signalUnknownCredentialWithRelyingPartyIdentifier:..., signalAllAcceptedCredentialsWithRelyingPartyIdentifier:... and signalCurrentUserDetailsWithRelyingPartyIdentifier:... methods previously ran its body directly on whatever queue the platform credential updater chose: it inspected the NSError *error argument, emitted RELEASE_LOG_ERROR(WebAuthn, ...), and invoked the IPC reply completionHandler with either ExceptionData { ExceptionCode::UnknownError, ... } or std::nullopt.
The patch wraps that entire body in ensureOnMainRunLoop([...] mutable { ... }), moving the CompletionHandler into the hopped lambda and holding the raw NSError * in a smart pointer across the hop — protect(error) at the first site, retainPtr(error) at the other two — so error.get().localizedDescription is still valid when the block finally runs. No guard, bounds check, or validation is added; the only semantic change is the execution context of the reply. Collateral: a new IPC-testing-API layout test that fires all three CoreIPC.UI.WebAuthenticatorCoordinatorProxy.Signal* messages and then sleeps 500 ms to let the background callback land, plus a glib TestExpectations skip (WebAuthn is not enabled there).
Asynchronous platform-framework callback delivered on a caller-chosen queue invoking a continuation whose thread affinity was fixed at construction.
Background
Where this lives.
The UI process hosts the browser chrome, WebPageProxy objects and all platform-framework integration. It is not sandboxed the way WebContent is, and the great majority of its state is main-run-loop confined. WebAuthenticatorCoordinatorProxy is the privileged-side IPC receiver that terminates the PublicKeyCredential signal APIs and bridges them to the Cocoa AuthenticationServices / credential-updater stack; the affected code is gated on USE(APPLE_INTERNAL_SDK) and, per the surrounding declarations, HAVE(WEB_AUTHN_AS_MODERN).
The WebAuthn Signal API.
PublicKeyCredential exposes signalUnknownCredential(), signalAllAcceptedCredentials() and signalCurrentUserDetails(), which let a relying party tell the platform credential store that a credential is stale, that only a given set of credential IDs is still valid, or that user metadata changed. In WebKit these are relayed from WebContent to the UI process as the SignalUnknownCredential, SignalAllAcceptedCredentials and SignalCurrentUserDetails messages declared in WebAuthenticatorCoordinatorProxy.messages.in, marked DispatchedFrom=WebContent, DispatchedTo=UI and gated by EnabledBy=WebAuthenticationEnabled.
Async IPC replies.
A message declared with -> (...) in a .messages.in file generates a receiver that constructs a CompletionHandler for the reply and passes it to the C++ handler; calling that handler encodes the reply and sends it back over the IPC::Connection.
CompletionHandler and its thread contract.
WTF's CompletionHandler stores a ThreadLikeAssertion m_callThread, defaulting to CompletionHandlerCallThread::ConstructionThread, and Out operator()(In... in) begins with assertIsCurrent(m_callThread). The alternatives MainThread and AnyThread exist for handlers deliberately meant to be called elsewhere. The contract is expressed as an assertion rather than a branch because the intent is to catch contract violations during development rather than to pay a check on every reply.
ensureOnMainRunLoop, makeBlockPtr, retainPtr.
ensureOnMainRunLoop(lambda) runs the lambda immediately if already on the main run loop and otherwise dispatches it there. makeBlockPtr wraps a C++ lambda as a heap-copied Objective-C block usable as a framework callback. retainPtr(obj) produces a RetainPtr owning a +1 reference to an Objective-C object so it outlives the current autorelease scope — which is what makes an NSError * safe to read after a queue hop.
IPC Testing API.
Layout tests marked IPCTestingAPIEnabled=true can construct and send raw IPC messages from JavaScript via CoreIPC.<destination>.<Receiver>.<Message>(...). That is how the regression test reaches these three handlers directly, without needing a real authenticator flow.
Analysis
This is a thread-affinity violation — a race condition rather than a memory-safety bug. The reply path (consuming the Function inside the CompletionHandler via std::exchange, constructing and encoding ExceptionData, and whatever main-thread-confined state the generated reply lambda holds) executed concurrently with, and unsynchronized against, the 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) ✗
In the diagram, the block that captures the reply handler is created on main but invoked on the updater's queue. Nothing in the pre-fix code re-established main-run-loop affinity before touching either the NSError or the handler. The shim's implementation is not part of the supplied context, so the off-main delivery is taken from the new test's own comment ("Give the platform credential-manager async callback time to fire on its background queue") plus the shape of the fix — a hop is only meaningful if the callback can arrive elsewhere. Likewise, main-run-loop construction of the reply handler follows from DispatchedTo=UI with no receive-queue attribute in the .messages.in declaration.
In assertion-enabled builds assertIsCurrent(m_callThread) fires and the violation surfaces immediately. In builds where that assertion is inert — the standard WTF release configuration, though ThreadAssertions.h is not in the supplied context — nothing stops the reply-encode path and its captured state from running on the wrong thread with no lock held.
The fix restores the invariant by hopping to ensureOnMainRunLoop before touching either the error or the handler. Note its scope precisely: it changes the thread only for the invoked path. If the platform releases the block without ever calling it, the CompletionHandler still captured by the outer makeBlockPtr lambda is destroyed on whatever thread performs that release, exactly as before the patch. That abandonment path is unchanged by this commit.
The discovery shape points at a targeted audit sweep rather than a lone crash report. The accompanying regression test is an IPC-testing-API test that drives all three Signal* messages directly and then sleeps to let the platform queue fire — the shape of a systematic pass over UI-process IPC receivers looking for reply handlers invoked outside their construction thread. An assertion failure from assertIsCurrent(m_callThread) in an internal-SDK build running WebAuthn signal flows plausibly seeded the sweep; all three sites were fixed together even though only one class of callback is involved, which reads as pattern-driven variant analysis across the file rather than a single reproducer.
On exploitability: an attacker who could reliably drive the resulting unsynchronized window would gain, at minimum, an attacker-timed crash of the UI process — which tears down the whole browser session, not just a tab. A stronger outcome would depend on what the generated reply lambda touches concurrently, and the supplied context does not include the generated receiver code that would settle that question.
This vulnerability weakens the thread-confinement invariant of the UI process along a path web content can drive across the IPC boundary. The security-model assumption at stake is that UI-process IPC reply handling and the state it touches are serialized on the main run loop; before the fix, replies for the three WebAuthn signal* messages ran on a queue owned by the platform credential updater instead. On build coverage: the affected block compiles only under USE(APPLE_INTERNAL_SDK), so open-source WebKit builds omit it — but Apple's shipping WebKit/Safari binaries are built against the internal SDK, so this is a difference in which builds contain the path, not a reduction in impact for the builds most users run.
Insight
WTF's CompletionHandler encodes its thread contract at construction time and enforces it via assertIsCurrent — an assertion, not a release-mode guard. That makes every place where a UI-process IPC reply handler is handed to a platform framework's completion block a latent instance of this bug, because the framework, not WebKit, picks the callback queue. The safe patterns are exactly two: hop with ensureOnMainRunLoop (what this patch does), or declare the handler with CompletionHandlerCallThread::AnyThread and make the whole reply path genuinely thread-safe. A smaller tell in the patch itself: the first hunk spells the retain as protect(error) while the other two use retainPtr(error) — two spellings for what the error.get() usage suggests is the same smart-pointer capture, inside one commit, which is exactly the kind of inconsistency that makes grep-based auditing of this pattern harder than it should be.
Audit directions
-
Continuations with fixed thread affinity handed to third-party async APIs. The invariant is that whoever owns the callback queue must not be the same party that owns the continuation's thread contract — so the boundary needs an explicit hop. Narrow: grep
Source/WebKit/UIProcessformakeBlockPtr([blocks that capture acompletionHandler(orCompletionHandler) and check whether the block body reaches the handler without an interveningensureOnMainRunLoop/RunLoop::main().dispatch. The sibling AuthenticationServices paths inWebAuthenticatorCoordinatorProxy.mm—performRequest,performRequestLegacy, and_WKASDelegate'sm_completionHandlerinvocations inauthorizationController:didCompleteWithAuthorization:/didCompleteWithError:— are the immediate neighbours. Wider: the same class shows up through other delivery mechanisms —dispatch_asynconto a non-main queue,WorkQueue::dispatch,NSURLSession/NSXPCConnectionreply blocks, and Swift-implemented shims bridged back throughWebKitSwiftSoftLink; the shape to notice in code-search results is any IPC message receiver whose handler stores or forwards its reply handler into an object it does not control the threading of. Widest: if a callback type carries thread affinity, every hand-off across an API boundary that does not document its callback queue needs an explicit re-entry to the owning context — the same audit question applies to Chromium'sbase::BindOnceplusSequencedTaskRunner, to Rust's!Sendcaptures escaping intospawn_blocking, and to AndroidHandler-bound callbacks passed to framework listeners; carry the question "who chose the thread this runs on, and did anyone check?" into any of them. In code review, the tell is acompletionHandler(...)call lexically inside a block passed as an Objective-CcompletionHandler:argument with no hop above it. -
Destruction context of captured completion handlers, not just invocation context. This commit's
ensureOnMainRunLoophop covers only the invoked path, so the abandonment path remains an open question here and at every sibling site. The pattern class is an owning smart pointer or handler whose last reference drop occurs on a thread that is not permitted to run the destructor — non-thread-safeRef/RefPtr/WeakPtrmembers captured alongside aCompletionHandlerare freed wherever the block is released. Narrow: for each Objective-C block inSource/WebKit/UIProcess/**/Cocoa/*.mmthat captures WebKit refcounted objects, check what happens on the path where the framework releases the block without ever calling it. Wider: the same shape appears with lambdas captured intoWorkQueue/Timer/NativePromisechains and withRetainPtrmembers of objects deallocated off-main; look for classes whose members are main-thread-confined but whose owning closure can be destroyed anywhere. Widest: the thread contract of a resource covers its release point as well as its use points — applicable to any language with RAII or deterministic destruction, including RustDropimpls on!Sendtypes smuggled into thread pools and C++shared_ptrcontrol-block races in codebases mixing confined and shared ownership; the mental tell is "where does the last owner go out of scope, and is that thread allowed to run this destructor?" In review, the visual tell is a capturedRefPtr/Ref/WeakPtrof a type that is notThreadSafeRefCountedinside a block handed to a framework. -
Invariants enforced only by debug-time assertions on untrusted-input boundaries. Investigate whether release-build behaviour of thread assertions is being relied on as a safety property anywhere in UI-process IPC. Narrow: enumerate the
CompletionHandlerconstructions in generated IPC receivers forDispatchedTo=UImessages and check which handlers are eventually invoked from platform callbacks — start from.messages.infiles declaring async replies inSource/WebKit/UIProcess/**and follow each handler to its terminal call site. Wider: the same question applies to everyassertIsCurrent/ASSERT(isMainRunLoop())/ASSERT(isMainThread())sprinkled through UI-process code that untrusted IPC can reach — these mark contracts that release builds do not enforce, and the shape to look for is a contract-documenting assertion in a function whose only callers are async framework callbacks. Widest: assertions document invariants, they do not enforce them; any invariant on a trust boundary needs a release-mode check or a structural guarantee — the same audit applies to Chromium'sDCHECK-guarded sequence checkers on Mojo receivers and to Rustdebug_assert!on FFI boundaries. Carry the question "is the only thing standing between this input and the invariant a construct that compiles out in production?" The review tell is a handler that leaves the main run loop's control flow — stored in a member, captured in a block, or moved onto a queue — without an explicitCompletionHandlerCallThread::AnyThreadorMainThreadannotation at construction.