CVE-2024-40780
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected process crash
Researchers: Huang Xilin of Ant Group Light-Year Security Lab
Key insight. A degenerate audio configuration — an extreme negative detune that rounds the effective playback rate to exactly zero — routes rendering into a special-case branch that copied a single source sample without validating that its index stayed within the buffer, an unchecked-index invariant that the surrounding non-zero-rate branches enforce but this one did not.
Patch Details
diff --git a/LayoutTests/webaudio/audiobuffersourcenode-detune-crash-expected.txt b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash-expected.txt
new file mode 100644
index 000000000000..914ba0b133c4
--- /dev/null
+++ b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash-expected.txt
@@ -0,0 +1,10 @@
+Attempting to create a AudioBufferSourceNode with a large negative detune value should not crash.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Test passed because it did not crash.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/webaudio/audiobuffersourcenode-detune-crash.html b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash.html
new file mode 100644
index 000000000000..e8af579db9d2
--- /dev/null
+++ b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script src="../resources/js-test-pre.js"></script>
+ <script src="resources/audio-testing.js"></script>
+ </head>
+ <body>
+ <script>
+ description("Attempting to create a AudioBufferSourceNode with a large negative detune value should not crash.");
+
+ jsTestIsAsync = true;
+
+ var context = new AudioContext();
+ var src = context.createBufferSource();
+ var buffer = context.createBuffer(1, 256, 44100);
+ src.buffer = buffer;
+ src.start(undefined, 1);
+ src.connect(context.listener.positionX, 0);
+ var panner = context.createPanner();
+ src.detune.value = -0xffffff;
+ panner.connect(context.destination);
+ setTimeout(() => {
+ testPassed("Test passed because it did not crash.");
+ finishJSTest();
+ }, 100);
+ </script>
+
+ <script src="../resources/js-test-post.js"></script>
+ </body>
+</html>
diff --git a/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp b/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
index f86bffb9b507..298bd48cdff5 100644
--- a/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
+++ b/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
@@ -328,9 +328,16 @@ bool AudioBufferSourceNode::renderFromBuffer(AudioBus* bus, unsigned destination
virtualReadIndex = readIndex;
} else if (!pitchRate) {
unsigned readIndex = static_cast<unsigned>(virtualReadIndex);
+ int deltaFrames = static_cast<int>(virtualDeltaFrames);
+ maxFrame = static_cast<unsigned>(virtualMaxFrame);
+
+ if (readIndex >= maxFrame)
+ readIndex -= deltaFrames;
for (unsigned i = 0; i < numberOfChannels; ++i)
std::fill_n(destinationChannels[i] + writeIndex, framesToProcess, sourceChannels[i][readIndex]);
+
+ virtualReadIndex = readIndex;
} else if (reverse) {
unsigned maxFrame = static_cast<unsigned>(virtualMaxFrame);
unsigned minFrame = static_cast<unsigned>(floorf(virtualMinFrame));
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2024-40780 |
| Bugzilla | 275273 |
| Component | WebKit |
| Bug Class | OOB |
| Severity | medium |
| Commit | 64c9479d6f29d936… |
| Advisory | Apple Advisory |
Terminology
- 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
1. 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.
2. 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.
3. 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 !pitchRate branch.
4. 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.
5. 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
The primitive is an out-of-bounds read of heap memory (a single source float per channel copied across the output), executed on the WebContent process’s audio rendering path. Consistent with the CVE description and the test’s intent, the realistic outcome is an unexpected process crash rather than a reliable info-leak or memory-corruption chain, since the value read is only used as playback samples and the index is not directly attacker-controlled to an arbitrary offset. It is confined to the sandboxed WebContent process; escalation to code execution from this OOB read alone is not demonstrated and would require additional primitives.
Audit Directions
Other branches of renderFromBuffer
Audit each else if branch in renderFromBuffer (the reverse branch, the interpolating resample branch) for the same missing bound: look for any sourceChannels[i][readIndex] / destinationChannels[...] indexing where readIndex/readIndex2 is not compared against maxFrame or bufferLength before use.
Degenerate-rate edge cases across Web Audio
Grep 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 indices
Across 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.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
AudioBufferSourceNode::renderFromBuffer |
Source/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. |
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/webaudio/audiobuffersourcenode-detune-crash-expected.txt b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash-expected.txt
new file mode 100644
index 000000000000..914ba0b133c4
--- /dev/null
+++ b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash-expected.txt
@@ -0,0 +1,10 @@
+Attempting to create a AudioBufferSourceNode with a large negative detune value should not crash.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Test passed because it did not crash.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/webaudio/audiobuffersourcenode-detune-crash.html b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash.html
new file mode 100644
index 000000000000..e8af579db9d2
--- /dev/null
+++ b/LayoutTests/webaudio/audiobuffersourcenode-detune-crash.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script src="../resources/js-test-pre.js"></script>
+ <script src="resources/audio-testing.js"></script>
+ </head>
+ <body>
+ <script>
+ description("Attempting to create a AudioBufferSourceNode with a large negative detune value should not crash.");
+
+ jsTestIsAsync = true;
+
+ var context = new AudioContext();
+ var src = context.createBufferSource();
+ var buffer = context.createBuffer(1, 256, 44100);
+ src.buffer = buffer;
+ src.start(undefined, 1);
+ src.connect(context.listener.positionX, 0);
+ var panner = context.createPanner();
+ src.detune.value = -0xffffff;
+ panner.connect(context.destination);
+ setTimeout(() => {
+ testPassed("Test passed because it did not crash.");
+ finishJSTest();
+ }, 100);
+ </script>
+
+ <script src="../resources/js-test-post.js"></script>
+ </body>
+</html>