Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Chromecast
DescriptionInsufficient validation of untrusted input in Chromecast
ComponentChromecast
Bug ClassLogic Error
Tracker499025645
Fix commit5278782cc63f (chromium/src) +85/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chromecast/starboard/media/media/drm_util.cc
modified
TEST
chromecast/starboard/media/media/drm_util_test.cc
modified

Files Changed

  • chromecast/starboard/media/media/drm_util.cc
  • chromecast/starboard/media/media/drm_util.h
  • chromecast/starboard/media/media/drm_util_test.cc
  • chromecast/starboard/media/media/starboard_audio_decoder.cc
  • chromecast/starboard/media/media/starboard_video_decoder.cc
From 5278782cc63f8c23e4e021d4c7d6b9285742ab0f Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <[email protected]>
Date: Wed, 06 May 2026 09:24:25 -0700
Subject: [PATCH] Fix integer signedness flip in Starboard media backend

This CL adds validation to ensure that DRM subsample sizes match the
total buffer size in the Starboard media backend. It also uses
base::checked_cast when converting subsample counts to int32_t to
prevent malicious large values from flipping to negative integers.

Fixed: 499025645
Change-Id: I7c6724b1fe9f8eaa9e38d93dc2e02950a4417cde
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7786259
Reviewed-by: Sandeep Vijayasekar <[email protected]>
Commit-Queue: Andrew Paseltiner <[email protected]>
Reviewed-by: Yuchen Liu <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1626238}
---

diff --git a/chromecast/starboard/media/media/drm_util.cc b/chromecast/starboard/media/media/drm_util.cc
index 549fb42..48449fd7 100644
--- a/chromecast/starboard/media/media/drm_util.cc
+++ b/chromecast/starboard/media/media/drm_util.cc
@@ -9,6 +9,8 @@
 #include "base/check.h"
 #include "base/containers/span.h"
 #include "base/logging.h"
