CVE-2026-106335
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/media/html_video_element.cc |
modified | |
ifthird_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.ccthird_party/blink/renderer/core/html/media/media_remoting_interstitial.ccthird_party/blink/renderer/core/html/media/html_media_element.hthird_party/blink/renderer/core/html/media/media_remoting_interstitial.hthird_party/blink/renderer/core/html/media/html_video_element.ccthird_party/blink/renderer/core/html/media/html_media_element.cc
Patch
From 23eea0685ff77f8d1b1e88982725bbc081cc2460 Mon Sep 17 00:00:00 2001 From: Jordan Bayles <[email protected]> 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 <[email protected]> Commit-Queue: Jordan Bayles <[email protected]> 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(
Regression Test / PoC
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(
Original Bug Report
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.ccthird_party/blink/renderer/core/html/media/html_media_element.ccthird_party/blink/renderer/core/html/media/html_video_element.ccthird_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
-
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), clearingWeakPersistenthandles that point to unmarked objects. - Next, it calls
ExecutePreFinalizers(v8/src/heap/cppgc/heap.cc:202). HTMLMediaElementhas a pre-finalizer (Dispose) that synchronously callsweb_media_player_->Shutdown(), which then callsHTMLVideoElement::MediaRemotingStopped(), ultimately invokingremoting_interstitial_->Hide().Hide()callstoggle_interstitial_timer_.StartOneShot(), which creates a newWeakPersistentto theMediaRemotingInterstitialobject. BecauseProcessWeaknesshas already run, this weak handle is not cleared in the current cycle.- The
MediaRemotingInterstitialis then swept and its memory is freed.
-
Use-After-Free:
- 300ms later, the delayed task executes
HeapTaskRunnerTimer::RunInternalTrampoline. - The trampoline checks the
WeakPersistentpointer. Since it was never cleared, it unwraps to a non-null, dangling pointer. - The trampoline invokes
MediaRemotingInterstitial::ToggleInterstitialTimerFired()on the freed memory.
- 300ms later, the delayed task executes
-
Remote Code Execution (RCE):
- An attacker can spray the Oilpan heap to reclaim the freed
MediaRemotingInterstitialmemory, placing a forged vtable pointer at the beginning of the object and controlling its internal state. - Inside
ToggleInterstitialTimerFired, the code checksIsVisible()andstate_. By controlling the sprayed memory, the attacker can setstate_tokHidden, forcing execution into theelse if (state_ == kHidden)branch. - This branch calls
SetInlineStyleProperty(...)onthis. Element::SetInlineStylePropertyeventually callsInlineStyleChanged, which invokes the virtual methodInvalidateStyleAttribute()onthis.- 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.
- An attacker can spray the Oilpan heap to reclaim the freed
Suggested Reproduction Steps
Note: These are potential steps as our tooling agent cannot execute code to verify the PoC directly.
- Create an
<video>element with a playable source via JavaScript. - Trigger Media Remoting (e.g.,
video.remote.prompt()) so theMediaRemotingInterstitialis created and reachesstate_ == kVisible. - Pause the video, remove it from the DOM, and drop all JavaScript references to the element.
- Force a full garbage collection (e.g., by allocating large
ArrayBuffers). - Spray the Oilpan heap with objects containing a forged vtable to reclaim the interstitial’s memory.
- 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.