CVE-2026-4459
Overview
Background
- `AudioParamHandler`
- the Blink WebAudio class that computes per-frame values for an
AudioParam, including automation curves, writing results into abase::span<float> valuesbuffer. - `HandleNaNValues`
- a helper that replaces NaN entries in
valueswithdefault_value, vectorised with SSE (_mm_loadu_ps/_mm_storeu_ps) on x86 and NEON (vld1q_f32/vst1q_f32) on ARM, each touching 4 floats at a time. - `UNSAFE_BUFFERS` vs `subspan`
UNSAFE_BUFFERS(values.data() + k)is unchecked pointer arithmetic, whereasvalues.subspan(k, 4u)is bounds-checked and traps if fewer than 4 elements remain.- SIMD tail handling
- a 4-wide loop must stop at the largest multiple of 4 not exceeding the length and leave the remainder to scalar code.
Root Cause Analysis
In HandleNaNValues, the pre-patch SIMD loops (both the x86 and CPU_ARM_NEON variants) ran for (k = 0; k < values.size(); k += 4) and loaded and stored 4 floats at values.data() + k; the SAFETY comment only justified k < values.size(), not k + 4 <= values.size(). When values.size() is at least 4 but not a multiple of 4, the last iteration reads and writes up to 3 floats past the end of the span, which matches the advisory’s “out of bounds read and write”.
The fix computes truncated_size = values.size() & ~3, bounds the loop by it, and accesses memory through values.subspan(k, 4u).data(), so the vector loop never crosses the end (the remaining elements are left to the code after the loop, which is not shown in the diff). Three further hunks — in functions the @@ headers do not name, inside loops bounded by fill_to_frame_trunc — replace _mm_storeu_ps(UNSAFE_BUFFERS(values.data() + write_index), ...), whose safety rested only on a DCHECK (absent in release builds), with checked values.subspan(write_index, 4u) stores. Although the commit message says “no functional change”, the HandleNaNValues loop bound is a real behavioural change that removes the overrun.
HandleNaNValues checked only that the start index was in range (k < values.size()) rather than that a full 4-element vector fit, so non-multiple-of-4 lengths overran the buffer; the fix truncates the loop bound to a multiple of 4 and uses bounds-checked subspan accesses.Attack Path
- Create Web Audio graph
a web page builds an
AudioContextwith nodes whoseAudioParamvalues are computed byAudioParamHandler. - Feed values through NaN handling
automation or input causes
AudioParamHandlerto callHandleNaNValueson avaluesspan during rendering. - Non-multiple-of-4 length the span length processed is at least 4 but not divisible by 4 (how a page obtains such a length is not shown in the diff and is (inferred)).
- Vector overrun the final SSE/NEON iteration loads and stores 4 floats starting at the last partial group, reading and writing up to 3 floats past the end of the buffer on the audio rendering thread.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/modules/webaudio/audio_param_handler.cc |
modified |
Files Changed
third_party/blink/renderer/modules/webaudio/audio_param_handler.cc
Audit Directions
- SIMD loops bounded by `k < size`search WebAudio (
audio_param_handler.cc,vector_math, convolver and resampler code) and other Blink SIMD paths for vector loops whose condition checks only the start index rather thank + width <= size. - `DCHECK`-only safety argumentsreview remaining
UNSAFE_BUFFERSsites whoseSAFETYcomments cite aDCHECK(such asfill_to_frame_trunc < values.size()), since those checks vanish in release builds. - Variable processing lengthstrace callers that pass sub-quantum or split-frame spans (e.g. automation event boundaries, configurable render sizes) into vectorised helpers to confirm every helper tolerates lengths that are not multiples of the vector width.
Patch
From d18fd084c4611b3897687aacb6f4af5368fcdf81 Mon Sep 17 00:00:00 2001 From: Michael Wilson <[email protected]> Date: Tue, 17 Mar 2026 12:47:03 -0700 Subject: [PATCH] [M146] Replace UNSAFE_BUFFERS in AudioParamHandler with safe operations This should cause no functional change. Also do a small optimization in HandleNaNValues for inputs that are larger than 4 by skipping the first if check, since we expect longer inputs in general. (cherry picked from commit 4e9562ca7b4214fe359b5f3b885443e301bc5f74) Bug: 401184803 Bug: 490642831 Bug: 490246422 Change-Id: I1044267b7e6370af4793fe973fe36224630ced95 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7649387 Reviewed-by: Hongchan Choi <[email protected]> Commit-Queue: Michael Wilson <[email protected]> Cr-Original-Commit-Position: refs/heads/main@{#1597152} Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7667367 Auto-Submit: Michael Wilson <[email protected]> Commit-Queue: Hongchan Choi <[email protected]> Cr-Commit-Position: refs/branch-heads/7680@{#2744} Cr-Branched-From: 76b7d80e5cda23fe6537eed26d68c92e995c7f39-refs/heads/main@{#1582197} --- diff --git a/third_party/blink/renderer/modules/webaudio/audio_param_handler.cc b/third_party/blink/renderer/modules/webaudio/audio_param_handler.cc index b3b95dd..88a8f39 100644 --- a/third_party/blink/renderer/modules/webaudio/audio_param_handler.cc +++ b/third_party/blink/renderer/modules/webaudio/audio_param_handler.cc @@ -51,40 +51,35 @@ void HandleNaNValues(base::span<float> values, float default_value) { unsigned k = 0; #if defined(ARCH_CPU_X86_FAMILY) - if (values.size() >= 4) { - __m128 defaults = _mm_set1_ps(default_value); - for (k = 0; k < values.size(); k += 4) { - // SAFETY: The for loop condition has been checked k < values.size(). - __m128 v = _mm_loadu_ps(UNSAFE_BUFFERS(values.data() + k)); - // cmpuord returns all 1's if v is NaN for each elmeent of v. - __m128 isnan = _mm_cmpunord_ps(v, v); - // Replace NaN parts with default. - __m128 result = _mm_and_ps(isnan, defaults); - // Merge in the parts that aren't NaN - result = _mm_or_ps(_mm_andnot_ps(isnan, v), result); - // SAFETY: The for loop condition has been checked k < values.size(). - _mm_storeu_ps(UNSAFE_BUFFERS(values.data() + k), result); - } + // Truncate to the next-lowest multiple of 4. + const size_t truncated_size = values.size() & ~3; + __m128 defaults = _mm_set1_ps(default_value); + for (k = 0; k < truncated_size; k += 4) { + __m128 v = _mm_loadu_ps(values.subspan(k, 4u).data()); + // cmpuord returns all 1's if v is NaN for each elmeent of v. + __m128 isnan = _mm_cmpunord_ps(v, v); + // Replace NaN parts with default. + __m128 result = _mm_and_ps(isnan, defaults); + // Merge in the parts that aren't NaN + result = _mm_or_ps(_mm_andnot_ps(isnan, v), result); + _mm_storeu_ps(values.subspan(k, 4u).data(), result); } #elif defined(CPU_ARM_NEON) - if (values.size() >= 4) { - uint32x4_t defaults = - reinterpret_cast<uint32x4_t>(vdupq_n_f32(default_value)); - for (k = 0; k < values.size(); k += 4) { - // SAFETY: The for loop condition has been checked k < values.size(). - float32x4_t v = vld1q_f32(UNSAFE_BUFFERS(values.data() + k)); - // Returns true (all ones) if v is not NaN - uint32x4_t is_not_nan = vceqq_f32(v, v); - // Get the parts that are not NaN - uint32x4_t result = - vandq_u32(is_not_nan, reinterpret_cast<uint32x4_t>(v)); - // Replace the parts that are NaN with the default and merge with previous - // result. (Note: vbic_u32(x, y) = x and not y) - result = vorrq_u32(result, vbicq_u32(defaults, is_not_nan)); - // SAFETY: The for loop condition has been checked k < values.size(). - vst1q_f32(UNSAFE_BUFFERS(values.data() + k), - reinterpret_cast<float32x4_t>(result)); - } + // Truncate to the next-lowest multiple of 4. + const size_t truncated_size = values.size() & ~3; + uint32x4_t defaults = + reinterpret_cast<uint32x4_t>(vdupq_n_f32(default_value)); + for (k = 0; k < truncated_size; k += 4) { + float32x4_t v = vld1q_f32(values.subspan(k, 4u).data()); + // Returns true (all ones) if v is not NaN + uint32x4_t is_not_nan = vceqq_f32(v, v); + // Get the parts that are not NaN + uint32x4_t result = vandq_u32(is_not_nan, reinterpret_cast<uint32x4_t>(v)); + // Replace the parts that are NaN with the default and merge with previous + // result. (Note: vbic_u32(x, y) = x and not y) + result = vorrq_u32(result, vbicq_u32(defaults, is_not_nan)); + vst1q_f32(values.subspan(k, 4u).data(), + reinterpret_cast<float32x4_t>(result)); } #endif @@ -1831,10 +1826,7 @@ // Process 4 loop steps. for (; write_index < fill_to_frame_trunc; write_index += 4) { - // SAFETY: DCHECK previously checked that `fill_to_frame_trunc < - // values.size()`. In the for loop, `write_index < fill_to_frame_trunc` so - // this is safe. - _mm_storeu_ps(UNSAFE_BUFFERS(values.data() + write_index), v_value); + _mm_storeu_ps(values.subspan(write_index, 4u).data(), v_value); v_value = _mm_add_ps(v_value, v_inc); } } @@ -2020,10 +2012,7 @@ v_value = _mm_set_ps1(value); v_result = _mm_add_ps(v_value, _mm_mul_ps(v_delta, v_c)); - // SAFETY: DCHECK previously checked that `fill_to_frame_trunc < - // values.size()`. In the for loop, `write_index < fill_to_frame_trunc` - // so this is safe. - _mm_storeu_ps(UNSAFE_BUFFERS(values.data() + write_index), v_result); + _mm_storeu_ps(values.subspan(write_index, 4u).data(), v_result); // Update value for next iteration. value += delta * c3; @@ -2184,10 +2173,7 @@ __m128 v_value = _mm_add_ps(v_c0, _mm_mul_ps(_mm_sub_ps(v_c1, v_c0), v_delta)); - // SAFETY: DCHECK previously checked that `fill_to_frame_trunc < - // values.size()`. In the for loop, `write_index < fill_to_frame_trunc` so - // this is safe. - _mm_storeu_ps(UNSAFE_BUFFERS(values.data() + write_index), v_value); + _mm_storeu_ps(values.subspan(write_index, 4u).data(), v_value); } // Pass along k to the serial loop. k = truncated_steps;
Original Bug Report
HandleNaNValues SIMD OOB read+write via non-aligned renderSizeHint in WebAudio AudioParam
Steps to reproduce the problem
- Build ASan Chrome for Linux (dcheck_always_on=false)
- Save the attached PoC as
poc.html - Run:
./chrome --enable-blink-features=WebAudioConfigurableRenderQuantum \ --no-sandbox poc.html
Problem Description
Root Cause
HandleNaNValues() in audio_param_handler.cc:51-94 has a hand-rolled SIMD loop that processes 4 floats at a time using SSE/NEON intrinsics:
void HandleNaNValues(base::span<float> values, float default_value) {
unsigned k = 0;
#if defined(ARCH_CPU_X86_FAMILY)
if (values.size() >= 4) {
for (k = 0; k < values.size(); k += 4) { // BUG: k+3 can exceed size()
__m128 v = _mm_loadu_ps(values.data() + k); // reads [k..k+3]
// ... NaN replacement logic ...
_mm_storeu_ps(values.data() + k, result); // writes [k..k+3]
}
}
#endif
std::ranges::replace_if(values.subspan(k), ...); // subspan(k) where k > size() => CHECK crash
}
The loop condition k < values.size() only guarantees k is within bounds, NOT k + 3. When values.size() % 4 != 0, the last iteration reads and writes up to 3 floats (12 bytes) past the end of the buffer. The “SAFETY” comments on lines 57 and 65 are incorrect – they claim the loop condition is sufficient, but _mm_loadu_ps accesses 4 consecutive floats.
After the loop, k exceeds values.size(), and values.subspan(k) triggers a hardened bounds CHECK crash in base::span.
How It Triggers
Any AudioParam with an audio-rate rendering connection (another node’s output connected to the param) will call HandleNaNValues() during CalculateFinalValues(). The values span size equals render_quantum_frames (from renderSizeHint). When this is not a multiple of 4 (e.g., 5, 6, 7, 9, 10, 11, …), the SIMD overflow occurs.
The GainNode PoC:
OfflineAudioContext({renderSizeHint: 5})– creates context with 5-frame render quantumcreateGain()– GainHandler allocatessample_accurate_gain_values_AudioFloatArray of 5 floats (20 bytes)oscillator.connect(gainNode.gain)– creates audio-rate rendering connection to gain param- During rendering, GainHandler::Process calls gain_->CalculateSampleAccurateValues(span of 5)
- Inside CalculateFinalValues, NumberOfRenderingConnections() > 0 triggers HandleNaNValues(span of 5, defaultValue)
- SSE loop: k=0 processes [0..3] OK, k=4 processes [4..7] – 12 bytes OOB read+write
- subspan(8) on 5-element span – CHECK(8 <= 5) – crash
Summary
HandleNaNValues SIMD OOB read+write via non-aligned renderSizeHint in WebAudio AudioParam
Custom Questions
Type of crash:
renderer
Crash state:
heap-buffer-overflow
Reporter credit:
Jihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: N/A \