Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Media
DescriptionInappropriate implementation in Media
ComponentMedia
Bug ClassLogic Error
Tracker497548558
Fix commit75237fb8ac76 (chromium/src) +130/-28
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
content/browser/media/session/media_session_controller.cc
modified
TEST_F
content/browser/media/session/media_session_controller_unittest.cc
modified

Files Changed

  • content/browser/media/media_web_contents_observer_unittest.cc
  • content/browser/media/session/media_session_controller.cc
  • content/browser/media/session/media_session_controller.h
  • content/browser/media/session/media_session_controller_unittest.cc
  • content/browser/media/session/media_session_impl.cc
  • content/browser/media/session/media_session_impl_service_routing_unittest.cc
From 75237fb8ac7628cb60939fce07b12eeebc460a9f Mon Sep 17 00:00:00 2001
From: Phil Yan <philyan@chromium.org>
Date: Wed, 01 Apr 2026 17:12:16 -0700
Subject: [PATCH] [Media] Prevent user activation bypass on system audio focus resume

When a system event (like audio focus regain after a notification)
triggered a media playback resume, the browser sent a RequestPlay
IPC to the renderer. HTMLMediaElement::RequestPlay() unconditionally
granted a Transient User Activation token to bypass background
playback restrictions in WebMediaPlayerImpl.

Because the system audio resume was indistinguishable from a user
interaction (like pressing a Bluetooth headset button), a malicious
background page could listen for audio focus events to harvest a
free User Activation token without user interaction.

This CL fixes the vulnerability by:
1. Adding a `triggered_by_user` flag to the `RequestPlay` Mojo IPC.
2. Only granting the Transient User Activation token in
   `HTMLMediaElement::RequestPlay` if the request was truly
   user-triggered.
3. Adding a new `UnlockBackgroundPlayback` method to the
   `WebMediaPlayer` interface so that background media playback
   can still resume smoothly without relying on a fake user
   activation token.

TODO: although `HTMLMediaElement::RequestPause` already has a
`triggered_by_user`, it's hardcoded to true in
`MediaSessionController::OnSuspend`. We need to set it dynamically by
plumbing the value from `MediaSessionImpl::OnSuspendInternal`.

Bug: 497548558
Change-Id: I3a86a6aa327b90fa2ff051c4fd07d893f1e4a30a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7714520
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: Alex Gough <ajgo@chromium.org>
Reviewed-by: Dale Curtis <dalecurtis@chromium.org>
Commit-Queue: Phil Yan <philyan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608880}
---

diff --git a/content/browser/media/media_web_contents_observer_unittest.cc b/content/browser/media/media_web_contents_observer_unittest.cc
index a6a8ac2d..10b9d3c 100644
--- a/content/browser/media/media_web_contents_observer_unittest.cc
+++ b/content/browser/media/media_web_contents_observer_unittest.cc
@@ -25,7 +25,7 @@
   }
 
   // media::mojom::MediaPlayer implementation.
-  void RequestPlay() override {}
+  void RequestPlay(bool triggered_by_user) override {}
   void RequestPause(bool triggered_by_user) override {}
   void RequestSeekForward(base::TimeDelta seek_time) override {}
   void RequestSeekBackward(base::TimeDelta seek_time) override {}
diff --git a/content/browser/media/session/media_session_controller.cc b/content/browser/media/session/media_session_controller.cc
index 4f3b326..947b8ae 100644
--- a/content/browser/media/session/media_session_controller.cc
+++ b/content/browser/media/session/media_session_controller.cc
@@ -56,11 +56,11 @@
       ->RequestPause(/*triggered_by_user=*/true);
 }
 
