CVE-2026-43726
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected process crash
Researchers: Utkarsh Pal, Tristan Madani (@TristanInSec) from Talence Security, stratan (@5tratan) of Almamater Technologies, Narendra Singh (@_3P1C), Josef Korbel (Citadelo), Gia Bui (@yabeow) from Calif.io
Key insight. renderFromBuffer indexed the buffer’s channel data without guarding an empty buffer (unsigned bufferLength-1 underflow) or an out-of-range readIndex, and setBufferForBindings only acquired content when playing — adding the bounds guards and always acquiring content fixes the OOB/UAF.
Patch Details
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 { };
}
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43726 |
| Bugzilla | 313857 |
| Component | JSC |
| Bug Class | OOB |
| Severity | medium |
| Commit | a7e4fdb9545042ae… |
| Advisory | Apple Advisory |
Terminology
- 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
1. Set an audio buffer source
Create an AudioBufferSourceNode and assign a buffer (possibly zero-length or with parameters driving readIndex out of range).
2. 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.
3. Render out of bounds
During rendering, an empty buffer underflows bufferLength-1 or readIndex >= bufferLength, reading past the channel data (or freed storage).
4. Crash / disclose memory
The out-of-bounds/UAF read crashes or leaks memory in the WebContent process.
Impact Assessment
An out-of-bounds read (and detached-buffer use-after-free) in the WebContent process reachable from ordinary Web Audio, with attacker control over buffer length and playback parameters. The advisory rates it a crash; OOB/UAF reads of audio buffer memory are controllable primitives toward memory disclosure.
Audit Directions
Render-path bounds
Audit AudioBufferSourceNode and other Web Audio render routines for index/length arithmetic (bufferLength-1, readIndex) lacking empty-buffer and range guards.
Content acquisition lifetime
Grep for acquireBufferContent / buffer-pointer caching gated on play state; the render thread must always hold retained content.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
AudioBufferSourceNode::renderFromBuffer |
Source/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::setBufferForBindings |
Source/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. |
errorMessageForTransfer |
Source/JavaScriptCore/runtime/ArrayBuffer.cpp |
modified | Tightens the assertion to ASSERT(!buffer->isDetachable()), reflecting the corrected detach/transfer state expectation. |
Files Changed
Source Files
Source/JavaScriptCore/runtime/ArrayBuffer.cppSource/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
Test Files
LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txtLayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html
Regression Test
The fix ships this proof-of-concept / regression test:
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>