Low watch CVSS 3.1 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in Media
DescriptionSide-channel information leakage in Media
ComponentMedia
Bug ClassInfo Disclosure
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.
Tracker497722578
Fix commitdb46e35db798 (chromium/src) +201/-46
CWECWE-1300
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-30 (per vendor advisory)
Fix landed2026-04-02 (public git)
Disclosed2026-05-05
Patch gap33 days — fix public in source before the advisory

Background

HLS encrypted segments
HTTP Live Streaming media can mark segments as AES-128 encrypted and point to a key via an #EXT-X-KEY URI that the player fetches before decrypting.
`would_taint_origin()`
a DataSource predicate that is true when a cross-origin resource was fetched without a CORS grant, so using its bytes must taint the playing origin.
`EncryptionData::KeyLocation`
an enum (kSafeOrigin / kUnsafeOrigin) that records whether the key URI’s host matched the manifest’s host when the segment was parsed.
Side-channel key probing
an attacker can learn whether a cross-origin, opaque response has particular bytes by observing whether decryption succeeds, turning the key fetch into a cross-origin read oracle.

Root Cause Analysis

The key-fetch completion path in hls_network_access_impl.cc (the enclosing function name is not shown in the @@ -53,7 +53,18 @@ hunk header) read the key stream and unconditionally called enc_data->ImportKey(stream->AsString()), regardless of where the key came from. Because the fetch could satisfy a cross-origin request whose response was opaque (would_taint_origin() true) and whose URI did not share the manifest’s origin (KeyLocation::kUnsafeOrigin), the player would import and use bytes from a resource the page is not allowed to read.

The patch TIGHTENS the path by inserting a guard before ImportKey: if the stream would_taint_origin() and the key location is kUnsafeOrigin, it returns kError with "insecure key request" and never imports the key. The regression test TestSegmentWithCORSKey confirms a key on example.net with a manifest on example.com and no Access-Control-Allow-Origin header is now rejected, while the same-origin TestSegmentWithKey still succeeds.

The fix works because it enforces that a usable key must be same-origin with the frame/manifest, a data: URL, or CORS-approved, so cross-origin bytes can no longer silently become a decryption key.

Key insight
The single mistake was importing an HLS decryption key without checking the key resource’s origin/CORS status, letting a cross-origin opaque response become a usable key; the fix adds a would_taint_origin() + kUnsafeOrigin guard that rejects such keys before ImportKey.

Attack Path

  1. Craft a malicious manifest The attacker serves an HLS manifest from their origin whose #EXT-X-KEY points at a cross-origin victim URL that returns no CORS headers.
  2. Trigger the key fetch When the player reaches the encrypted segment it fetches the key; the response is opaque so would_taint_origin() is true and the parser marked it KeyLocation::kUnsafeOrigin.
  3. Pre-patch import Before the fix the code called enc_data->ImportKey(stream->AsString()) on those cross-origin bytes and attempted decryption.
  4. Observe the oracle By varying the key URL and watching decryption success/failure or timing, the attacker infers bytes of the cross-origin resource (side-channel information disclosure, the impact the advisory states as low severity).

Impact Assessment

An attacker-controlled page can use the HLS key-fetch path as a cross-origin read side channel, inferring content of resources the origin is not permitted to read; this runs in the renderer/media pipeline and requires the victim to play attacker-chosen HLS content. The vendor advisory rates this low severity and describes it as side-channel information leakage in Media; no exploitation-in-the-wild status is stated.

Changed Functions

FunctionChangeNotes
if
media/filters/hls_network_access_impl_unittest.cc
modified
BindOnce
media/filters/hls_network_access_impl_unittest.cc
modified
TEST_F
media/filters/hls_network_access_impl_unittest.cc
modified

Files Changed

  • media/filters/hls_network_access_impl.cc
  • media/filters/hls_network_access_impl_unittest.cc

