← WebKit — 2026-W38

56909e17f99a6561d2b9cf760a9f68f705dbaad3  Incorrect process termination when a file-backed blob is posted to another process

severity high class UAF confidence 0.8 signal score 13.81 Source/WebCore/dom patch-now AI review CONFIRMED
Primitive: premature blob URL/data revocation across cross-process message dispatch (postMessage/BroadcastChannel/service worker)
Triage note: Adds retainBlobURLsWhileMessageIsInFlight/CompletionHandlerCallingScope so the network process holds blob URL handles until MessagePort/BroadcastChannel/service-worker messages are dispatched, and registerInternalBlobURLOptionallyFileBacked now refuses to file-back a blob whose path the process is not allowed; fixes a blob whose backing data is freed/revoked before the destination reads it plus a sandbox file-access gap.
Structural signals: smartptr_member_added (+6.47), null_guard (+2.59), uaf_lifetime (+4.75), area:Source/WebCore/dom (+2.0), large_churn (-2.0)

The bug at a glance

Cross-process message delivery (MessagePort.postMessage, BroadcastChannel, service-worker client messaging) released the blob URL keep-alive handles carried inside a SerializedScriptValue as soon as the send call returned, rather than when the destination context actually dispatched the message. Because dispatch is asynchronous and cross-thread/cross-process, the blob’s backing data could be revoked/freed before the destination deserialized and read it. This is in WebCore (WebContent, with blob registration backed by the network process) and web-reachable from ordinary script. The diff supports a premature-revocation / use-after-free of blob-backed data (UAF/high is reasonable); the strict freed-object-reused escalation depends on blob registry/backing lifetime not fully shown here, so the hardest UAF claims are (inferred). The diff appears truncated, so one referenced change (refusing to file-back disallowed paths) is not verifiable from the supplied hunk.

A serialized message keeps its blob URLs alive only via a completion-handler scope; threading that scope all the way to the point of dispatch (instead of dropping it at the send site) is what actually keeps the blob’s bytes alive in flight.

Root cause

The invariant is that every blob URL referenced by a SerializedScriptValue must stay registered (its backing kept alive) from the moment a message is posted until the destination context dispatches it. SerializedScriptValue carries blobURLs()/blobHandles() (Vector), and a CompletionHandlerCallingScope is the token whose destruction fires the completion handler that releases those keep-alive handles.

Pre-patch, the send paths created the keep-alive scope locally and let it run out at the end of the synchronous send call. 1. script posts a message containing a Blob/File over a MessagePort, BroadcastChannel, or to a service-worker client; 2. the message is queued (m_pendingMessages, queueTaskKeepingObjectAlive, postTaskTo) for asynchronous dispatch on another thread/context/process; 3. the local completion scope is destroyed when the send function returns, signaling that the blob URLs may be released; 4. before the destination task runs and deserializes, the blob URL is revoked/its backing freed; 5. the destination reads a blob whose data is gone — a use-after-free / failed-fetch of the blob backing. The fix threads a CompletionHandlerCallingScope (named blobURLsInFlight / messageDispatched) through the entire pipeline so the keep-alive lives exactly as long as the in-flight message: MessagePortChannel now stores std::pair<MessageWithMessagePorts, CompletionHandlerCallingScope> in m_pendingMessages and only releases the scopes when the batch is taken/dispatched; BroadcastChannel::dispatchMessage carries the scope into the queued task; and the service-worker path renames postMessageToServiceWorkerClient to dispatchMessageToServiceWorkerClient and carries messageDispatched into postTaskTo and through ServiceWorkerThread::queueTaskToPostMessage until finishedFiringMessageEvent runs on the main thread.

Lifetime detail: the blob keep-alive is a reference token (URLKeepingBlobAlive) released by the completion handler, not a GC object; the C++ channel objects use RefCounted/ThreadSafeWeakPtr and are separately protected. The bug is that the keep-alive token’s lifetime was scoped to the send call rather than to message dispatch, so the owner ref was dropped too early. Whether the destination ever observes a genuinely freed-and-reused backing (true UAF) vs a revoked-but-still-allocated blob depends on the blob registry/network-process backing lifetime, which is not in the supplied diff — so I mark the hardest UAF escalation (inferred).

