High patch-now ✔︎ verified CVSS 4.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebAudio
DescriptionHeap buffer overflow in WebAudio
ComponentWebAudio
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: Diff confirms the loop bound was values.size() not a multiple-of-4, overrunning the SIMD store by up to 3 floats; truncated_size fixes it. Bounded write is consistent with the CVSS 4.3. Accurate.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker490642831
Fix commit4e9562ca7b42 (chromium/src) +30/-44
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedSyn4pse
Reported2026-03-08 (per vendor advisory)
Fix landed2026-03-10 (public git)
Disclosed2026-04-07
Patch gap28 days — fix public in source before the advisory

Background

`HandleNaNValues`
a helper in the WebAudio param pipeline that scans a base::span<float> and replaces any NaN sample with a default value, using SIMD to process four floats at a time.
`UNSAFE_BUFFERS`
a Chromium macro that wraps raw pointer arithmetic to opt a region out of hardened -Wunsafe-buffer-usage checking, signalling the author has manually proven bounds.
`_mm_loadu_ps` / `_mm_storeu_ps`
x86 SSE intrinsics that read/write 16 bytes (four floats) starting at a pointer; vld1q_f32 / vst1q_f32 are the NEON equivalents.
Render quantum sizing
audio buffers are usually a multiple of four frames, but values.size() is not guaranteed to be, which is the edge case this bug depends on.

Root Cause Analysis

The SIMD loop in HandleNaNValues was gated by if (values.size() >= 4) and then iterated with the condition for (k = 0; k < values.size(); k += 4). Because k advances in steps of four while the bound was the full values.size(), the final iteration ran whenever a multiple of four was still strictly less than the size — i.e. for any input whose length is not a multiple of four, the last pass read and wrote four floats starting past the valid tail. The _mm_loadu_ps/_mm_storeu_ps (and NEON vld1q_f32/vst1q_f32) calls, wrapped in UNSAFE_BUFFERS(values.data() + k), therefore touched up to three floats beyond the end of the heap buffer.

The fix computes truncated_size = values.size() & ~3 and loops k < truncated_size, so the SIMD body only ever processes complete four-element groups and the remainder is left to the serial tail loop that consumes the carried k. Switching the pointer math to values.subspan(k, 4u).data() additionally makes the span enforce the four-element window instead of trusting a hand-written bound.

Key insight
The loop terminated on k < values.size() rather than on the largest multiple of four at or below the size, so a non-multiple-of-four length let the vectorized store run one group too far; truncating the bound with & ~3 removes the overshoot.

Attack Path

  1. Reach the param path From a renderer, script drives a WebAudio graph whose AudioParam automation feeds HandleNaNValues with a sample buffer whose length is not a multiple of four.
  2. Trigger the trailing SIMD pass With values.size() not divisible by four, the old condition k < values.size() admits one extra iteration beyond the last full group.
  3. Overrun the buffer _mm_storeu_ps(UNSAFE_BUFFERS(values.data() + k), ...) writes 16 bytes starting inside the final partial group, placing up to three floats past the end of the heap allocation (heap buffer overflow).
  4. Downstream corruption Adjacent heap data is overwritten with sanitized/default-merged float values; the specific object corrupted and any further primitive are not shown by the diff (inferred).

Impact Assessment

An attacker controlling a renderer (ordinary web content running the WebAudio API) can cause a bounded heap buffer overflow in the audio processing code, writing a few floats past the end of a heap buffer. The vendor advisory rates this high severity and labels it a heap buffer overflow in WebAudio; it does not report in-the-wild exploitation. Any escalation from the overflow to a controlled read/write or code execution is not demonstrated by the patch (inferred).

Changed Functions

FunctionChangeNotes
for
third_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 loop bounds
    Flag any vectorized loop that increments an index by the vector width but compares against the full element count instead of a width-truncated count (& ~(N-1)); the tail iteration is the classic overrun.
  • `UNSAFE_BUFFERS` with runtime lengths
    Audit every UNSAFE_BUFFERS(ptr + k) whose safety argument rests on a loop condition, and confirm the condition actually bounds k + width, not just k.
  • Span conversion gaps
    Look for remaining raw-pointer SIMD/DSP code in modules/webaudio and other media paths that still uses data() + offset rather than subspan(offset, width).
From 4e9562ca7b4214fe359b5f3b885443e301bc5f74 Mon Sep 17 00:00:00 2001
From: Michael Wilson <[email protected]>
Date: Tue, 10 Mar 2026 10:08:27 -0700
Subject: [PATCH] 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.

Bug: 401184803
Bug: 490642831
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-Commit-Position: refs/heads/main@{#1597152}
---

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;
Loading diff…

Original Bug Report

reported by [email protected]

OOB Read in AudioParamHandler::CalculateFinalValues

Summary

AudioParamHandler::CalculateFinalValues forwards a render-quantum-sized span into HandleNaNValues, but HandleNaNValues processes the span in 4-float SIMD chunks using k < values.size() instead of truncating to a multiple of 4. Once WebAudioConfigurableRenderQuantummakes odd render quanta such as133script-reachable, the last SIMD iteration reads and writes beyond theAudioParam` buffer and produces OOB.

> NOTE: this issue’s root cause is difference with the previous reported issues 487357842, 487357842

Details

The current WebAudio implementation allows script to choose a non-default render quantum through BaseAudioContext.renderQuantumSize and validates that value in AudioContext::Create. That value flows into audio-rate AudioParam processing, where AudioParamHandler::CalculateFinalValues calls `HandleNaNValues(values, DefaultValue()) on a span whose length is the active render quantum.

In HandleNaNValues, the issue is that the SIMD loop advances by four but is bounded only by k < values.size(). For a 133-frame quantum, the final iteration executes with k == 132, so the _mm_loadu_ps/_mm_storeu_pspair touches elements132..135even though only element132is inside the span. After that,values.subspan(k) is evaluated with k == 136, which is also beyond the span length:

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) {
      __m128 v = _mm_loadu_ps(values.data() + k);
      __m128 isnan = _mm_cmpunord_ps(v, v);
      __m128 result = _mm_and_ps(isnan, defaults);
      result = _mm_or_ps(_mm_andnot_ps(isnan, v), result);
      _mm_storeu_ps(values.data() + k, result);
    }
  }
#endif
  std::ranges::replace_if(values.subspan(k),
                          [](float value) { return std::isnan(value); },
                          default_value);
}

Bisection

This issue is introduced by the commit https://chromium-review.googlesource.com/c/chromium/src/+/6804197, which makes this issue became script-reachable when renderSizeHint is propagated into the CalculateFinalValues and HandleNaNValues.

Reproduction

Download the chromium from https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1595156.zip

Run with

./chrome --no-sandbox --enable-experimental-web-platform-features poc.html

You would observe the OOB crash shown in asan.txt

Suggested Fix

Clamp HandleNaNValues to a SIMD-safe prefix before issuing 4-wide loads and stores. We may compute simd_end = values.size() & ~3u, iterate while k < simd_end, and keep the scalar tail in the `replace_if(values.subspan(k), …) path.

View on issue tracker