[2] AudioBufferSourceNode caches channel pointers over an unpinned ArrayBuffer
A buffer you can free between assigning it and pressing play.
High — the free is fully script-controlled: the page decides when to detach, when to collect, and when to start the node, so the read on the real-time audio thread lands on reclaimed memory at a time of its choosing. The two clamp defects on the same path mean the stale read is not even bounded to the original allocation's length.
Web Audio splits work across two threads: page script builds a graph on the main thread, and a real-time rendering thread pulls sample frames through that graph in fixed-size quanta. AudioBufferSourceNode — the node that plays a script-supplied AudioBuffer — bridges the two: assigning node.buffer on the main thread caches, per channel, a span pointing directly into the Float32Array backing store that holds the samples. Those backing stores are JSC ArrayBuffers, which script can detach at any time via structured-clone transfer, so anything holding a raw span into one is expected to pin it non-detachable first.
The angle: a page can assign a buffer, transfer its channel data away, collect the original allocation, and then start the node — driving the real-time audio thread to read sample frames out of freed heap with no length bound on the read index.
Patch Details
Four changes land on AudioBufferSourceNode, plus one on JSC's transfer-error path. setBufferForBindings previously guarded the pinning call with an isPlayingOrScheduled() test before calling acquireBufferContent(); the patch removes the condition so acquisition happens unconditionally at assignment time. renderFromBuffer gains an early if (!bufferLength) return false; guard on entry; its loop-wrap clamp is corrected from static_cast<double>(bufferLength - 1) to static_cast<double>(bufferLength) - 1, moving the -1 out of unsigned arithmetic and into the double domain; and the zero-pitch-rate branch gains a readIndex < bufferLength test before it loads m_sourceChannels[i][readIndex]. In JSC, the assertion in errorMessageForTransfer is relaxed from ASSERT(buffer->isLocked()) to ASSERT(!buffer->isDetachable()).
A cached raw pointer whose pinning is gated on a lifecycle state that occurs after the pointer is taken, leaving the whole interval between capture and activation unprotected.
Background
The two-thread model. Graph construction, buffer assignment, and parameter changes happen on the main thread from script. Rendering happens on a dedicated real-time thread that calls each node's process() once per quantum and must never block, which is why nodes cache what they need up front rather than re-resolving it per callback.
AudioBuffer and its backing store. An AudioBuffer holds one Float32Array per channel. The samples live in a JSC ArrayBuffer, which is detachable by default: structuredClone(view, {transfer:[view]}) moves the contents to a clone and leaves the original detached, after which the original allocation is eligible for collection.
Pinning. Holding a raw reference into a detachable backing store requires making it non-detachable first, which is what acquireBufferContent() does — inferred from the regression test's expectation that a subsequent transfer throws TypeError, and from the ASSERT(!buffer->isDetachable()) now guarding the transfer-error path.
The read cursor. renderFromBuffer walks m_virtualReadIndex through the buffer, advancing by the effective pitch rate per output frame, and clamps that cursor against the buffer length when looping. A pitchRate of zero is a distinct branch: the node holds a single sample and std::ranges::fills it across the quantum rather than interpolating.
Analysis
The root cause is an invariant encoded in the wrong place. The gate on acquireBufferContent() expressed the belief that the cached pointers only need the backing store kept alive while the node is rendering. But the pointers are captured at assignment time, not at start() time — so every millisecond between node.buffer = b and node.start() leaves live cached spans over an unpinned, detachable allocation.
main thread audio thread
----------- ------------
node.buffer = b
+- cache m_sourceChannels[i]
(no acquireBufferContent:
node not playing yet)
structuredClone(chan, {transfer}) <-- failure window opens
+- ArrayBuffer detached
drop clone; gc()
+- backing store freed
node.loop = true; node.start()
process() -> renderFromBuffer()
m_sourceChannels[i][readIndex]
<- read of freed memory
The remaining hunks describe what happens once that state is reached, and they are informative about reachability. The newly added if (!bufferLength) return false; establishes that bufferLength can legitimately be zero on entry — the expected value for a detached channel. Pre-fix, the loop-wrap clamp computed static_cast<double>(bufferLength - 1) with bufferLength unsigned, so 0 - 1 wraps to the type maximum and the std::min clamp on m_virtualReadIndex degenerates into a no-op. The read index is then no longer bounded by the buffer at all.
Independently, the !pitchRate branch — reached by setting playbackRate.value = 0 — indexed m_sourceChannels[i][readIndex] with no readIndex < bufferLength test at all. The patch adds one there, which indicates this load site was reachable with an out-of-range readIndex. Setting loop = true prevents the node from finishing, so the read path keeps executing quantum after quantum rather than firing once.
Put together, the primitive is a repeated read of freed memory from the real-time audio thread, at an index the page influences through playbackRate and loop parameters and which is not clamped to the original allocation's extent. The values read are audible output, giving a low-bandwidth but continuous read-back channel out of reclaimed heap.
This vulnerability weakens the isolation between JSC's collector-managed ArrayBuffer storage and WebCore's real-time rendering path: an allocation the collector has reclaimed and reused stays addressable, and readable, from a thread that never re-validates it.
Audit directions
- Pinning gated on a lifecycle state later than the capture. The dangerous shape is
cache the pointer now, pin it when we start using it— the gap between the two is unprotected by construction. Audit the other Web Audio source and processor nodes foracquire*/pin*calls guarded byisPlaying/isActive/isScheduledpredicates, starting from the nodes that cache channel spans. In code review, any acquisition call sitting inside a state predicate, in a function that also stores a raw span, warrants asking when the span was taken. - Unsigned length arithmetic feeding a clamp.
length - 1on an unsigned type silently turns a clamp into a no-op whenever zero is reachable, and the "zero is reachable" precondition is often created by an unrelated lifetime bug. Grep for- 1insidestd::min/std::maxarguments in the audio and graphics resamplers. The visual tell is astatic_cast<double>(x - 1)wherexis a length or count. - Fast paths that skip the slow path's bounds test. The zero-pitch-rate branch existed precisely to avoid interpolation work and dropped the range test along with it. Enumerate the specialized branches in
renderFromBuffer-shaped loops elsewhere in WebCore audio and check each against the general path's guards.