+#include "base/numerics/safe_conversions.h"
+#include "base/numerics/safe_math.h"
 
 namespace chromecast {
 namespace media {
@@ -37,6 +39,34 @@
   return drm_sample_info_.get();
 }
 
+// static
+bool DrmInfoWrapper::VerifySubsamplesMatchSize(
+    const CastDecoderBuffer& buffer) {
+  if (buffer.end_of_stream()) {
+    return true;
+  }
+
+  const CastDecryptConfig* decrypt_config = buffer.decrypt_config();
+  if (!decrypt_config) {
+    return true;
+  }
+
+  base::CheckedNumeric<size_t> total_size = 0;
+  for (const SubsampleEntry& subsample : decrypt_config->subsamples()) {
+    total_size += subsample.clear_bytes;
+    total_size += subsample.cypher_bytes;
+  }
+
+  if (!total_size.IsValid() || total_size.ValueOrDie() != buffer.data_size()) {
+    LOG(ERROR) << "Subsample sizes do not equal input size. Total size: "
+               << total_size.ValueOrDefault(0)
+               << ", expected size: " << buffer.data_size();
+    return false;
+  }
+
+  return true;
+}
+
 DrmInfoWrapper DrmInfoWrapper::Create(const CastDecoderBuffer& buffer) {
   const CastDecryptConfig* decrypt_config = buffer.decrypt_config();
   if (!decrypt_config) {
@@ -97,8 +127,10 @@
   subsample_mappings->reserve(decrypt_config->subsamples().size());
   for (const SubsampleEntry& subsample : decrypt_config->subsamples()) {
     StarboardDrmSubSampleMapping mapping;
-    mapping.clear_byte_count = subsample.clear_bytes;
-    mapping.encrypted_byte_count = subsample.cypher_bytes;
+    mapping.clear_byte_count =
+        base::checked_cast<int32_t>(subsample.clear_bytes);
+    mapping.encrypted_byte_count =
+        base::checked_cast<int32_t>(subsample.cypher_bytes);
     subsample_mappings->push_back(std::move(mapping));
   }
 
@@ -185,8 +217,10 @@
   subsample_mappings->reserve(decrypt_config.subsamples().size());
   for (const ::media::SubsampleEntry& subsample : decrypt_config.subsamples()) {
     StarboardDrmSubSampleMapping mapping;
-    mapping.clear_byte_count = subsample.clear_bytes;
-    mapping.encrypted_byte_count = subsample.cypher_bytes;
+    mapping.clear_byte_count =
+        base::checked_cast<int32_t>(subsample.clear_bytes);
+    mapping.encrypted_byte_count =
+        base::checked_cast<int32_t>(subsample.cypher_bytes);
     subsample_mappings->push_back(std::move(mapping));
   }
 
diff --git a/chromecast/starboard/media/media/drm_util.h b/chromecast/starboard/media/media/drm_util.h
index 8bdf486..133e019 100644
--- a/chromecast/starboard/media/media/drm_util.h
+++ b/chromecast/starboard/media/media/drm_util.h
@@ -37,6 +37,9 @@
   // encrypted, GetDrmSampleInfo() will return null.
   static DrmInfoWrapper Create(const ::media::DecoderBuffer& buffer);
 
+  // Verifies that the subsamples in `buffer` match its data size.
+  static bool VerifySubsamplesMatchSize(const CastDecoderBuffer& buffer);
+
   // DrmInfoWrapper is movable but not copyable.
   DrmInfoWrapper(DrmInfoWrapper&& other);
   DrmInfoWrapper& operator=(DrmInfoWrapper&& other);
diff --git a/chromecast/starboard/media/media/drm_util_test.cc b/chromecast/starboard/media/media/drm_util_test.cc
index 5005823..70c75872 100644
--- a/chromecast/starboard/media/media/drm_util_test.cc
+++ b/chromecast/starboard/media/media/drm_util_test.cc
@@ -207,6 +207,42 @@
               Pointee(MatchesDrmInfo(expected_drm_info)));
 }
 
+TEST(DrmUtilTest, VerifySubsamplesMatchSizeDetectsInvalidSizes) {
+  constexpr auto kBufferData = std::to_array<uint8_t>({1, 2, 3, 4, 5});
+  constexpr std::string_view kId = "drm_id";
+  constexpr std::string_view kIv = "0123456789abcdef";
+
+  // Total size (3+3=6) > buffer size (5).
+  const ::media::SubsampleEntry invalid_subsample(3, 3);
+  std::unique_ptr<::media::DecryptConfig> decrypt_config =
+      ::media::DecryptConfig::CreateCencConfig(
+          std::string(kId), std::string(kIv), {invalid_subsample});
+  auto buffer = base::MakeRefCounted<DecoderBufferAdapter>(
+      CreateChromiumBuffer(std::move(decrypt_config), kBufferData));
+
+  EXPECT_FALSE(DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer));
+
+  // Total size (1+1=2) < buffer size (5).
+  const ::media::SubsampleEntry invalid_subsample_2(1, 1);
+  std::unique_ptr<::media::DecryptConfig> decrypt_config_2 =
+      ::media::DecryptConfig::CreateCencConfig(
+          std::string(kId), std::string(kIv), {invalid_subsample_2});
+  auto buffer_2 = base::MakeRefCounted<DecoderBufferAdapter>(
+      CreateChromiumBuffer(std::move(decrypt_config_2), kBufferData));
+
+  EXPECT_FALSE(DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer_2));
+
+  // Correct size (2+3=5) == buffer size (5).
+  const ::media::SubsampleEntry valid_subsample(2, 3);
+  std::unique_ptr<::media::DecryptConfig> decrypt_config_3 =
+      ::media::DecryptConfig::CreateCencConfig(
+          std::string(kId), std::string(kIv), {valid_subsample});
+  auto buffer_3 = base::MakeRefCounted<DecoderBufferAdapter>(
+      CreateChromiumBuffer(std::move(decrypt_config_3), kBufferData));
+
+  EXPECT_TRUE(DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer_3));
+}
+
 }  // namespace
 }  // namespace media
 }  // namespace chromecast
diff --git a/chromecast/starboard/media/media/starboard_audio_decoder.cc b/chromecast/starboard/media/media/starboard_audio_decoder.cc
index 45ab9f5..dfd818b81 100644
--- a/chromecast/starboard/media/media/starboard_audio_decoder.cc
+++ b/chromecast/starboard/media/media/starboard_audio_decoder.cc
@@ -169,6 +169,10 @@
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
   CHECK(buffer);
 