Audit Directions

  • Cross-origin resource reuse
    Audit any media/network path that fetches an auxiliary resource (keys, init segments, sidecar data) and consumes its bytes without re-checking would_taint_origin() or a recorded origin classification.
  • Origin metadata computed-but-unused
    Where a parser records an origin/safety tag (like KeyLocation) at parse time, verify every consumer actually branches on it before trusting the data.
  • Decryption/compare oracles
    Look for success/failure or timing differences that depend on opaque cross-origin bytes, which can leak content even when the bytes are never surfaced directly.
From db46e35db798aeee6cea733fcb31f5d8680d6416 Mon Sep 17 00:00:00 2001
From: Ted Meyer <[email protected]>
Date: Thu, 02 Apr 2026 11:23:13 -0700
Subject: [PATCH] Restrict origins for HLS keys

HLS encrypted content should not be able to use a key which was loaded
from a source that is "insecure". A key-source is considered secure when
it meets one of these requirements:
 - it is not a cross-origin key (hosted on the same domain as the frame
   in which it is being accessed)
 - it is a data:// url (fully embedded in the manifest)
 - it is on the same domain as the manifest from which it is loaded.
 - it has the proper Access-Control-Allow-Origin header for the top
   frame in which the media is being played.

Bug: 497722578
Change-Id: Id54803fc48b8417ab31e1041f15f0e3acb6225b6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7718533
Commit-Queue: Ted (Chromium) Meyer <[email protected]>
Reviewed-by: Dale Curtis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1609376}
---

diff --git a/media/filters/hls_network_access_impl.cc b/media/filters/hls_network_access_impl.cc
index 9af1324..3d1bdb5 100644
--- a/media/filters/hls_network_access_impl.cc
+++ b/media/filters/hls_network_access_impl.cc
@@ -53,7 +53,18 @@
     std::move(cb).Run(std::move(result).error().AddHere());
     return;
   }
+
   auto stream = std::move(result).value();