-void MediaSessionController::OnResume(int player_id) {
+void MediaSessionController::OnResume(int player_id, bool triggered_by_user) {
   DCHECK_EQ(player_id_, player_id);
   web_contents_->media_web_contents_observer()
       ->GetMediaPlayerRemote(id_)
-      ->RequestPlay();
+      ->RequestPlay(triggered_by_user);
 }
 
 void MediaSessionController::OnSeekForward(int player_id,
@@ -153,7 +153,7 @@
   if (is_paused_) {
     web_contents_->media_web_contents_observer()
         ->GetMediaPlayerRemote(id_)
-        ->RequestPlay();
+        ->RequestPlay(/*triggered_by_user=*/true);
   }
   web_contents_->media_web_contents_observer()
       ->GetMediaPlayerRemote(id_)
diff --git a/content/browser/media/session/media_session_controller.h b/content/browser/media/session/media_session_controller.h
index e4db1a3..d1067b4a 100644
--- a/content/browser/media/session/media_session_controller.h
+++ b/content/browser/media/session/media_session_controller.h
@@ -59,7 +59,7 @@
 
   // MediaSessionPlayerObserver implementation.
   void OnSuspend(int player_id) override;
-  void OnResume(int player_id) override;
+  void OnResume(int player_id, bool triggered_by_user) override;
   void OnSeekForward(int player_id, base::TimeDelta seek_time) override;
   void OnSeekBackward(int player_id, base::TimeDelta seek_time) override;
   void OnSeekTo(int player_id, base::TimeDelta seek_time) override;
diff --git a/content/browser/media/session/media_session_controller_unittest.cc b/content/browser/media/session/media_session_controller_unittest.cc
index 2db7e2e..4c19d87 100644
--- a/content/browser/media/session/media_session_controller_unittest.cc
+++ b/content/browser/media/session/media_session_controller_unittest.cc
@@ -112,8 +112,9 @@
   }
 
   // media::mojom::MediaPlayer implementation.
-  void RequestPlay() override {
+  void RequestPlay(bool triggered_by_user) override {
     received_play_ = true;
+    received_play_triggered_by_user_ = triggered_by_user;
     run_loop_->Quit();
   }
 
@@ -186,6 +187,9 @@
 
   // Getters used from MediaSessionControllerTest.
   bool received_play() const { return received_play_; }
+  bool received_play_triggered_by_user() const {
+    return received_play_triggered_by_user_;
+  }
 
   PauseRequestType received_pause() const { return received_pause_type_; }
 
@@ -220,6 +224,7 @@
   mojo::AssociatedReceiver<media::mojom::MediaPlayer> receiver_{this};
 
   bool received_play_{false};
+  bool received_play_triggered_by_user_{false};
   double received_volume_multiplier_{0};
   PauseRequestType received_pause_type_{PauseRequestType::kNone};
   base::TimeDelta received_seek_forward_time_;
@@ -318,8 +323,9 @@
     media_player_->WaitUntilReceivedMessage();
   }
 
-  void Resume() {
-    controller_->OnResume(controller_->get_player_id_for_testing());
+  void Resume(bool triggered_by_user = true) {
+    controller_->OnResume(controller_->get_player_id_for_testing(),
+                          triggered_by_user);
     media_player_->WaitUntilReceivedMessage();
   }
 
@@ -354,7 +360,11 @@
   }
 
   // Helpers to check the results of using the basic controls.
-  bool ReceivedMessagePlay() { return media_player_->received_play(); }
+  bool ReceivedMessagePlay(bool triggered_by_user) {
+    return media_player_->received_play() &&
+           media_player_->received_play_triggered_by_user() ==
+               triggered_by_user;
+  }
 
   bool ReceivedMessagePause(bool triggered_by_user) {
     TestMediaPlayer::PauseRequestType expected_pause_request =
@@ -419,7 +429,7 @@
 
   // Likewise verify the resume behavior.
   Resume();
-  EXPECT_TRUE(ReceivedMessagePlay());
+  EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
 
   // ...as well as the seek behavior.
   const base::TimeDelta kTestSeekForwardTime = base::Seconds(1);
@@ -496,7 +506,14 @@
 
   // Likewise verify the resume behavior.
   Resume();
-  EXPECT_TRUE(ReceivedMessagePlay());
+  EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
+
+  Suspend();
+  EXPECT_TRUE(ReceivedMessagePause(/*triggered_by_user=*/true));
+
+  // Verify the system resume behavior.
+  Resume(/*triggered_by_user=*/false);
+  EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/false));
 }
 
 TEST_F(MediaSessionControllerTest, PositionState) {
diff --git a/content/browser/media/session/media_session_impl.cc b/content/browser/media/session/media_session_impl.cc
index 24115ab..7d425bdf 100644
--- a/content/browser/media/session/media_session_impl.cc
+++ b/content/browser/media/session/media_session_impl.cc
@@ -977,8 +977,10 @@
   if (suspend_type == SuspendType::kSystem && suspend_type_ != suspend_type)
     return;
 
+  const bool triggered_by_user = (suspend_type == SuspendType::kUI);
+
   for (const auto& it : normal_players_)
-    it.first.observer->OnResume(it.first.player_id);
+    it.first.observer->OnResume(it.first.player_id, triggered_by_user);
 
   RebuildAndNotifyMediaSessionInfoChanged();
 }
