Medium patch-now ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Media
DescriptionUse after free in Media
ComponentMedia
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker482958590
Fix commit2f6df8745945 (chromium/src) +54/-28
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedsherkito
Reported2026-02-09 (per vendor advisory)
Fix landed2026-03-06 (public git)
Disclosed2026-04-07
Patch gap32 days — fix public in source before the advisory

Background

`WebMediaPlayer`
the Blink/public interface (implemented by WebMediaPlayerImpl and WebMediaPlayerMS) that drives media playback for an HTMLMediaElement.
`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.

Key insight
The core mistake was destroying a 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

  1. Set up a media element Web content creates a <video>/<audio> element (or a MediaStream-backed one driving WebMediaPlayerMS) and starts playback so a live WebMediaPlayer holds a reference to its client element.
  2. 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.
  3. Dangling access Code resuming after the re-entrant destruction dereferences the now-freed WebMediaPlayer or the element’s stale references to it, the use-after-free the advisory describes.
  4. 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

An attacker who can run ordinary web content in a stock build can instantiate media elements and provoke the re-entrant teardown, yielding a use-after-free of 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

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

Files Changed

  • third_party/blink/public/platform/web_media_player.h
  • third_party/blink/public/web/modules/mediastream/web_media_player_ms.h
  • third_party/blink/renderer/core/exported/web_media_player_impl_unittest.cc
  • third_party/blink/renderer/core/html/media/html_media_element.cc
  • third_party/blink/renderer/modules/mediacapturefromelement/html_video_element_capturer_source_unittest.cc
  • third_party/blink/renderer/modules/mediastream/web_media_player_ms.cc

Audit Directions

  • Synchronous destruction of callback-holding objects
    look for reset()/delete on objects that issue callbacks into their owner; prefer DeleteSoon or an explicit shutdown-then-post pattern to avoid re-entrant free.
  • Const/owning raw_ptr back-references
    members 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 hacks
    treat base::debug::Alias/manual keep-alive tricks (the removed PREVENT_CLIENT_GC) as a signal that an object’s lifetime is not actually guaranteed, and review those paths for re-entrancy.
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_);
Loading diff…

Regression Test / PoC

shipped with the fix
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 {}
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free in WMPI

Steps to reproduce the problem

  1. .\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 \

View on issue tracker