CVE-2026-79177
Overview
Background
- `AudibilityBypassTracker`
- Per-document (
DocumentUserData) tracker recording grants that let a media player bypass the browser’s audio service for audibility accounting, used byMediaFoundationRenderer. - `MediaFoundationRenderer`
- A Windows media renderer running in a utility process that bypasses the browser’s audio service, whose creation authorizes the frame for an audibility bypass claim.
- Audibility bypass grant
- Authorization allowing a player to be counted as (in)audible without going through the audio service, which drives the tab audio indicator and power wake-locks.
- `ScopedGrant` / `base::ScopedClosureRunner`
- A move-only RAII holder that runs a revocation closure (
RevokeGrant) when destroyed, tying a grant to an object’s lifetime.
Root Cause Analysis
Pre-patch, AudibilityBypassTracker::AddGrant() merely incremented a document-wide integer pending_grants_, and grants were fungible: CreateMediaFoundationRenderer called AddGrant() but never tied that grant to the renderer’s Mojo pipe lifetime. A compromised renderer could repeatedly invoke CreateMediaFoundationRenderer and immediately drop the pipes, minting an unbounded number of pending grants that persisted in the pool with no owner and no revocation. Any player could then ClaimGrant() from this pool to spoof the tab audio indicator and acquire power wake-locks without actually running an authorized MediaFoundationRenderer.
The fix makes AddGrant() return a move-only ScopedGrant (a base::ScopedClosureRunner bound to RevokeGrant(rfh_id, grant_id)), interposes a MediaFoundationRendererExtensionProxy that owns the ScopedGrant and is a self-owned receiver on the extension pipe, so if either the renderer or the utility process disconnects, the grant is revoked; internally the integer counter becomes a flat_set<int> pending_grants_ and a flat_map<MediaPlayerId,int> active_grants_ keyed by unique grant_id.
MediaFoundationRenderer’s Mojo pipe—storing it as a fungible document-wide counter with no owner—so grants could be minted and abandoned indefinitely; the fix binds each grant to a ScopedGrant owned by a pipe-lifetime-observing proxy that revokes on disconnect.Attack Path
- Compromise a renderer
The attacker controls a renderer process able to issue
CreateMediaFoundationRendererrequests to the browser’sMediaInterfaceProxy. - Mint grants
Repeatedly call
CreateMediaFoundationRenderer; each call invokesAudibilityBypassTracker::AddGrant(), incrementing the document-widepending_grants_counter. - Drop the pipes Immediately drop the renderer/extension Mojo pipes; pre-patch there was no disconnect handler, so the pending grants remained in the pool unrevoked.
- Claim spoofed grants
Have arbitrary players call
ClaimGrant()to consume pooled grants, spoofing the tab audio indicator and acquiring power wake-locks without an authorizedMediaFoundationRendereractually playing.
Impact Assessment
BUILDFLAG(IS_WIN)). Preconditions are a compromised renderer able to call CreateMediaFoundationRenderer. The advisory classifies this as incorrect authorization in Media with no exploited-in-the-wild status stated; no memory-safety escalation is claimed.Changed Functions
| Function | Change | Notes |
|---|---|---|
MediaFoundationRendererExtensionProxycontent/browser/media/media_interface_proxy.cc |
modified | |
ifcontent/browser/media/media_interface_proxy.cc |
modified | |
ifcontent/browser/media/media_web_contents_observer.cc |
modified | |
TEST_Fcontent/browser/media/media_web_contents_observer_unittest.cc |
modified |
Files Changed
content/browser/media/media_web_contents_observer.cccontent/browser/media/media_interface_proxy.cccontent/browser/media/media_web_contents_observer_unittest.cccontent/browser/media/media_web_contents_observer.h
Audit Directions
- Grant lifetime vs. pipe lifetimeAudit authorization grants issued on Mojo interface creation that are not tied to that interface’s connection lifetime, especially fungible counters/pools with no per-grant owner.
- Unbounded mint from compromised rendererLook for browser-side state a renderer can increment via repeated IPC without a revocation or cap, where dropping the pipe leaves residual authorization.
- RAII revocation coverageVerify every
ScopedClosureRunner/RAII-guarded capability is actually owned by an object whose destruction is guaranteed on both renderer and utility/service disconnect.
Patch
From 9a158bed51d4160609cbdc6242c338bfcc97e8f3 Mon Sep 17 00:00:00 2001 From: Sangbaek Park <[email protected]> Date: Mon, 06 Jul 2026 17:02:04 -0700 Subject: [PATCH] media: Fix AudibilityBypassTracker authorization spoofing vulnerability This patch addresses a security vulnerability where a compromised renderer could mint an arbitrary number of audibility bypass grants by repeatedly invoking `CreateMediaFoundationRenderer` and dropping the pipes. These grants could then be used by unauthorized players to spoof the tab audio indicator and acquire power wake-locks. The fix resolves this by tightly coupling each audibility bypass grant to the Mojo pipeline lifetime of the specific `MediaFoundationRenderer`. A `MediaFoundationRendererExtensionProxy` now intercepts the extension Mojo pipe and owns a move-only `ScopedGrant`. If either the renderer or the utility process disconnects the pipe, the `ScopedGrant` is destroyed and the bypass authorization is explicitly revoked. By moving away from a fungible document-wide integer pool to a strict RAII lifecycle tied directly to the extension pipe, spoofing attempts are thwarted. Unit tests added: { MediaWebContentsObserverTest.GrantRevokedOnDisconnect } Bug: 520002854 Change-Id: I34d8a8e41f183d5e1a5e148c55a8b8ae0380836a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7932763 Commit-Queue: Sangbaek Park <[email protected]> Reviewed-by: Dale Curtis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1657586} --- diff --git a/content/browser/media/media_interface_proxy.cc b/content/browser/media/media_interface_proxy.cc index b7d670b..7557b2b 100644 --- a/content/browser/media/media_interface_proxy.cc +++ b/content/browser/media/media_interface_proxy.cc @@ -38,6 +38,7 @@ #include "media/mojo/buildflags.h" #include "media/mojo/mojom/frame_interface_factory.mojom.h" #include "media/mojo/mojom/media_service.mojom.h" +#include "media/mojo/mojom/renderer_extensions.mojom.h" #include "mojo/public/cpp/bindings/callback_helpers.h" #include "mojo/public/cpp/bindings/remote_set.h" @@ -294,6 +295,50 @@ #endif // BUILDFLAG(IS_WIN) }; +#if BUILDFLAG(IS_WIN) +// Proxies the MediaFoundationRendererExtension to observe the lifetime of the +// MediaFoundationRenderer in the utility process. When either the renderer +// or the utility process disconnects, the audibility bypass grant is revoked. +class MediaFoundationRendererExtensionProxy + : public media::mojom::MediaFoundationRendererExtension { + public: + MediaFoundationRendererExtensionProxy( + mojo::PendingRemote<media::mojom::MediaFoundationRendererExtension> + target_remote, + AudibilityBypassTracker::ScopedGrant grant) + : target_remote_(std::move(target_remote)), grant_(std::move(grant)) { + target_remote_.set_disconnect_handler(base::BindOnce( + &MediaFoundationRendererExtensionProxy::OnTargetDisconnect, + base::Unretained(this))); + } + + ~MediaFoundationRendererExtensionProxy() override = default; + + // media::mojom::MediaFoundationRendererExtension implementation. + void GetDCOMPSurface(GetDCOMPSurfaceCallback callback) override { + target_remote_->GetDCOMPSurface(std::move(callback)); + } + void SetVideoStreamEnabled(bool enabled) override { + target_remote_->SetVideoStreamEnabled(enabled); + } + void SetOutputRect(const gfx::Rect& rect, + SetOutputRectCallback callback) override { + target_remote_->SetOutputRect(rect, std::move(callback)); + } + + private: + void OnTargetDisconnect() { + // The utility process disconnected (MediaFoundationRenderer destroyed). + // Revoke the grant by resetting it. + grant_.RunAndReset(); + target_remote_.reset(); + } + + mojo::Remote<media::mojom::MediaFoundationRendererExtension> target_remote_; + AudibilityBypassTracker::ScopedGrant grant_; +}; +#endif // BUILDFLAG(IS_WIN) + } // namespace MediaInterfaceProxy::MediaInterfaceProxy(RenderFrameHost* render_frame_host) @@ -439,13 +484,24 @@ // with a CDM path in CreateCdm(). auto* factory = GetMediaFoundationServiceInterfaceFactory(base::FilePath()); if (factory) { - factory->CreateMediaFoundationRenderer( - std::move(media_log_remote), std::move(receiver), - std::move(renderer_extension_receiver)); - // `MediaFoundationRenderer` bypasses the browser's audio service. // Authorize the frame for audibility bypass claims. - AudibilityBypassTracker::AddGrant(&render_frame_host()); + AudibilityBypassTracker::ScopedGrant grant = + AudibilityBypassTracker::AddGrant(&render_frame_host()); + + mojo::PendingRemote<media::mojom::MediaFoundationRendererExtension> + utility_extension_remote; + auto utility_extension_receiver = + utility_extension_remote.InitWithNewPipeAndPassReceiver(); + + mojo::MakeSelfOwnedReceiver( + std::make_unique<MediaFoundationRendererExtensionProxy>( + std::move(utility_extension_remote), std::move(grant)), + std::move(renderer_extension_receiver)); + + factory->CreateMediaFoundationRenderer( + std::move(media_log_remote), std::move(receiver), + std::move(utility_extension_receiver)); } } #endif // BUILDFLAG(IS_WIN) diff --git a/content/browser/media/media_web_contents_observer.cc b/content/browser/media/media_web_contents_observer.cc index 2b7eba1..124085e 100644 --- a/content/browser/media/media_web_contents_observer.cc +++ b/content/browser/media/media_web_contents_observer.cc @@ -6,7 +6,6 @@ #include <algorithm> #include <memory> -#include <set> #include <tuple> #include "base/debug/crash_logging.h" @@ -655,6 +654,14 @@ has_played_before_ = true; } +void MediaWebContentsObserver::OnAudibilityBypassRevoked( + const MediaPlayerId& id) { + auto it = media_player_observer_hosts_.find(id); + if (it != media_player_observer_hosts_.end()) { + it->second->NotifyAudioStreamMonitorIfNeeded(); + } +} + void MediaWebContentsObserver::OnAudioOutputSinkChangedWithRawDeviceId( const MediaPlayerId& player_id, const std::string& raw_device_id) { @@ -821,10 +828,16 @@ AudibilityBypassTracker::~AudibilityBypassTracker() = default; // static -void AudibilityBypassTracker::AddGrant(RenderFrameHost* rfh) { +AudibilityBypassTracker::ScopedGrant AudibilityBypassTracker::AddGrant( + RenderFrameHost* rfh) { if (rfh) { - GetOrCreateForCurrentDocument(rfh)->pending_grants_++; + auto* tracker = GetOrCreateForCurrentDocument(rfh); + int grant_id = ++tracker->next_grant_id_; + tracker->pending_grants_.insert(grant_id); + return ScopedGrant(base::BindOnce(&AudibilityBypassTracker::RevokeGrant, + rfh->GetGlobalId(), grant_id)); } + return ScopedGrant(); } // static @@ -834,12 +847,13 @@ return false; } auto* tracker = GetForCurrentDocument(rfh); - if (tracker && tracker->authorized_players_.contains(id)) { + if (tracker && tracker->active_grants_.contains(id)) { return true; } - if (tracker && tracker->pending_grants_ > 0) { - tracker->pending_grants_--; - tracker->authorized_players_.insert(id); + if (tracker && !tracker->pending_grants_.empty()) { + int grant_id = *tracker->pending_grants_.begin(); + tracker->pending_grants_.erase(tracker->pending_grants_.begin()); + tracker->active_grants_[id] = grant_id; return true; } return false; @@ -853,7 +867,37 @@ } auto* tracker = GetForCurrentDocument(rfh); if (tracker) { - tracker->authorized_players_.erase(id); + tracker->active_grants_.erase(id); + } +} + +// static +void AudibilityBypassTracker::RevokeGrant(GlobalRenderFrameHostId rfh_id, + int grant_id) { + auto* rfh = RenderFrameHost::FromID(rfh_id); + if (!rfh) { + return; + } + auto* tracker = GetForCurrentDocument(rfh); + if (!tracker) { + return; + } + if (tracker->pending_grants_.erase(grant_id)) { + return; + } + for (auto it = tracker->active_grants_.begin(); + it != tracker->active_grants_.end(); ++it) { + if (it->second == grant_id) { + MediaPlayerId id = it->first; + tracker->active_grants_.erase(it); + auto* web_contents = + static_cast<WebContentsImpl*>(WebContents::FromRenderFrameHost(rfh)); + if (web_contents && web_contents->media_web_contents_observer()) { + web_contents->media_web_contents_observer()->OnAudibilityBypassRevoked( + id); + } + break; + } } } diff --git a/content/browser/media/media_web_contents_observer.h b/content/browser/media/media_web_contents_observer.h index c00bd80..654323f 100644 --- a/content/browser/media/media_web_contents_observer.h +++ b/content/browser/media/media_web_contents_observer.h @@ -13,6 +13,8 @@ #include "base/containers/flat_map.h" #include "base/containers/flat_set.h" +#include "base/functional/callback.h" +#include "base/functional/callback_helpers.h" #include "base/memory/raw_ptr.h" #include "base/memory/weak_ptr.h" #include "build/build_config.h" @@ -71,7 +73,9 @@ public: ~AudibilityBypassTracker() override; - static void AddGrant(RenderFrameHost* rfh); + using ScopedGrant = base::ScopedClosureRunner; + + static ScopedGrant AddGrant(RenderFrameHost* rfh); static bool ClaimGrant(const MediaPlayerId& id); static void ReleaseGrant(const MediaPlayerId& id); @@ -80,8 +84,11 @@ explicit AudibilityBypassTracker(RenderFrameHost* rfh); DOCUMENT_USER_DATA_KEY_DECL(); - size_t pending_grants_ = 0; - base::flat_set<MediaPlayerId> authorized_players_; + static void RevokeGrant(GlobalRenderFrameHostId rfh_id, int grant_id); + + int next_grant_id_ = 0; + base::flat_set<int> pending_grants_; + base::flat_map<MediaPlayerId, int> active_grants_; }; // This class manages all RenderFrame based media related managers at the @@ -126,6 +133,9 @@ void RenderFrameHostChanged(RenderFrameHost* old_host, RenderFrameHost* new_host) override; + // Called when an audibility bypass grant is revoked. + void OnAudibilityBypassRevoked(const MediaPlayerId& id); + // MediaPlayerObserverClient implementation. void GetHasPlayedBefore(GetHasPlayedBeforeCallback callback) override; @@ -248,10 +258,10 @@ media_session::mojom::RemotePlaybackMetadataPtr remote_playback_metadata) override; void OnVideoVisibilityChanged(bool meets_visibility_threshold) override; + void NotifyAudioStreamMonitorIfNeeded(); private: PlayerInfo* GetPlayerInfo(); - void NotifyAudioStreamMonitorIfNeeded(); void OnReceivedMediaDeviceSalt( const std::string& hashed_device_id, diff --git a/content/browser/media/media_web_contents_observer_unittest.cc b/content/browser/media/media_web_contents_observer_unittest.cc index 80f57f7f..b0cb6f01 100644 --- a/content/browser/media/media_web_contents_observer_unittest.cc +++ b/content/browser/media/media_web_contents_observer_unittest.cc @@ -363,7 +363,8 @@ auto player = CreateAndAddPlayer(player_host); // Simulate audibility bypass authorization for the document. - AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); + auto grant = + AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); SetMediaMetadata(player.observer, /*has_audio=*/true, /*has_video=*/false); @@ -395,7 +396,7 @@ auto child_player = CreateAndAddPlayer(child_player_host); // Authorize only the main frame. - AudibilityBypassTracker::AddGrant(main_rfh); + auto grant = AudibilityBypassTracker::AddGrant(main_rfh); // Child frame attempts bypass. SetMediaMetadata(child_player.observer, /*has_audio=*/true, @@ -422,7 +423,8 @@ auto original_player = CreateAndAddPlayer(player_host); // Simulate MediaFoundationRenderer creation grant. - AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); + auto grant = + AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); // Original player claims the grant successfully. SetMediaMetadata(original_player.observer, true, false); @@ -456,8 +458,10 @@ auto player_host = SetupPlayerHost(); // Simulate two MediaFoundationRenderer creation grants. - AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); - AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); + auto grant1 = + AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); + auto grant2 = + AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame()); auto player1 = CreateAndAddPlayer(player_host); auto player2 = CreateAndAddPlayer(player_host); @@ -489,5 +493,30 @@ EXPECT_FALSE(IsWebContentsAudible()); } +// This test reproduces the issue where an attacker can mint bypass grants. +// By creating grants and then disconnecting the pipe, the grant should be +// automatically revoked, preventing the bypass from being exploited. +TEST_F(MediaWebContentsObserverTest, GrantRevokedOnDisconnect) { + RenderFrameHost* rfh = contents()->GetPrimaryMainFrame(); + MediaPlayerId player_id(rfh->GetGlobalId(), 1); + + // Simulate a compromised renderer requesting MediaFoundationRenderer + // creation, which mints a new bypass grant. + auto grant = AudibilityBypassTracker::AddGrant(rfh); + + // The player can successfully claim the grant. + EXPECT_TRUE(AudibilityBypassTracker::ClaimGrant(player_id)); + + // In the exploit scenario, the attacker disconnects the dummy Mojo pipe + // but attempts to continue using the grant. + // With the fix, the disconnection automatically calls RevokeGrant. + grant.RunAndReset(); + + // After revocation, the player should be unregistered from the bypass + // authorization. Next time it tries to claim, it should fail. + AudibilityBypassTracker::ReleaseGrant(player_id); + EXPECT_FALSE(AudibilityBypassTracker::ClaimGrant(player_id)); +} + } // namespace } // namespace content
Regression Test / PoC
diff --git a/content/browser/media/media_web_contents_observer_unittest.cc b/content/browser/media/media_web_contents_observer_unittest.cc
index 80f57f7f..b0cb6f01 100644
--- a/content/browser/media/media_web_contents_observer_unittest.cc
+++ b/content/browser/media/media_web_contents_observer_unittest.cc
@@ -363,7 +363,8 @@
auto player = CreateAndAddPlayer(player_host);
// Simulate audibility bypass authorization for the document.
- AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
+ auto grant =
+ AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
SetMediaMetadata(player.observer, /*has_audio=*/true, /*has_video=*/false);
@@ -395,7 +396,7 @@
auto child_player = CreateAndAddPlayer(child_player_host);
// Authorize only the main frame.
- AudibilityBypassTracker::AddGrant(main_rfh);
+ auto grant = AudibilityBypassTracker::AddGrant(main_rfh);
// Child frame attempts bypass.
SetMediaMetadata(child_player.observer, /*has_audio=*/true,
@@ -422,7 +423,8 @@
auto original_player = CreateAndAddPlayer(player_host);
// Simulate MediaFoundationRenderer creation grant.
- AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
+ auto grant =
+ AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
// Original player claims the grant successfully.
SetMediaMetadata(original_player.observer, true, false);
@@ -456,8 +458,10 @@
auto player_host = SetupPlayerHost();
// Simulate two MediaFoundationRenderer creation grants.
- AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
- AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
+ auto grant1 =
+ AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
+ auto grant2 =
+ AudibilityBypassTracker::AddGrant(contents()->GetPrimaryMainFrame());
auto player1 = CreateAndAddPlayer(player_host);
auto player2 = CreateAndAddPlayer(player_host);
@@ -489,5 +493,30 @@
EXPECT_FALSE(IsWebContentsAudible());
}
+// This test reproduces the issue where an attacker can mint bypass grants.
+// By creating grants and then disconnecting the pipe, the grant should be
+// automatically revoked, preventing the bypass from being exploited.
+TEST_F(MediaWebContentsObserverTest, GrantRevokedOnDisconnect) {
+ RenderFrameHost* rfh = contents()->GetPrimaryMainFrame();
+ MediaPlayerId player_id(rfh->GetGlobalId(), 1);
+
+ // Simulate a compromised renderer requesting MediaFoundationRenderer
+ // creation, which mints a new bypass grant.
+ auto grant = AudibilityBypassTracker::AddGrant(rfh);
+
+ // The player can successfully claim the grant.
+ EXPECT_TRUE(AudibilityBypassTracker::ClaimGrant(player_id));
+
+ // In the exploit scenario, the attacker disconnects the dummy Mojo pipe
+ // but attempts to continue using the grant.
+ // With the fix, the disconnection automatically calls RevokeGrant.
+ grant.RunAndReset();
+
+ // After revocation, the player should be unregistered from the bypass
+ // authorization. Next time it tries to claim, it should fail.
+ AudibilityBypassTracker::ReleaseGrant(player_id);
+ EXPECT_FALSE(AudibilityBypassTracker::ClaimGrant(player_id));
+}
+
} // namespace
} // namespace content