CVE-2026-3916
Overview
Background
- `SpeechRecognizerImpl`
- the browser-process (
content/browser/speech/) implementation of Web Speech recognition, which collects audio intoAudioChunkobjects and passes them to the recognition engine. - `AudioDataS16` buffer
- a Mojo message from the renderer carrying interleaved signed 16-bit PCM (
data) together with renderer-declaredchannel_countandframe_count. - `media::AudioBus::FromInterleaved` / `ToInterleaved`
- helpers that convert between planar
AudioBusstorage and interleaved sample buffers, reading or writingchannels × framessamples according to the dimensions they are given. - `mojo::ReportBadMessage`
- flags an IPC message from a less-privileged process as malicious, which typically ends that renderer, instead of processing it.
Root Cause Analysis
In SpeechRecognizerImpl::AddAudioFromRenderer() (the name comes from the patch’s own static_assert message, not the hunk header), the browser built an AudioBus from the renderer-supplied buffer->channel_count and buffer->frame_count. It then called data->FromInterleaved<media::SignedInt16SampleTypeTraits>(buffer->data.data(), buffer->frame_count), passing a raw pointer with no length. FromInterleaved reads channel_count × frame_count samples from that pointer, but nothing checked that buffer->data actually held that many, so a message declaring dimensions larger than its payload made the browser read past the end of buffer->data. The output chunk size was also computed with unchecked int arithmetic (channel_count * frame_count * kNumBitsPerAudioSample / 8), and non-positive dimensions were never rejected.
The fix rejects channel_count <= 0 or frame_count <= 0 with mojo::ReportBadMessage, computes total_samples with base::CheckMul, and requires buffer->data.size() to equal it exactly, otherwise reporting a bad message. It also drops the AudioBus round trip, sizing the AudioChunk with checked math and copying with the bounds-checked span::copy_from. A separate hunk in the local-capture conversion path, whose enclosing function the hunk does not name, only replaces new AudioChunk(...) with base::MakeRefCounted and the bit constant with kBytesPerAudioSample; it is a code-health change, not the core fix.
channel_count × frame_count as the length of the sample payload and read that many samples through a raw pointer. The fix checks those dimensions against buffer->data.size() with checked math and rejects mismatched messages as bad IPC.Attack Path
- Start speech recognition fed by the renderer
A page uses Web Speech recognition in a mode where the renderer sends audio to the browser as
AudioDataS16buffers handled byAddAudioFromRenderer(). - Send a mismatched buffer
A renderer sends a buffer whose
channel_count × frame_countexceeds the number ofint16_tsamples indata; a well-behaved renderer would not do this, so this most likely requires a compromised renderer (inferred). - Browser over-reads
Before the fix,
AudioBus::FromInterleavedreadchannel_count × frame_countsamples frombuffer->data.data(), reading past the end of the IPC payload in the browser process. - Leak or crash
The out-of-bounds bytes are copied into the
AudioChunkand passed to the recognition pipeline, which could leak browser-process heap contents into recognition input, or crash the browser (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/speech/speech_recognizer_impl.cc |
modified |
Files Changed
content/browser/speech/speech_recognizer_impl.cccontent/browser/speech/speech_recognizer_impl.h
Audit Directions
- Renderer-declared dimensions vs. payload lengthLook for browser-side Mojo handlers that receive a buffer plus separate count/size fields (audio, video frames, images, shared memory regions) and check that the counts are validated against the actual payload size before any copy.
- Raw-pointer `FromInterleaved` and similar APIsSearch for
AudioBus::FromInterleaved,CopyFromandmemcpycalls that take a bare pointer plus a renderer-influenced count, and move them to span-based overloads that enforce bounds. - Unchecked size arithmetic on IPC fieldsFind multiplications of IPC-supplied
intfields used for allocation or copy lengths, and requirebase::CheckMul/base::CheckedNumericplusReportBadMessageon failure.
Patch
From 88a0535d5bab4c1e7e97af17882a3c00d021fba9 Mon Sep 17 00:00:00 2001 From: Thomas Guilbert <[email protected]> Date: Wed, 18 Feb 2026 13:08:43 -0800 Subject: [PATCH] [CodeHealth] Spanify (de)interleaving in SpeechRecognizerImpl This CL updates an instance of `AudioBus::ToInterleaved()` to use its safer, spanified counterpart. It also removes one round trip of deinterleaving/interleaving + copy converting to/from float, which saves one memory copy. This extra round trip might have had the intention of clipping/sanitizing incoming data, which is only useful for `float`, not `int16_t`. Finally, this CL also hardens by rejecting potentially bad messages coming from the renderer, and using checked math when calculating memory allocation sizes. See linked bugs for additional details. Bug: 373960632, 482828615 Change-Id: I03c0e3342651bc6b55aa3505206e54c1c56bac5e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7573019 Reviewed-by: Frank Liberato <[email protected]> Reviewed-by: Nasko Oskov <[email protected]> Reviewed-by: Tom Sepez <[email protected]> Commit-Queue: Thomas Guilbert <[email protected]> Cr-Commit-Position: refs/heads/main@{#1586654} --- diff --git a/content/browser/speech/speech_recognizer_impl.cc b/content/browser/speech/speech_recognizer_impl.cc index b246f4fc..b94a4cec 100644 --- a/content/browser/speech/speech_recognizer_impl.cc +++ b/content/browser/speech/speech_recognizer_impl.cc @@ -156,12 +156,12 @@ // See http://crbug.com/506051 for details. audio_converter_.Convert(output_bus_.get()); // Create an audio chunk based on the converted result. - scoped_refptr<AudioChunk> chunk(new AudioChunk( + auto chunk = base::MakeRefCounted<AudioChunk>( output_parameters_.GetBytesPerBuffer(media::kSampleFormatS16), - kNumBitsPerAudioSample / 8)); + kBytesPerAudioSample); - static_assert(SpeechRecognizerImpl::kNumBitsPerAudioSample == 16, - "kNumBitsPerAudioSample must match interleaving type."); + static_assert(SpeechRecognizerImpl::kBytesPerAudioSample == sizeof(int16_t), + "kBytesPerAudioSample must match interleaving type."); output_bus_->ToInterleaved<media::SignedInt16SampleTypeTraits>( chunk->SamplesData16AsWriteableSpan()); return chunk; @@ -334,17 +334,33 @@ return; } - std::unique_ptr<media::AudioBus> data = - AudioBus::Create(buffer->channel_count, buffer->frame_count); + if (buffer->channel_count <= 0 || buffer->frame_count <= 0) { + mojo::ReportBadMessage("AudioDataS16: non-positive dimensions"); + return; + } - data->FromInterleaved<media::SignedInt16SampleTypeTraits>( - buffer->data.data(), buffer->frame_count); + auto total_samples = + base::CheckMul(buffer->channel_count, buffer->frame_count); - scoped_refptr<AudioChunk> chunk(new AudioChunk( - buffer->channel_count * buffer->frame_count * kNumBitsPerAudioSample / 8, - kNumBitsPerAudioSample / 8)); - data->ToInterleaved<media::SignedInt16SampleTypeTraits>( - chunk->SamplesData16AsWriteableSpan()); + if (!total_samples.IsValid() || + buffer->data.size() != total_samples.ValueOrDie<size_t>()) { + mojo::ReportBadMessage("AudioDataS16: size mismatch"); + return; + } + + // If ever the sample format changed to `float`, we would have to clip and + // sanitize data coming from the renderer. `int16_t` doesn't need it, since it + // cannot represent invalid values or values outside the [-1.0, 1.0] range. + static_assert(SpeechRecognizerImpl::kBytesPerAudioSample == sizeof(int16_t), + "`AddAudioFromRenderer()` expects `int16_t`."); + + const auto chunk_size = + base::CheckMul(total_samples, kBytesPerAudioSample).ValueOrDie<size_t>(); + auto chunk = + base::MakeRefCounted<AudioChunk>(chunk_size, kBytesPerAudioSample); + + chunk->SamplesData16AsWriteableSpan().copy_from(buffer->data); + FSMEventArgs event_args(EVENT_AUDIO_DATA); event_args.audio_chunk = std::move(chunk); GetIOThreadTaskRunner({})->PostTask( diff --git a/content/browser/speech/speech_recognizer_impl.h b/content/browser/speech/speech_recognizer_impl.h index 317e103..4760bcba 100644 --- a/content/browser/speech/speech_recognizer_impl.h +++ b/content/browser/speech/speech_recognizer_impl.h @@ -48,6 +48,7 @@ static constexpr media::ChannelLayoutConfig kChannelLayoutConfig = media::ChannelLayoutConfig::Mono(); static constexpr int kNumBitsPerAudioSample = 16; + static constexpr size_t kBytesPerAudioSample = kNumBitsPerAudioSample / 8; static constexpr int kNoSpeechTimeoutMs = 8000; static constexpr int kEndpointerEstimationTimeMs = 300;
Original Bug Report
Heap OOB read in SpeechRecognizerImpl::AddAudioFromRenderer
Summary
SpeechRecognizerImpl fails to validate that the size of the data array in the AudioDataS16 Mojo struct matches the declared channel_count * frame_count. This allows a compromised renderer to trigger a heap out-of-bounds read in the Browser Process.
VULNERABILITY DETAILS
Heap-buffer-overflow (OOB read) in the browser process via SpeechRecognizerImpl::AddAudioFromRenderer(). A compromised renderer can read attacker-controlled amounts of browser-process heap memory through the media.mojom.SpeechRecognitionAudioForwarder Mojo interface. No permissions are required, the audio forwarder path bypasses the microphone permission check.
The per-packet AudioDataS16 Mojo struct has independent channel_count, frame_count, and data fields with no validation that data.size() >= channel_count * frame_count. The deprecated raw-pointer FromInterleaved() overload at speech_recognizer_impl.cc:341 reads channel_count * frame_count int16 values from the undersized data buffer, causing an OOB heap read. The read size is (channel_count * frame_count - data.size()) * 2 bytes, fully attacker-controlled per packet.
There is also a secondary integer overflow at line 345 where channel_count * frame_count * 16 / 8 is computed in int32 this can undersize the AudioChunk allocation, but requires a 1GB+ AudioBus to trigger so it’s only DoS.
The OOB-read data is converted to float, written to an AudioChunk, then FLAC-encoded (level 0, lossless) and uploaded to Google’s speech API. The int16→float32→int16→FLAC pipeline is bit-for-bit lossless (float32 mantissa exceeds int16 precision), so the heap bytes survive encoding intact.
Introduced in commit 881ab7a9d5 (“Add MediaStreamTrack support to the Web Speech API”, Evan Liu, 2024-08-01, https://chromium-review.googlesource.com/c/chromium/src/+/5631655).
VERSION Chrome Version: 146.0.7673.0 (trunk) affected since Chrome 129 (Aug 2024) Operating System: Ubuntu 22.04 / WSL2
REPRODUCTION CASE
Attached: poc-speech-oob-read.html (primary), poc-variable-read-sizes.html (shows 100/1024/4096/65536-byte reads)
The PoCs use MojoJS to simulate a compromised renderer crafting raw Mojo messages. A real attacker with renderer RCE would construct identical messages directly.
cd chromium/src
ASAN_OPTIONS=detect_odr_violation=0 ./out/asan-debug/chrome
–no-sandbox –enable-blink-features=MojoJS,MojoJSTest –disable-gpu
file:///path/to/poc-speech-oob-read.html 2>&1 | tee /tmp/asan.log
Build: is_debug=true is_asan=true is_lsan=true is_component_build=true
--no-sandbox is a WSL2 requirement; the bug is in the browser-process Mojo handler and is hit regardless of sandbox state.
detect_odr_violation=0 is needed for ASan component builds (expected cppgc ODR violation).
The PoC binds media.mojom.SpeechRecognizer, sends Start() with an audio_forwarder receiver (bypasses mic permission), waits for the session to initialize, then sends AddAudioFromRenderer with channel_count=2, frame_count=1024, data=[10 values] reads 4076 bytes past the 20-byte allocation.
In release builds the OOB read is silent with no crash.
FOR CRASHES Type of crash: browser process (Chrome_IOThread) Crash State (full ASan log attached)
==35997==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x717bf9f18bf4 at pc 0x754d48186b8b bp 0x714be7753730 sp 0x714be7753728 READ of size 2 at 0x717bf9f18bf4 thread T8 (Chrome_IOThread) #0 0x754d48186b8a in void media::AudioBus::CopyConvertFromInterleavedSourceToAudioBus<media::FixedSampleTypeTraits<short>>(…) media/base/audio_bus.h:494:27 #1 0x754d481869ce in void media::AudioBus::FromInterleavedPartial<media::FixedSampleTypeTraits<short>>(…) media/base/audio_bus.h:422:3 #2 0x754d4817f758 in void media::AudioBus::FromInterleaved<media::FixedSampleTypeTraits<short>>(…) media/base/audio_bus.h:390:3 #3 0x754d48173ecc in content::SpeechRecognizerImpl::AddAudioFromRenderer(…) content/browser/speech/speech_recognizer_impl.cc:341:9 #4 0x754d3e126c20 in media::mojom::SpeechRecognitionAudioForwarderStubDispatch::Accept(…) gen/media/mojo/mojom/speech_recognition_audio_forwarder.mojom.cc:192:13
0x717bf9f18bf4 is located 0 bytes after 20-byte region [0x717bf9f18be0,0x717bf9f18bf4) allocated by thread T8 (Chrome_IOThread) here: #0 0x5db19b595a5d in operator new(unsigned long) … #8 0x754d3e03269f in mojo::ArrayTraits<std::vector<short>>::Resize(…) mojo/public/cpp/bindings/array_traits.h:149:17
SUMMARY: AddressSanitizer: heap-buffer-overflow media/base/audio_bus.h:494:27 in void media::AudioBus::CopyConvertFromInterleavedSourceToAudioBus<…>(…)
SUGGESTED FIX
Attached: suggested-fix.patch
Validate data.size() == channel_count * frame_count (with base::CheckMul for overflow) before the FromInterleaved call, and switch to the span-based FromInterleaved overload which has CHECK_LE. The speech service at chrome/services/speech/ already does this validation. The browser-process SpeechRecognizerImpl just lacks it.
CREDIT INFORMATION
Reporter credit: Grischa Hauser