+  if (stream->would_taint_origin() &&
+      enc_data->GetKeyLocation() ==
+          hls::MediaSegment::EncryptionData::KeyLocation::kUnsafeOrigin) {
+    // Do not accept keys which would taint the origin, unless it is on the same
+    // origin as the manifest which includes the key.
+    std::move(cb).Run({HlsDataSourceProvider::ReadStatus::Codes::kError,
+                       "insecure key request"});
+    return;
+  }
+
   enc_data->ImportKey(stream->AsString());
   if (enc_data->NeedsKeyFetch()) {
     std::move(cb).Run({HlsDataSourceProvider::ReadStatus::Codes::kError,
diff --git a/media/filters/hls_network_access_impl_unittest.cc b/media/filters/hls_network_access_impl_unittest.cc
index 895763e41..8cfd990 100644
--- a/media/filters/hls_network_access_impl_unittest.cc
+++ b/media/filters/hls_network_access_impl_unittest.cc
@@ -15,11 +15,6 @@
 
 namespace {
 
-enum class KeyMode {
-  kPresent,
-  kAbsent,
-};
-
 enum class InitMode {
   kPresent,
   kAbsent,
@@ -61,20 +56,25 @@
       std::optional<std::tuple<uint64_t, uint64_t>> byte_range,
       std::optional<std::tuple<uint64_t, uint64_t>> init_br,
       InitMode init_mode = InitMode::kAbsent,
-      KeyMode key_mode = KeyMode::kAbsent) {
+      std::optional<std::string> key_location = std::nullopt) {
     scoped_refptr<hls::MediaSegment::InitializationSegment> init = nullptr;
     scoped_refptr<hls::MediaSegment::EncryptionData> enc_data = nullptr;
     if (init_mode == InitMode::kPresent) {
       init = base::MakeRefCounted<hls::MediaSegment::InitializationSegment>(
           GURL("https://foo.com"), ByteRangeFromTuple(init_br));
     }
-    if (key_mode == KeyMode::kPresent) {
+    auto manifest_uri = GURL("https://example.com");
+    if (key_location.has_value()) {
+      auto key_uri = GURL(*key_location);
       enc_data = base::MakeRefCounted<hls::MediaSegment::EncryptionData>(
-          GURL("https://example.com/enc.key"), hls::XKeyTagMethod::kAES128,
-          hls::XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 0));
+          GURL(*key_location), hls::XKeyTagMethod::kAES128,
+          hls::XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 0),
+          key_uri.host() == manifest_uri.host()
+              ? hls::MediaSegment::EncryptionData::KeyLocation::kSafeOrigin
+              : hls::MediaSegment::EncryptionData::KeyLocation::kUnsafeOrigin);
     }
     return base::MakeRefCounted<hls::MediaSegment>(
-        base::Seconds(1), 0, 0, GURL("https://example.com"), std::move(init),
+        base::Seconds(1), 0, 0, manifest_uri, std::move(init),
         std::move(enc_data), ByteRangeFromTuple(byte_range), std::nullopt,
         false, false, init_mode == InitMode::kPresent, false);
   }
@@ -131,7 +131,7 @@
   factory_->AddReadExpectation(1000, 16384, 0);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -153,7 +153,7 @@
   factory_->AddReadExpectation(49153, 16384, 0);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -177,7 +177,7 @@
   factory_->PregenerateNextMock();
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -202,7 +202,7 @@
   factory_->PregenerateNextMock();
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -220,7 +220,7 @@
   factory_->AddReadExpectation(100, 100, 100);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -244,7 +244,7 @@
   factory_->AddReadExpectation(98404, 1696, 1696);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -262,7 +262,7 @@
   factory_->AddReadExpectation(100, 16384, 16384);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/true, /*include_init=*/true,
+      *segment, /*read_chunked=*/true, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -276,7 +276,7 @@
 
 TEST_F(HlsNetworkAccessImplUnittest, TestSegmentWithKey) {
   auto segment = MakeSegment(std::nullopt, std::nullopt, InitMode::kAbsent,
-                             KeyMode::kPresent);
+                             "https://example.com/enc.key");
 
   // This actually has to be 16 non-zero bytes.
   auto* ds_for_keyfetch = factory_->PregenerateNextMock();
@@ -289,6 +289,8 @@
       });
   EXPECT_CALL(*ds_for_keyfetch, Read(16, SpanSizeEq(16384), _))
       .WillOnce(base::test::RunOnceCallback<2>(0));
+  EXPECT_CALL(*ds_for_keyfetch, WouldTaintOrigin())
+      .WillRepeatedly(testing::Return(true));
 
   // Then expect media content to be read.
   factory_->AddReadExpectation(0, 16384, 1000);
