84cde5ed5a `SerializedScriptValue::deserialize` cannot be called more than once (affects BroadcastChannel postMessage())
Triage note: Deserializing one SSV multiple times (fan-out to many recipients) over shared/transferred memory is a plausible aliasing/UAF class, and the tests target SharedArrayBuffer/WasmMemory.
Contents
The bug at a glance
SerializedScriptValue::deserialize() moves several Internals members (array-buffer contents, detached handles, wasm modules/memory), so it is single-use. BroadcastChannel.postMessage() fans one SerializedScriptValue out to multiple same-origin recipients and re-deserialized it per recipient; after the first deserialize the moved-from members are empty, so later recipients received an incomplete message and, for shared/transferred backing (SharedArrayBuffer, shared WebAssembly.Memory, ArrayBuffers), the second consumption operated on already-moved state. Graded medium: it is a same-origin correctness/robustness defect (multi-recipient message loss) with a plausible use-after-move/aliasing angle on shared memory, but the patch demonstrates only message-integrity failures, not a proven UAF exploit.
The bug is a use-after-move fan-out: a value type designed to be deserialized exactly once is consumed N times when a BroadcastChannel has N local recipients. The fix adds SerializedScriptValue::clone() (a deep, share-aware copy of Internals) and hands every recipient except the last its own clone, moving the original only into the final recipient.
Root cause
OBSERVED (commit message): ‘Since ::deserialize(…) moves some of the SerializedScriptValue::Internals members, it cannot be called twice.’ When a BroadcastChannel message has multiple recipients in the same process via postMessage(), ‘only the first recipient gets the complete message.’
OBSERVED: In WebBroadcastChannelRegistry::postMessageLocally, the pre-patch loop dispatched to every matching channel with message.copyRef() – i.e. every recipient shared the same SerializedScriptValue Ref and each dispatch ultimately deserialized it. copyRef() only bumps the refcount; it does not duplicate the moved-once Internals, so the first deserialize empties the shared Internals for all subsequent recipients.
OBSERVED: The fix computes numberOfMessagesNeeded (recipient count minus self), then per recipient does ‘auto messageToDispatch = numberOfMessagesNeeded– > 1 ? message->clone() : WTF::move(message);’ – every recipient but the last gets a fresh clone(); the last takes ownership of the original by move.
OBSERVED: SerializedScriptValue::clone() returns create(m_internals->clone()); SerializedScriptValueInternals::clone() performs a member-wise deep copy. For array-buffer-backed data it uses copyArrayBufferContentsArray, which for each ArrayBufferContents calls content.shareWith(result->last()) – creating a new ArrayBufferContents that shares the same underlying buffer (correct for SharedArrayBuffer / shared semantics) rather than moving it out. sharedBufferContentsArray is copied the same way; wasmModulesArray/wasmMemoryHandlesArray are duplicated via makeUnique copies; detached handles (RTC data channels, media-stream tracks/handles, offscreen canvases, image bitmaps, filesystem handle keep-alives, blob handles) are each deep/isolated-copied through new copy()/isolatedCopy()/clone() helpers.
INFERRED: Before the fix, the second-and-later recipients deserialized from Internals whose moved-out members (e.g. arrayBufferContentsArray, detached*Handles, wasm arrays) were already emptied/consumed. For plain data this yielded an incomplete/wrong message; for shared/transferred backing (SharedArrayBuffer, shared WasmMemory) the repeated consumption of already-moved shared state is the aliasing/use-after-move hazard the advisory class flags. The .htaccess adds COOP/COEP headers so the SharedArrayBuffer and shared-WasmMemory tests run in a cross-origin-isolated context.
Key code
Fan-out fix: clone for all but the last recipient (verbatim, WebBroadcastChannelRegistry.cpp)
size_t numberOfMessagesNeeded = channelIdentifiersForName.size();
for (auto& channelIdentifier : channelIdentifiersForName) {
if (channelIdentifier == sourceInProcess)
--numberOfMessagesNeeded;
}
for (auto& channelIdentifier : channelIdentifiersForName) {
if (channelIdentifier == sourceInProcess)
continue;
auto messageToDispatch = numberOfMessagesNeeded-- > 1 ? message->clone() : WTF::move(message);
WebCore::BroadcastChannel::dispatchMessageTo(channelIdentifier, WTF::move(messageToDispatch), [callbackAggregator] { });
}
Patch walkthrough
Source/WebKit/WebProcess/WebCoreSupport/WebBroadcastChannelRegistry.cpp— The core fix. postMessageLocally now counts the real recipients (numberOfMessagesNeeded, excluding sourceInProcess) and, in the dispatch loop, gives each recipient message->clone() while numberOfMessagesNeeded– > 1 and WTF::move(message) for the final one. This guarantees each recipient deserializes from its own complete SerializedScriptValue instead of a shared, moved-from one.Source/WebCore/bindings/js/SerializedScriptValue.cpp— Adds SerializedScriptValue::clone() (returns create(m_internals->clone())), SerializedScriptValueInternals::clone() (member-wise deep copy of every Internals field), and the copyArrayBufferContentsArray helper that rebuilds an ArrayBufferContentsArray using content.shareWith(…) so shared buffers are duplicated by sharing the backing store rather than moving it out.Source/WebCore/bindings/js/SerializedScriptValue.h / SerializedScriptValueInternals.h— Declare the new WEBCORE_EXPORT clone() methods on SerializedScriptValue and SerializedScriptValueInternals.Source/WebCore/html/ImageBitmap.cpp / ImageBitmap.h— Adds a DetachedImageBitmap copy constructor that clones the underlying SerializedImageBuffer (makeUniqueRefFromNonNullUniquePtr(other.m_bitmap->clone())) so detached image bitmaps can be duplicated for extra recipients.Source/WebCore/platform/graphics/ImageBuffer.cpp / ImageBuffer.h— Adds a virtual SerializedImageBuffer::clone() (default nullptr) and a DefaultSerializedImageBuffer override returning a copy over the same ImageBuffer, enabling deep-copy of serialized image buffers.Source/WebKit/WebProcess/GPU/graphics/RemoteImageBufferProxy.h— Adds RemoteSerializedImageBufferProxy::clone() plus a private copy constructor so GPU-process-backed serialized image buffers can also be cloned for fan-out.Source/WebCore/platform/mediastream/MediaStreamTrackDataHolder.cpp / .h— Adds MediaStreamTrackDataHolder::copy() (isolatedCopy of data with ShouldUpdateId::No plus copyRef of the source) used by Internals::clone to duplicate detached media-stream tracks.Source/WebCore/Modules/filesystem/FileSystemStorageConnection.cpp / .h— Adds FileSystemHandleKeepAlive::copy() so filesystem handle keep-alives are duplicated (a fresh keep-alive on the same global identifier/connection) rather than moved.Source/WebCore/html/OffscreenCanvas.h— Adds placeholderSource() accessor so Internals::clone can reconstruct DetachedOffscreenCanvas entries (size, originClean, placeholder source) for additional recipients.LayoutTests/http/tests/broadcastchannel/*— Adds a broadcast-channel test harness plus 24 per-type tests (array, arraybuffer, blob, map, set, sharedarraybuffer, sharedwasmmemory, wasmmodule, videoframe, mediastreamtrack, etc.), each fanning a value to 10 workers and asserting all 10 receive it intact; .htaccess sets COOP/COEP for the SharedArrayBuffer and shared-WasmMemory cases.
Background
SerializedScriptValue and Internals — SerializedScriptValue wraps the structured-clone byte stream plus a SerializedScriptValueInternals holding out-of-band, move-only resources: array-buffer contents, shared-buffer contents, detached image bitmaps/offscreen canvases, media-stream tracks, RTC channels, wasm modules and memory handles, blob and filesystem-handle keep-alives. deserialize() reconstitutes JS objects and moves several of these members out, so a given SSV is meant to be deserialized once.
BroadcastChannel.postMessage() fan-out — A message posted on a BroadcastChannel is delivered to every other same-origin BroadcastChannel with the same name. WebBroadcastChannelRegistry::postMessageLocally dispatches to each local recipient channel; with N recipients the single serialized message must yield N independent deserializations.
copyRef() vs deep clone — The pre-patch code used message.copyRef(), which only increments the SerializedScriptValue refcount – all recipients shared one Internals. Deserializing that shared, move-once Internals more than once leaves later recipients reading moved-from (empty) members. clone() instead deep-copies Internals so each recipient owns a complete, independent message.
ArrayBufferContents::shareWith and shared backing — copyArrayBufferContentsArray duplicates each ArrayBufferContents via shareWith, producing a new handle onto the same underlying buffer. This is the correct duplication for SharedArrayBuffer and shared WebAssembly.Memory (recipients must observe the same shared memory) while avoiding moving the single backing store out from under later recipients.
COOP/COEP cross-origin isolation — SharedArrayBuffer and shared WebAssembly.Memory are only available in cross-origin-isolated contexts. The added .htaccess sets Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp for the sharedarraybuffer/sharedwasmmemory tests so those transferable/shared types can be exercised through BroadcastChannel.
Vulnerability window
- Design constraint — SerializedScriptValue::deserialize moves Internals members and is single-use by construction.
- Latent defect — BroadcastChannel fan-out shares one SSV via copyRef() across multiple local recipients, so only the first recipient’s deserialize sees complete data.
- Symptom — Multi-recipient postMessage delivers incomplete messages to all but the first recipient; for shared/transferred backing this re-consumes already-moved state (rdar://171134726).
- Fix — Add SerializedScriptValue::clone()/Internals::clone() and per-type deep/share-aware copy helpers; hand each recipient but the last a clone, moving the original into the last (originally 305413.445@safari-7624-branch; upstream 315347@main).
- Verification — 24 broadcast-channel layout tests fan each supported SSV type to 10 workers and assert all receive it intact, with COOP/COEP set for the shared-memory cases.
Proof of concept
Verbatim added test postmessage-sharedarraybuffer.html. The shared harness (broadcastchannel-test-harness.js, also added) spins up WORKER_COUNT = 10 workers each holding a BroadcastChannel(“test”), then the page posts the value (here a SharedArrayBuffer(8)) once; the test passes only if all 10 workers report the intact value (‘SharedArrayBuffer:8’). Pre-patch, only the first recipient would receive a complete message because the shared SerializedScriptValue’s Internals were moved out on the first deserialize. The companion postmessage-sharedwasmmemory.html does the same with new WebAssembly.Memory({initial:1, maximum:1, shared:true}); both rely on the added COOP/COEP .htaccess for cross-origin isolation. These are integrity assertions, not crash reproducers.
<!DOCTYPE HTML><!-- webkit-test-runner [ jscOptions=--useSharedArrayBuffer=true ] -->
<html>
<body>
<pre id="output"></pre>
<script src="resources/broadcastchannel-test-harness.js"></script>
<script>
broadcastChannelTest(function() { return new SharedArrayBuffer(8); }, "SharedArrayBuffer:8");
</script>
</body>
</html>
Exploitation
- Reachability — Same-origin script only: open multiple BroadcastChannels of one name (e.g. across workers/tabs) and postMessage a value backed by shared/transferred memory (SharedArrayBuffer, shared WasmMemory, ArrayBuffer).
- Observed effect — Later recipients receive an incomplete/empty message because Internals were moved out by the first deserialize – a message-integrity failure. This is the behavior the added tests pin down.
- UAF/aliasing angle — INFERRED/speculative: re-deserializing already-moved shared-buffer/wasm-memory state is a use-after-move on out-of-band resources and is the reason the class is flagged UAF. The patch does not include a crash or corruption reproducer, so a concrete UAF primitive is unproven from the artifacts alone.
- Honest assessment — Demonstrated impact is same-origin message corruption/loss on multi-recipient BroadcastChannel; any memory-corruption exploitation is hypothetical and would require establishing that a second deserialize dereferences freed backing rather than merely observing empty members.
Detection & hunting
For defenders and SOC / detection engineers:
- Multi-recipient message integrity —
- Repeated deserialize instrumentation —
- clone() coverage —
Audit directions
- Other multi-recipient SSV dispatch —
- Internals member completeness —
- shareWith vs move semantics —
- Detached-resource copy helpers —