+  if (!DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer)) {
+    return BufferStatus::kBufferFailed;
+  }
+
   if (buffer->end_of_stream()) {
     return PushEndOfStream();
   }
diff --git a/chromecast/starboard/media/media/starboard_video_decoder.cc b/chromecast/starboard/media/media/starboard_video_decoder.cc
index ba39800..a901ddd 100644
--- a/chromecast/starboard/media/media/starboard_video_decoder.cc
+++ b/chromecast/starboard/media/media/starboard_video_decoder.cc
@@ -185,6 +185,10 @@
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
   DCHECK(buffer);
 
+  if (!DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer)) {
+    return BufferStatus::kBufferFailed;
+  }
+
   // At this point the VideoPipelineImpl (the delegate) should be in the
   // kPlaying state, so it is safe to update the resolution.
   MediaPipelineBackend::Decoder::Delegate* const delegate = GetDelegate();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chromecast/starboard/media/media/drm_util_test.cc b/chromecast/starboard/media/media/drm_util_test.cc
index 5005823..70c75872 100644
--- a/chromecast/starboard/media/media/drm_util_test.cc
+++ b/chromecast/starboard/media/media/drm_util_test.cc
@@ -207,6 +207,42 @@
               Pointee(MatchesDrmInfo(expected_drm_info)));
 }
 
+TEST(DrmUtilTest, VerifySubsamplesMatchSizeDetectsInvalidSizes) {
+  constexpr auto kBufferData = std::to_array<uint8_t>({1, 2, 3, 4, 5});
+  constexpr std::string_view kId = "drm_id";
+  constexpr std::string_view kIv = "0123456789abcdef";
+
+  // Total size (3+3=6) > buffer size (5).
+  const ::media::SubsampleEntry invalid_subsample(3, 3);
+  std::unique_ptr<::media::DecryptConfig> decrypt_config =
+      ::media::DecryptConfig::CreateCencConfig(
+          std::string(kId), std::string(kIv), {invalid_subsample});
+  auto buffer = base::MakeRefCounted<DecoderBufferAdapter>(
+      CreateChromiumBuffer(std::move(decrypt_config), kBufferData));
+
+  EXPECT_FALSE(DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer));
+
+  // Total size (1+1=2) < buffer size (5).
+  const ::media::SubsampleEntry invalid_subsample_2(1, 1);
+  std::unique_ptr<::media::DecryptConfig> decrypt_config_2 =
+      ::media::DecryptConfig::CreateCencConfig(
+          std::string(kId), std::string(kIv), {invalid_subsample_2});
+  auto buffer_2 = base::MakeRefCounted<DecoderBufferAdapter>(
+      CreateChromiumBuffer(std::move(decrypt_config_2), kBufferData));
+
+  EXPECT_FALSE(DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer_2));
+
+  // Correct size (2+3=5) == buffer size (5).
+  const ::media::SubsampleEntry valid_subsample(2, 3);
+  std::unique_ptr<::media::DecryptConfig> decrypt_config_3 =
+      ::media::DecryptConfig::CreateCencConfig(
+          std::string(kId), std::string(kIv), {valid_subsample});
+  auto buffer_3 = base::MakeRefCounted<DecoderBufferAdapter>(
+      CreateChromiumBuffer(std::move(decrypt_config_3), kBufferData));
+
+  EXPECT_TRUE(DrmInfoWrapper::VerifySubsamplesMatchSize(*buffer_3));
+}
+
 }  // namespace
 }  // namespace media
 }  // namespace chromecast
Loading diff…

Original Bug Report

reported by [email protected]

Potential OOB write in browser via Starboard media subsample integer signedness flip

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A potential integer signedness flip vulnerability exists in the Chromecast Starboard media backend. A compromised renderer can send maliciously large DRM subsample counts that bypass validation and are converted to negative integers before being passed to the vendor’s Starboard API. This negative offset may cause out-of-bounds pointer arithmetic during decryption, potentially allowing an attacker to write arbitrary data to the browser process heap and escape the sandbox.