@@ -297,7 +299,7 @@
   ASSERT_NE(segment->GetEncryptionData(), nullptr);
   ASSERT_TRUE(segment->GetEncryptionData()->NeedsKeyFetch());
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce(
           [&](scoped_refptr<hls::MediaSegment> segment,
               HlsDataSourceProvider::ReadResult result) {
@@ -314,4 +316,43 @@
   task_environment_.RunUntilIdle();
 }
 
+TEST_F(HlsNetworkAccessImplUnittest, TestSegmentWithCORSKey) {
+  auto segment = MakeSegment(std::nullopt, std::nullopt, InitMode::kAbsent,
+                             "https://example.net/enc.key");
+
+  // This actually has to be 16 non-zero bytes.
+  auto* ds_for_keyfetch = factory_->PregenerateNextMock();
+  EXPECT_CALL(*ds_for_keyfetch, Initialize)
+      .WillOnce(base::test::RunOnceCallback<0>(true));
+  EXPECT_CALL(*ds_for_keyfetch, Read(0, SpanSizeEq(16384), _))
+      .WillOnce([](int64_t, base::span<uint8_t> data, DataSource::ReadCB cb) {
+        std::ranges::fill(data.first<16>(), 'x');
+        std::move(cb).Run(16);
+      });
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/filters/hls_network_access_impl_unittest.cc b/media/filters/hls_network_access_impl_unittest.cc
index 895763e41..8cfd990 100644
--- a/media/filters/hls_network_access_impl_unittest.cc
+++ b/media/filters/hls_network_access_impl_unittest.cc
@@ -15,11 +15,6 @@
 
 namespace {
 
-enum class KeyMode {
-  kPresent,
-  kAbsent,
-};
-
 enum class InitMode {
   kPresent,
   kAbsent,
@@ -61,20 +56,25 @@
       std::optional<std::tuple<uint64_t, uint64_t>> byte_range,
       std::optional<std::tuple<uint64_t, uint64_t>> init_br,
       InitMode init_mode = InitMode::kAbsent,
-      KeyMode key_mode = KeyMode::kAbsent) {
+      std::optional<std::string> key_location = std::nullopt) {
     scoped_refptr<hls::MediaSegment::InitializationSegment> init = nullptr;
     scoped_refptr<hls::MediaSegment::EncryptionData> enc_data = nullptr;
     if (init_mode == InitMode::kPresent) {
       init = base::MakeRefCounted<hls::MediaSegment::InitializationSegment>(
           GURL("https://foo.com"), ByteRangeFromTuple(init_br));
     }
-    if (key_mode == KeyMode::kPresent) {
+    auto manifest_uri = GURL("https://example.com");
+    if (key_location.has_value()) {
+      auto key_uri = GURL(*key_location);
       enc_data = base::MakeRefCounted<hls::MediaSegment::EncryptionData>(
-          GURL("https://example.com/enc.key"), hls::XKeyTagMethod::kAES128,
-          hls::XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 0));
+          GURL(*key_location), hls::XKeyTagMethod::kAES128,
+          hls::XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 0),
+          key_uri.host() == manifest_uri.host()
+              ? hls::MediaSegment::EncryptionData::KeyLocation::kSafeOrigin
+              : hls::MediaSegment::EncryptionData::KeyLocation::kUnsafeOrigin);
     }
     return base::MakeRefCounted<hls::MediaSegment>(
-        base::Seconds(1), 0, 0, GURL("https://example.com"), std::move(init),
+        base::Seconds(1), 0, 0, manifest_uri, std::move(init),
         std::move(enc_data), ByteRangeFromTuple(byte_range), std::nullopt,
         false, false, init_mode == InitMode::kPresent, false);
   }
@@ -131,7 +131,7 @@
   factory_->AddReadExpectation(1000, 16384, 0);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -153,7 +153,7 @@
   factory_->AddReadExpectation(49153, 16384, 0);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -177,7 +177,7 @@
   factory_->PregenerateNextMock();
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -202,7 +202,7 @@
   factory_->PregenerateNextMock();
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -220,7 +220,7 @@
   factory_->AddReadExpectation(100, 100, 100);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -244,7 +244,7 @@
   factory_->AddReadExpectation(98404, 1696, 1696);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -262,7 +262,7 @@
   factory_->AddReadExpectation(100, 16384, 16384);
 
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/true, /*include_init=*/true,
+      *segment, /*read_chunked=*/true, /*include_init_segment=*/true,
       base::BindOnce([&](HlsDataSourceProvider::ReadResult result) {
         ASSERT_TRUE(result.has_value());
         auto stream = std::move(result).value();
@@ -276,7 +276,7 @@
 
 TEST_F(HlsNetworkAccessImplUnittest, TestSegmentWithKey) {
   auto segment = MakeSegment(std::nullopt, std::nullopt, InitMode::kAbsent,
-                             KeyMode::kPresent);
+                             "https://example.com/enc.key");
 
   // This actually has to be 16 non-zero bytes.
   auto* ds_for_keyfetch = factory_->PregenerateNextMock();
@@ -289,6 +289,8 @@
       });
   EXPECT_CALL(*ds_for_keyfetch, Read(16, SpanSizeEq(16384), _))
       .WillOnce(base::test::RunOnceCallback<2>(0));
+  EXPECT_CALL(*ds_for_keyfetch, WouldTaintOrigin())
+      .WillRepeatedly(testing::Return(true));
 
   // Then expect media content to be read.
   factory_->AddReadExpectation(0, 16384, 1000);
@@ -297,7 +299,7 @@
   ASSERT_NE(segment->GetEncryptionData(), nullptr);
   ASSERT_TRUE(segment->GetEncryptionData()->NeedsKeyFetch());
   network_access_->ReadMediaSegment(
-      *segment, /*read_chunked=*/false, /*include_init=*/true,
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
       base::BindOnce(
           [&](scoped_refptr<hls::MediaSegment> segment,
               HlsDataSourceProvider::ReadResult result) {
@@ -314,4 +316,43 @@
   task_environment_.RunUntilIdle();
 }
 
+TEST_F(HlsNetworkAccessImplUnittest, TestSegmentWithCORSKey) {
+  auto segment = MakeSegment(std::nullopt, std::nullopt, InitMode::kAbsent,
+                             "https://example.net/enc.key");
+
+  // This actually has to be 16 non-zero bytes.
+  auto* ds_for_keyfetch = factory_->PregenerateNextMock();
+  EXPECT_CALL(*ds_for_keyfetch, Initialize)
+      .WillOnce(base::test::RunOnceCallback<0>(true));
+  EXPECT_CALL(*ds_for_keyfetch, Read(0, SpanSizeEq(16384), _))
+      .WillOnce([](int64_t, base::span<uint8_t> data, DataSource::ReadCB cb) {
+        std::ranges::fill(data.first<16>(), 'x');
+        std::move(cb).Run(16);
+      });
+  EXPECT_CALL(*ds_for_keyfetch, Read(16, SpanSizeEq(16384), _))
+      .WillOnce(base::test::RunOnceCallback<2>(0));
+  EXPECT_CALL(*ds_for_keyfetch, WouldTaintOrigin())
+      .WillRepeatedly(testing::Return(true));
+
+  // Then expect media content to be read.
+  factory_->AddReadExpectation(0, 16384, 1000);
+  factory_->AddReadExpectation(1000, 16384, 0);
+
+  ASSERT_NE(segment->GetEncryptionData(), nullptr);
+  ASSERT_TRUE(segment->GetEncryptionData()->NeedsKeyFetch());
+  network_access_->ReadMediaSegment(
+      *segment, /*read_chunked=*/false, /*include_init_segment=*/true,
+      base::BindOnce(
+          [&](scoped_refptr<hls::MediaSegment> segment,
+              HlsDataSourceProvider::ReadResult result) {
+            // The key was hosted on example.net while the manifest is hosted
+            // on example.com. The example.net request did not provide a
+            // Access-Control-Allow-Origin header in it's request, so the use
+            // of the key is blocked, and the segment cannot be decrypted.
+            ASSERT_FALSE(result.has_value());
+          },
+          segment));
+  task_environment_.RunUntilIdle();
+}
+
 }  // namespace media
diff --git a/media/formats/hls/media_playlist_unittest.cc b/media/formats/hls/media_playlist_unittest.cc
index 505f656e..a8f97424 100644
--- a/media/formats/hls/media_playlist_unittest.cc
+++ b/media/formats/hls/media_playlist_unittest.cc
@@ -1494,7 +1494,8 @@
   builder.ExpectSegment(
       HasEncryptionData,
       std::make_tuple(GURL("http://localhost/enc.key"), XKeyTagMethod::kAES128,
-                      XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 0x42)));
+                      XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 0x42),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
   builder.ExpectOk();
 
   builder.AppendLine("#EXT-X-KEY:METHOD=NONE");
