CVE-2024-40779
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. The interpolation branch enforced its buffer-boundary invariant only partially (it wrapped readIndex2 but not the general case), so reverse playback at a fractional start offset could still index past the buffer; the fix is a defensive per-iteration bounds check on both interpolation indices before dereferencing the source.
Patch Details
diff --git a/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash-expected.txt b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash-expected.txt
new file mode 100644
index 000000000000..654ddf7f17ef
--- /dev/null
+++ b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash-expected.txt
@@ -0,0 +1 @@
+This test passes if it does not crash.
diff --git a/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash.html b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash.html
new file mode 100644
index 000000000000..5fb2dd8c8a5f
--- /dev/null
+++ b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash.html
@@ -0,0 +1,25 @@
+<html>
+<head>
+ <script>
+ async function main() {
+ var ctx = new AudioContext();
+ var src = new AudioBufferSourceNode(ctx);
+ src.buffer = ctx.createBuffer(1, 8192, 44100);
+ src.start(undefined, 0.5);
+ src.playbackRate.value = -1;
+ src.connect(ctx.destination, 0, 0);
+ if (window.testRunner)
+ testRunner.notifyDone();
+ }
+ </script>
+</head>
+<body onload="main()">
+ <p>This test passes if it does not crash.</p>
+ <script>
+ if (window.testRunner) {
+ testRunner.waitUntilDone();
+ testRunner.dumpAsText();
+ }
+ </script>
+</body>
+</html>
diff --git a/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp b/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
index 298bd48cdff5..740b793e0ec5 100644
--- a/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
+++ b/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
@@ -350,6 +350,12 @@ bool AudioBufferSourceNode::renderFromBuffer(AudioBus* bus, unsigned destination
if (readIndex2 >= maxFrame)
readIndex2 = m_isLooping ? minFrame : readIndex;
+ // Final sanity check on buffer access.
+ // FIXME: as an optimization, try to get rid of this inner-loop check and
+ // put assertions and guards before the loop.
+ if (readIndex >= bufferLength || readIndex2 >= bufferLength)
+ break;
+
// Linear interpolation.
for (unsigned i = 0; i < numberOfChannels; ++i) {
float* destination = destinationChannels[i];
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2024-40779 |
| Bugzilla | 275431 |
| Component | WebKit |
| Bug Class | OOB |
| Severity | medium |
| Commit | ff52ff7cb64eeed8… |
| Advisory | Apple Advisory |
Terminology
- Linear interpolation resampling — Producing an output sample by blending two adjacent source frames (readIndex and readIndex2) weighted by the fractional playback position, used when the playback rate is not a whole number.
- Reverse playback — A negative playbackRate that walks the source buffer backward, which reverses the direction readIndex moves and stresses the boundary-wrap logic.
- readIndex / readIndex2 — The two integer source-frame indices bracketing the current fractional read position in the interpolation branch.
- bufferLength / maxFrame — The number of valid frames in the source channel; indices must remain strictly below it to be in bounds.
- Out-of-bounds read — Reading memory beyond the allocated source channel array, here dereferencing source[readIndex] with an index that escaped the valid frame range.
Root Cause Analysis
This is an out-of-bounds read in the interpolating (resampling) branch of AudioBufferSourceNode::renderFromBuffer in Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp. That branch performs linear interpolation between two adjacent source frames, readIndex and readIndex2, when the playback rate is non-integral or reversed. Just above the patched lines the code already conditionally rewinds/wraps readIndex2 (if (readIndex2 >= maxFrame) readIndex2 = m_isLooping ? minFrame : readIndex;), showing the intended invariant that both interpolation indices must stay within the buffer’s frame range. The invariant was not fully enforced: under certain rate/offset combinations readIndex (and/or readIndex2) could still be at or beyond bufferLength when the interpolation loop dereferences source[readIndex] and source[readIndex2], producing an OOB read of the source channel data. The added layout test triggers it with a reverse playback (playbackRate.value = -1) grain started at a fractional offset (start(undefined, 0.5)) on an 8192-frame buffer, i.e. the resampler runs backward and the computed indices fall outside the valid window. The fix inserts a final in-loop guard — if (readIndex >= bufferLength || readIndex2 >= bufferLength) break; — that bails out of the interpolation loop before any out-of-bounds dereference; the accompanying FIXME comment explicitly frames it as a last-ditch sanity check to be later replaced by pre-loop assertions/guards. This restores the invariant that both interpolation indices are valid before the source buffer is read. Note the diff shows only the guard being added, not the full computation of readIndex/readIndex2/maxFrame/minFrame, so the exact arithmetic that let the index escape (interaction of reverse rate, fractional start offset, and virtualReadIndex advancement) is inferred from the surrounding wrap logic and the reverse-playback trigger rather than shown line-by-line.
Attack Path
1. Set up context and buffer source
Attacker web content creates new AudioContext() and new AudioBufferSourceNode(ctx), and assigns a buffer via ctx.createBuffer(1, 8192, 44100).
2. Start the grain at a fractional offset
Call src.start(undefined, 0.5) so playback begins at a non-frame-aligned offset, forcing the interpolating (resampling) code path rather than a plain integer copy.
3. Reverse the playback rate
Set src.playbackRate.value = -1 so rendering proceeds in reverse, driving the readIndex/readIndex2 computation toward the buffer boundary in a way the pre-patch wrap logic did not fully cover.
4. Connect to force rendering
src.connect(ctx.destination, 0, 0) schedules the node so the audio thread calls renderFromBuffer and enters the linear-interpolation loop.
5. Trigger the OOB read
The loop dereferences source[readIndex]/source[readIndex2] with an index at/beyond bufferLength, reading past the channel buffer. The observable result is an out-of-bounds read leading to an unexpected process crash.
Impact Assessment
The primitive is an out-of-bounds read of source-buffer-adjacent heap memory on the WebContent audio rendering thread; the read values feed the interpolation output, not an attacker-controlled arbitrary-offset leak, so the realistic and CVE-stated outcome is an unexpected crash rather than a robust info-disclosure or corruption primitive. It is contained within the sandboxed WebContent process. Turning this into RCE would require chaining with other bugs; the guard’s break-on-overflow shows the fix targets crash prevention rather than a demonstrated corruption channel.
Audit Directions
Same branch, both indices and all wrap paths
Re-examine renderFromBuffer’s interpolation branch: verify the new break covers every dereference and that the earlier readIndex2 = m_isLooping ? minFrame : readIndex; reassignment can’t leave readIndex itself out of range; grep for readIndex2, maxFrame, minFrame, m_isLooping.
Reverse and looping grain math
Audit the reverse-playback and looping code paths in the webaudio module for index computations that assume forward motion; grep for reverse, playbackRate, floorf, and negative-rate handling where virtualReadIndex is advanced.
Resamplers indexing without pre-loop bounds
Across WebCore audio DSP (e.g. AudioResampler, SincResampler, multichannel resamplers), grep for interpolation loops that read source[index]/source[index+1] and confirm a bounds check exists before the loop rather than relying on inner-loop patches.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
AudioBufferSourceNode::renderFromBuffer |
Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp |
modified | Adds a final in-loop bounds check in the linear-interpolation branch — `if (readIndex >= bufferLength |
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash-expected.txt b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash-expected.txt
new file mode 100644
index 000000000000..654ddf7f17ef
--- /dev/null
+++ b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash-expected.txt
@@ -0,0 +1 @@
+This test passes if it does not crash.
diff --git a/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash.html b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash.html
new file mode 100644
index 000000000000..5fb2dd8c8a5f
--- /dev/null
+++ b/LayoutTests/webaudio/crashtest/audiobuffer-sourcenode-resampler-crash.html
@@ -0,0 +1,25 @@
+<html>
+<head>
+ <script>
+ async function main() {
+ var ctx = new AudioContext();
+ var src = new AudioBufferSourceNode(ctx);
+ src.buffer = ctx.createBuffer(1, 8192, 44100);
+ src.start(undefined, 0.5);
+ src.playbackRate.value = -1;
+ src.connect(ctx.destination, 0, 0);
+ if (window.testRunner)
+ testRunner.notifyDone();
+ }
+ </script>
+</head>
+<body onload="main()">
+ <p>This test passes if it does not crash.</p>
+ <script>
+ if (window.testRunner) {
+ testRunner.waitUntilDone();
+ testRunner.dumpAsText();
+ }
+ </script>
+</body>
+</html>