Key code

Carry a keep-alive scope with each pending message until dispatch

using PendingMessage = std::pair<MessageWithMessagePorts, CompletionHandlerCallingScope>;
...
// postMessageToRemote now takes CompletionHandlerCallingScope&& blobURLsInFlight
m_pendingMessages[i].append({ WTF::move(message), WTF::move(blobURLsInFlight) });
...
auto messages = WTF::map(pendingMessages, [](auto& pendingMessage) {
    return WTF::move(pendingMessage.first);
});
auto blobURLsInFlight = WTF::map(WTF::move(pendingMessages), [](auto&& pendingMessage) {
    return WTF::move(pendingMessage.second);
});

Fix approach

The patch makes the blob-URL keep-alive co-terminous with in-flight message delivery by threading a CompletionHandlerCallingScope through every hop. CompletionHandlerCallingScope gains an explicit operator bool(). MessagePortChannel::postMessageToRemote and MessagePortChannelRegistry::didPostMessageToRemote take a blobURLsInFlight scope; pending messages become a pair of {message, scope} so the scope is held in m_pendingMessages and only moved out (and eventually destroyed, releasing the blob URLs) when takeAllMessagesForPort delivers the batch. BroadcastChannel::dispatchMessage takes a CompletionHandlerCallingScope&& and captures it into the queued PostedMessageQueue task, with the top-level scope wrapping a CompletionHandler dispatched AnyThread via callOnMainThread. The service-worker path renames the entry point to dispatchMessageToServiceWorkerClient and carries messageDispatched through postTaskTo and ServiceWorkerThread::queueTaskToPostMessage until finishedFiringMessageEvent. Net effect: the blob keep-alive is released only after the destination has dispatched the message.

Patch walkthrough

  • Source/WTF/wtf/CompletionHandler.h — Adds explicit operator bool() to CompletionHandlerCallingScope so callers can test whether a scope still holds a handler.
  • Source/WebCore/bindings/js/SerializedScriptValue.h — Exports blobURLs() (WEBCORE_EXPORT) so the keep-alive handles can be read from the message-dispatch layers.
  • Source/WebCore/dom/BroadcastChannel.cpp / .h — dispatchMessage now takes a CompletionHandlerCallingScope&& and captures it into the queued task; the calling scope wraps the completion handler as AnyThread and re-dispatches via callOnMainThread, keeping blob URLs alive until the broadcast is delivered.
  • Source/WebCore/dom/messageports/MessagePortChannel.cpp / .h — postMessageToRemote gains a blobURLsInFlight scope; m_pendingMessages becomes Vector<std::pair<MessageWithMessagePorts, CompletionHandlerCallingScope»; takeAllMessagesForPort splits messages from their scopes and holds the scopes until the batch is delivered.
  • Source/WebCore/dom/messageports/MessagePortChannelRegistry.cpp / .h — didPostMessageToRemote forwards the blobURLsInFlight scope (defaulted to {}) down to the channel.
  • Source/WebCore/workers/service/SWClientConnection.cpp / .h — postMessageToServiceWorkerClient is renamed dispatchMessageToServiceWorkerClient and takes a messageDispatched scope, captured into the postTaskTo lambda so it lives until the destination document/context posts the message.
  • Source/WebCore/workers/service/context/ServiceWorkerThread.cpp / .h — queueTaskToPostMessage takes messageDispatched and carries it through queueTaskToFireEvent into the callOnMainThread finishedFiringMessageEvent callback, releasing the keep-alive only after the message event has fired.

Background

Blob URL / URLKeepingBlobAlive — A blob: URL maps to backing bytes held by the blob registry; URLKeepingBlobAlive is a handle that keeps that registration (and backing) alive while held.

SerializedScriptValue — The structured-clone payload of a postMessage; it records the blob URLs/handles embedded in the cloned object so they can be kept alive across the transfer.

CompletionHandlerCallingScope — A move-only scope object that invokes a stored CompletionHandler when destroyed (or released); here its destruction is the signal that the in-flight blob URLs may be released.

