Released June 29, 2026

CVE-2026-43726

CVSS 6.5 severity medium CWE-416 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

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.cpp
  • Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp

Test Files

  • LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txt
  • LayoutTests/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>