@@ -1516,7 +1517,8 @@
   builder.ExpectSegment(
       HasEncryptionData,
       std::make_tuple(GURL("http://localhost/enc.key"), XKeyTagMethod::kAES128,
-                      XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 3)));
+                      XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 3),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
   builder.ExpectOk();
 
   builder.AppendLine("#EXTINF:1.600000,");
@@ -1527,7 +1529,8 @@
   builder.ExpectSegment(
       HasEncryptionData,
       std::make_tuple(GURL("http://localhost/enc.key"), XKeyTagMethod::kAES128,
-                      XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 4)));
+                      XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 4),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
   builder.ExpectOk();
 
   builder.AppendLine(
@@ -1541,7 +1544,8 @@
       HasEncryptionData,
       std::make_tuple(GURL("http://localhost/enc.key"),
                       XKeyTagMethod::kSampleAES, XKeyTagKeyFormat::kIdentity,
-                      std::make_tuple(0, 5)));
+                      std::make_tuple(0, 5),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
   builder.ExpectOk();
 
   builder.AppendLine(
@@ -1556,7 +1560,74 @@
       HasEncryptionData,
       std::make_tuple(GURL("http://localhost/enc.key"),
                       XKeyTagMethod::kSampleAESCTR, XKeyTagKeyFormat::kIdentity,
-                      std::make_tuple(0, 6)));
+                      std::make_tuple(0, 6),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
+  builder.ExpectOk();
+
+  builder.AppendLine(
+      "#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI=\"https://example.com/"
+      "key\",KEYFORMAT="
+      "\"identity\"");
+  builder.AppendLine("#EXTINF:1.60000,");
+  builder.AppendLine("data06.ts");
+  builder.ExpectAdditionalSegment();
+  builder.ExpectSegment(HasUri, GURL("http://localhost/data06.ts"));
+  builder.ExpectSegment(HasMediaSequenceNumber, 7);
+  builder.ExpectSegment(
+      HasEncryptionData,
+      std::make_tuple(
+          GURL("https://example.com/key"), XKeyTagMethod::kSampleAESCTR,
+          XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 7),
+          MediaSegment::EncryptionData::KeyLocation::kUnsafeOrigin));
+  builder.ExpectOk();
+
+  builder.AppendLine(
+      "#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI=\"data:text/"
+      "plain;base64,SGVsbG8sIFdvcmxkIQ==\",KEYFORMAT="
+      "\"identity\"");
+  builder.AppendLine("#EXTINF:1.60000,");
+  builder.AppendLine("data06.ts");
+  builder.ExpectAdditionalSegment();
+  builder.ExpectSegment(HasUri, GURL("http://localhost/data06.ts"));
+  builder.ExpectSegment(HasMediaSequenceNumber, 8);
+  builder.ExpectSegment(
+      HasEncryptionData,
+      std::make_tuple(GURL("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="),
+                      XKeyTagMethod::kSampleAESCTR, XKeyTagKeyFormat::kIdentity,
+                      std::make_tuple(0, 8),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
+  builder.ExpectOk();
+
+  builder.AppendLine(
+      "#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI=\"//keyhost/key\","
+      "KEYFORMAT=\"identity\"");
+  builder.AppendLine("#EXTINF:1.60000,");
+  builder.AppendLine("data07.ts");
+  builder.ExpectAdditionalSegment();
+  builder.ExpectSegment(HasUri, GURL("http://localhost/data07.ts"));
+  builder.ExpectSegment(HasMediaSequenceNumber, 9);
+  builder.ExpectSegment(
+      HasEncryptionData,
+      std::make_tuple(
+          GURL("http://keyhost/key"), XKeyTagMethod::kSampleAESCTR,
+          XKeyTagKeyFormat::kIdentity, std::make_tuple(0, 9),
+          MediaSegment::EncryptionData::KeyLocation::kUnsafeOrigin));
+  builder.ExpectOk();
+
+  builder.AppendLine(
+      "#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI=\"/keypath/key\","
+      "KEYFORMAT=\"identity\"");
+  builder.AppendLine("#EXTINF:1.60000,");
+  builder.AppendLine("data08.ts");
+  builder.ExpectAdditionalSegment();
+  builder.ExpectSegment(HasUri, GURL("http://localhost/data08.ts"));
+  builder.ExpectSegment(HasMediaSequenceNumber, 10);
+  builder.ExpectSegment(
+      HasEncryptionData,
+      std::make_tuple(GURL("http://localhost/keypath/key"),
+                      XKeyTagMethod::kSampleAESCTR, XKeyTagKeyFormat::kIdentity,
+                      std::make_tuple(0, 10),
+                      MediaSegment::EncryptionData::KeyLocation::kSafeOrigin));
   builder.ExpectOk();
 }
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Cross-origin response length and verification oracle via HLS EXT-X-KEY

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The HLS demuxer fetches EXT-X-KEY URIs with credentials by default when the crossorigin attribute is omitted from a video element, processing the opaque response as an AES key. This creates observable side channels through decryption error events, potentially allowing attackers to leak the length of cross-origin responses or verify guesses of 16/32-byte secrets.

Affected files:

  • media/formats/hls/media_playlist.cc
  • media/filters/hls_network_access_impl.cc
  • media/formats/hls/media_segment.cc
  • third_party/blink/renderer/platform/media/web_media_player_impl.cc

Estimated timestamp from git blame: 2025-09-03

The built-in HLS demuxer in Chromium contains a potential vulnerability that allows for cross-origin information leaks. When processing an #EXT-X-KEY tag in an HLS manifest, the demuxer resolves the URI and fetches the key using the <video> element’s CORS mode.

If the crossorigin attribute is not specified on the video element, the mode defaults to RequestMode::kNoCors with CredentialsMode::kInclude. Consequently, requests for the AES key will include SameSite=None cookies. If the response is not blocked by Opaque Response Blocking (ORB)—for instance, if it lacks a protected Content-Type and nosniff header—the raw bytes of the opaque response body are passed directly to the C++ media stack and used as an AES key (MediaSegment::EncryptionData::ImportKey).

This behavior creates two potential side-channel oracles observable via the <video> element’s JavaScript error event:

  1. Length Oracle: In media/formats/hls/media_segment.cc, KeyIsValidSize() requires the key to be exactly 16 or 32 bytes. If the cross-origin response is any other length, the demuxer immediately fails (kFailedToDecryptSegment), triggering an observable error event.
  2. Verification Oracle: If the length is exactly 16 or 32 bytes, the implementation attempts to decrypt the segment using crypto::aes_cbc::Decrypt. This function relies on BoringSSL’s EVP_DecryptFinal_ex2, which enforces PKCS#7 padding validation. Because the attacker controls the ciphertext (the segment data) and the IV, they can craft a ciphertext that decrypts to valid PKCS#7 padding only if their guessed key matches the actual cross-origin response. An incorrect guess results in invalid padding ~99.6% of the time, throwing a decryption error and an observable <video> error event. A correct guess succeeds the padding check, altering the observable state.

Potential Attacker Steps

Note: These are suggested steps based on static code analysis; our tooling agent does not yet have the ability to run or verify exploit code.

  1. An attacker creates a webpage hosting a <video> element without a crossorigin attribute.
  2. The video source points to an attacker-controlled HLS playlist (.m3u8).
  3. The playlist contains an #EXT-X-KEY tag pointing to a target cross-origin endpoint (e.g., http://victim.com/secret).
  4. The playlist points to an attacker-crafted encrypted media segment.
  5. The attacker uses JavaScript to monitor the <video> element for error events.
  6. By repeatedly changing the video source to use different crafted ciphertexts, the attacker can use the padding validation as a 1-bit verification oracle to systematically brute-force 16- or 32-byte secrets stored on the cross-origin endpoint.

Suggested Fix

Cryptographic key material should never be sourced from an opaque, cross-origin response.

To fix this, the HLS implementation should verify that the response used for #EXT-X-KEY does not taint the origin. In media/filters/hls_network_access_impl.cc (OnKeyFetch), check if the HlsDataSourceStream was flagged with WouldTaintOrigin(). If it was, the key fetch should be rejected and an error propagated. Alternatively, enforce that #EXT-X-KEY fetches strictly require a successful CORS preflight/response, regardless of the media element’s overall crossorigin state.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from 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
Links in the report