Heap use-after-free in ClipboardItemBindingsDataSource::clearItemTypeLoaders()
CVE: CVE-2026-43727 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A use-after-free issue was addressed with improved memory management. Credit: Tommy DeVoss from Braze Security Team (@thedawgyg), Gia Bui (@yabeow) from Calif.io, Gurpreet Shergill
High. Two write() calls on the same half-settled clipboard item are enough to make a completion handler tear down the very list its caller is iterating — an attacker-triggered free reachable from ordinary web content. Getting past a crash means reclaiming the freed buffer inside a synchronous window that script cannot re-enter.
The Async Clipboard API lets a page hand the browser a bag of promises: new ClipboardItem({...}) maps each MIME type to a promise, and navigator.clipboard.write() must resolve every one of them before a single byte reaches the platform pasteboard. WebCore models that with a per-item data source, ClipboardItemBindingsDataSource, which spins up one loader object per type and counts down as each promise settles. The invariant holding the whole arrangement together is the mundane kind that is easy to lose in a refactor: the list of loaders must not be reachable-and-mutable from the callbacks that list is dispatching.
The angle: A page that calls the clipboard write entry point twice on the same partially-settled item can get the renderer's loader list freed mid-iteration and a virtual call dispatched through a stale pointer.
Source/WebCore/Modules/async-clipboard/ClipboardItemBindingsDataSource.cpp
LayoutTests/editing/async-clipboard/clipboard-write-item-crash.html
Patch Details
One production-code change, three lines net. clearItemTypeLoaders() used to iterate the member vector m_itemTypeLoaders directly by reference, call invokeCompletionHandler() on each element, and then clear() the member once the loop finished. The patch swaps the member's contents into a stack-local vector with std::exchange(m_itemTypeLoaders, { }) and iterates that instead. The trailing clear() disappears because it is now redundant — std::exchange already leaves the member default-constructed.
Two properties of that rewrite matter and neither is incidental. First, the member is emptied before any completion handler runs, so nothing a handler can reach still refers to the list being walked. Second, the surviving copy of the list lives in the caller's stack frame rather than in the heap-allocated object, which means the loop's backing store outlives the object that owned it. An index-based loop or a re-check of m_itemTypeLoaders.size() on each iteration would have addressed only the weaker of the two teardown scenarios; a stack-local vector addresses both.
The rest of the commit is the regression test: a ClipboardItem whose type map contains one promise that never settles alongside two that are already resolved, submitted to navigator.clipboard.write() twice with a microtask drain in between. The expected output is simply PASS under ASAN — a crash-only test, which is the usual shape for a lifetime bug with no observable script-visible behaviour.
Worth noting from the commit trailer: this landed first on safari-7624-branch as 305413.812 and was merged to main afterwards, which is why the main-branch commit date sits after the June 29 Safari 26.5.2 release.
Background
Async Clipboard API. new ClipboardItem({ "text/plain": promise, ... }) constructs a clipboard item whose payload for each MIME type is supplied by a JS promise rather than by an immediate string. navigator.clipboard.write([item]) asks the engine to resolve every one of those promises and hand the aggregated result to the platform pasteboard as a single write. Because promises can settle at arbitrary later times, the write is inherently a multi-step asynchronous operation with state living in WebCore across microtask boundaries.
Promise settlement in WebCore. Native code observes a JS promise through DOMPromise::whenSettledWithResult(), which registers a callback invoked when the promise settles. An already-resolved promise fires its callback at the next microtask checkpoint; a promise constructed as new Promise(() => {}) never settles, and its registered callback stays pending indefinitely.
The data source. ClipboardItemBindingsDataSource is the WebCore object backing a JS-constructed ClipboardItem. It holds m_itemPromises (the type→promise pairs handed over by script), m_itemTypeLoaders (one helper object per type, created when a write begins), a counter m_numberOfPendingClipboardTypes tracking how many types are still outstanding, and m_completionHandler, the callback that finishes the write. Its state machine assumes one write is in flight at a time; in collectDataForWriting() that assumption is expressed as ASSERT(!m_completionHandler), a debug-only check that compiles out of release builds.
Type loaders. ClipboardItemTypeLoader is a refcounted per-type helper produced by ClipboardItemTypeLoader::create(destination, type, callback). Its invokeCompletionHandler() runs whatever callback the data source supplied at creation time. The data source keeps them in a Vector<Ref<ClipboardItemTypeLoader>>.
WTF idioms in the diff. Ref<T> is a non-null refcounted smart pointer; destroying the last Ref to an object destroys the object. A WTF::Vector stores its elements in one contiguous heap buffer, so clearing, reassigning, or destroying the vector both releases that buffer and destroys the Ref elements it held. for (auto& x : container) binds x to successive elements in place — for the duration of the loop the iterator is a raw pointer into whichever backing store the container had when the loop started. std::exchange(member, { }) reads the member's current value into a new object and leaves the member default-constructed; applied to a vector, it transfers ownership of the backing buffer and leaves the member empty.
Re-entrancy. A method is re-entrant at any point where it hands control to a callback that can synchronously call back into the same object and mutate its state before returning. Completion handlers stored inside other objects are the canonical source of such points, because the callee's captures decide what the callback can reach.
Analysis
This is textbook re-entrant iterator invalidation: the loop body dispatches a callback that can destroy the container the loop is walking.
clearItemTypeLoaders() m_itemTypeLoaders (heap buffer)
--------------------------------- ------------------------------
it = &m_itemTypeLoaders[0] ─────► [ L0 ][ L1 ][ L2 ]
L0->invokeCompletionHandler()
└─ --m_numberOfPendingClipboardTypes == 0
└─ invokeCompletionHandler() ◄── re-entry into the data source
└─ write completes; loaders released / owner torn down
[ freed ]
++it; it->invokeCompletionHandler() ◄── Ref loaded from freed memory,
virtual call through stale pointer
The loaders' completion handlers are not inert. Each one created in collectDataForWriting() captures the data source and the item:
auto itemTypeLoader = ClipboardItemTypeLoader::create(destination, type,
[this, protectedItem = Ref { m_item.get() }] {
ASSERT(m_numberOfPendingClipboardTypes);
if (!--m_numberOfPendingClipboardTypes)
invokeCompletionHandler();
});
That is the arrow marked re-entry in the diagram. Invoking L0's handler decrements the data source's pending-type counter, and when the counter hits zero the handler calls straight into ClipboardItemBindingsDataSource::invokeCompletionHandler() — the object's own write-completion path — from inside the loop that is still holding a raw iterator into m_itemTypeLoaders. Completing the write tears the loader list down, either by emptying or reassigning m_itemTypeLoaders outright, or by dropping the last reference to the enclosing data source and destroying the vector along with the object. The capture list is itself an artifact of prior lifetime work: the lambda protects m_item with a Ref, so the hazard of a callback outliving its owner was already on the authors' radar for the ClipboardItem — just not for the data source's own iteration state.
Either teardown route frees the vector's contiguous backing buffer while the range-for's pointer still aims into it. The loop then advances, loads the next Ref<ClipboardItemTypeLoader> out of freed memory, and performs ->invokeCompletionHandler() on whatever pointer now occupies that slot. Even in the milder case where the buffer happens to still be mapped, the nested teardown has already destroyed the Ref elements it contained, so the loop is dispatching a virtual call through an already-destroyed loader. The old code's trailing m_itemTypeLoaders.clear() compounds the damage by releasing references a second time on those stale elements.
Reaching that state requires more than one loader and a way to make the count reach zero mid-loop, which is exactly the shape the regression test builds:
- Construct a
ClipboardItemwith three types — one never-settling promise, two already-resolved. - Call
navigator.clipboard.write([item]).collectDataForWriting()populates three loaders and sets the pending count to three. await 0lets the microtask checkpoint run, settling the two resolved promises and driving the pending count down to one.- Call
navigator.clipboard.write([item])again on the same item. The second write starts withclearItemTypeLoaders(), which begins iterating the first write's loader list. - The first loader's handler decrements the remaining pending type to zero and re-enters the data source's completion path, which tears the list down under the running loop.
- The loop advances into freed memory.
Nothing about that sequence needs privileged API surface or an unusual configuration beyond the Async Clipboard API being enabled and whatever user-activation gate the shipping write path applies; the test flips AsyncClipboardAPIEnabled=true explicitly via a test-runner flag. Note also what step 4 quietly exposes: the "one write at a time" contract is enforced only by ASSERT(!m_completionHandler), so in a release build the second write happily overwrites the in-flight handler and counter instead of rejecting.
The fix restores the invariant at its root rather than patching the symptom. Because std::exchange empties the member before the first handler runs, the re-entrant completion path finds an already-empty m_itemTypeLoaders and has nothing to tear down; because the surviving copy sits on the stack, it stays valid even if that same path destroys the data source itself. The vulnerability weakened memory safety inside the WebContent process by letting a JS-visible entry point control the lifetime of a list WebCore was actively walking; afterwards, that list's lifetime is owned by the stack frame doing the walking. The realistic minimum outcome before the fix is an attacker-triggered renderer crash from ordinary web content, matching Apple's stated impact. If the freed buffer were reclaimed by an attacker-influenced allocation before the stale slot is dereferenced, the stale Ref load and the call through it could give a controlled-pointer dispatch and a refcount decrement at an attacker-influenced address — still inside the sandboxed WebContent process, so a separate sandbox escape would be needed to reach the system.
Two overlapping clipboard writes let a completion handler destroy the loader vector — or its owner — while clearItemTypeLoaders() still holds a raw iterator into its backing store.
Insight
Takeaway: any clear-style method whose loop body invokes stored completion handlers should std::exchange(member, { }) into a stack local first — an index-based loop or a size re-check survives the container being mutated, but only a stack-owned copy survives this being destroyed by the callback.
Audit directions
-
Containers reachable from the callbacks they dispatch. Narrow: grep WebCore for range-for loops over
m_-prefixedVector/HashMapmembers whose body callsinvokeCompletionHandler,->callback(),handler(), ordispatchEvent— start inSource/WebCore/Modules/async-clipboard/, the siblingClipboardItemDataSourcesubclasses, andFileReaderLoaderclient-notification paths. The narrow match tell is precise: a range-for over a member container with a callback invocation in the body and aclear()or reassignment of that same member after the loop. Wider: the class appears wherever a pending-operation registry is drained in place —CompletionHandlervectors,Vector<Ref<PendingScript>>, promise-settlement callback lists,HashMap<..., CompletionHandler>teardown loops; the tell in code-search results is a drain loop whose callee capturesthisor aprotectedThispointing back at the iterating object. Widest: this is the general reentrant-iterator-invalidation class and it holds in any language with in-place callback dispatch — Chromium'sbase::ObserverListexists specifically to solve it, .NET snapshots a multicast delegate's invocation list for the same reason, and Rust's borrow checker rejects the shape at compile time. The portable invariant: snapshot or detach the collection before invoking anything that can see the collection. -
Callbacks that can free
this. Narrow: audit the remainder ofClipboardItemBindingsDataSource.cppandClipboard.cppfor methods that callm_completionHandler(...)orinvokeCompletionHandler()and then keep touching members, checking whether each takes a localRef { *this }/protectedThisfirst — the loader lambdas here already protectm_item, so the pattern was half-applied. The narrow tell is a member read or write on a line lexically after a completion-handler invocation with no ownership anchor in scope. Wider: the same shape recurs in any WebCore class whose completion handler is owned by an external caller —FileReaderLoaderclients,ResourceLoadercompletion paths,DeferredPromiseresolution sites, media-element async callbacks; navigate via callers ofCompletionHandler::operator()in non-terminal method positions. Widest: self-destruction through a dispatched callback recurs in Qt signal/slot withdeleteLater, Objective-C delegate callbacks that release the delegator, and NodeEventEmitterhandlers that dispose the emitter. The portable invariant: if a callback can free the dispatcher, the dispatcher must either hold a self-reference or make the dispatch its last statement. -
Single-use async state machines guarded only by an assertion. Narrow:
collectDataForWriting()'sASSERT(!m_completionHandler)is the only thing standing between a secondnavigator.clipboard.write()and a silently overwrittenm_completionHandler/m_numberOfPendingClipboardTypes, and it compiles out in release; verify whether the second write now rejects or serialises rather than clobbering. The tell is anASSERT(!m_someHandler)immediately followed by an assignment to that same handler. Wider: any DOM object handed to two overlapping async consumers — aBlob/Filefed to twoFileReaderreads, aReadableStreamlocked to two readers, anImageBitmaptransferred while decoding; navigate via WebIDL entry points that store aCompletionHandlerin a member without first checking the member is empty. Widest: the general "single-use resource reused concurrently" class — file handles submitted twice to an event loop, futures polled from two tasks, connection objects reused across in-flight requests. The portable invariant: if a state machine has a busy state, entering it twice must be rejected at runtime, not asserted in debug.