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 in WebAudio
DescriptionOut of bounds read in WebAudio
ComponentWebAudio
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker490533968
Fix commitb05c9128c8aa (chromium/src) +175/-203
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-03-07 (per vendor advisory)
Fix landed2026-03-10 (public git)
Disclosed2026-03-23
Patch gap13 days — fix public in source before the advisory

Background

`Delay` (Blink platform audio)
The delay-line DSP behind DelayNode (DelayHandler keeps one Delay kernel per channel in kernels_). It writes input into a circular buffer_ and reads it back at write_index - delayTime * sampleRate, using linear interpolation between read_index1 and read_index2.
a-rate vs k-rate processing
With a sample-accurate (IsAudioRate()) delayTime AudioParam, ProcessARate computes a per-sample delay_times_ array, partly in SIMD via ProcessARateVector (SSE2/NEON) with a scalar tail in ProcessARateScalar. With a k-rate param, ProcessKRate uses one SetDelayTime value per render quantum.
Spanification / `UNSAFE_TODO`
Chromium’s ongoing migration from raw float* plus manual length arithmetic to base::span. With base::span, subspan, first, copy_from and indexing are bounds-checked (CHECK) in release builds. UNSAFE_TODO(...) marks raw-pointer accesses still awaiting conversion.

Root Cause Analysis

Before the patch, the WebAudio delay path passed bare pointers everywhere. DelayHandler passed Channel(i)->Data()/MutableData() into ProcessARate/ProcessKRate. ProcessARateVector and ProcessARateScalar read buffer[read_index1], buffer[read_index2] and delay_times + k, and stored to destination + k, all on raw pointers wrapped in UNSAFE_TODO. CopyToCircularBuffer used two memcpys whose sizes came from signed int arithmetic (buffer_length - write_index, std::max(0, frames - remainder)). None of these accesses had a release-mode bounds check, and the only range checks (DCHECK_GE(read_index1, 0), DCHECK_LT(...)) were debug-only. So if a computed read index or length ever left [0, buffer_.size()), the renderer silently read outside the delay buffer or the per-quantum arrays. The shown hunks do not contain a corrected index formula.

The fix replaces every raw access with base::span operations (buffer_[...], delay_times_.as_span().subspan(k, 4u), destination.subspan(k, 4u), buffer.subspan(write_index, first_size).copy_from(...)), so an out-of-range index now CHECK-fails instead of reading adjacent heap memory. It also moves the index math from int to size_t and adds DCHECK_GE(read_position, 0) and DCHECK_GE(buffer.size(), write_index). The diff is truncated after ProcessARateScalar’s sample reads, and the delay.h and hrtf_panner.cc hunks are not shown, so any logic change in those parts cannot be confirmed.

Key insight
The delay line trusted floating-point-derived read indices and signed length arithmetic to stay in range, then used them on unchecked raw pointers into buffer_, delay_times_ and the bus channels. The fix makes every such access a bounds-checked base::span operation, turning a silent out-of-bounds read into a safe crash.

Attack Path

  1. Build a delay graph A page creates an AudioContext with a DelayNode (or another Delay user; hrtf_panner.cc is also in the changed file list) and connects a source to it.
  2. Drive the delay time Script sets or automates delayTime so DelayHandler takes the a-rate path (CalculateSampleAccurateValues into DelayTimes()) or the k-rate path (SetDelayTime(FinalValue())). This feeds attacker-influenced values into desired_delay_frames and read_position.
  3. Out-of-range index or length In some edge case, the computed read_index1/read_index2, the k offset into delay_times_, or the CopyToCircularBuffer sizes fall outside their buffers. The exact triggering value is not visible in the shown hunks (inferred).
  4. Unchecked read Pre-patch, buffer[read_index] or delay_times + k reads past the heap allocation, and the interpolated samples are written into the node’s output (inferred data path). Post-patch, the span check aborts the renderer.
  5. Information disclosure Out-of-bounds floats mixed into audio output could be recovered by script, for example via an AnalyserNode or OfflineAudioContext rendering, leaking renderer heap contents (inferred).

Impact Assessment

