[1] Dangling client back-reference in PlatformSpeechSynthesizer
The callback kept the speech engine alive and let its listener die.
High. A non-owning back-reference survives an asynchronous hop to the main thread and is then consumed by a pure-virtual call, so a vtable pointer is loaded out of released memory. Escalation depends on reclaiming the freed client allocation before the deferred callback runs.
Back-reference lifetime in WebKit's speech-synthesis stack is normally kept safe by a strict ownership direction: the DOM object owns the platform backend downward, and the backend only ever points upward at an owner guaranteed to be alive. SpeechSynthesis — the DOM object behind window.speechSynthesis — holds a RefPtr<PlatformSpeechSynthesizer>, the platform bridge that drives the system text-to-speech engine (AVSpeechSynthesizer on Cocoa, Flite on GStreamer, Spiel on Linux, plus a test mock). The backend's upward pointer was a plain C++ reference, which is sound only while every call through it is synchronous with respect to a stack frame already holding the owner alive.
The angle: any page can call speechSynthesis.cancel() or enumerate voices, drop the last reference to the speech object, and have a deferred main-thread callback make a virtual call through freed memory in the WebContent process.
The pre-fix storage of the back-reference:
Source/WebCore/platform/PlatformSpeechSynthesizer.h
PlatformSpeechSynthesizerClient& m_speechSynthesizerClient;
And the deferred call site in the mock backend that the commit calls out:
Source/WebCore/platform/mock/PlatformSpeechSynthesizerMock.cpp
callOnMainThread([protectedThis = Ref { *this }, utterance]() {
protectedThis->client().speakingErrorOccurred(*utterance);
});
Patch Details
The raw PlatformSpeechSynthesizerClient& member is replaced with storage that each dispatch site promotes to a RefPtr before calling into the client, so the client is pinned for the duration of every callback rather than merely assumed alive. Three deferred call sites are converted: the mock's cancel() path shown above; the Cocoa asynchronous voice-list completion, which ran protectedThis->appendVoices(voices.get()); protectedThis->m_speechSynthesizerClient.voicesDidChange(); — whether that path could actually reach a freed client turns on whether protectedThis is a strong capture or a null-checked promotion from a weak handle, and PlatformSpeechSynthesizerCocoa.mm is truncated before that block, so which of the two it is stays open; and the Spiel initializeVoiceList lambda. The commit message groups the latter two with the mock case as "latent async bugs with the same pattern."
Non-owning back-reference dereferenced after an asynchronous hop that holds a strong reference to the callee but none to the caller it calls back into.
Background
Where this lives. The Web Speech API's synthesis half is exposed to script as window.speechSynthesis and runs entirely in the WebContent process. SpeechSynthesis is the DOM-visible object; PlatformSpeechSynthesizer is the engine-agnostic bridge beneath it, with a per-platform backend behind that.
Ownership direction. Per SpeechSynthesis.h, class SpeechSynthesis : public PlatformSpeechSynthesizerClient, ... public RefCounted<SpeechSynthesis>, public ActiveDOMObject, public EventTarget, and it owns the synthesizer downward via RefPtr<PlatformSpeechSynthesizer> m_platformSpeechSynthesizer. The synthesizer needs to report results back up — speaking finished, an error occurred, the voice list changed — so it keeps a pointer to its client.
PlatformSpeechSynthesizerClient. This is the callback interface the DOM object implements. speakingErrorOccurred, voicesDidChange, and didFinishSpeaking are all pure virtual on it, which means every upward notification is a vtable dispatch: the call loads the vtable pointer of the object's PlatformSpeechSynthesizerClient subobject — which lands at offset 0 only because that base happens to be listed first among SpeechSynthesis's several bases — before it reads any field.
Ref / RefPtr and lambda captures. Ref { *this } inside a lambda capture list takes a strong reference that keeps the captured object alive until the lambda is destroyed. It says nothing about any other object the lambda will touch — a capture protects what it captures, and only that.
Analysis
The root cause is an asymmetric capture across an asynchronous boundary: the deferred work item takes a strong reference on the synthesizer and none on the client it is going to call into, while the synthesizer's link to that client is a raw reference that no longer participates in lifetime at all.
main thread (script) deferred main-thread work item
──────────────────── ──────────────────────────────
cancel() ──────────────► callOnMainThread(Ref{*this}, utterance)
drop last RefPtr
~SpeechSynthesis() ◄── client freed here
... protectedThis alive (strong)
client().speakingErrorOccurred()
└─► vtable read on freed block
▲ failure window
The strong protectedThis capture is precisely what makes the bug reachable: it guarantees the synthesizer survives to the point where it dereferences a client that did not. The commit message attributes the introduction of this asynchrony to 309349@main.
There is a second, synchronous leg that the same fix closes. SpeechSynthesis::voicesDidChange() calls dispatchEvent(Event::create(eventNames().voiceschangedEvent, ...)), so the client's handler runs author script. A raw reference offers no protection if that script drops the last reference to the SpeechSynthesis object mid-call; the RefPtr promotion holds the client alive for the duration of each dispatch regardless of what the event handler does.
On exploitability: the freed access lands on a pure-virtual call, so the vtable slot of the reclaimed block's PlatformSpeechSynthesizerClient subobject is consumed as a code pointer rather than as ordinary data. That is the strongest shape this class of bug takes — control of that slot is control of an indirect branch target. Building that from script requires winning the allocation race between ~SpeechSynthesis() and the queued main-thread work item, which is a same-thread ordering the attacker influences but does not directly schedule; the reclaim step is a projected direction rather than something the change itself establishes.
This vulnerability weakens WebContent-process memory safety on a surface reachable from ordinary page script with no user gesture.
Audit directions
- Strong-self capture with raw-client dispatch. A lambda that captures
Ref { *this }orprotectedThisand then calls through a member that is a raw reference or raw pointer protects exactly the wrong object. The two other sites this commit fixed — the Cocoa asynchronous voice-list completion and Spiel'sinitializeVoiceListlambda — are the same shape, so the pattern is known to replicate across platform backends of the same abstraction. In code review, a capture list containingprotectedThisin a function body that later dereferences aFoo&member deserves the question "who holds that one alive?" - Client handlers that dispatch events.
voicesDidChange()runs script viadispatchEvent. Any platform-layer notification that funnels into anEventTargetis a re-entrancy point where the notifier's owner can be released underneath it; the audit target is platform callback interfaces whose implementers areEventTargetsubclasses, checked for whether the caller holds a reference across the call rather than just before it.