Medium CVSS 6.5 webkit OOB 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC Runtime
Bug ClassOOB
Tracker313857
Fix commita7e4fdb95450 (WebKit/WebKit) +73/-4
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedUtkarsh Pal, Tristan Madani (@TristanInSec) from Talence Security, stratan (@5tratan) of Almamater Technologies, Narendra Singh (@_3P1C), Josef Korbel (Citadelo), Gia Bui (@yabeow) from Calif.io
Disclosed2026-06-29

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.

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.

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.

Changed Functions

FunctionChangeNotes
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

  • LayoutTests/webaudio/audiobuffersource-detached-buffer-crash-expected.txt
  • LayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html
  • Source/JavaScriptCore/runtime/ArrayBuffer.cpp
  • Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp

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.
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 { };
 }
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.