2c2b96df94cb454c6e58536b16fead32d4dd1de3 [ macOS Debug ] imported/w3c/web-platform-tests/webaudio/the-audio-api/the-audionode-interface/audionode-channel-rules.html is a flaky crash
Triage note: Replaces range-for + clear() with a takeLast() drain loop in handleDeferredDecrementConnectionCounts/handleDeferredDerefs, avoiding iterator invalidation/UAF when decrement/deref callbacks re-enter and mutate the list during graph teardown.
Contents
The bug at a glance
Reaching the bug requires only ordinary WebAudio graph teardown (disconnecting nodes / letting a context be collected), which is scriptable from any page, but it is a timing-dependent re-entrancy race that surfaced as a flaky debug crash rather than a deterministically controllable free. The primitive is a use-after-free of an AudioNode during the deferred-deref drain, which is a strong corruption primitive, but the difficulty of shaping the re-entrant mutation and the fact that it manifested as a rare crash keep it at Medium/6.5 rather than High.
BaseAudioContext keeps two worklists of AudioNodes whose reference-count work was deferred until the graph lock is held: m_deferredBreakConnectionList and m_deferredDerefList. The old code walked each Vector with a range-for and then called clear(), but the very callbacks it invokes inside the loop — decrementConnectionCountWithLock() and derefWithLock() — can drop the last reference to a node and, re-entrantly, mutate the same Vector during that destruction. Mutating a Vector you are iterating invalidates the range-for’s backing pointer, so the loop keeps dereferencing a stale, potentially-freed element. The fix drains each list with a takeLast() loop so an element is removed from the Vector before its callback (and any re-entrant destruction) runs.
Root cause
The vulnerable state is a pair of member Vectors on BaseAudioContext, m_deferredBreakConnectionList and m_deferredDerefList, that accumulate AudioNodes whose connection-count decrement or deref must run under the graph lock. handleDeferredDecrementConnectionCounts() and handleDeferredDerefs() are called while the context holds the graph lock (ASSERT(isGraphOwner())) during post-render / teardown processing, and they process every queued node.
The reaching path is standard audio-graph churn: script disconnects nodes or drops references, the render thread defers the actual ref-count work onto these lists, and the next drain iterates them. The old implementation used for (auto& node : m_deferredBreakConnectionList) node->decrementConnectionCountWithLock(); followed by m_deferredBreakConnectionList.clear(); (and the analogous derefWithLock() loop). The range-for caches begin()/end() pointers into the Vector’s inline buffer.
This is unsafe because decrementConnectionCountWithLock() and derefWithLock() can release the last strong reference to a node. Destroying an AudioNode tears down its connections, which re-enters BaseAudioContext and can append to or otherwise mutate the very list being iterated. A Vector mutation may reallocate the backing store or shift elements, invalidating the range-for iterator; the loop then reads and dereferences a dangling element pointer — a use-after-free — and the trailing clear() operates on a list whose contents changed underneath it.
The fix converts both loops to a drain: while (!list.isEmpty()) { auto* node = list.takeLast().unsafeGet(); node->...WithLock(); }. takeLast() pops the element out of the Vector before its callback runs, so any re-entrant mutation of the list during destruction cannot invalidate an outstanding iterator — there is no live iterator — and the node reference is materialized locally. The clear() is dropped because the loop empties the list by construction.
Key code
BaseAudioContext.cpp: drain-with-takeLast() replaces range-for + clear()
void BaseAudioContext::handleDeferredDecrementConnectionCounts()
{
ASSERT(isGraphOwner());
while (!m_deferredBreakConnectionList.isEmpty()) {
SUPPRESS_UNCHECKED_LOCAL auto* node = m_deferredBreakConnectionList.takeLast().unsafeGet(); // NOLINT.
node->decrementConnectionCountWithLock();
}
}
void BaseAudioContext::handleDeferredDerefs()
{
ASSERT(isGraphOwner());
while (!m_deferredDerefList.isEmpty()) {
SUPPRESS_UNCHECKED_LOCAL auto* node = m_deferredDerefList.takeLast().unsafeGet(); // NOLINT.
node->derefWithLock();
}
}
Patch walkthrough
Source/WebCore/Modules/webaudio/BaseAudioContext.cpp— handleDeferredDecrementConnectionCounts() and handleDeferredDerefs() each replace afor (auto& node : list) node->...WithLock();plus a trailinglist.clear();with awhile (!list.isEmpty()) { auto* node = list.takeLast().unsafeGet(); node->...WithLock(); }drain. takeLast() removes the entry from the Vector before invoking the callback, so a re-entrant append or destruction triggered by decrementConnectionCountWithLock()/derefWithLock() can no longer invalidate a live range-for iterator or leave a stale element to be dereferenced. SUPPRESS_UNCHECKED_LOCAL / unsafeGet() is used because the raw node pointer is only used transiently under the held graph lock.
Background
Deferred deref lists — WebAudio cannot safely change AudioNode ref counts on the render thread while the audio graph is live, so BaseAudioContext queues the work onto m_deferredBreakConnectionList and m_deferredDerefList and flushes it later under the graph lock (isGraphOwner()).
Range-for iterator invalidation — WTF::Vector’s range-based for caches begin/end pointers into its buffer; appending during iteration can reallocate or shift the buffer, leaving those pointers dangling — a classic C++ iterator-invalidation UAF when the loop body can mutate the container.
Re-entrant node destruction — decrementConnectionCountWithLock()/derefWithLock() may drop a node’s last reference; destroying an AudioNode disconnects it, which re-enters BaseAudioContext and can push more entries onto the same deferred list being drained.
Vulnerability window
- Setup — Page builds an audio graph and then disconnects nodes or drops references, causing ref-count work to be deferred onto m_deferredBreakConnectionList / m_deferredDerefList.
- Drain begins — During post-render/teardown, handleDeferredDecrementConnectionCounts()/handleDeferredDerefs() start a range-for over the Vector under the graph lock.
- Re-entry — A callback releases a node’s last reference; the node’s destructor disconnects it and re-enters the context, mutating the list mid-iteration.
- Invalidation — The Vector reallocates/shifts, invalidating the range-for pointers; the loop dereferences a stale/freed element — use-after-free.
- Symptom — Manifested as the flaky debug crash in audionode-channel-rules.html (bug 321961 / rdar://185147134).
- Fix — Both loops rewritten as takeLast() drains so an element is removed before its callback runs; the trailing clear() is removed.
Triggering
No dedicated PoC is included; the patch says ‘No new tests needed’ and references the pre-existing flaky WPT audionode-channel-rules.html. Triggering is timing-dependent: it requires an AudioNode deref/decrement callback during a deferred-list drain to re-entrantly mutate the same list at exactly the point where the range-for buffer is invalidated. A reliable, weaponized trigger would depend on internal graph-teardown scheduling not expressed in the diff, so no concrete corruption primitive can be reconstructed honestly from the patch alone.
Exploitation
- Groom — Construct an audio graph whose teardown queues many nodes onto the deferred lists and arranges that destroying one node re-enters the context and appends to the list being drained, forcing a reallocation of the Vector buffer.
- Trigger UAF — During the drain, the range-for reads a freed/relocated element; a released AudioNode’s memory is dereferenced, giving a use-after-free read/callthrough on a controlled-lifetime object.
- Reliability caveat — The window is narrow and timing-dependent (it surfaced only as a flaky debug crash), so turning it into a deterministic free/reallocate-and-reclaim primitive is non-trivial and not demonstrated by the patch.
Detection & hunting
For defenders and SOC / detection engineers:
- Crash signature — Crashes or ASan/GuardMalloc reports inside handleDeferredDecrementConnectionCounts / handleDeferredDerefs iterating m_deferredBreakConnectionList / m_deferredDerefList during BaseAudioContext teardown.
- Flaky WPT — Intermittent debug-build crashes in webaudio the-audionode-interface tests (e.g. audionode-channel-rules.html) are a fingerprint of this re-entrant-mutation race.
- Static pattern — Range-for over a member Vector whose loop body can call code that mutates that same Vector, followed by clear().
Audit directions
- WebAudio deferred queues — Audit every drain of a BaseAudioContext/AudioNode worklist for range-for-then-clear patterns where the callback may re-enter and mutate the container; prefer takeLast()/takeFirst() drains.
- Ref-count-under-lock callbacks — Review other *WithLock() deref/decrement paths that can trigger node destruction and re-entrancy during graph teardown.
- Vector iteration + destructor side effects — Search WebCore for iteration over containers of RefPtr/ref-counted objects where releasing a reference inside the loop can append to or resize the same container.