High watch CVSS 9.6 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Web Speech
DescriptionOut of bounds read in Web Speech
ComponentWeb Speech
Sub-componentCodeHealth
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.
Tracker482828615
Fix commit88a0535d5bab (chromium/src) +30/-13
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGrischa Hauser
Reported2026-02-09 · bounty $36000 (per vendor advisory)
Fix landed2026-02-18 (public git)
Disclosed2026-03-10
Patch gap20 days — fix public in source before the advisory

Background

`SpeechRecognizerImpl`
the browser-process (content/browser/speech/) implementation of Web Speech recognition, which collects audio into AudioChunk objects 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-declared channel_count and frame_count.
`media::AudioBus::FromInterleaved` / `ToInterleaved`
helpers that convert between planar AudioBus storage and interleaved sample buffers, reading or writing channels × frames samples 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.

Key insight
The browser trusted the renderer’s declared 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

  1. 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 AudioDataS16 buffers handled by AddAudioFromRenderer().
  2. Send a mismatched buffer A renderer sends a buffer whose channel_count × frame_count exceeds the number of int16_t samples in data; a well-behaved renderer would not do this, so this most likely requires a compromised renderer (inferred).
  3. Browser over-reads Before the fix, AudioBus::FromInterleaved read channel_count × frame_count samples from buffer->data.data(), reading past the end of the IPC payload in the browser process.
  4. Leak or crash The out-of-bounds bytes are copied into the AudioChunk and passed to the recognition pipeline, which could leak browser-process heap contents into recognition input, or crash the browser (inferred).

Impact Assessment

The bug is an out-of-bounds read in the browser process, triggered by malformed Mojo data from the renderer. The commit describes the fix as “rejecting potentially bad messages coming from the renderer”, and a renderer has to send dimensions that do not match its payload, so the realistic precondition is a compromised renderer plus an active Web Speech recognition session that takes renderer-supplied audio (inferred, as the diff does not show the renderer-side sender). The vendor rates it high severity, and no exploitation in the wild is reported; using it to leak browser memory as part of a sandbox escape chain is (inferred).

Changed Functions

FunctionChangeNotes
if
content/browser/speech/speech_recognizer_impl.cc
modified

Files Changed

  • content/browser/speech/speech_recognizer_impl.cc
  • content/browser/speech/speech_recognizer_impl.h

Audit Directions

  • Renderer-declared dimensions vs. payload length
    Look 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 APIs
    Search for AudioBus::FromInterleaved, CopyFrom and memcpy calls 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 fields
    Find multiplications of IPC-supplied int fields used for allocation or copy lengths, and require base::CheckMul / base::CheckedNumeric plus ReportBadMessage on failure.
From 88a0535d5bab4c1e7e97af17882a3c00d021fba9 Mon Sep 17 00:00:00 2001
From: Thomas Guilbert <tguilbert@chromium.org>
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 <liberato@chromium.org>
Reviewed-by: Nasko Oskov <nasko@chromium.org>
Reviewed-by: Tom Sepez <tsepez@chromium.org>
Commit-Queue: Thomas Guilbert <tguilbert@chromium.org>
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;
 
Loading diff…

Original Bug Report

reported by gr...@gmail.com

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

View on issue tracker