diff --git a/content/browser/media/session/media_session_impl_service_routing_unittest.cc b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
index 404d062..16298fb 100644
--- a/content/browser/media/session/media_session_impl_service_routing_unittest.cc
+++ b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
@@ -64,7 +64,7 @@
   ~MockMediaSessionPlayerObserver() override = default;
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/media/media_web_contents_observer_unittest.cc b/content/browser/media/media_web_contents_observer_unittest.cc
index a6a8ac2d..10b9d3c 100644
--- a/content/browser/media/media_web_contents_observer_unittest.cc
+++ b/content/browser/media/media_web_contents_observer_unittest.cc
@@ -25,7 +25,7 @@
   }
 
   // media::mojom::MediaPlayer implementation.
-  void RequestPlay() override {}
+  void RequestPlay(bool triggered_by_user) override {}
   void RequestPause(bool triggered_by_user) override {}
   void RequestSeekForward(base::TimeDelta seek_time) override {}
   void RequestSeekBackward(base::TimeDelta seek_time) override {}
diff --git a/content/browser/media/session/media_session_controller_unittest.cc b/content/browser/media/session/media_session_controller_unittest.cc
index 2db7e2e..4c19d87 100644
--- a/content/browser/media/session/media_session_controller_unittest.cc
+++ b/content/browser/media/session/media_session_controller_unittest.cc
@@ -112,8 +112,9 @@
   }
 
   // media::mojom::MediaPlayer implementation.
-  void RequestPlay() override {
+  void RequestPlay(bool triggered_by_user) override {
     received_play_ = true;
+    received_play_triggered_by_user_ = triggered_by_user;
     run_loop_->Quit();
   }
 
@@ -186,6 +187,9 @@
 
   // Getters used from MediaSessionControllerTest.
   bool received_play() const { return received_play_; }
+  bool received_play_triggered_by_user() const {
+    return received_play_triggered_by_user_;
+  }
 
   PauseRequestType received_pause() const { return received_pause_type_; }
 
@@ -220,6 +224,7 @@
   mojo::AssociatedReceiver<media::mojom::MediaPlayer> receiver_{this};
 
   bool received_play_{false};
+  bool received_play_triggered_by_user_{false};
   double received_volume_multiplier_{0};
   PauseRequestType received_pause_type_{PauseRequestType::kNone};
   base::TimeDelta received_seek_forward_time_;
@@ -318,8 +323,9 @@
     media_player_->WaitUntilReceivedMessage();
   }
 
-  void Resume() {
-    controller_->OnResume(controller_->get_player_id_for_testing());
+  void Resume(bool triggered_by_user = true) {
+    controller_->OnResume(controller_->get_player_id_for_testing(),
+                          triggered_by_user);
     media_player_->WaitUntilReceivedMessage();
   }
 
@@ -354,7 +360,11 @@
   }
 
   // Helpers to check the results of using the basic controls.
