High watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in WebAudio
DescriptionOut of bounds read and write in WebAudio
ComponentWebAudio
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker490246422
Fix commitd18fd084c461 (chromium/src) +30/-44
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Reported2026-03-06 (per vendor advisory)
Fix landed2026-03-17 (public git)
Disclosed2026-03-18
Patch gap1 day — fix public in source before the advisory

Background

`AudioParamHandler`
the Blink WebAudio class that computes per-frame values for an AudioParam, including automation curves, writing results into a base::span<float> values buffer.
`HandleNaNValues`
a helper that replaces NaN entries in values with default_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, whereas values.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.

Key insight
The 4-wide SIMD loop in 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

  1. Create Web Audio graph a web page builds an AudioContext with nodes whose AudioParam values are computed by AudioParamHandler.
  2. Feed values through NaN handling automation or input causes AudioParamHandler to call HandleNaNValues on a values span during rendering.
  3. 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)).
  4. 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

The bug gives an out-of-bounds read and write of up to 3 floats (12 bytes) past a WebAudio value buffer in the renderer process, on x86 and ARM NEON builds; what memory is adjacent and any escalation to code execution in the sandboxed renderer are (inferred). Web Audio is reachable by ordinary web content in a stock build, but the diff does not show which configuration produces a non-multiple-of-4 span length, so the exact precondition is not established here. The vendor rates this as high severity; the advisory provided does not state in-the-wild exploitation.

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 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 than k + width <= size.
  • `DCHECK`-only safety arguments
    review remaining UNSAFE_BUFFERS sites whose SAFETY comments cite a DCHECK (such as fill_to_frame_trunc < values.size()), since those checks vanish in release builds.
  • Variable processing lengths
    trace 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.
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;
Loading diff…

Original Bug Report

reported by [email protected]

HandleNaNValues SIMD OOB read+write via non-aligned renderSizeHint in WebAudio AudioParam

Steps to reproduce the problem

  1. Build ASan Chrome for Linux (dcheck_always_on=false)
  2. Save the attached PoC as poc.html
  3. 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:

  1. OfflineAudioContext({renderSizeHint: 5}) – creates context with 5-frame render quantum
  2. createGain() – GainHandler allocates sample_accurate_gain_values_ AudioFloatArray of 5 floats (20 bytes)
  3. oscillator.connect(gainNode.gain) – creates audio-rate rendering connection to gain param
  4. During rendering, GainHandler::Process calls gain_->CalculateSampleAccurateValues(span of 5)
  5. Inside CalculateFinalValues, NumberOfRenderingConnections() > 0 triggers HandleNaNValues(span of 5, defaultValue)
  6. SSE loop: k=0 processes [0..3] OK, k=4 processes [4..7] – 12 bytes OOB read+write
  7. 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 \

View on issue tracker