CVE-2026-11106
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
GetNextSegmentURIAndCacheStatusmedia/filters/hls_data_source_provider.cc |
modified | |
ifmedia/filters/hls_data_source_provider.cc |
modified | |
ifmedia/filters/hls_data_source_provider_impl.cc |
modified |
Files Changed
media/base/data_source.hmedia/filters/hls_data_source_provider.ccmedia/filters/hls_data_source_provider.hmedia/filters/hls_data_source_provider_impl.ccmedia/filters/hls_data_source_provider_impl.h
Patch
From d2e04a198f272720a153b1b7cffd62ba2d3b89e0 Mon Sep 17 00:00:00 2001
From: Ted Meyer <tmathmeyer@chromium.org>
Date: Tue, 14 Apr 2026 17:49:07 -0700
Subject: [PATCH] Totally disallow range requests + CORS tainting
There are just too many data leakages when allowing cross origin
requests that can have controlled byte ranges. The original bug points
to the use of encrypted content, but the issue still arises with
non-encrypted content as well, since segments aren't verified to have
any full iframes. This would become even more obvious with LLHLS (once
that is implemented).
Fixed: 500508725
Change-Id: I4220ca4a3aaa7a29107472586491a704bee93605
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7748953
Reviewed-by: Dale Curtis <dalecurtis@chromium.org>
Commit-Queue: Ted (Chromium) Meyer <tmathmeyer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1614839}
---
diff --git a/media/base/data_source.h b/media/base/data_source.h
index e3ed9c6..9a35b27 100644
--- a/media/base/data_source.h
+++ b/media/base/data_source.h
@@ -41,6 +41,16 @@
using DataSourceCb = base::OnceCallback<void(std::unique_ptr<DataSource>)>;
using EventCb = base::RepeatingCallback<void(const DataSource*)>;
+ enum class RangeMode {
+ kRangeRequest,
+ kFullRequest,
+ };
+
+ enum class CacheMode {
+ kBypassCache,
+ kHitCache,
+ };
+
enum { kReadError = -1, kAborted = -2 };
// Used to specify video preload states. They are "hints" to the browser about
@@ -60,7 +70,7 @@
public:
virtual ~Factory();
virtual void Create(const GURL& uri,
- bool ignore_cache,
+ CacheMode cache_mode,
DataSourceCb cb) = 0;
};
diff --git a/media/filters/hls_data_source_provider.cc b/media/filters/hls_data_source_provider.cc
index 6159520..03dc5133 100644
--- a/media/filters/hls_data_source_provider.cc
+++ b/media/filters/hls_data_source_provider.cc
@@ -46,12 +46,15 @@
return std::get<0>(GetNextSegmentURIAndCacheStatus());
}
-std::pair<GURL, bool> HlsDataSourceStream::GetNextSegmentURIAndCacheStatus() {
+std::tuple<GURL, DataSource::CacheMode, DataSource::RangeMode>
+HlsDataSourceStream::GetNextSegmentURIAndCacheStatus() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
CHECK(requires_next_data_source_);
CHECK(!segments_.empty());
const auto& first = segments_.front();
+ auto range_mode = DataSource::RangeMode::kFullRequest;
if (first.range) {
+ range_mode = DataSource::RangeMode::kRangeRequest;
read_position_ = first.range->GetOffset();
max_read_position_ = first.range->GetEnd();
} else {
@@ -59,10 +62,9 @@
max_read_position_ = std::nullopt;
}
GURL new_url = std::move(first.uri);
- bool bypass_cache = first.bypass_cache;
segments_.pop();
requires_next_data_source_ = false;
- return std::make_pair(new_url, bypass_cache);
+ return std::make_tuple(new_url, first.cache_mode, range_mode);
}
bool HlsDataSourceStream::CanReadMore() const {
diff --git a/media/filters/hls_data_source_provider.h b/media/filters/hls_data_source_provider.h
index 0036510..e1ec9de9 100644
--- a/media/filters/hls_data_source_provider.h
+++ b/media/filters/hls_data_source_provider.h
@@ -15,6 +15,7 @@
#include "base/functional/callback.h"
#include "base/sequence_checker.h"
#include "base/types/id_type.h"
+#include "media/base/data_source.h"
#include "media/base/media_export.h"
#include "media/base/status.h"
#include "media/formats/hls/types.h"
@@ -52,7 +53,7 @@
struct UrlDataSegment {
const GURL uri;
const std::optional<hls::types::ByteRange> range;
- const bool bypass_cache;
+ const DataSource::CacheMode cache_mode;
};
using SegmentQueue = base::queue<UrlDataSegment>;
@@ -119,6 +120,15 @@
// in this playback is tainted.
void set_would_taint_origin() { would_taint_origin_ = true; }
+ // A stream is considered to require a range request if any of the sub-URIs
+ // in the stream use range requests.
+ void set_requires_range_request() { requires_range_request_ = true; }
+
+ // A stream is never allowed to have a tainted origin and be a range request.
+ bool HasIncompatibleRangeAndOrigin() const {
+ return would_taint_origin_ && requires_range_request_;
+ }
+
// Often the network data for HLS consists of plain-text manifest files, so
// this supports accessing the fetched data as a string view.
std::string_view AsString() const;
@@ -136,7 +146,8 @@
// segments. It is invalid to call this method if `RequiresNextDataSource`
// does not return true. This method will also update the internal range if
// the segment has one.
- std::pair<GURL, bool> GetNextSegmentURIAndCacheStatus();
+ std::tuple<GURL, DataSource::CacheMode, DataSource::RangeMode>
+ GetNextSegmentURIAndCacheStatus();
// Has the stream read all possible data?
bool CanReadMore() const;
@@ -168,6 +179,11 @@
// to false.
bool would_taint_origin_ = false;
+ // This is critical for security. Range requests are not allowed to be mixed
+ // with cross-origin requests at any point. Once this has been set, it must
+ // not be unset.
+ bool requires_range_request_ = false;
+
// The memory usage represents the total memory usage for _all_ streams used
// in this playback.
uint64_t memory_usage_ = 0;
diff --git a/media/filters/hls_data_source_provider_impl.cc b/media/filters/hls_data_source_provider_impl.cc
index bcef619..4c21da1 100644
--- a/media/filters/hls_data_source_provider_impl.cc
+++ b/media/filters/hls_data_source_provider_impl.cc
@@ -104,14 +104,15 @@
// try to make one. Creating a new data source will re-enter this function to
// complete `callback`.
if (stream->RequiresNextDataSource()) {
- auto [new_uri, bypass_cache] = stream->GetNextSegmentURIAndCacheStatus();
+ auto [new_uri, cache_mode, range_mode] =
+ stream->GetNextSegmentURIAndCacheStatus();
TRACE_EVENT_BEGIN("media", "HLS::CreateDataSource",
perfetto::Track::FromPointer(this), "uri", new_uri);
data_source_factory_->Create(
- std::move(new_uri), bypass_cache,
+ std::move(new_uri), cache_mode,
base::BindOnce(&HlsDataSourceProviderImpl::OnDataSourceCreated,
- weak_factory_.GetWeakPtr(), std::move(stream),
- std::move(callback)));
+ weak_factory_.GetWeakPtr(), range_mode,
+ std::move(stream), std::move(callback)));
return;
}
@@ -157,6 +158,7 @@
}
void HlsDataSourceProviderImpl::OnDataSourceCreated(
+ DataSource::RangeMode range_mode,
std::unique_ptr<HlsDataSourceStream> stream,
ReadCb callback,
std::unique_ptr<DataSource> data_source) {
@@ -169,6 +171,20 @@
data_source_map_.erase(old_data_source);
}
would_taint_origin_ |= data_source->WouldTaintOrigin();
+ if (would_taint_origin_) {
+ stream->set_would_taint_origin();
+ }
+ if (range_mode == DataSource::RangeMode::kRangeRequest) {
+ stream->set_requires_range_request();
+ }
+
+ if (stream->HasIncompatibleRangeAndOrigin()) {
+ std::move(callback).Run(
+ {ReadStatus::Codes::kError,
+ "Range requests are not allowed for cross-origin content"});
+ return;
+ }
+
auto pair = data_source_map_.try_emplace(stream_id, std::move(data_source));
// Cross origin data sources have an asynchronous initialize method which
// must be called after they're put into `data_source_map_`. Other types of
diff --git a/media/filters/hls_data_source_provider_impl.h b/media/filters/hls_data_source_provider_impl.h
index b110c2fe..7329e96 100644
--- a/media/filters/hls_data_source_provider_impl.h
+++ b/media/filters/hls_data_source_provider_impl.h
@@ -37,7 +37,8 @@
Regression Test / PoC
diff --git a/media/filters/hls_data_source_provider_impl_unittest.cc b/media/filters/hls_data_source_provider_impl_unittest.cc
index b2229a6..c2eee17c 100644
--- a/media/filters/hls_data_source_provider_impl_unittest.cc
+++ b/media/filters/hls_data_source_provider_impl_unittest.cc
@@ -340,4 +340,148 @@
task_environment_.RunUntilIdle();
}
-} // namespace blink
+TEST_F(HlsDataSourceProviderImplUnittest, TestCrossoriginRangeRequest) {
+ HlsDataSourceProvider::SegmentQueue segments;
+ // Read 10 bytes from offset 0.
+ segments.emplace(GURL("http://example.com"),
+ hls::types::ByteRange::Validate(10, 0));
+
+ auto* mock_data_source = factory_->PregenerateNextMock();
+ EXPECT_CALL(*mock_data_source, WouldTaintOrigin())
+ .WillRepeatedly(Return(true));
+
+ bool has_error = false;
+ impl_->ReadFromCombinedUrlQueue(
+ std::move(segments),
+ base::BindOnce(
+ [](bool* error_canary, HlsDataSourceProvider::ReadResult result) {
+ if (!result.has_value()) {
+ *error_canary =
+ (std::move(result).error() ==
+ HlsDataSourceProvider::ReadStatus::Codes::kError);
+ }
+ },
+ &has_error));
+
+ task_environment_.RunUntilIdle();
+ ASSERT_TRUE(has_error);
+}
+
+TEST_F(HlsDataSourceProviderImplUnittest, TestPersistentTainting) {
+ // First, a full request that is cross-origin.
+ {
+ HlsDataSourceProvider::SegmentQueue segments;
+ segments.emplace(GURL("http://example.com"), std::nullopt);
+
+ auto* mock_data_source = factory_->PregenerateNextMock();
+ EXPECT_CALL(*mock_data_source, WouldTaintOrigin())
+ .WillRepeatedly(Return(true));
+ EXPECT_CALL(*mock_data_source, Initialize)
+ .WillOnce(base::test::RunOnceCallback<0>(true));
+ EXPECT_CALL(*mock_data_source, Read(0, SpanSizeEq(16384), _))
+ .WillOnce(base::test::RunOnceCallback<2>(16384));
+ EXPECT_CALL(*mock_data_source, Stop());
+
+ bool has_result = false;
+ impl_->ReadFromCombinedUrlQueue(
+ std::move(segments),
+ base::BindOnce(
+ [](bool* canary, HlsDataSourceProvider::ReadResult result) {
+ ASSERT_TRUE(result.has_value());
+ *canary = true;
+ },
+ &has_result));
+ task_environment_.RunUntilIdle();
+ ASSERT_TRUE(has_result);
+ }
+
+ // Now, a range request that is NOT cross-origin (but the provider is already
+ // tainted).
+ {
+ HlsDataSourceProvider::SegmentQueue segments;
+ segments.emplace(GURL("http://example.com"),
+ hls::types::ByteRange::Validate(10, 0));
+
+ auto* mock_data_source = factory_->PregenerateNextMock();
+ // This one doesn't taint, but the provider is already tainted.
+ EXPECT_CALL(*mock_data_source, WouldTaintOrigin())
+ .WillRepeatedly(Return(false));
+
+ bool has_error = false;
+ impl_->ReadFromCombinedUrlQueue(
+ std::move(segments),
+ base::BindOnce(
+ [](bool* error_canary, HlsDataSourceProvider::ReadResult result) {
+ if (!result.has_value()) {
+ *error_canary =
+ (std::move(result).error() ==
+ HlsDataSourceProvider::ReadStatus::Codes::kError);
+ }
+ },
+ &has_error));
+
+ task_environment_.RunUntilIdle();
+ ASSERT_TRUE(has_error);
+ }
+}
+
+TEST_F(HlsDataSourceProviderImplUnittest, TestRangeThenCrossorigin) {
+ HlsDataSourceProvider::SegmentQueue segments;
+ // First segment: same-origin range request.
+ segments.emplace(GURL("http://a.com/init"),
+ hls::types::ByteRange::Validate(10, 0));
+ // Second segment: cross-origin non-range request.
+ segments.emplace(GURL("http://b.com/media"), std::nullopt);
+
+ // Setup for the first segment (init)
+ {
+ auto* mock_data_source = factory_->PregenerateNextMock();
+ EXPECT_CALL(*mock_data_source, WouldTaintOrigin())
+ .WillRepeatedly(Return(false));
+ EXPECT_CALL(*mock_data_source, Initialize)
+ .WillOnce(base::test::RunOnceCallback<0>(true));
+ EXPECT_CALL(*mock_data_source, Read(0, SpanSizeEq(10), _))
+ .WillOnce(base::test::RunOnceCallback<2>(10));
+ EXPECT_CALL(*mock_data_source, Stop());
+ }
+
+ std::unique_ptr<HlsDataSourceStream> stream;
+ impl_->ReadFromCombinedUrlQueue(
+ std::move(segments), base::BindOnce(
+ [](std::unique_ptr<HlsDataSourceStream>* extract,
+ HlsDataSourceProvider::ReadResult result) {
+ ASSERT_TRUE(result.has_value());
+ *extract = std::move(result).value();
+ },
+ &stream));
+
+ task_environment_.RunUntilIdle();
+ ASSERT_NE(stream, nullptr);
+ ASSERT_TRUE(stream->RequiresNextDataSource());
+
+ // Setup for the second segment (media) - it's cross-origin!
+ {
+ auto* mock_data_source = factory_->PregenerateNextMock();
+ EXPECT_CALL(*mock_data_source, WouldTaintOrigin())
+ .WillRepeatedly(Return(true));
+ // It should fail before Initialize/Read/Stop.
+ }
+
+ bool has_error = false;
+ impl_->ReadFromExistingStream(
+ std::move(stream),
+ base::BindOnce(
+ [](bool* error_canary, HlsDataSourceProvider::ReadResult result) {
+ if (!result.has_value()) {
+ *error_canary =
+ (std::move(result).error() ==
+ HlsDataSourceProvider::ReadStatus::Codes::kError);
+ }
+ },
+ &has_error));
+
+ task_environment_.RunUntilIdle();
+ ASSERT_TRUE(has_error);
+}
+
+} // namespace media
diff --git a/media/test/pipeline_integration_test_base.cc b/media/test/pipeline_integration_test_base.cc
index acf6cac..36a5f0c6 100644
--- a/media/test/pipeline_integration_test_base.cc
+++ b/media/test/pipeline_integration_test_base.cc
@@ -82,7 +82,7 @@
public:
~TestDataSourceFactory() override = default;
void Create(const GURL& uri,
- bool,
+ DataSource::CacheMode,
DataSource::DataSourceCb callback) override {
auto file_data_source = std::make_unique<FileDataSource>();
base::FilePath file_path(
diff --git a/third_party/blink/renderer/platform/media/multi_buffer_data_source_unittest.cc b/third_party/blink/renderer/platform/media/multi_buffer_data_source_unittest.cc
index 101651f..d23776ad 100644
--- a/third_party/blink/renderer/platform/media/multi_buffer_data_source_unittest.cc
+++ b/third_party/blink/renderer/platform/media/multi_buffer_data_source_unittest.cc
@@ -2115,11 +2115,14 @@
MultiBufferDataSource::Factory factory(
std::move(media_log),
base::BindRepeating(
- [](UrlIndex* url_index, const GURL& url, bool ignore_cache,
+ [](UrlIndex* url_index, const GURL& url,
+ media::DataSource::CacheMode cache_mode,
base::OnceCallback<void(scoped_refptr<UrlData>)> cb) {
std::move(cb).Run(url_index->GetByUrl(
KURL(url), UrlData::CORS_UNSPECIFIED,
- ignore_cache ? UrlData::kCacheDisabled : UrlData::kNormal));
+ cache_mode == media::DataSource::CacheMode::kBypassCache
+ ? UrlData::kCacheDisabled
+ : UrlData::kNormal));
},
base::Unretained(&url_index_)),
/*is_audio_element=*/true,
@@ -2130,7 +2133,7 @@
/*tick_clock=*/nullptr, task_runner_);
std::unique_ptr<media::DataSource> created_source;
- factory.Create(GURL(kHttpUrl), false,
+ factory.Create(GURL(kHttpUrl), media::DataSource::CacheMode::kHitCache,
base::BindOnce(
[](std::unique_ptr<media::DataSource>* out_source,
std::unique_ptr<media::DataSource> source) {
@@ -2155,13 +2158,17 @@
TEST_F(MultiBufferDataSourceTest, FactoryCreationDefault) {
auto media_log = std::make_unique<NiceMock<media::MockMediaLog>>();
MultiBufferDataSource::Factory factory(
- std::move(media_log),
+ /*media_log=*/std::move(media_log),
+ /*get_url_data=*/
base::BindRepeating(
- [](UrlIndex* url_index, const GURL& url, bool ignore_cache,
+ [](UrlIndex* url_index, const GURL& url,
+ media::DataSource::CacheMode cache_mode,
base::OnceCallback<void(scoped_refptr<UrlData>)> cb) {
std::move(cb).Run(url_index->GetByUrl(
KURL(url), UrlData::CORS_UNSPECIFIED,
- ignore_cache ? UrlData::kCacheDisabled : UrlData::kNormal));
+ cache_mode == media::DataSource::CacheMode::kBypassCache
+ ? UrlData::kCacheDisabled
+ : UrlData::kNormal));
},
base::Unretained(&url_index_)),
/*is_audio_element=*/false,
@@ -2169,7 +2176,7 @@
task_runner_);
std::unique_ptr<media::DataSource> created_source;
- factory.Create(GURL(kHttpUrl), false,
+ factory.Create(GURL(kHttpUrl), media::DataSource::CacheMode::kHitCache,
base::BindOnce(
[](std::unique_ptr<media::DataSource>* out_source,
std::unique_ptr<media::DataSource> source) {
Original Bug Report
HLS SOP bypass via cross-origin init segment decryption oracle
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: Chrome’s HLS demuxer improperly concatenates and decrypts cross-origin initialization segments with media segments when using AES-128 encryption. This creates a padding and container-sniffing oracle that allows an attacker to leak cross-origin data byte-by-byte. The leak is achieved by observing the success or failure of container parsing via DOM events on a <video> element.
Affected files:
media/formats/hls/media_segment.ccmedia/filters/hls_data_source_provider.ccmedia/filters/hls_manifest_demuxer_engine.ccmedia/filters/hls_network_access_impl.ccmedia/formats/hls/media_playlist.ccmedia/formats/hls/types.ccmedia/filters/hls_rendition_impl.cc
Estimated timestamp from git blame: 2025-10-24
Root Cause
Per the HLS specification (RFC 8216 §4.3.2.4), METHOD=AES-128 “does not apply to Media Initialization Sections.” However, Chromium’s HLS implementation handles initialization and media segments by fetching them and appending them to a single buffer (HlsDataSourceStream::buffer_).
When HlsManifestDemuxerEngine::DetermineBitstreamContainer attempts to identify the container type, it extracts this combined buffer and passes it to MediaSegment::GetPlaintextStreamSource. If AES-128 encryption is enabled, this function calls crypto::aes_cbc::Decrypt() on the entire concatenated buffer at once.
Because the attacker can specify a cross-origin resource as the initialization segment (via #EXT-X-MAP and BYTERANGE) and provide their own attacker-controlled media segment, they control all but the first few bytes of the ciphertext. By manipulating the media segment to ensure the final block passes PKCS7 padding checks, they can force the demuxer to decrypt the combined buffer.
Potential Exploitation Mechanism
An attacker can construct a deterministic oracle to leak cross-origin data by exploiting the container sniffing logic. Below are the suggested steps an attacker would follow to trigger this vulnerability:
- Craft Playlist: The attacker creates an HLS playlist (
leak.m3u8) utilizing AES-128 encryption. The encryption key is provided via a relative path ordata:URI (which is marked askSafeOrigin, bypassing tainting checks inHlsNetworkAccessImpl::OnKeyFetch). - Target Selection: The playlist uses an
#EXT-X-MAPtag withBYTERANGE="1@0"pointing to the cross-origin target resource. This requests exactly one byte of the secret. - Media Segment Crafting: The playlist includes a standard media segment pointing to a 31-byte file on the attacker’s server.
- Fetch and Concatenate: When the browser loads the playlist via a
<video>tag, the demuxer fetches the 1-byte init segment (via ano-corsrequest) and the 31-byte media segment, appending both to a single 32-byte buffer (two AES blocks). - Bypass Padding Check: The ciphertext consists of $C_0$ (1 secret byte + 15 controlled bytes) and $C_1$ (16 controlled bytes). The plaintext of the second block is $P_1 = D_k(C_1) \oplus C_0$. The attacker crafts $C_1$ such that $D_k(C_1)[15] \oplus C_0[15] = 0x01$. This forces the final decrypted byte ($P_1[15]$) to be
0x01. BoringSSL’s PKCS7 validation will see a padding length of 1 and only check the last byte, completely ignoring the unknown byte in $P_1[0]$ and guaranteeing a successful decryption. - Container Magic Sniffing: The demuxer passes the successfully decrypted plaintext to
CheckBitstreamForContainerMagic, which inspects the first byte ($P_0[0] = D_k(C_0)[0] \oplus IV[0]$). - Oracle Feedback:
- If $P_0[0]$ equals
0x47(the MPEG-TS magic byte), initialization succeeds, and the<video>element eventually fires aloadedmetadataevent. - If $P_0[0] \neq 0x47$, initialization fails, returning
DEMUXER_ERROR_COULD_NOT_PARSE, and the<video>element fires anerrorevent.
- If $P_0[0]$ equals
- Sweeping the IV: The attacker creates 256
<video>tags pointing to playlists with different $IV[0]$ values. The browser caches the 1-byte init segment, making the sweep instantaneous. By observing which video tag firesloadedmetadata, the attacker determines the exact $IV[0]$ that resulted in0x47. - Offline Computation: The attacker calculates $D_k(C’_0)[0]$ offline for all 256 possible values of the first byte of $C’_0$ (since the key and the other 15 bytes are known) and compares it to $0x47 \oplus IV[0]$ to deduce the unknown cross-origin byte.
- Full Leak: The attacker repeats the process, incrementing the
BYTERANGEoffset to leak the entire file byte-by-byte.
While Opaque Response Blocking (ORB) protects HTML, JSON, and XML, this attack successfully leaks any cross-origin resource permitted by ORB, such as binary files or media assets.
Suggested Fix
The demuxer should strictly adhere to RFC 8216 §4.3.2.4 and refuse to decrypt initialization segments when AES-128 is specified. Initialization segments and media segments should be maintained in separate buffers, and the decryption routines in MediaSegment::GetPlaintextStreamSource should only ever be applied to the media segment data, never to the initialization segment data.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
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.