CVE-2026-13797
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchromecast/starboard/media/media/drm_util.cc |
modified | |
TESTchromecast/starboard/media/media/drm_util_test.cc |
modified |
Files Changed
chromecast/starboard/media/media/drm_util.ccchromecast/starboard/media/media/drm_util.hchromecast/starboard/media/media/drm_util_test.ccchromecast/starboard/media/media/starboard_audio_decoder.ccchromecast/starboard/media/media/starboard_video_decoder.cc
Patch
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();
Regression Test / PoC
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
Original Bug Report
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.ccchromecast/starboard/media/media/starboard_api_wrapper.hchromecast/starboard/media/media/starboard_video_decoder.ccchromecast/starboard/media/media/starboard_audio_decoder.ccchromecast/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.
- Compromise Renderer: An attacker achieves code execution in a sandboxed renderer process.
- Establish DRM Session: The attacker initiates a valid EME session (e.g., Widevine) to register a valid decryption key in the browser’s
StarboardDrmKeyTracker. - Send Malicious Buffer: The attacker hooks
mojom::DemuxerStream::Readto return aDecoderBufferwith aDecryptConfigcontaining aclear_bytesvalue of0x80000000and a smallcypher_bytespayload. - Trigger OOB Decryption: The browser receives the buffer. The Starboard adapter converts the
0x80000000to-2147483648and passes it to the vendor’s Starboard implementation. - 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 thecypher_bytesciphertext and writes the attacker-controlled plaintext to this out-of-bounds location, achieving a sandbox escape.
Suggested Fix
- Enforce Bounds Checking: Add a validation check at the beginning of
StarboardVideoDecoder::PushBufferandStarboardAudioDecoder::PushBuffer:if (!DecoderBuffer::DoSubsamplesMatch(*buffer)) { // Drop buffer / report error return BufferStatus::kBufferFailed; } - Safe Casting: Update
DrmInfoWrapper::Create()to safely cast the byte counts usingbase::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.