-  bool ReceivedMessagePlay() { return media_player_->received_play(); }
+  bool ReceivedMessagePlay(bool triggered_by_user) {
+    return media_player_->received_play() &&
+           media_player_->received_play_triggered_by_user() ==
+               triggered_by_user;
+  }
 
   bool ReceivedMessagePause(bool triggered_by_user) {
     TestMediaPlayer::PauseRequestType expected_pause_request =
@@ -419,7 +429,7 @@
 
   // Likewise verify the resume behavior.
   Resume();
-  EXPECT_TRUE(ReceivedMessagePlay());
+  EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
 
   // ...as well as the seek behavior.
   const base::TimeDelta kTestSeekForwardTime = base::Seconds(1);
@@ -496,7 +506,14 @@
 
   // Likewise verify the resume behavior.
   Resume();
-  EXPECT_TRUE(ReceivedMessagePlay());
+  EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/true));
+
+  Suspend();
+  EXPECT_TRUE(ReceivedMessagePause(/*triggered_by_user=*/true));
+
+  // Verify the system resume behavior.
+  Resume(/*triggered_by_user=*/false);
+  EXPECT_TRUE(ReceivedMessagePlay(/*triggered_by_user=*/false));
 }
 
 TEST_F(MediaSessionControllerTest, PositionState) {
diff --git a/content/browser/media/session/media_session_impl_service_routing_unittest.cc b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
index 404d062..16298fb 100644
--- a/content/browser/media/session/media_session_impl_service_routing_unittest.cc
+++ b/content/browser/media/session/media_session_impl_service_routing_unittest.cc
@@ -64,7 +64,7 @@
   ~MockMediaSessionPlayerObserver() override = default;
 
   MOCK_METHOD1(OnSuspend, void(int player_id));
-  MOCK_METHOD1(OnResume, void(int player_id));
+  MOCK_METHOD2(OnResume, void(int player_id, bool triggered_by_user));
   MOCK_METHOD2(OnSeekForward, void(int player_id, base::TimeDelta seek_time));
   MOCK_METHOD2(OnSeekBackward, void(int player_id, base::TimeDelta seek_time));
   MOCK_METHOD2(OnSeekTo, void(int player_id, base::TimeDelta seek_time));
diff --git a/content/browser/media/session/media_session_service_impl_browsertest.cc b/content/browser/media/session/media_session_service_impl_browsertest.cc
index 9aa20b3..d4f839d 100644
--- a/content/browser/media/session/media_session_service_impl_browsertest.cc
+++ b/content/browser/media/session/media_session_service_impl_browsertest.cc
@@ -51,7 +51,7 @@
   ~MockMediaSessionPlayerObserver() override = default;
 
   void OnSuspend(int player_id) override {}
-  void OnResume(int player_id) override {}
+  void OnResume(int player_id, bool triggered_by_user) override {}
   void OnSeekForward(int player_id, base::TimeDelta seek_time) override {}
   void OnSeekBackward(int player_id, base::TimeDelta seek_time) override {}
   void OnSeekTo(int player_id, base::TimeDelta seek_time) override {}
diff --git a/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc b/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc
index 1912b8d3..7818495 100644
--- a/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc
+++ b/content/browser/picture_in_picture/picture_in_picture_service_impl_unittest.cc
@@ -134,7 +134,7 @@
   }
 
   // media::mojom::MediaPlayer implementation.
-  void RequestPlay() override {}
+  void RequestPlay(bool triggered_by_user) override {}
   void RequestPause(bool triggered_by_user) override {}
   void RequestSeekForward(base::TimeDelta seek_time) override {}
   void RequestSeekBackward(base::TimeDelta seek_time) override {}
diff --git a/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc b/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
index cd777aee..10306279 100644
--- a/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
+++ b/third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
@@ -1038,6 +1038,20 @@
   EXPECT_FALSE(IsSuspended());
 }
 