Affected files:

  • chromecast/starboard/media/media/drm_util.cc
  • chromecast/starboard/media/media/starboard_api_wrapper.h
  • chromecast/starboard/media/media/starboard_video_decoder.cc
  • chromecast/starboard/media/media/starboard_audio_decoder.cc
  • chromecast/starboard/media/media/starboard_decoder.cc

Estimated timestamp from git blame: 2025-06-05

Summary

There is a potential out-of-bounds (OOB) write vulnerability in the Chromecast Starboard media backend. The issue stems from a lack of subsample size validation coupled with an integer signedness flip when converting Chromium’s DecryptConfig to the Starboard API’s DRM structures. This allows a compromised renderer to pass negative byte counts to the vendor’s decryption API, likely resulting in backwards OOB pointer arithmetic and heap corruption in the browser process.

Root Cause Analysis

When a media::DecoderBuffer containing encrypted media is sent via IPC from the renderer to the browser process, MojoDecoderBufferReader deserializes its DecryptConfig. The deserialization logic (ValidateAndConvertMojoDecryptConfig) does not validate that the sum of the subsamples sizes matches the DecoderBuffer’s total data_size. In Chromium, this validation is intentionally left to individual decoders via DecoderBuffer::DoSubsamplesMatch().

However, the Starboard media backend (StarboardVideoDecoder::PushBuffer and StarboardAudioDecoder::PushBuffer) fails to call DecoderBuffer::DoSubsamplesMatch() or VerifySubsamplesMatchSize().

Instead, it proceeds to convert the configuration using DrmInfoWrapper::Create(). During this conversion, uint32_t values from Chromium’s SubsampleEntry are implicitly narrowed into int32_t fields in StarboardDrmSubSampleMapping:

// chromecast/starboard/media/media/drm_util.cc
for (const ::media::SubsampleEntry& subsample : decrypt_config.subsamples()) {
  StarboardDrmSubSampleMapping mapping;
  mapping.clear_byte_count = subsample.clear_bytes; // Implicit uint32_t -> int32_t cast
  mapping.encrypted_byte_count = subsample.cypher_bytes;
  subsample_mappings->push_back(std::move(mapping));
}

If an attacker provides a clear_bytes value like 0x80000000, it is reinterpreted as -2147483648. This negative value is passed directly to the vendor’s Starboard API (SbPlayerWriteSample2) along with a pointer to a base::HeapArray allocated in the browser process.

Potential Exploit Steps

Note: These are suggested steps based on static analysis, as our tooling does not yet have the ability to run live exploit code.

  1. Compromise Renderer: An attacker achieves code execution in a sandboxed renderer process.
  2. Establish DRM Session: The attacker initiates a valid EME session (e.g., Widevine) to register a valid decryption key in the browser’s StarboardDrmKeyTracker.
  3. Send Malicious Buffer: The attacker hooks mojom::DemuxerStream::Read to return a DecoderBuffer with a DecryptConfig containing a clear_bytes value of 0x80000000 and a small cypher_bytes payload.
  4. Trigger OOB Decryption: The browser receives the buffer. The Starboard adapter converts the 0x80000000 to -2147483648 and passes it to the vendor’s Starboard implementation.
  5. Memory Corruption: Following standard Common Encryption (CENC) semantics, the vendor implementation will likely advance an internal buffer pointer to skip the unencrypted data (ptr += clear_byte_count). The negative value shifts the pointer backwards by 2GB, well outside the allocated heap buffer. The subsequent AES decryption operation uses the valid key to decrypt the cypher_bytes ciphertext and writes the attacker-controlled plaintext to this out-of-bounds location, achieving a sandbox escape.

Suggested Fix

  1. Enforce Bounds Checking: Add a validation check at the beginning of StarboardVideoDecoder::PushBuffer and StarboardAudioDecoder::PushBuffer:
    if (!DecoderBuffer::DoSubsamplesMatch(*buffer)) {
      // Drop buffer / report error
      return BufferStatus::kBufferFailed;
    }
    
  2. Safe Casting: Update DrmInfoWrapper::Create() to safely cast the byte counts using base::checked_cast<int32_t> to explicitly crash or fail safely if an overflow is attempted.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker