CVE-2026-5883
Overview
Background
- `WebMediaPlayer`
- the Blink/public interface (implemented by
WebMediaPlayerImplandWebMediaPlayerMS) that drives media playback for anHTMLMediaElement. - `MediaPlayerClient` / `WebMediaPlayerClient`
- the client interface the player calls back into; for a
<video>/<audio>element this is the element itself, so the player and the element reference each other. - Re-entrant destruction
- destroying an object while a method on that same object (or a callback it issued) is still executing on the stack, so returning code touches freed memory.
- `DeleteSoon`
- a task-runner helper that takes ownership of an object and destroys it later on a posted task, after the current stack has unwound.
Root Cause Analysis
The WebMediaPlayer teardown path in html_media_element.cc destroyed the player synchronously with web_media_player_.reset(). Because a player callback can re-enter the element and trigger teardown, the player could be destructed while one of its own methods was still on the stack, so code resuming after the call operated on a freed WebMediaPlayer (and the element’s references to it), a use-after-free. In WebMediaPlayerMS the old code relied on a PREVENT_CLIENT_GC alias hack to keep client_ alive during such calls, but nothing guaranteed the player object itself survived re-entrant destruction.
The fix introduces a Shutdown() step that clears the player’s back-references (client_, delegate_, gpu_factories_) and dooms its weak pointers, then hands the owning pointer to DeleteSoon so the object is deleted on a later task once the stack has fully unwound. This converts every synchronous, potentially re-entrant destruction into an asynchronous one, removing the window in which freed player memory is touched.
WebMediaPlayer synchronously while it could still be executing or being called into; the fix adds a Shutdown() to sever the cross-references and posts the destruction via DeleteSoon so destruction never runs re-entrantly.Attack Path
- Set up a media element
Web content creates a
<video>/<audio>element (or a MediaStream-backed one drivingWebMediaPlayerMS) and starts playback so a liveWebMediaPlayerholds a reference to its client element. - Trigger re-entrant teardown
An action that tears down the player (navigation, source change, element removal) occurs while the player is mid-callback into the element, so the old
web_media_player_.reset()destructs the player from within its own call chain. - Dangling access
Code resuming after the re-entrant destruction dereferences the now-freed
WebMediaPlayeror the element’s stale references to it, the use-after-free the advisory describes. - Exploitation Controlling allocation timing to reclaim the freed player memory could turn the dangling access into attacker-influenced behavior (inferred); the diff shows only the lifetime bug, not a specific primitive.
Impact Assessment
WebMediaPlayer in the renderer process. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; any escalation to a controllable read/write or code execution is (inferred) and not demonstrated by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/media/html_media_element.cc |
modified |
Files Changed
third_party/blink/public/platform/web_media_player.hthird_party/blink/public/web/modules/mediastream/web_media_player_ms.hthird_party/blink/renderer/core/exported/web_media_player_impl_unittest.ccthird_party/blink/renderer/core/html/media/html_media_element.ccthird_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.ccthird_party/blink/renderer/modules/mediastream/web_media_player_ms.cc
Audit Directions
- Synchronous destruction of callback-holding objectslook for
reset()/deleteon objects that issue callbacks into their owner; preferDeleteSoonor an explicit shutdown-then-post pattern to avoid re-entrant free. - Const/owning raw_ptr back-referencesmembers like the former
const raw_ptr<MediaPlayerClient> client_that cannot be cleared keep stale links alive during teardown; audit for back-pointers that are never nulled before destruction. - GC-prevention hackstreat
base::debug::Alias/manual keep-alive tricks (the removedPREVENT_CLIENT_GC) as a signal that an object’s lifetime is not actually guaranteed, and review those paths for re-entrancy.
Patch
From 2f6df874594524706ee2d13883ff889b4c9cd1d8 Mon Sep 17 00:00:00 2001 From: Dale Curtis <[email protected]> Date: Thu, 05 Mar 2026 16:33:22 -0800 Subject: [PATCH] Always post destruction of WebMediaPlayer instances There are lots of ways that re-entrant destruction of players can happen. Fixing them all piecemeal is fragile and making WMP garbage collected is difficult since it's a blink/public interface. For now just add a Shutdown mechanism and post destruction such that we never have re-entrant destruction. Bug: 482958590, 459524033 Change-Id: I9ccdaeed448850a5133deb464dcaeafa7447fe94 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7609443 Reviewed-by: Daniel Cheng <[email protected]> Reviewed-by: Frank Liberato <[email protected]> Auto-Submit: Dale Curtis <[email protected]> Commit-Queue: Dale Curtis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1595039} --- diff --git a/third_party/blink/public/platform/web_media_player.h b/third_party/blink/public/platform/web_media_player.h index 389ac03..d91446fa 100644 --- a/third_party/blink/public/platform/web_media_player.h +++ b/third_party/blink/public/platform/web_media_player.h @@ -188,6 +188,11 @@ virtual ~WebMediaPlayer() = default; + // Called just before the WebMediaPlayer is posted for destruction such that + // the WebMediaPlayer can clear any references to WebMediaPlayerClient and + // perform any other necessary cleanup. + virtual void Shutdown() = 0; + virtual LoadTiming Load(LoadType, const WebMediaPlayerSource&, CorsMode, diff --git a/third_party/blink/public/web/modules/mediastream/web_media_player_ms.h b/third_party/blink/public/web/modules/mediastream/web_media_player_ms.h index ea7d538..431201f15 100644 --- a/third_party/blink/public/web/modules/mediastream/web_media_player_ms.h +++ b/third_party/blink/public/web/modules/mediastream/web_media_player_ms.h @@ -99,6 +99,8 @@ ~WebMediaPlayerMS() override; + void Shutdown() override; + WebMediaPlayer::LoadTiming Load(LoadType load_type, const WebMediaPlayerSource& source, CorsMode cors_mode, @@ -279,7 +281,7 @@ const WebTimeRanges buffered_; - const raw_ptr<MediaPlayerClient> client_; + raw_ptr<MediaPlayerClient> client_ = nullptr; // WebMediaPlayer notifies the |delegate_| of playback state changes using // |delegate_id_|; an id provided after registering with the delegate. The @@ -293,7 +295,7 @@ // before the frame is destroyed). RenderFrameImpl owns of |delegate_|, and is // guaranteed to outlive |this|. It is therefore safe use a raw pointer // directly. - raw_ptr<WebMediaPlayerDelegate> delegate_; + raw_ptr<WebMediaPlayerDelegate> delegate_ = nullptr; int delegate_id_; const int player_id_; @@ -327,7 +329,7 @@ const scoped_refptr<base::SequencedTaskRunner> media_task_runner_; const scoped_refptr<base::TaskRunner> worker_task_runner_; - raw_ptr<media::GpuVideoAcceleratorFactories> gpu_factories_; + raw_ptr<media::GpuVideoAcceleratorFactories> gpu_factories_ = nullptr; // Used for DCHECKs to ensure methods calls executed in the correct thread. THREAD_CHECKER(thread_checker_); 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 66a2bf80..ec320fe 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 @@ -395,6 +395,7 @@ // // NOTE: This should be done before any other member variables are // destructed since WMPI may reference them during destruction. + wmpi_->Shutdown(); wmpi_.reset(); CycleThreads(); @@ -461,6 +462,7 @@ media_thread_.task_runner()); compositor_ = compositor.get(); + CHECK(!wmpi_); wmpi_ = std::make_unique<WebMediaPlayerImpl>( GetWebLocalFrame(), &client_, &encrypted_client_, &delegate_, std::move(factory_selector), url_index_.get(), std::move(compositor), @@ -2758,6 +2760,7 @@ EXPECT_TRUE(dump_manager->IsDumpProviderRegisteredForTesting(media_dumper)); CycleThreads(); + wmpi_->Shutdown(); wmpi_.reset(); CycleThreads(); 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 d6b34fb..1a0a13e 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 @@ -4181,7 +4181,12 @@ GetAudioSourceProvider().SetClient(nullptr); if (web_media_player_) { audio_source_provider_.Wrap(nullptr); - web_media_player_.reset(); + // Never destruct WMPI synchronously since it may be actively calling into + // the media element. Instead, post a task to delete it asynchronously. + web_media_player_->Shutdown(); + GetDocument() + .GetTaskRunner(TaskType::kInternalMedia) + ->DeleteSoon(FROM_HERE, std::move(web_media_player_)); // Do not clear `opener_document_` here; new players might still use it. // The lifetime of the mojo endpoints are tied to the WebMediaPlayer's, so diff --git a/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc b/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc index a6f1128c..2a6beed 100644 --- a/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc +++ b/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc @@ -50,6 +50,7 @@ bool is_cache_disabled) override { return LoadTiming::kImmediate; } + void Shutdown() override {} void Play() override {} void Pause(PauseReason pause_reason) override {} void Seek(double seconds) override {} diff --git a/third_party/blink/renderer/modules/mediastream/web_media_player_ms.cc b/third_party/blink/renderer/modules/mediastream/web_media_player_ms.cc index 97498b7c..f827e74 100644 --- a/third_party/blink/renderer/modules/mediastream/web_media_player_ms.cc +++ b/third_party/blink/renderer/modules/mediastream/web_media_player_ms.cc @@ -12,7 +12,6 @@ #include <string> #include <utility> -#include "base/debug/alias.h" #include "base/functional/bind.h" #include "base/functional/callback.h" #include "base/memory/raw_ptr.h" @@ -65,19 +64,6 @@ #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h" #include "third_party/blink/renderer/platform/wtf/functional.h" -// Put this macro in a scope to prevent `client_` from being GC'd. -// This is important for any method that might be called from anywhere -// where GC of the element is not prevented. GC is prevented if the -// call into `this` came from the element itself (directly or indirectly, -// as long as the element's `this` is on the stack), or HasPendingActivation() -// returns true. In other cases, especially callbacks from the "outside -// world", one should PREVENT_CLIENT_GC to keep the element from being -// garbage collected. Failure to do this can cause `this` to be destroyed -// when the player is finalized. -#define PREVENT_CLIENT_GC \ - auto client_copy_ = client_; \ - base::debug::Alias(&client_copy_) - namespace blink { namespace { @@ -410,6 +396,12 @@ WebMediaPlayerMS::~WebMediaPlayerMS() { DCHECK_CALLED_ON_VALID_THREAD(thread_checker_); + // Ensure Shutdown() has been called. + CHECK(!client_); +} + +void WebMediaPlayerMS::Shutdown() { + DCHECK_CALLED_ON_VALID_THREAD(thread_checker_); SendLogMessage( String::Format("%s() [delegate_id=%d]", __func__, delegate_id_)); @@ -458,11 +450,14 @@ delegate_->PlayerGone(delegate_id_); delegate_->RemoveObserver(delegate_id_); + delegate_ = nullptr; + client_ = nullptr; + gpu_factories_ = nullptr; + weak_factory_.InvalidateWeakPtrsAndDoom(); } void WebMediaPlayerMS::OnAudioRenderErrorCallback() { DCHECK_CALLED_ON_VALID_THREAD(thread_checker_); - PREVENT_CLIENT_GC; if (watch_time_reporter_) watch_time_reporter_->OnError(media::AUDIO_RENDERER_ERROR); @@ -1322,7 +1317,6 @@ bool is_opaque) { DVLOG(1) << __func__; DCHECK_CALLED_ON_VALID_THREAD(thread_checker_);
Regression Test / PoC
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 66a2bf80..ec320fe 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
@@ -395,6 +395,7 @@
//
// NOTE: This should be done before any other member variables are
// destructed since WMPI may reference them during destruction.
+ wmpi_->Shutdown();
wmpi_.reset();
CycleThreads();
@@ -461,6 +462,7 @@
media_thread_.task_runner());
compositor_ = compositor.get();
+ CHECK(!wmpi_);
wmpi_ = std::make_unique<WebMediaPlayerImpl>(
GetWebLocalFrame(), &client_, &encrypted_client_, &delegate_,
std::move(factory_selector), url_index_.get(), std::move(compositor),
@@ -2758,6 +2760,7 @@
EXPECT_TRUE(dump_manager->IsDumpProviderRegisteredForTesting(media_dumper));
CycleThreads();
+ wmpi_->Shutdown();
wmpi_.reset();
CycleThreads();
diff --git a/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc b/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc
index a6f1128c..2a6beed 100644
--- a/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc
+++ b/third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc
@@ -50,6 +50,7 @@
bool is_cache_disabled) override {
return LoadTiming::kImmediate;
}
+ void Shutdown() override {}
void Play() override {}
void Pause(PauseReason pause_reason) override {}
void Seek(double seconds) override {}
diff --git a/third_party/blink/renderer/modules/mediastream/web_media_player_ms_test.cc b/third_party/blink/renderer/modules/mediastream/web_media_player_ms_test.cc
index 8668de84..fefe39e 100644
--- a/third_party/blink/renderer/modules/mediastream/web_media_player_ms_test.cc
+++ b/third_party/blink/renderer/modules/mediastream/web_media_player_ms_test.cc
@@ -545,6 +545,7 @@
submitter_ptr_ = submitter_.get();
}
~WebMediaPlayerMSTest() override {
+ player_->Shutdown();
player_.reset();
base::RunLoop().RunUntilIdle();
}
@@ -698,6 +699,7 @@
void WebMediaPlayerMSTest::InitializeWebMediaPlayerMS() {
enable_surface_layer_for_video_ = testing::get<0>(GetParam());
+ CHECK(!player_);
player_ = std::make_unique<WebMediaPlayerMS>(
nullptr, this, &delegate_, std::make_unique<media::NullMediaLog>(),
scheduler::GetSingleThreadTaskRunnerForTesting(),
diff --git a/third_party/blink/renderer/platform/testing/empty_web_media_player.h b/third_party/blink/renderer/platform/testing/empty_web_media_player.h
index 7156660f..cd186443 100644
--- a/third_party/blink/renderer/platform/testing/empty_web_media_player.h
+++ b/third_party/blink/renderer/platform/testing/empty_web_media_player.h
@@ -28,6 +28,7 @@
const WebMediaPlayerSource&,
CorsMode,
bool is_cache_disabled) override;
+ void Shutdown() override { weak_ptr_factory_.InvalidateWeakPtrsAndDoom(); }
void Play() override {}
void Pause(PauseReason pause_reason) override {}
void Seek(double seconds) override {}
Original Bug Report
Use-After-Free in WMPI
Steps to reproduce the problem
- .\chrome.exe –js-flags="–expose-gc"
Problem Description
Use-After-Free occurs when a parent object is destroyed by GC while accessing a WMPI object.
GC can be called via the [0] function.
There are several places where this function can be called, but I chose to use the [1] function.
To call the [1] function directly without timing it, I called the [2] function.\
[0]https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/media/web_media_player_impl.cc;l=3334;drc=e63596721df61bbc199c38c4a102597ad81ad154
[1]https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/media/web_media_player_impl.cc;l=1860;drc=e63596721df61bbc199c38c4a102597ad81ad154
[2]https://source.chromium.org/chromium/chromium/src/+/main:content/renderer/media/renderer_web_media_player_delegate.cc;l=335;drc=e63596721df61bbc199c38c4a102597ad81ad154;bpv=0;bpt=1
This vulnerability didn’t work properly in the ASAN build, so I triggered it using Chromium built using the args.gn below.
Also, since the crash didn’t occur in the release version without spraying, I used poc_helper.diff for the crash test below, and attached the crash dump log instead of the ASAN log.\
is_component_build = false
is_debug = false
dcheck_always_on = false
is_asan = false
enable_nacl = false
I believe the vulnerability started with commit 50a635ebbf250f8f35ea060d564b102b804dcea7.
Summary
Use-After-Free in WMPI
Custom Questions
Type of crash:
renderer
Reporter credit:
sherkito
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: N/A \
- https://source.chromium.org/chromium/chromium/src/+/main:content/renderer/media/renderer_web_media_player_delegate.cc;l=335;drc=e63596721df61bbc199c38c4a102597ad81ad154;bpv=0;bpt=1
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/media/web_media_player_impl.cc;l=1860;drc=e63596721df61bbc199c38c4a102597ad81ad154
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/media/web_media_player_impl.cc;l=3334;drc=e63596721df61bbc199c38c4a102597ad81ad154