+TEST_F(WebMediaPlayerImplTest, UnlockBackgroundPlayback) {
+  InitializeWebMediaPlayerImpl();
+
+  // Page hidden should lock background video playback.
+  delegate_.SetPageHiddenForTesting(true);
+  wmpi_->OnPageHidden();
+  EXPECT_TRUE(IsVideoLockedWhenPausedWhenHidden());
+
+  // UnlockBackgroundPlayback should unlock background video playback without
+  // user activation.
+  wmpi_->UnlockBackgroundPlayback();
+  EXPECT_FALSE(IsVideoLockedWhenPausedWhenHidden());
+}
+
 // Verify LoadAndWaitForCurrentData() functions without issue.
 TEST_F(WebMediaPlayerImplTest, LoadAndDestroy) {
   InitializeWebMediaPlayerImpl();
diff --git a/third_party/blink/renderer/core/html/media/html_media_element_test.cc b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
index 18f4c90..6c51f5b 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element_test.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
@@ -54,6 +54,12 @@
 
 namespace {
 
+// Helper to simulate a Mojo IPC message from the Browser process.
+void SimulateRequestPlay(HTMLMediaElement* element, bool triggered_by_user) {
+  static_cast<media::mojom::blink::MediaPlayer*>(element)->RequestPlay(
+      triggered_by_user);
+}
+
 enum class TestURLScheme {
   kHttp,
   kHttps,
@@ -85,8 +91,11 @@
 
 class MockWebMediaPlayer : public EmptyWebMediaPlayer {
  public:
+  MOCK_METHOD0(Play, void());
+  MOCK_METHOD0(UnlockBackgroundPlayback, void());
   MOCK_METHOD1(Pause, void(PauseReason));
   MOCK_METHOD0(OnTimeUpdate, void());
+  MOCK_CONST_METHOD0(Paused, bool());
   MOCK_CONST_METHOD0(Seekable, WebTimeRanges());
   MOCK_METHOD0(OnFrozen, void());
   MOCK_CONST_METHOD0(HasAudio, bool());
@@ -325,7 +334,8 @@
  protected:
   void SetUp() override {
     // Sniff the media player pointer to facilitate mocking.
-    auto mock_media_player = std::make_unique<MockWebMediaPlayer>();
+    auto mock_media_player =
+        std::make_unique<testing::NiceMock<MockWebMediaPlayer>>();
     media_player_weak_ = mock_media_player->AsWeakPtr();
     media_player_ = mock_media_player.get();
 
@@ -1807,6 +1817,43 @@
   Media()->Play();
 }
 
+TEST_P(HTMLMediaElementTest, RequestPlay_UserTriggered) {
+  Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+  test::RunPendingTasks();
+  SetReadyState(HTMLMediaElement::kHaveEnoughData);
+  test::RunPendingTasks();
+
+  // It should just play directly when triggered by the user.
+  EXPECT_CALL(*MockMediaPlayer(),
+              SetWasPlayedWithUserActivationAndHighMediaEngagement(testing::_));
+  EXPECT_CALL(*MockMediaPlayer(), Paused()).WillRepeatedly(Return(true));
+  EXPECT_CALL(*MockMediaPlayer(), Play());
+  SimulateRequestPlay(Media(), /*triggered_by_user=*/true);
+
+  // The frame should be granted transient user activation.
+  EXPECT_TRUE(LocalFrame::HasTransientUserActivation(
+      Media()->GetDocument().GetFrame()));
+}
+
+TEST_P(HTMLMediaElementTest, RequestPlay_SystemTriggered) {
+  Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+  test::RunPendingTasks();
+  SetReadyState(HTMLMediaElement::kHaveEnoughData);
+  test::RunPendingTasks();
+
+  // It should call UnlockBackgroundPlayback when triggered by the system.
+  EXPECT_CALL(*MockMediaPlayer(),
+              SetWasPlayedWithUserActivationAndHighMediaEngagement(testing::_));
+  EXPECT_CALL(*MockMediaPlayer(), UnlockBackgroundPlayback());
+  EXPECT_CALL(*MockMediaPlayer(), Paused()).WillRepeatedly(Return(true));
+  EXPECT_CALL(*MockMediaPlayer(), Play());
+  SimulateRequestPlay(Media(), /*triggered_by_user=*/false);
+
+  // The frame should NOT be granted transient user activation.
+  EXPECT_FALSE(LocalFrame::HasTransientUserActivation(
+      Media()->GetDocument().GetFrame()));
+}
+
 TEST_P(HTMLMediaElementTest, PlayedWithUserActivation) {
   Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
   test::RunPendingTasks();
Loading diff…

Original Bug Report

reported by vm...@google.com

User Activation Bypass via HTMLMediaElement::RequestPlay and System Audio Focus Resume

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

Overview: A logic flaw in HTMLMediaElement::RequestPlay allows media playback resumed by system events (like regaining audio focus on Android) to unconditionally grant user activation to the frame. This allows an attacker to bypass security requirements for gesture-gated APIs like requestFullscreen or window.open without user interaction. The root cause is a missing ’triggered_by_user’ parameter in the RequestPlay Mojo interface, unlike RequestPause.

Affected files:

  • third_party/blink/renderer/core/html/media/html_media_element.cc
  • media/mojo/mojom/media_player.mojom
  • content/browser/media/session/media_session_controller.cc

Estimated timestamp from git blame: 2021-02-19

Description

There is a potential user activation (gesture) bypass vulnerability in Chromium’s media playback handling. When media playback is requested from the browser process via Mojo, the HTMLMediaElement::RequestPlay function in the renderer unconditionally grants the frame a fresh 5-second transient user activation state.

While this is intended for genuine user interactions (like clicking ‘play’ on global media controls), the RequestPlay Mojo interface (media::mojom::MediaPlayer) takes no arguments and cannot differentiate between user-initiated and system-initiated events. This contrasts with the adjacent RequestPause(bool triggered_by_user) method.

This flaw becomes exploitable when a system event temporarily suspends and then automatically resumes media playback. On Android, a transient audio focus loss (e.g., a notification sound) triggers an automatic suspension (SuspendType::kSystem) and subsequent resume. When audio focus is regained, the browser instructs the renderer to resume playback by calling RequestPlay(), inadvertently granting the attacker’s page a fresh user activation token without any user interaction.

Potential Exploitation Steps

An attacker could follow these steps to trigger the vulnerability and bypass gesture gates:

  1. Attacker Setup: The attacker hosts a page with a <video> or <audio> element. The attacker registers a JavaScript play event listener on this element that attempts to call a gesture-gated API, such as element.requestFullscreen().
  2. Initial Playback: The user navigates to the page and interacts with it once (or the page autoplays), starting media playback and giving Chrome OS-level audio focus.
  3. Transient Focus Loss: An external system event (e.g., an incoming text message notification) occurs. The Android OS signals AUDIOFOCUS_LOSS_TRANSIENT to Chrome’s AudioFocusDelegate.java.
  4. System Suspend: This triggers a JNI call to AudioFocusDelegateAndroid::OnSuspend (content/browser/media/session/audio_focus_delegate_android.cc), which suspends the media session with SuspendType::kSystem.
  5. Transient Focus Gain: The notification finishes, and Android signals AUDIOFOCUS_GAIN.
  6. System Resume: AudioFocusDelegate.java calls the native onResume(), triggering AudioFocusDelegateAndroid::OnResume. This resumes the session with SuspendType::kSystem.
  7. Mojo IPC: The resume propagates through MediaSessionImpl to MediaSessionController::OnResume, which calls RequestPlay() on the media::mojom::MediaPlayer Mojo remote.
  8. Unconditional Activation Grant: In the renderer process, HTMLMediaElement::RequestPlay() (third_party/blink/renderer/core/html/media/html_media_element.cc:5104) is executed. It unconditionally calls LocalFrame::NotifyUserActivation(..., kInteraction), granting the frame a fresh 5-second transient activation token and syncing this state to the browser process.
  9. Bypass Execution: The media resumes, firing the queued play event. The attacker’s event listener executes, and the call to element.requestFullscreen() succeeds because the security gate (HasTransientUserActivation()) checks the newly granted, illegitimate activation state.

Note: These are potential steps based on extensive code tracing. Our automated tooling does not yet have the capability to run code to confirm a working Proof of Concept.

Proposed Fix

  1. Update the media::mojom::MediaPlayer Mojo interface (media/mojo/mojom/media_player.mojom) to include a triggered_by_user boolean parameter for RequestPlay, identical to RequestPause.
  2. Plumb this parameter from the browser process, ensuring system-initiated resumes (e.g., from AudioFocusDelegateAndroid) pass false, while user-initiated actions (e.g., from global media controls) pass true.
  3. Update HTMLMediaElement::RequestPlay in the renderer to only call LocalFrame::NotifyUserActivation if triggered_by_user is true.

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