MessagePortChannel / m_pendingMessages — The cross-process broker that queues messages between entangled MessagePorts until the remote side takes them; pending messages wait here between post and delivery.

BroadcastChannel / service-worker client messaging — Other asynchronous, cross-context message delivery paths (postMessage fan-out, SWClientConnection) that also serialize blob-bearing values and dispatch them later.

queueTaskKeepingObjectAlive / postTaskTo — Mechanisms that defer work to a target context/thread; the deferral is precisely the window during which the blob keep-alive must persist.

Vulnerability window

  1. Setup — Script holds a Blob and an entangled MessagePort / open BroadcastChannel / controlled page under a service worker.
  2. Trigger — Script postMessage()s the Blob to the remote side; the serialized value carries the blob URL keep-alive handles.
  3. Premature release — Pre-patch, the keep-alive scope is destroyed when the send call returns, before the asynchronous destination task runs.
  4. Revoke/free — The blob URL is revoked or its backing is dropped in the window before dispatch.
  5. Dispatch — The destination context deserializes and reads a blob whose data is already gone (failed read / UAF of backing).

Proof of concept

No regression test ships in the supplied (and apparently truncated) diff. Conceptual trigger (web path): create a Blob, obtain a URL/handle for it, postMessage it across a MessagePort or BroadcastChannel (or to a service-worker client) to a destination in another process/thread, then immediately revoke the blob URL / drop all other references; before the destination’s queued task dispatches, the blob backing is released, so the destination observes a dead blob. Reliably winning the post->dispatch window and reclaiming the freed backing is what would turn this into a controlled UAF (inferred).

Exploitation

  1. Reach — Ordinary untrusted script using MessagePort/BroadcastChannel/service-worker messaging with Blob/File payloads; no permissions needed.
  2. Trigger — Post a blob-bearing message to a cross-process/thread destination and race the blob revocation against asynchronous dispatch.
  3. Primitive (primary) — Premature release of the blob-URL keep-alive, yielding a blob whose backing is revoked/freed before the destination reads it.
  4. Primitive (secondary) — If the backing is reclaimed before the read, the destination may deserialize attacker-reclaimed bytes as blob data (inferred), crossing into the destination context’s view of the blob.
  5. Open question (gating) — Whether blob-registry/network-process backing is actually freed (vs merely unregistered) within the window, and whether the reclaimed bytes are controllable, gates UAF escalation beyond a failed/empty read (inferred).

Detection & hunting

For defenders and detection engineers:

  • Keep-alive scopes dropped at send site — Audit postMessage/broadcast/service-worker dispatch for CompletionHandlerCallingScope/makeScopeExit objects whose lifetime ends before the async destination task runs.
  • Blob read failures after messaging — Instrument blob fetches in message-event handlers for revoked/missing backing immediately after cross-process delivery.
  • Pending-message queue without retention — Check MessagePortChannel-style queues that store messages but not their blob keep-alive tokens.

Remaining risk in the patch

  • Diff truncated / file-backing path unverified — The supplied diff cuts off in ServiceWorkerThread.h and does not include the registerInternalBlobURLOptionallyFileBacked sandbox-path change the triage describes, so that half of the fix (refusing to file-back disallowed paths) is not verifiable here.
  • Default-{} scope on didPostMessageToRemote — didPostMessageToRemote declares blobURLsInFlight defaulted to {}; any caller that omits it still delivers with an empty keep-alive, so the fix only closes the window for call sites that actually pass a populated scope.

Audit directions

  • Other serialized-message deliverers — Review every path that queues a SerializedScriptValue for async dispatch (Worker, SharedWorker, window.postMessage cross-process) for a keep-alive scope carried to dispatch.
  • Blob file-backing permission — Verify registerInternalBlobURLOptionallyFileBacked and related APIs refuse to file-back paths the process is not sandbox-permitted to read (the part referenced by triage but not fully in this diff).
  • CompletionHandlerCallingScope usage — Audit other uses of scope-exit-style completion signaling to ensure the scope outlives the work it guards, not just the function that created it.

Before / after

Loading diff…