A web page with WebAudio access could read memory beyond the delay-line or per-quantum buffers in the renderer process and might exfiltrate it through the audio graph’s output (inferred). No user interaction beyond loading the page and starting audio is implied. The advisory classifies it as a High-severity “Out of bounds read in WebAudio”, and the patch shows no write primitive or sandbox impact. The advisory does not say it was exploited in the wild.

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.cc
modified
for
third_party/blink/renderer/platform/audio/cpu/x86/delay_sse2.cc
modified
if
third_party/blink/renderer/platform/audio/delay.cc
modified
for
third_party/blink/renderer/platform/audio/delay.cc
modified

Files Changed

  • third_party/blink/renderer/modules/webaudio/delay_handler.cc
  • third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.cc
  • third_party/blink/renderer/platform/audio/cpu/x86/delay_sse2.cc
  • third_party/blink/renderer/platform/audio/delay.cc
  • third_party/blink/renderer/platform/audio/delay.h
  • third_party/blink/renderer/platform/audio/hrtf_panner.cc

Audit Directions

  • Remaining `UNSAFE_TODO` in audio DSP
    delay_sse2.cc still indexes read_index1[m]/read_index2[m] via UNSAFE_TODO. Grep third_party/blink/renderer/platform/audio/ and modules/webaudio/ for raw Data()/MutableData() pointer arithmetic and memcpy with computed sizes, especially in HRTFPanner, DynamicsCompressor, Reverb/FFTConvolver and WaveShaper.
  • Float-to-index conversions
    Audit every place where a user-controlled AudioParam value (delayTime, detune, playbackRate) is multiplied by sampleRate and cast to an integer index. Check for negative values, NaN/Inf and values at or beyond buffer_length that wrap only once, and make sure range checks are not DCHECK-only.
  • SIMD versus scalar parity
    Compare the SSE2, NEON and scalar implementations of the same kernel for differing clamping and wrap logic (WrapIndexVector versus the scalar if (read_position >= buffer_length)). A divergence between them is a common source of platform-specific out-of-bounds bugs.
diff --git a/third_party/blink/renderer/modules/webaudio/delay_handler.cc b/third_party/blink/renderer/modules/webaudio/delay_handler.cc
index 3a2393a..6be84f1 100644
--- a/third_party/blink/renderer/modules/webaudio/delay_handler.cc
+++ b/third_party/blink/renderer/modules/webaudio/delay_handler.cc
@@ -73,16 +73,16 @@
           CHECK(delay_time_->IsAudioRate());
           delay_time_->CalculateSampleAccurateValues(
               kernels_[i]->DelayTimes().first(frames_to_process));
-          kernels_[i]->ProcessARate(source_bus->Channel(i)->Data(),
-                                    destination_bus->Channel(i)->MutableData(),
+          kernels_[i]->ProcessARate(source_bus->Channel(i)->Span(),
+                                    destination_bus->Channel(i)->MutableSpan(),
                                     frames_to_process);
         }
       } else {
         for (unsigned i = 0; i < kernels_.size(); ++i) {
           CHECK(!delay_time_->IsAudioRate());
           kernels_[i]->SetDelayTime(delay_time_->FinalValue());
-          kernels_[i]->ProcessKRate(source_bus->Channel(i)->Data(),
-                                    destination_bus->Channel(i)->MutableData(),
+          kernels_[i]->ProcessKRate(source_bus->Channel(i)->Span(),
+                                    destination_bus->Channel(i)->MutableSpan(),
                                     frames_to_process);
         }
       }
diff --git a/third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.cc b/third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.cc
index 8d95504..7239918 100644
--- a/third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.cc
+++ b/third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.cc
@@ -5,6 +5,7 @@
 #include <arm_neon.h>
 
 #include <algorithm>
+#include <array>
 
 #include "base/compiler_specific.h"
 #include "build/build_config.h"
@@ -51,16 +52,14 @@
                        reinterpret_cast<uint32x4_t>(v_buffer_length), cmp)));
 }
 
