Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebAudio
DescriptionOut of bounds read in WebAudio
ComponentWebAudio
Bug ClassOOB
Tracker490533968
Fix commitb05c9128c8aa (chromium/src) +175/-203
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-23

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/platform/audio/cpu/arm/delay_neon.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
From b05c9128c8aa84f028bc336d899ac6c852622429 Mon Sep 17 00:00:00 2001
From: Michael Wilson <mjwilson@chromium.org>
Date: Tue, 10 Mar 2026 16:19:05 -0700
Subject: [PATCH] Replace UNSAFE_TODO in Delay with safe operations

This should cause no functional change.

It also required updating call sites to use span instead of pointers.

Bug: 401184803
Bug: 490533968
Change-Id: I13fd424ea4fa7cc679a5206ecb5ee7dd67c025e1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7649841
Reviewed-by: Hongchan Choi <hongchan@chromium.org>
Commit-Queue: Michael Wilson <mjwilson@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1597393}
---

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 {
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