CVE-2026-43726
Overview
Background
- AudioBufferSourceNode.renderFromBuffer
- Web Audio render routine that reads sample frames from an AudioBuffer’s channel arrays by index.
- acquireBufferContent
- Retains the buffer’s channel data for the render thread; skipping it can leave channel pointers to released/detached storage.
- Unsigned underflow / bounds
- bufferLength - 1 with bufferLength == 0 wraps to a huge value; readIndex must be checked against bufferLength before indexing.
Root Cause Analysis
This fixes out-of-bounds reads (and a detached-buffer lifetime bug) in Web Audio’s AudioBufferSourceNode when rendering from a buffer. renderFromBuffer reads sample frames from the buffer’s channel data (m_sourceChannels[i][readIndex]) using indices derived from the playback/loop parameters and bufferLength. Pre-patch it lacked bounds guards: with a zero-length buffer, bufferLength - 1 (unsigned) underflows to a huge value used to clamp m_virtualReadIndex; and readIndex could reach or exceed bufferLength, so the fill loop read m_sourceChannels[i][readIndex] out of bounds of the channel array.
The fix adds if (!bufferLength) return false; early, changes the loop-wrap clamp to static_cast<double>(bufferLength) - 1 (computing in double to avoid the unsigned underflow), and adds if (readIndex >= bufferLength) return false; before the fill — so no out-of-bounds sample read occurs. Separately, setBufferForBindings previously only called acquireBufferContent() when the node isPlayingOrScheduled(); the fix always calls acquireBufferContent(), so the source channel pointers always reference retained buffer content rather than storage that could be released/detached (a use-after-free of the underlying ArrayBuffer-backed audio data). A related assertion in ArrayBuffer.cpp (errorMessageForTransfer) is tightened to ASSERT(!buffer->isDetachable()).
The restored invariants are that renderFromBuffer never indexes past the buffer’s channel data (guarding empty buffers and out-of-range readIndex without unsigned underflow), and that the source’s buffer content is always acquired/retained so the render path cannot read freed/detached storage.
Attack Path
- Set an audio buffer source Create an AudioBufferSourceNode and assign a buffer (possibly zero-length or with parameters driving readIndex out of range).
- Avoid/undo content acquisition Exploit that acquireBufferContent was skipped unless playing/scheduled, so the backing storage can be released/detached while the render path holds channel pointers.
- Render out of bounds During rendering, an empty buffer underflows bufferLength-1 or readIndex >= bufferLength, reading past the channel data (or freed storage).
- Crash / disclose memory The out-of-bounds/UAF read crashes or leaks memory in the WebContent process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
AudioBufferSourceNode::renderFromBufferSource/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp |
modified | Adds if (!bufferLength) return false; and if (readIndex >= bufferLength) return false; guards, and clamps with static_cast<double>(bufferLength) - 1 to avoid unsigned underflow, preventing out-of-bounds sample reads. |
AudioBufferSourceNode::setBufferForBindingsSource/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp |
modified | Always calls acquireBufferContent() (not only when playing/scheduled) so the source channel pointers reference retained content and cannot read released/detached buffer storage. |
errorMessageForTransferSource/JavaScriptCore/runtime/ArrayBuffer.cpp |
modified | Tightens the assertion to ASSERT(!buffer->isDetachable()), reflecting the corrected detach/transfer state expectation. |
Files Changed
LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txtLayoutTests/webaudio/audiobuffersource-detached-buffer-crash.htmlSource/JavaScriptCore/runtime/ArrayBuffer.cppSource/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
Audit Directions
- Render-path boundsAudit AudioBufferSourceNode and other Web Audio render routines for index/length arithmetic (bufferLength-1, readIndex) lacking empty-buffer and range guards.
- Content acquisition lifetimeGrep for acquireBufferContent / buffer-pointer caching gated on play state; the render thread must always hold retained content.
Patch
diff --git a/LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txt b/LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txt
new file mode 100644
index 000000000000..9a88feda2a21
--- /dev/null
+++ b/LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txt
@@ -0,0 +1,11 @@
+AudioBufferSourceNode should not read from a freed channel buffer when the buffer is set before scheduling and its backing ArrayBuffer is then transferred and collected.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS transferError instanceof TypeError is true
+PASS Rendering completed without crashing.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html b/LayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html
new file mode 100644
index 000000000000..7f57e86b43b0
--- /dev/null
+++ b/LayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../resources/js-test-pre.js"></script>
+</head>
+<body>
+<script>
+description("AudioBufferSourceNode should not read from a freed channel buffer when the buffer is set before scheduling and its backing ArrayBuffer is then transferred and collected.");
+
+jsTestIsAsync = true;
+
+function gc()
+{
+ if (window.GCController)
+ return GCController.collect();
+ for (let i = 0; i < 200; i++)
+ new ArrayBuffer(1 << 20);
+}
+
+var transferError = null;
+
+(async () => {
+ const sampleRate = 44100;
+ const ctx = new OfflineAudioContext(1, 256, sampleRate);
+ const audioBuffer = ctx.createBuffer(1, 0x4000, sampleRate);
+
+ const node = ctx.createBufferSource();
+ node.buffer = audioBuffer;
+
+ const channelBuffer = audioBuffer.getChannelData(0).buffer;
+ try {
+ structuredClone(channelBuffer, { transfer: [channelBuffer] });
+ testFailed("Transfer should have thrown because the buffer is pinned.");
+ } catch (e) {
+ transferError = e;
+ }
+ shouldBeTrue("transferError instanceof TypeError");
+ gc();
+ gc();
+
+ node.loop = true;
+ node.playbackRate.value = 0;
+ node.connect(ctx.destination);
+ node.start();
+
+ await ctx.startRendering();
+ testPassed("Rendering completed without crashing.");
+ finishJSTest();
+})();
+</script>
+<script src="../resources/js-test-post.js"></script>
+</body>
+</html>
diff --git a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
index 2a4dc89d8557..3bfc6fbbeccf 100644
--- a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
+++ b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
@@ -722,7 +722,7 @@ Expected<int64_t, GrowFailReason> SharedArrayBufferContents::grow(const Abstract
ASCIILiteral errorMessageForTransfer(ArrayBuffer* buffer)
{
- ASSERT(buffer->isLocked());
+ ASSERT(!buffer->isDetachable());
if (buffer->isShared())
return "Cannot transfer a SharedArrayBuffer"_s;
if (buffer->isWasmMemory())
diff --git a/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp b/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
index 8a411a37605e..81ce2bbdb3e6 100644
--- a/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
+++ b/Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
@@ -214,6 +214,9 @@ bool AudioBufferSourceNode::renderFromBuffer(AudioBus& bus, unsigned destination
double pitchRate = totalPitchRate();
bool reverse = pitchRate < 0;
+ if (!bufferLength)
+ return false;
+
// Avoid converting from time to sample-frames twice by computing
// the grain end time first before computing the sample frame.
unsigned maxFrame;
@@ -249,7 +252,7 @@ bool AudioBufferSourceNode::renderFromBuffer(AudioBus& bus, unsigned destination
// Wrap back to the beginning of the loop.
m_virtualReadIndex = (m_loopStart < 0) ? 0 : (m_loopStart * m_buffer->sampleRate());
- m_virtualReadIndex = std::min(m_virtualReadIndex, static_cast<double>(bufferLength - 1));
+ m_virtualReadIndex = std::min(m_virtualReadIndex, static_cast<double>(bufferLength) - 1);
}
// Sanity check that our playback rate isn't larger than the loop size.
@@ -340,6 +343,9 @@ bool AudioBufferSourceNode::renderFromBuffer(AudioBus& bus, unsigned destination
if (readIndex >= maxFrame)
readIndex -= deltaFrames;
+ if (readIndex >= bufferLength)
+ return false;
+
for (unsigned i = 0; i < numberOfChannels; ++i)
std::ranges::fill(m_destinationChannels[i].subspan(writeIndex).first(framesToProcess), m_sourceChannels[i][readIndex]);
@@ -476,8 +482,7 @@ ExceptionOr<void> AudioBufferSourceNode::setBufferForBindings(RefPtr<AudioBuffer
if (m_isGrain)
adjustGrainParameters();
- if (isPlayingOrScheduled())
- acquireBufferContent();
+ acquireBufferContent();
return { };
}