-std::tuple<unsigned, int> Delay::ProcessARateVector(
-    float* destination,
-    uint32_t frames_to_process) const {
-  const int buffer_length = buffer_.size();
-  const float* buffer = buffer_.Data();
+std::tuple<size_t, size_t> Delay::ProcessARateVector(
+    base::span<float> destination,
+    size_t frames_to_process) const {
+  const size_t buffer_length = buffer_.size();
 
   const float sample_rate = sample_rate_;
-  const float* delay_times = delay_times_.Data();
 
-  int w_index = write_index_;
+  size_t w_index = write_index_;
 
   const float32x4_t v_sample_rate = vdupq_n_f32(sample_rate);
   const float32x4_t v_all_zeros = vdupq_n_f32(0);
@@ -74,25 +73,26 @@
   const int32x4_t v_incr = vdupq_n_s32(4);
 
   // Temp arrays for storing the samples needed for interpolation
-  float sample1[4] __attribute((aligned(16)));
-  float sample2[4] __attribute((aligned(16)));
+  std::array<float, 4> sample1 __attribute((aligned(16)));
+  std::array<float, 4> sample2 __attribute((aligned(16)));
 
   // Temp array for holding the indices so we can access them
   // individually.
-  int read_index1[4] __attribute((aligned(16)));
-  int read_index2[4] __attribute((aligned(16)));
+  std::array<int, 4> read_index1 __attribute((aligned(16)));
+  std::array<int, 4> read_index2 __attribute((aligned(16)));
 
   // Initialize the write index vector, and  wrap the values if needed.
-  int32x4_t v_write_index = {w_index + 0, w_index + 1, w_index + 2,
-                             w_index + 3};
+  int32x4_t v_write_index = {
+      static_cast<int32_t>(w_index + 0), static_cast<int32_t>(w_index + 1),
+      static_cast<int32_t>(w_index + 2), static_cast<int32_t>(w_index + 3)};
   v_write_index = WrapIndexVector(v_write_index, v_buffer_length_int);
 
   int number_of_loops = frames_to_process / 4;
-  int k = 0;
+  size_t k = 0;
 
   for (int n = 0; n < number_of_loops; ++n, k += 4) {
-    const float32x4_t v_delay_time =
-        vmaxq_f32(UNSAFE_TODO(vld1q_f32(delay_times + k)), v_all_zeros);
+    const float32x4_t v_delay_time = vmaxq_f32(
+        vld1q_f32(delay_times_.as_span().subspan(k, 4u).data()), v_all_zeros);
     const float32x4_t v_desired_delay_frames =
         vmulq_f32(v_delay_time, v_sample_rate);
 
@@ -115,16 +115,16 @@
 
     // Save indices so we can access the components individually for
     // getting the aamples from the buffer.
-    vst1q_s32(read_index1, v_read_index1);
-    vst1q_s32(read_index2, v_read_index2);
+    vst1q_s32(read_index1.data(), v_read_index1);
+    vst1q_s32(read_index2.data(), v_read_index2);
 
     for (int m = 0; m < 4; ++m) {
-      UNSAFE_TODO(sample1[m]) = UNSAFE_TODO(buffer[read_index1[m])];
-      UNSAFE_TODO(sample2[m]) = UNSAFE_TODO(buffer[read_index2[m])];
+      sample1[m] = buffer_[read_index1[m]];
+      sample2[m] = buffer_[read_index2[m]];
     }
 
-    const float32x4_t v_sample1 = vld1q_f32(sample1);
-    const float32x4_t v_sample2 = vld1q_f32(sample2);
+    const float32x4_t v_sample1 = vld1q_f32(sample1.data());
+    const float32x4_t v_sample2 = vld1q_f32(sample2.data());
 
     v_write_index = vaddq_s32(v_write_index, v_incr);
     v_write_index = WrapIndexVector(v_write_index, v_buffer_length_int);
@@ -133,7 +133,7 @@
     const float32x4_t sample = vaddq_f32(
         v_sample1,
         vmulq_f32(interpolation_factor, vsubq_f32(v_sample2, v_sample1)));
-    UNSAFE_TODO(vst1q_f32(destination + k, sample));
+    vst1q_f32(destination.subspan(k, 4u).data(), sample);
   }
 
   // Update |w_index| based on how many frames we processed here, wrapping
@@ -146,8 +146,8 @@
   return std::make_tuple(k, w_index);
 }
 
-void Delay::HandleNaN(float* delay_times,
-                      uint32_t frames_to_process,
+void Delay::HandleNaN(base::span<float> delay_times,
+                      size_t frames_to_process,
                       float max_time) {
   unsigned k = 0;
   int number_of_loops = frames_to_process / 4;
@@ -156,7 +156,7 @@
 
   // This is approximately 4 times faster than the scalar version.
   for (int loop = 0; loop < number_of_loops; ++loop, k += 4) {
-    float32x4_t x = UNSAFE_TODO(vld1q_f32(delay_times + k));
+    float32x4_t x = vld1q_f32(delay_times.subspan(k, 4u).data());
     // x == x only fails when x is NaN.  Then cmp is set to 0. Otherwise
     // 0xffffffff
     uint32x4_t cmp = vceqq_f32(x, x);
@@ -180,14 +180,14 @@
     xint = vorrq_u32(xint, cmp);
 
     // Finally, save the float result.
-    UNSAFE_TODO(
-        vst1q_f32(delay_times + k, reinterpret_cast<float32x4_t>(xint)));
+    vst1q_f32(delay_times.subspan(k, 4u).data(),
+              reinterpret_cast<float32x4_t>(xint));
   }
 
   // Handle any frames not done in the loop above.
   for (; k < frames_to_process; ++k) {
-    if (std::isnan(UNSAFE_TODO(delay_times[k]))) {
-      UNSAFE_TODO(delay_times[k]) = max_time;
+    if (std::isnan(delay_times[k])) {
+      delay_times[k] = max_time;
     }
   }
 }
diff --git a/third_party/blink/renderer/platform/audio/cpu/x86/delay_sse2.cc b/third_party/blink/renderer/platform/audio/cpu/x86/delay_sse2.cc
index ed7fb615..436dc31 100644
--- a/third_party/blink/renderer/platform/audio/cpu/x86/delay_sse2.cc
+++ b/third_party/blink/renderer/platform/audio/cpu/x86/delay_sse2.cc
@@ -52,15 +52,13 @@
   return _mm_sub_ps(v_position, _mm_and_ps(v_buffer_length, cmp));
 }
 
-std::tuple<unsigned, int> Delay::ProcessARateVector(
-    float* destination,
-    uint32_t frames_to_process) const {
-  const int buffer_length = buffer_.size();
-  const float* buffer = buffer_.Data();
+std::tuple<size_t, size_t> Delay::ProcessARateVector(
+    base::span<float> destination,
+    size_t frames_to_process) const {
+  const size_t buffer_length = buffer_.size();
 
   const float sample_rate = sample_rate_;
-  const float* delay_times = delay_times_.Data();
-  int w_index = write_index_;
+  size_t w_index = write_index_;
 
   const __m128 v_sample_rate = _mm_set1_ps(sample_rate);
   const __m128 v_all_zeros = _mm_setzero_ps();
@@ -82,14 +80,15 @@
       _mm_set_epi32(w_index + 3, w_index + 2, w_index + 1, w_index + 0);
   v_write_index = WrapIndexVector(v_write_index, v_buffer_length_int);
 
-  const int number_of_loops = frames_to_process / 4;
-  int k = 0;
+  const size_t number_of_loops = frames_to_process / 4;
+  size_t k = 0;
 
-  for (int n = 0; n < number_of_loops; ++n, k += 4) {
+  for (size_t n = 0; n < number_of_loops; ++n, k += 4) {
     // It's possible that `delay_time` contains negative values. Make sure
     // they are greater than zero.
     const __m128 v_delay_time =
-        _mm_max_ps(_mm_loadu_ps(UNSAFE_TODO(delay_times + k)), v_all_zeros);
+        _mm_max_ps(_mm_loadu_ps(delay_times_.as_span().subspan(k, 4u).data()),
+                   v_all_zeros);
     const __m128 v_desired_delay_frames =
         _mm_mul_ps(v_delay_time, v_sample_rate);
 
@@ -116,8 +115,8 @@
         reinterpret_cast<const uint32_t*>(&v_read_index2);
 
     for (int m = 0; m < 4; ++m) {
-      sample1[m] = UNSAFE_TODO(buffer[read_index1[m]]);
-      sample2[m] = UNSAFE_TODO(buffer[read_index2[m]]);
+      sample1[m] = buffer_[UNSAFE_TODO(read_index1[m])];
+      sample2[m] = buffer_[UNSAFE_TODO(read_index2[m])];
     }
 
     const __m128 v_sample1 = _mm_load_ps(sample1.data());
@@ -129,7 +128,7 @@
     const __m128 sample = _mm_add_ps(
         v_sample1,
         _mm_mul_ps(interpolation_factor, _mm_sub_ps(v_sample2, v_sample1)));
-    _mm_store_ps(UNSAFE_TODO(destination + k), sample);
+    _mm_store_ps(destination.subspan(k, 4u).data(), sample);
   }
 
   // Update |w_index|_ based on how many frames we processed here, wrapping
@@ -142,8 +141,8 @@
   return std::make_tuple(k, w_index);
 }
 
-void Delay::HandleNaN(float* delay_times,
-                      uint32_t frames_to_process,
+void Delay::HandleNaN(base::span<float> delay_times,
+                      size_t frames_to_process,
                       float max_time) {
   unsigned k = 0;
   const unsigned number_of_loops = frames_to_process / 4;
@@ -152,7 +151,7 @@
 
   // This is approximately 4 times faster than the scalar version.
   for (unsigned loop = 0; loop < number_of_loops; ++loop, k += 4) {
-    __m128 x = _mm_loadu_ps(UNSAFE_TODO(delay_times + k));
+    __m128 x = _mm_loadu_ps(delay_times.subspan(k, 4u).data());
     // 0xffffffff if x is NaN. Otherwise 0
     __m128 cmp = _mm_cmpunord_ps(x, x);
 
@@ -166,13 +165,13 @@
     // Merge i (bitwise or) x and cmp.  This makes x = max_time if x was NaN and
     // preserves x if not.
     x = _mm_or_ps(x, cmp);
-    _mm_storeu_ps(UNSAFE_TODO(delay_times + k), x);
+    _mm_storeu_ps(delay_times.subspan(k, 4u).data(), x);
   }
 
   // Handle any frames not done in the loop above.
   for (; k < frames_to_process; ++k) {
-    if (std::isnan(UNSAFE_TODO(delay_times[k]))) {
-      UNSAFE_TODO(delay_times[k]) = max_time;
+    if (std::isnan(delay_times[k])) {
+      delay_times[k] = max_time;
     }
   }
 }
diff --git a/third_party/blink/renderer/platform/audio/delay.cc b/third_party/blink/renderer/platform/audio/delay.cc
index 097005e..ea4159a 100644
--- a/third_party/blink/renderer/platform/audio/delay.cc
+++ b/third_party/blink/renderer/platform/audio/delay.cc
@@ -38,31 +38,29 @@
 
 namespace {
 
-void CopyToCircularBuffer(float* buffer,
-                          int write_index,
-                          int buffer_length,
-                          const float* source,
-                          uint32_t frames_to_process) {
+void CopyToCircularBuffer(base::span<float> buffer,
+                          size_t write_index,
+                          base::span<const float> source,
+                          size_t frames_to_process) {
   // The algorithm below depends on this being true because we don't expect to
   // have to fill the entire buffer more than once.
-  DCHECK_GE(static_cast<uint32_t>(buffer_length), frames_to_process);
+  DCHECK_GE(buffer.size(), frames_to_process);
+  DCHECK_GE(buffer.size(), write_index);
 
   // Copy `frames_to_process` values from `source` to the circular buffer that
-  // starts at `buffer` of length `buffer_length`.  The copy starts at index
+  // starts at `buffer` of length `buffer.size()`.  The copy starts at index
   // `write_index` into the buffer.
-  float* write_pointer = &UNSAFE_TODO(buffer[write_index]);
-  int remainder = buffer_length - write_index;
+  const size_t remainder = buffer.size() - write_index;
 
-  // Copy the sames over, carefully handling the case where we need to wrap
-  // around to the beginning of the buffer.
-  UNSAFE_TODO(
-      memcpy(write_pointer, source,
-             sizeof(*write_pointer) *
-                 std::min(static_cast<int>(frames_to_process), remainder)));
-  UNSAFE_TODO(
-      memcpy(buffer, source + remainder,
-             sizeof(*write_pointer) *
-                 std::max(0, static_cast<int>(frames_to_process) - remainder)));
+  // Carefully handle the case where we need to wrap around to the beginning of
+  // the buffer.
+  const size_t first_size = std::min(frames_to_process, remainder);
+  buffer.subspan(write_index, first_size).copy_from(source.first(first_size));
+
+  if (frames_to_process > remainder) {
+    const size_t second_size = frames_to_process - remainder;
+    buffer.first(second_size).copy_from(source.subspan(remainder, second_size));
+  }
 }
 
 }  // namespace
@@ -103,7 +101,7 @@
 
 #if !(defined(ARCH_CPU_X86_FAMILY) || defined(CPU_ARM_NEON))
 // Default scalar versions if simd/neon are not available.
-std::tuple<unsigned, int> Delay::ProcessARateVector(
+std::tuple<size_t, size_t> Delay::ProcessARateVector(
     float* destination,
     uint32_t frames_to_process) const {
   // We don't have a vectorized version, so just do nothing and return the 0 to
@@ -111,10 +109,10 @@
   return std::make_tuple(0, write_index_);
 }
 
-void Delay::HandleNaN(float* delay_times,
-                      uint32_t frames_to_process,
+void Delay::HandleNaN(base::span<float> delay_times,
+                      size_t frames_to_process,
                       float max_time) {
-  for (unsigned k = 0; k < frames_to_process; ++k) {
+  for (size_t k = 0; k < frames_to_process; ++k) {
     if (std::isnan(delay_times[k])) {
       delay_times[k] = max_time;
     }
@@ -122,78 +120,60 @@
 }
 #endif
 
-int Delay::ProcessARateScalar(unsigned start,
-                              int w_index,
-                              float* destination,
-                              uint32_t frames_to_process) const {
-  const int buffer_length = buffer_.size();
-  const float* buffer = buffer_.Data();
+size_t Delay::ProcessARateScalar(size_t start,
+                                 size_t w_index,
+                                 base::span<float> destination,
+                                 size_t frames_to_process) const {
+  const size_t buffer_length = buffer_.size();
 
-  DCHECK(buffer_length);
-  DCHECK(destination);
-  DCHECK_GE(write_index_, 0);
   DCHECK_LT(write_index_, buffer_length);
 
-  float sample_rate = sample_rate_;
-  const float* delay_times = delay_times_.Data();
-
   for (unsigned i = start; i < frames_to_process; ++i) {
-    double delay_time = std::fmax(UNSAFE_TODO(delay_times[i]), 0);
-    double desired_delay_frames = delay_time * sample_rate;
+    double delay_time = std::fmax(delay_times_[i], 0);
+    double desired_delay_frames = delay_time * sample_rate_;
 
     double read_position = w_index + buffer_length - desired_delay_frames;
     if (read_position >= buffer_length) {
       read_position -= buffer_length;
     }
+    DCHECK_GE(read_position, 0);
 
     // Linearly interpolate in-between delay times.
-    int read_index1 = static_cast<int>(read_position);
-    DCHECK_GE(read_index1, 0);
+    size_t read_index1 = static_cast<size_t>(read_position);
     DCHECK_LT(read_index1, buffer_length);
-    int read_index2 = read_index1 + 1;
+    size_t read_index2 = read_index1 + 1;
     if (read_index2 >= buffer_length) {
       read_index2 -= buffer_length;
     }
-    DCHECK_GE(read_index2, 0);
     DCHECK_LT(read_index2, buffer_length);
 
     float interpolation_factor = read_position - read_index1;
 
-    float sample1 = UNSAFE_TODO(buffer[read_index1]);
-    float sample2 = UNSAFE_TODO(buffer[read_index2]);
+    float sample1 = buffer_[read_index1];
+    float sample2 = buffer_[read_index2];
 
... (file diff truncated)
Loading diff…

Original Bug Report

reported by je...@gmail.com

NaN poisoning in k-rate SetValueCurve leads to out-of-bounds read in DelayNode

NaN poisoning in k-rate SetValueCurve leads to out-of-bounds read in DelayNode

Summary

A crafted setValueCurveAtTime call on a k-rate DelayNode.delayTime AudioParam can produce a NaN delay value that propagates unchecked through Delay::ProcessKRate, causing static_cast<int>(NaN) to yield INT_MIN on x86/x64. This results in a massive out-of-bounds memory read (approximately 8 GB before the ring buffer), crashing the renderer process. The vulnerability affects all desktop platforms (Linux, macOS, Windows) on x86/x64 architectures.

Root Cause

When AudioParam::setValueCurveAtTime(curve, startTime, duration) is called, the event creation code pre-computes the curve sampling rate as a per-second value:

// audio_param_handler.cc — CreateSetValueCurveEvent
double curve_points = (curve.size() - 1) / duration;

This computation runs on the main thread, where IEEE 754 subnormal handling is standard. If duration is a normal double just above DBL_MIN (approximately 2.225e-308) and the curve has enough points, the division overflows to positive infinity. For instance, a 6-element curve with duration = 2.3e-308 produces 5 / 2.3e-308 ≈ 2.17e308, which exceeds DBL_MAX and becomes +inf. The event stores this infinite CurvePointsPerSecond without any validation; the only checks on the event fields are DCHECK(std::isfinite(...)) assertions that are compiled out in release builds.

The vulnerability manifests during audio rendering on the audio thread, which runs with x86 MXCSR flags DAZ (Denormals Are Zero) and FTZ (Flush To Zero) enabled for performance. The key property of the chosen duration is that it must be a normal (non-subnormal) double so that the audio thread does not flush it to zero when computing frame coverage. With duration = 2.3e-308, the product sampleRate * duration = 48000 * 2.3e-308 ≈ 1.1e-303 is a normal double, and ceil(1.1e-303) = 1, meaning the SetValueCurve event covers exactly one audio frame. This allows the event’s processing loop to execute.

Inside ProcessSetValueCurve, the scalar fallback path computes a virtual curve index for each frame:

// audio_param_handler.cc — ProcessSetValueCurve, scalar loop
double current_virtual_index =
    curve_virtual_index + k * curve_points_per_frame;

On the first iteration (k = 0) with curve_virtual_index = 0 and curve_points_per_frame = +inf, IEEE 754 arithmetic produces 0 * inf = NaN. The subsequent clamping of the curve index uses comparison operators that propagate NaN:

// audio_param_handler.cc — ProcessSetValueCurve, scalar loop
double delta = std::min(current_virtual_index - curve_index0, 1.0);

Since std::min uses operator< and NaN comparisons return false, the NaN survives as delta. The interpolated value c0 + (c1 - c0) * NaN evaluates to NaN because any arithmetic with NaN produces NaN.

The a-rate (sample-accurate) path is protected against NaN through HandleNaNValues() in CalculateFinalValues, but the k-rate path through FinalValue() and ValueForContextTime() lacks this protection entirely. After ValuesForFrameRangeImpl returns the NaN value, it passes through Vclip with a single-element span, which takes the scalar code path using ClampTo(). The ClampTo implementation in math_extras.h relies on comparison operators:

// math_extras.h — ClampToDirectComparison
if (value >= max) return max;
if (value <= min) return min;
return value;

Both comparisons against NaN evaluate to false, so the NaN value is returned unchanged. A DCHECK(!__builtin_isnan(...)) guard exists but is absent in release builds.

The NaN delay value then enters Delay::ProcessKRate, where it passes through another ClampTo call unimpeded, and is converted to a buffer index:

// delay.cc — ProcessKRate
double delay_time = DelayTime(sample_rate);
delay_time = ClampTo(delay_time, 0.0, max_time);
double desired_delay_frames = delay_time * sample_rate;
double read_position = w_index + buffer_length - desired_delay_frames;
int read_index1 = static_cast<int>(read_position);
float* read_pointer = &buffer[read_index1];
memcpy(sample1, read_pointer, sizeof(*sample1) * std::min(frames_to_process, remainder));

On x86/x64, static_cast<int>(NaN) compiles to cvttsd2si, which returns INT_MIN (0x80000000) for NaN inputs. This produces read_index1 = -2147483648, causing the subsequent pointer arithmetic and memcpy to read approximately 8 GB before the start of the ring buffer. The access lands in unmapped memory, producing a SEGV_MAPERR signal.

Reproduce

To reproduce this issue, check out Chromium at commit e256102970bf347f2cc827935dbcb09ee18a3b60 and configure an ASAN release build. Place the following in out/asan-release/args.gn:

is_debug = false
is_asan = true
is_component_build = true
symbol_level = 1

Build Chrome with autoninja -C out/asan-release chrome. No source patches are required.

Copy poc.html from this directory to the Chromium source root ~/chromium/src/poc.html, then launch Chrome with ASAN:

ASAN_OPTIONS=detect_odr_violation=0 \
  xvfb-run -a out/asan-release/chrome \
  --no-sandbox --disable-gpu \
  --user-data-dir=/tmp/poc-test \
  poc.html

On headless servers, xvfb-run -a provides a virtual display. The renderer process will crash within a few seconds with Received signal 11 SEGV_MAPERR, confirming an out-of-bounds memory read from inside blink::Delay::ProcessKRate. The crash address will be approximately 8 GB before the delay line ring buffer, which is the result of static_cast<int>(NaN) producing INT_MIN (0x80000000) as a buffer index on x86/x64. The crash log in asan.log contains the full stack trace and register dump.

Crash Log

Received signal 11 SEGV_MAPERR 7b9950013800
#0 0x55f29ae6f046 ___interceptor_backtrace
#1 0x7f9bdad5f3e2 base::debug::CollectStackTrace                                base/debug/stack_trace_posix.cc:1048
#2 0x7f9bdad04c53 base::debug::StackTrace::StackTrace                            base/debug/stack_trace.cc:280
#3 0x7f9bdad5e67b base::debug::StackDumpSignalHandler                            base/debug/stack_trace_posix.cc:483
#4 0x7f9b6a242520 (signal handler)
#5 0x7f9b6a2c4881 memcpy                                                         memmove-vec-unaligned-erms.S:220
#6 0x55f29aec727c __asan_memcpy
#7 0x7f9b7f23d4cc blink::Delay::ProcessKRate                                     delay.cc:268
#8 0x7f9b72903999 blink::DelayHandler::Process                                   delay_handler.cc:84
#9 0x7f9b7283a8fe blink::AudioHandler::ProcessIfNecessary                        audio_handler.cc:331
#10 0x7f9b7285c7c6 blink::AudioNodeOutput::Pull                                  audio_node_output.cc:135
#11 0x7f9b7285995c blink::AudioNodeInput::SumAllConnections                      audio_node_input.cc:132
#12 0x7f9b72859de6 blink::AudioNodeInput::Pull                                   audio_node_input.cc:162
#13 0x7f9b7293900c blink::OfflineAudioDestinationHandler::RenderIfNotSuspended   offline_audio_destination_handler.cc:304
#14 0x7f9b72937b02 blink::OfflineAudioDestinationHandler::DoOfflineRendering     offline_audio_destination_handler.cc:188
#15 0x7f9b7293a2ad base::internal::Invoker<...>::RunOnce                         bind_internal.h:740
#16 0x7f9bdab614f3 base::TaskAnnotator::RunTaskImpl                              callback.h:155
#17 0x7f9bdabe29df ThreadControllerWithMessagePumpImpl::DoWorkImpl               task_annotator.h:112
#18 0x7f9bdabe19b7 ThreadControllerWithMessagePumpImpl::DoWork                   thread_controller_with_message_pump_impl.cc:346
#19 0x7f9bdaa03592 base::MessagePumpDefault::Run                                 message_pump_default.cc:42
#20 0x7f9bdabe4059 ThreadControllerWithMessagePumpImpl::Run                      thread_controller_with_message_pump_impl.cc:650
#21 0x7f9bdaacbb53 base::RunLoop::Run                                           run_loop.cc:135
#22 0x7f9b8007000d blink::scheduler::NonMainThreadImpl::SimpleThreadImpl::Run    non_main_thread_impl.cc:178
#23 0x7f9bdacde6fd base::ThreadFunc                                              platform_thread_posix.cc:102
#24 0x55f29aec7137 asan_thread_start
#25 0x7f9b6a294ac3 start_thread                                                  pthread_create.c:442
#26 0x7f9b6a326850 clone3                                                        clone3.S:81
  r8: 00000f9feb863b30  r9: 0000000000000008 r10: 0000000000000008 r11: 0000000000000000
 r12: 00007b9950013800 r13: 00000000000001bf r14: 0000000000000200 r15: 00007b9950013800
  di: 00007cfb5c35d780  si: 00007b9950013800  bp: 00007b9a92d7ad30  bx: 00007cfb5c35d780
  dx: 0000000000000200  ax: 00007cfb5c35d780  cx: 00000f9feb863af0  sp: 00007b9a92d7a4e8
  ip: 00007f9b6a2c4881 efl: 0000000000010206 cgf: 002b000000000033 erf: 0000000000000004
 trp: 000000000000000e msk: 0000000000000000 cr2: 00007b9950013800
[end of stack trace]

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker