Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Media
DescriptionUse after free in Media
ComponentMedia
Sub-componentMedia Remoting
Bug ClassUAF
Tracker502105238
Fix commit23eea0685ff7 (chromium/src) +77/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/media/html_video_element.cc
modified
if
third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
  • third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc
  • third_party/blink/renderer/core/html/media/html_media_element.h
  • third_party/blink/renderer/core/html/media/media_remoting_interstitial.h
  • third_party/blink/renderer/core/html/media/html_video_element.cc
  • third_party/blink/renderer/core/html/media/html_media_element.cc
From 23eea0685ff77f8d1b1e88982725bbc081cc2460 Mon Sep 17 00:00:00 2001
From: Jordan Bayles <jophba@chromium.org>
Date: Wed, 09 Sep 2026 13:01:05 -0700
Subject: [PATCH] [Media Remoting] Stop interstitial transition when element is disconnected

WebMediaPlayer implementations call MediaRemotingStopped() from
Shutdown(), which can run during element teardown or garbage collection
while the <video> and its UA shadow tree are no longer connected.

In that state, MediaRemotingInterstitial::Hide() has no rendered tree to
fade out. Handling this by immediately clearing the visible state and
stopping the transition timer (without re-arming the HeapTaskRunnerTimer
or triggering DOM/style allocations forbidden during GC) prevents
unnecessary work and potential Use-After-Free crashes.

Also update FakeWebMediaPlayer in unit tests to forward
MediaRemotingStopped() from Shutdown() like WebMediaPlayerImpl, and add
a test verifying GC collection of a remoting element.

Fixed: 502105238
Change-Id: I7025df1c57ea434a3e48a752daff1c29b95811c0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7982045
Reviewed-by: David Baron <dbaron@chromium.org>
Commit-Queue: Jordan Bayles <jophba@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1694826}
---
diff --git a/third_party/blink/renderer/core/html/media/html_media_element.cc b/third_party/blink/renderer/core/html/media/html_media_element.cc
index 550e100f..c7cf4e6 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element.cc
@@ -496,6 +496,7 @@
   // doesn't get dispatched during the object destruction.
   // See Document::isDelayingLoadEvent().
   // Also see http://crbug.com/275223 for more details.
+  is_disposing_ = true;
   ClearMediaPlayerAndAudioSourceProviderClientWithoutLocking();
 
   progress_event_timer_.Shutdown();
diff --git a/third_party/blink/renderer/core/html/media/html_media_element.h b/third_party/blink/renderer/core/html/media/html_media_element.h
index e4a3776..1aed99b79 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element.h
+++ b/third_party/blink/renderer/core/html/media/html_media_element.h
@@ -472,6 +472,7 @@
   HTMLMediaElement(const QualifiedName&, Document&);
   ~HTMLMediaElement() override;
   void Dispose();
+  bool IsDisposing() const { return is_disposing_; }
 
   // Returns a constant reference to the HeapMojoAssociatedRemoteSet holding all
   // the bound remotes for the media::mojom::blink::MediaPlayerObserver
@@ -1117,6 +1118,8 @@
       HeapMojoAssociatedReceiverSet<media::mojom::blink::MediaPlayer,
                                     HTMLMediaElement>>>
       media_player_receiver_set_;
+
+  bool is_disposing_ = false;
 };
 
 template <>
diff --git a/third_party/blink/renderer/core/html/media/html_video_element.cc b/third_party/blink/renderer/core/html/media/html_video_element.cc
index 8e6d4e47..f811ed644 100644
--- a/third_party/blink/renderer/core/html/media/html_video_element.cc
+++ b/third_party/blink/renderer/core/html/media/html_video_element.cc
@@ -1068,6 +1068,9 @@
 void HTMLVideoElement::MediaRemotingStopped(int error_code) {
   is_remote_rendering_ = false;
   remote_device_friendly_name_.Reset();
+  if (IsDisposing()) {
+    return;
+  }
   OnRemotePlaybackMetadataChange();
   if (remoting_interstitial_) {
     remoting_interstitial_->Hide(error_code);
diff --git a/third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc b/third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc
index 8a905460..8184239 100644
--- a/third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc
+++ b/third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc
@@ -85,6 +85,13 @@
     return;
   if (toggle_interstitial_timer_.IsActive())
     toggle_interstitial_timer_.Stop();
+  if (!isConnected()) {
+    // Nothing is being rendered, so immediately hide the interstitial
+    // without scheduling a fade-out transition timer.
+    state_ = kHidden;
+    DidHide();
+    return;
+  }
   if (error_code == MediaPlayerClient::kMediaRemotingStopNoText) {
     state_ = kHidden;
   } else {
@@ -102,6 +109,11 @@
   toggle_interstitial_timer_.StartOneShot(kHiddenAnimationDuration, FROM_HERE);
 }
 
+void MediaRemotingInterstitial::DidHide() {
+  SetInlineStyleProperty(CSSPropertyID::kDisplay, CSSValueID::kNone);
+  toast_message_->setInnerText(WebString());
+}
+
 void MediaRemotingInterstitial::ToggleInterstitialTimerFired(TimerBase*) {
   toggle_interstitial_timer_.Stop();
   if (IsVisible()) {
@@ -115,8 +127,7 @@
     SetInlineStyleProperty(CSSPropertyID::kOpacity, 1,
                            CSSPrimitiveValue::UnitType::kNumber);
   } else if (state_ == kHidden) {
-    SetInlineStyleProperty(CSSPropertyID::kDisplay, CSSValueID::kNone);
-    toast_message_->setInnerText(WebString());
+    DidHide();
   } else {
     // Show |toast_message_| only.
     toast_message_->RemoveInlineStyleProperty(CSSPropertyID::kDisplay);
diff --git a/third_party/blink/renderer/core/html/media/media_remoting_interstitial.h b/third_party/blink/renderer/core/html/media/media_remoting_interstitial.h
index cfc4c59..4e2953a 100644
--- a/third_party/blink/renderer/core/html/media/media_remoting_interstitial.h
+++ b/third_party/blink/renderer/core/html/media/media_remoting_interstitial.h
@@ -55,6 +55,7 @@
   void DidMoveToNewDocument(Document&) override;
 
   void ToggleInterstitialTimerFired(TimerBase*);
+  void DidHide();
 
   // Indicates whether the interstitial should be visible. It is set/changed
   // when Show()/Hide() is called.
diff --git a/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc b/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
index 007fe32..1825935c 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
@@ -71,6 +71,10 @@
     ScheduleTimeIncrement();
   }
   void Pause(PauseReason pause_reason) override { playing_ = false; }
+  void Shutdown() override {
+    client_->MediaRemotingStopped(MediaPlayerClient::kMediaRemotingStopNoText);
+    EmptyWebMediaPlayer::Shutdown();
+  }
   bool Paused() const override { return !playing_; }
   bool IsEnded() const override { return current_time_ == duration_; }
 
@@ -242,6 +246,58 @@
 }
 
 TEST_F(HTMLMediaElementEventListenersTest,
+       RemovingFromDocumentWhileRemotingCollectsAll) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("<video></video>");
+  Video()->SetSrc(AtomicString("http://example.com"));
+  test::RunPendingTasks();
+  EXPECT_NE(WebMediaPlayer(), nullptr);
+  SimulateNetworkState(HTMLMediaElement::kNetworkIdle);
+  SimulateReadyState(HTMLMediaElement::kHaveCurrentData);
+
+  Video()->MediaRemotingStarted(WebString());
+  EXPECT_TRUE(Video()->IsRemotingInterstitialVisible());
+
+  WeakPersistent<HTMLVideoElement> weak_persistent_video = Video();
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("");
+  UpdateAllLifecyclePhasesForTest();
+  test::RunPendingTasks();
+
+  // The element is paused, idle, and detached, so it should be collectable.
+  // The player's Shutdown() runs MediaRemotingStopped() on the client which
+  // hides the interstitial; that must not schedule new work on the dying
+  // shadow tree.
+  ThreadState::Current()->CollectAllGarbageForTesting();
+  EXPECT_EQ(weak_persistent_video, nullptr);
+}
+
+TEST_F(HTMLMediaElementEventListenersTest,
+       MediaRemotingStoppedWhileDisconnectedImmediatelyHides) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("<video></video>");
+  Video()->SetSrc(AtomicString("http://example.com"));
+  test::RunPendingTasks();
+  EXPECT_NE(WebMediaPlayer(), nullptr);
+  SimulateNetworkState(HTMLMediaElement::kNetworkIdle);
+  SimulateReadyState(HTMLMediaElement::kHaveCurrentData);
+
+  Video()->MediaRemotingStarted(WebString());
+  EXPECT_TRUE(Video()->IsRemotingInterstitialVisible());
+
+  Persistent<HTMLVideoElement> video_holder = Video();
+  GetDocument().body()->RemoveChild(Video());
+  EXPECT_FALSE(video_holder->isConnected());
+
+  // Stopping remoting while disconnected should immediately hide the
+  // interstitial and reset state without scheduling fade animation timers.
+  video_holder->MediaRemotingStopped(
+      MediaPlayerClient::kMediaRemotingStopNoText);
+  EXPECT_FALSE(video_holder->IsRemotingInterstitialVisible());
+
+  // Run any delayed tasks; the interstitial should remain hidden.
+  test::RunDelayedTasks(base::Seconds(1));
+  EXPECT_FALSE(video_holder->IsRemotingInterstitialVisible());
+}
+
+TEST_F(HTMLMediaElementEventListenersTest,
        ReInsertingInDocumentCollectsControls) {
   EXPECT_EQ(Video(), nullptr);
   GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc b/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
index 007fe32..1825935c 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element_event_listeners_test.cc
@@ -71,6 +71,10 @@
     ScheduleTimeIncrement();
   }
   void Pause(PauseReason pause_reason) override { playing_ = false; }
+  void Shutdown() override {
+    client_->MediaRemotingStopped(MediaPlayerClient::kMediaRemotingStopNoText);
+    EmptyWebMediaPlayer::Shutdown();
+  }
   bool Paused() const override { return !playing_; }
   bool IsEnded() const override { return current_time_ == duration_; }
 
@@ -242,6 +246,58 @@
 }
 
 TEST_F(HTMLMediaElementEventListenersTest,
+       RemovingFromDocumentWhileRemotingCollectsAll) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("<video></video>");
+  Video()->SetSrc(AtomicString("http://example.com"));
+  test::RunPendingTasks();
+  EXPECT_NE(WebMediaPlayer(), nullptr);
+  SimulateNetworkState(HTMLMediaElement::kNetworkIdle);
+  SimulateReadyState(HTMLMediaElement::kHaveCurrentData);
+
+  Video()->MediaRemotingStarted(WebString());
+  EXPECT_TRUE(Video()->IsRemotingInterstitialVisible());
+
+  WeakPersistent<HTMLVideoElement> weak_persistent_video = Video();
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("");
+  UpdateAllLifecyclePhasesForTest();
+  test::RunPendingTasks();
+
+  // The element is paused, idle, and detached, so it should be collectable.
+  // The player's Shutdown() runs MediaRemotingStopped() on the client which
+  // hides the interstitial; that must not schedule new work on the dying
+  // shadow tree.
+  ThreadState::Current()->CollectAllGarbageForTesting();
+  EXPECT_EQ(weak_persistent_video, nullptr);
+}
+
+TEST_F(HTMLMediaElementEventListenersTest,
+       MediaRemotingStoppedWhileDisconnectedImmediatelyHides) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("<video></video>");
+  Video()->SetSrc(AtomicString("http://example.com"));
+  test::RunPendingTasks();
+  EXPECT_NE(WebMediaPlayer(), nullptr);
+  SimulateNetworkState(HTMLMediaElement::kNetworkIdle);
+  SimulateReadyState(HTMLMediaElement::kHaveCurrentData);
+
+  Video()->MediaRemotingStarted(WebString());
+  EXPECT_TRUE(Video()->IsRemotingInterstitialVisible());
+
+  Persistent<HTMLVideoElement> video_holder = Video();
+  GetDocument().body()->RemoveChild(Video());
+  EXPECT_FALSE(video_holder->isConnected());
+
+  // Stopping remoting while disconnected should immediately hide the
+  // interstitial and reset state without scheduling fade animation timers.
+  video_holder->MediaRemotingStopped(
+      MediaPlayerClient::kMediaRemotingStopNoText);
+  EXPECT_FALSE(video_holder->IsRemotingInterstitialVisible());
+
+  // Run any delayed tasks; the interstitial should remain hidden.
+  test::RunDelayedTasks(base::Seconds(1));
+  EXPECT_FALSE(video_holder->IsRemotingInterstitialVisible());
+}
+
+TEST_F(HTMLMediaElementEventListenersTest,
        ReInsertingInDocumentCollectsControls) {
   EXPECT_EQ(Video(), nullptr);
   GetDocument().body()->SetInnerHTMLWithoutTrustedTypes(
Loading diff…

Original Bug Report

reported by vm...@google.com

Use-After-Free in MediaRemotingInterstitial via Oilpan GC Phase Ordering

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 without the Chrome Security team.

Overview: A Use-After-Free vulnerability exists in MediaRemotingInterstitial due to an Oilpan GC phase ordering issue. A timer initiated during an HTMLMediaElement pre-finalizer creates a WeakPersistent handle after the garbage collector has already processed weak handles, allowing the timer to fire on a swept object.

Affected files:

  • third_party/blink/renderer/core/html/media/media_remoting_interstitial.cc
  • third_party/blink/renderer/core/html/media/html_media_element.cc
  • third_party/blink/renderer/core/html/media/html_video_element.cc
  • third_party/blink/renderer/platform/media/web_media_player_impl.cc

Estimated timestamp from git blame: 2025-08-26

Summary

A potential Use-After-Free (UAF) vulnerability exists in the Renderer process due to an interaction between Blink’s garbage collector (Oilpan) and MediaRemotingInterstitial.

When an HTMLMediaElement is garbage collected, its pre-finalizer initiates a shutdown sequence that calls Hide() on its associated MediaRemotingInterstitial. This method starts a HeapTaskRunnerTimer. Because this timer is started during the pre-finalization phase—which occurs after Oilpan’s weak processing phase (ProcessWeakness)—the newly created WeakPersistent handle is not cleared during the current GC cycle. Consequently, the task runner retains a dangling pointer to the unmarked interstitial, which is subsequently swept and its memory reclaimed. When the timer fires, execution continues on the reclaimed memory.

Technical Details & Exploitation Path

  1. GC Phase Ordering Bug:

    • During a full GC cycle, Oilpan first marks live objects.
    • It then enters ProcessWeakness (v8/src/heap/cppgc/marker.cc:322), clearing WeakPersistent handles that point to unmarked objects.
    • Next, it calls ExecutePreFinalizers (v8/src/heap/cppgc/heap.cc:202).
    • HTMLMediaElement has a pre-finalizer (Dispose) that synchronously calls web_media_player_->Shutdown(), which then calls HTMLVideoElement::MediaRemotingStopped(), ultimately invoking remoting_interstitial_->Hide().
    • Hide() calls toggle_interstitial_timer_.StartOneShot(), which creates a new WeakPersistent to the MediaRemotingInterstitial object. Because ProcessWeakness has already run, this weak handle is not cleared in the current cycle.
    • The MediaRemotingInterstitial is then swept and its memory is freed.
  2. Use-After-Free:

    • 300ms later, the delayed task executes HeapTaskRunnerTimer::RunInternalTrampoline.
    • The trampoline checks the WeakPersistent pointer. Since it was never cleared, it unwraps to a non-null, dangling pointer.
    • The trampoline invokes MediaRemotingInterstitial::ToggleInterstitialTimerFired() on the freed memory.
  3. Remote Code Execution (RCE):

    • An attacker can spray the Oilpan heap to reclaim the freed MediaRemotingInterstitial memory, placing a forged vtable pointer at the beginning of the object and controlling its internal state.
    • Inside ToggleInterstitialTimerFired, the code checks IsVisible() and state_. By controlling the sprayed memory, the attacker can set state_ to kHidden, forcing execution into the else if (state_ == kHidden) branch.
    • This branch calls SetInlineStyleProperty(...) on this.
    • Element::SetInlineStyleProperty eventually calls InlineStyleChanged, which invokes the virtual method InvalidateStyleAttribute() on this.
    • This virtual call dereferences the attacker’s forged vtable pointer, leading to direct Remote Code Execution within the renderer process.
    • Note: Because the object is managed by Oilpan (Blink GC), it is not protected by MiraclePtr (BackupRefPtr), which primarily protects PartitionAlloc allocations.

Suggested Reproduction Steps

Note: These are potential steps as our tooling agent cannot execute code to verify the PoC directly.

  1. Create an <video> element with a playable source via JavaScript.
  2. Trigger Media Remoting (e.g., video.remote.prompt()) so the MediaRemotingInterstitial is created and reaches state_ == kVisible.
  3. Pause the video, remove it from the DOM, and drop all JavaScript references to the element.
  4. Force a full garbage collection (e.g., by allocating large ArrayBuffers).
  5. Spray the Oilpan heap with objects containing a forged vtable to reclaim the interstitial’s memory.
  6. Wait >300ms for the toggle_interstitial_timer_ to fire.

Suggested Fix

The core issue is that MediaRemotingInterstitial holds a timer that can be started during its destruction sequence but is never explicitly stopped when the object is swept.

We recommend adding a pre-finalizer to MediaRemotingInterstitial that explicitly stops the timer:

// media_remoting_interstitial.h
class MediaRemotingInterstitial final : public HTMLDivElement {
  USING_PRE_FINALIZER(MediaRemotingInterstitial, Dispose);
  // ...
  void Dispose();
};

// media_remoting_interstitial.cc
void MediaRemotingInterstitial::Dispose() {
  toggle_interstitial_timer_.Stop();
}

Alternatively, ToggleInterstitialTimerFired could check if the object is still alive or valid, though explicit cleanup via a pre-finalizer is the standard approach in Blink for objects holding active timers.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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.

View on issue tracker