CVE-2026-6308
Overview
Background
- `MediaSessionImpl`
- The browser-process object (in
content/browser/media/session/) that aggregates a page’s media players and services media-control actions routed over Mojo. - `normal_players_`
- A container of the session’s currently active “normal” media players; several control actions are only meaningful when it holds exactly one entry.
- `DCHECK_EQ`
- A debug-only assertion that is compiled out in release builds, so a condition it guards is not enforced in shipping Chrome.
- Asynchronous Mojo dispatch
- Media-control actions arrive as async Mojo IPCs, so the player count can change between when an action is offered/queued and when it actually executes.
Root Cause Analysis
MediaSessionImpl::SetMute and MediaSessionImpl::RequestMediaRemoting (in content/browser/media/session/media_session_impl.cc) guarded their single-player assumption only with DCHECK_EQ(normal_players_.size(), 1u) and then immediately dereferenced normal_players_.begin()->first.... Because DCHECK is stripped from release builds, the dereference ran unconditionally in shipping Chrome, so if the player count was 0 the code dereferenced begin() of an empty container (an invalid/end iterator), and if it was 2+ it operated on an arbitrary first element—violating the invariant that exactly one normal player exists when these actions run. As the commit message explains, async Mojo means an action legitimately requested with one normal player can execute once there are 0 or 2+, so the assertion was an incorrect substitute for a runtime check.
The fix replaces each DCHECK_EQ with a real if (normal_players_.size() != 1u) { return; } early return that executes in all build configurations before the dereference. The first hunk (the enter-picture-in-picture path, named only by its comment, not in the hunk) already had the runtime if (... != 1u) return; guard, so the patch there merely removes the now-redundant DCHECK_EQ that could still crash debug builds.
DCHECK that disappears in release builds, leaving normal_players_.begin() to be dereferenced on an empty or multi-element container; the fix substitutes an always-present runtime early return.Attack Path
- Set up one normal player
A malicious or cooperating page creates a media session state in which exactly one normal player exists, making the
SetMute/RequestMediaRemotingaction available. - Issue the control action The action is dispatched to the browser process over async Mojo while the single-player precondition still holds.
- Race the player count
Before the action runs, the page adds or removes players (via further async operations) so that by execution time
normal_players_.size()is 0 (or 2+). - Out-of-bounds dereference
In a release build the removed
DCHECKno longer applies andnormal_players_.begin()->first.observer->...reads from an empty/invalid iterator, producing the out-of-bounds read reported by the advisory.
Impact Assessment
normal_players_.begin() on an empty container, giving an out-of-bounds read in the browser process (MediaSessionImpl runs browser-side), with the precondition of being able to drive media-session state and win the async timing. The vendor advisory rates this high severity and classifies it as an out of bounds read in Media; no exploited-in-the-wild status is stated. Any escalation beyond the OOB read (information disclosure or crash leveraged further) is (inferred) and not demonstrated by the diff.Files Changed
content/browser/media/session/media_session_impl.cc
Audit Directions
- `DCHECK` used as a security preconditionGrep
MediaSessionImpland other Mojo-facing browser code for aDCHECK/DCHECK_EQimmediately followed by a dereference (begin()->,front(),[0]); each needs an always-compiled runtime guard. - TOCTOU across async MojoReview handlers whose preconditions (sizes, states, existence of an element) are validated when an action is offered but may change by the time the async IPC executes; re-validate at execution time.
- Empty-container `begin()`/`front()` dereferencesAudit code that indexes “the one” element of a collection for paths where the collection can legitimately become empty or hold multiple elements under racing updates.
Patch
From 41bfbc009df8336d2c813cd3963e75a28a022e43 Mon Sep 17 00:00:00 2001 From: Tommy Steimel <[email protected]> Date: Tue, 31 Mar 2026 16:11:55 -0700 Subject: [PATCH] [Media Session] Don't assume there is still 1 normal player There are some actions in MediaSessionImpl that are only available when there is exactly 1 normal player, so when they're called, there's a DCHECK that we do in fact have 1 normal player. However, since Mojo calls are asynchronous, it's possible for one of these actions to be legitimately called with 1 normal player, but by the time it runs there are either 0 or 2+ normal players. This CL changes these instances to no longer DCHECK that there is 1 normal player and instead just early return if there isn't. Bug: 497412658 Change-Id: I0fdf3c6779c224db996091b2fd463bc3cb9464f3 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7719021 Reviewed-by: Benjamin Keen <[email protected]> Commit-Queue: Tommy Steimel <[email protected]> Cr-Commit-Position: refs/heads/main@{#1608166} --- diff --git a/content/browser/media/session/media_session_impl.cc b/content/browser/media/session/media_session_impl.cc index 542bd7e..24115ab 100644 --- a/content/browser/media/session/media_session_impl.cc +++ b/content/browser/media/session/media_session_impl.cc @@ -1290,7 +1290,6 @@ return; } - DCHECK_EQ(normal_players_.size(), 1u); if (normal_players_.size() != 1u) { // There should be one and only one player when we enter picture-in-picture. return; @@ -1355,13 +1354,23 @@ } void MediaSessionImpl::SetMute(bool mute) { - DCHECK_EQ(normal_players_.size(), 1u); + // The SetMute action should only be available when there is one normal + // player, though due to the asynchronous nature of mojo, we may no longer + // have 1 normal player. In that case, just return. + if (normal_players_.size() != 1u) { + return; + } normal_players_.begin()->first.observer->OnSetMute( normal_players_.begin()->first.player_id, mute); } void MediaSessionImpl::RequestMediaRemoting() { - DCHECK_EQ(normal_players_.size(), 1u); + // The RequestMediaRemoting action should only be available when there is one + // normal player, though due to the asynchronous nature of mojo, we may no + // longer have 1 normal player. In that case, just return. + if (normal_players_.size() != 1u) { + return; + } normal_players_.begin()->first.observer->OnRequestMediaRemoting( normal_players_.begin()->first.player_id); }
Original Bug Report
Potential RCE in Browser Process via out-of-bounds read in MediaSessionImpl
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: MediaSessionImpl::SetMute and RequestMediaRemoting lack bounds checking on the normal_players_ map in release builds. Dereferencing an empty map iterator causes an out-of-bounds read that perfectly aligns with the capacity pointer of the adjacent one_shot_players_ vector. This allows an attacker to hijack a virtual function call on an unprotected raw pointer, potentially achieving remote code execution in the browser process.
Affected files:
content/browser/media/session/media_session_impl.cccontent/browser/media/session/media_session_impl.h
Estimated timestamp from git blame: 2022-11-18
Vulnerability Summary
A potential out-of-bounds read exists in MediaSessionImpl::SetMute and MediaSessionImpl::RequestMediaRemoting within the browser process. Both methods rely on a DCHECK to ensure the normal_players_ map is not empty before dereferencing its begin() iterator. In release builds where DCHECK is compiled out, an empty map dereference causes an out-of-bounds memory read into the adjacent class member. This read perfectly aligns with a vector’s capacity pointer, allowing an attacker to supply a fake object and hijack a virtual function call to achieve a full sandbox escape (Browser RCE).
Root Cause Analysis
In content/browser/media/session/media_session_impl.cc, SetMute and RequestMediaRemoting are implemented as follows:
void MediaSessionImpl::SetMute(bool mute) {
DCHECK_EQ(normal_players_.size(), 1u);
normal_players_.begin()->first.observer->OnSetMute(
normal_players_.begin()->first.player_id, mute);
}
Unlike EnterPictureInPicture which has an explicit runtime check (if (normal_players_.size() != 1u) return;), these methods do not check the container size in release builds.
normal_players_ is a std::map<PlayerIdentifier, AudioFocusType>. In libc++, std::map (via __tree) has a size of 24 bytes on 64-bit systems. When the map is empty, begin() returns an iterator pointing to the internal sentinel node (__end_node_), which sits at offset 8 within the std::map object.
Dereferencing the iterator (operator->) to access first expects a full __tree_node, where the value payload is stored 32 bytes past the node pointer. Thus, the read occurs at 8 + 32 = 40 bytes relative to the start of normal_players_.
Since normal_players_ is 24 bytes, this reads exactly 16 bytes past the end of the map. In MediaSessionImpl, the very next member is one_shot_players_ (a base::flat_set, backed by std::vector). A std::vector has its __cap_ (capacity) pointer at offset 16. Therefore, the out-of-bounds read fetches the one_shot_players_ capacity pointer.
The payload’s first member is MediaSessionPlayerObserver* observer inside the PlayerIdentifier struct. Crucially, this pointer is annotated with RAW_PTR_EXCLUSION, meaning it is completely unprotected by MiraclePtr/BackupRefPtr. The code then uses this out-of-bounds pointer for a virtual function call (observer->OnSetMute(...)).
Potential Exploitation Steps
Note: These are suggested steps based on static analysis, as our tooling agent cannot yet run live exploit code.
- Heap Grooming: An attacker creates a malicious page that adds exactly enough
MediaStreamplayers (via WebRTC/getUserMedia) to populate theone_shot_players_vector such that its backing buffer sits adjacent to an attacker-controlled ArrayBuffer on the PartitionAlloc heap. The attacker places a fakeMediaSessionPlayerObservervtable at the very beginning of this ArrayBuffer. - State Setup: The page plays standard
<video>media, populatingnormal_players_and activating the Global Media Controls (GMC) UI in the browser toolbar. - Race Condition Trigger: The attacker tricks the user into clicking the “Mute” button in the GMC UI (e.g., by playing an annoying looping sound and highlighting the browser UI).
- Execution:
- The UI click asynchronously dispatches a
SetMuteMojo message to the browser’s UI thread. - Exactly at the same time, the attacker’s JavaScript synchronously removes the
<video>element, sending an IPC that clearsnormal_players_on the UI thread. - The
SetMuteMojo message is processed shortly after, invokingMediaSessionImpl::SetMutewith an empty map. - The empty map dereference reads the
one_shot_players_capacity pointer (__cap_). __cap_points exactly to the end of the vector’s heap buffer—which is the start of the attacker’s groomed ArrayBuffer containing the fake vtable.observer->OnSetMute()uses the fake vtable, hijacking control flow and resulting in arbitrary Remote Code Execution in the browser process.
- The UI click asynchronously dispatches a
Recommended Fix
Add the same runtime bounds check used in MediaSessionImpl::EnterPictureInPicture to SetMute and RequestMediaRemoting:
void MediaSessionImpl::SetMute(bool mute) {
if (normal_players_.size() != 1u)
return;
normal_players_.begin()->first.observer->OnSetMute(
normal_players_.begin()->first.player_id, mute);
}
void MediaSessionImpl::RequestMediaRemoting() {
if (normal_players_.size() != 1u)
return;
normal_players_.begin()->first.observer->OnRequestMediaRemoting(
normal_players_.begin()->first.player_id);
}
Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939
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.