CVE-2024-40780
Overview
Background
- AudioBufferSourceNode
- A Web Audio node that plays back the samples of an in-memory AudioBuffer, with controllable start offset, playbackRate and detune.
- detune / playbackRate
- AudioParams that jointly determine the effective sample-read rate; detune is combined exponentially (cents, 2^(detune/1200)) so extreme values can drive the effective rate to zero.
- virtualReadIndex
- The floating-point cursor tracking the current read position within the source buffer as renderFromBuffer produces output frames.
- renderFromBuffer
- The per-render-quantum routine on the audio thread that copies/resamples source buffer frames into the destination channels.
- Out-of-bounds read
- Accessing memory outside the intended bounds of a buffer, here reading a source sample at an index past the channel’s valid frame range.
Root Cause Analysis
The bug is an out-of-bounds read in AudioBufferSourceNode::renderFromBuffer in Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp, specifically in the branch taken when the effective playback rate (pitchRate) evaluates to zero (the else if (!pitchRate) path). In that branch the code computes unsigned readIndex = static_cast<unsigned>(virtualReadIndex); and then, for every channel, does std::fill_n(destinationChannels[i] + writeIndex, framesToProcess, sourceChannels[i][readIndex]); — it repeatedly copies a single source sample located at readIndex. The implicit invariant is that readIndex must remain a valid index into the source buffer channel (i.e. readIndex < bufferLength/maxFrame). Before the patch nothing constrained readIndex in this branch: virtualReadIndex is advanced/positioned from the grain start offset and the accumulated playback state, and it can point past the valid frame range (or at maxFrame, the exclusive end), so sourceChannels[i][readIndex] reads out of bounds. The trigger, shown by the added layout test, is a very large negative detune value (detune.value = -0xffffff) combined with a start offset; the detune is applied as an exponential factor to the playback rate, so a large negative detune drives computedPlaybackRate toward zero, and once the rounded pitchRate is exactly 0 this vulnerable zero-rate branch is entered.
The fix reads the frame bounds into locals — int deltaFrames = static_cast<int>(virtualDeltaFrames); and maxFrame = static_cast<unsigned>(virtualMaxFrame); — and clamps: if (readIndex >= maxFrame) readIndex -= deltaFrames;, wrapping readIndex back into the buffer’s valid grain window before the fill, and finally writes the corrected value back with virtualReadIndex = readIndex; so the render loop’s state stays consistent. This restores the invariant that readIndex indexes a valid frame of the source buffer. The precise derivation of virtualReadIndex/virtualDeltaFrames/virtualMaxFrame is not fully shown in the diff (only their use in this branch is), so the exact numeric path by which readIndex exceeded maxFrame is inferred from the surrounding code and the added clamp rather than displayed line-by-line.
Attack Path
- Create an AudioContext and buffer source
From attacker-controlled web content, construct
new AudioContext(), create a small AudioBuffer (e.g. createBuffer(1, 256, 44100)) and an AudioBufferSourceNode, and assign the buffer to it. - Schedule a grain with an offset
Call
src.start(undefined, 1)(or similar) so the playback begins at a nonzero offset into the buffer, positioning virtualReadIndex away from the buffer start. - Force pitchRate to zero via detune
Set
src.detune.value = -0xffffff; because detune is applied as an exponential (2^(detune/1200)) factor to the computed playback rate, an extreme negative detune collapses the effective rate to zero, steering rendering into the!pitchRatebranch. - Connect into the graph to force rendering Connect the source into the audio graph (destination/panner) so the audio rendering thread invokes renderFromBuffer and executes the zero-rate fill loop.
- Trigger the OOB read
With readIndex left unclamped at/beyond maxFrame,
sourceChannels[i][readIndex]reads memory past the channel data. In practice this yields an out-of-bounds read of adjacent heap memory, most reliably producing a WebContent-process crash.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
AudioBufferSourceNode::renderFromBufferSource/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp |
modified | In the zero playback-rate (`!pitchRate`) branch, introduces deltaFrames/maxFrame locals and clamps readIndex with `if (readIndex >= maxFrame) readIndex -= deltaFrames;` before the per-channel std::fill_n, and writes the corrected index back to virtualReadIndex to prevent the OOB source read. |
Audit Directions
- Other branches of renderFromBufferAudit each
else ifbranch in renderFromBuffer (the reverse branch, the interpolating resample branch) for the same missing bound: look for anysourceChannels[i][readIndex]/destinationChannels[...]indexing where readIndex/readIndex2 is not compared against maxFrame or bufferLength before use. - Degenerate-rate edge cases across Web AudioGrep the webaudio module for playback-rate and detune handling (
computePlaybackRate,pitchRate,!pitchRate,virtualReadIndex) and check what happens when the rate collapses to 0 or overflows, since zero/near-zero rate is the trigger here. - std::fill_n / memcpy with derived indicesAcross WebCore audio and DSP code, grep for
std::fill_n(and buffer copies whose source or offset comes from a float-to-unsigned cast of an accumulating cursor; verify a clamp precedes the cast/use.