Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Chromecast
DescriptionUse after free in Chromecast
ComponentChromecast
Bug ClassUAF
Tracker503617302
Fix commit2dfbd25331c4 (chromium/src) +90/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
TEST_F
chromecast/starboard/media/renderer/starboard_renderer_test.cc
modified
TEST_F
media/mojo/clients/mojo_renderer_unittest.cc
modified

Files Changed

  • chromecast/starboard/media/renderer/starboard_renderer.cc
  • chromecast/starboard/media/renderer/starboard_renderer_test.cc
  • media/mojo/clients/BUILD.gn
  • media/mojo/clients/mojo_renderer_unittest.cc
  • media/mojo/services/mojo_renderer_service.cc
From 2dfbd25331c40ab4bd4454ea20c8d03d3898e334 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Mon, 27 Apr 2026 18:06:39 -0700
Subject: [PATCH] media: Fix Use-After-Free in StarboardRenderer and MojoRendererService

A compromised renderer could trigger a Use-After-Free in the Cast
browser process by initializing a MojoRendererService twice. The first
initialization successfully allocates an SbPlayer, but a second call
(allowed in release builds due to a debug-only DCHECK) could fail,
destroying the underlying player while leaving a dangling pointer in
GeometryChangeHandler.

This CL fixes the issue by:

1. Adding a hard check in MojoRendererService::Initialize to prevent
   re-initialization and reporting a bad message if attempted.
2. Adding a CHECK in StarboardRenderer::Initialize to ensure it is only
   called once, providing defense-in-depth.

Fixed: 503617302
Change-Id: Iffd137bfe533ae35848916667faaf5f22372b002
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7772958
Reviewed-by: Simeon Anfinrud <sanfin@chromium.org>
Reviewed-by: Colin Blundell <blundell@chromium.org>
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1621456}
---

diff --git a/chromecast/starboard/media/renderer/starboard_renderer.cc b/chromecast/starboard/media/renderer/starboard_renderer.cc
index f456c8b6..7fb4693 100644
--- a/chromecast/starboard/media/renderer/starboard_renderer.cc
+++ b/chromecast/starboard/media/renderer/starboard_renderer.cc
@@ -55,6 +55,7 @@
                                    ::media::PipelineStatusCallback init_cb) {
   CHECK(media_task_runner_->RunsTasksInCurrentSequence());
   CHECK(client);
+  CHECK(!player_manager_);
 
   ::media::DemuxerStream* audio_stream =
       media_resource->GetFirstStream(::media::DemuxerStream::Type::AUDIO);
diff --git a/chromecast/starboard/media/renderer/starboard_renderer_test.cc b/chromecast/starboard/media/renderer/starboard_renderer_test.cc
index 36cfdd1e..cca300bc 100644
--- a/chromecast/starboard/media/renderer/starboard_renderer_test.cc
+++ b/chromecast/starboard/media/renderer/starboard_renderer_test.cc
@@ -649,6 +649,56 @@
   EXPECT_EQ(renderer.GetMediaTime(), kMediaTime);
 }
 
+// Regression test for crbug.com/503617302.
+TEST_F(StarboardRendererTest, DoesNotSetSbPlayerOnInitializationFailure) {
+  const gfx::RectF geometry(0, 0, 1920, 1080);
+  const gfx::OverlayTransform transform =
+      gfx::OverlayTransform::OVERLAY_TRANSFORM_NONE;
+
+  // Set up the mock to fail player creation.
+  EXPECT_CALL(*starboard_, CreatePlayer(_, _)).WillOnce(Return(nullptr));
+
+  // The player's bounds should NOT be set after a failed initialization, even
+  // if geometry changes occur.
+  EXPECT_CALL(*starboard_, SetPlayerBounds(_, _, _, _, _, _)).Times(0);
+
+  StarboardRenderer renderer(std::move(starboard_), task_runner_, plane_id_,
+                             /*enable_buffering=*/true,
+                             &geometry_setter_service_, &cast_metrics_helper_);
+  RunPendingTasks();
+
+  EXPECT_CALL(
+      pipeline_status_fn_,
+      Call(HasStatusCode(
+          ::media::PipelineStatusCodes::PIPELINE_ERROR_INITIALIZATION_FAILED)))
+      .Times(1);
+  renderer.Initialize(
+      &media_resource_, &client_,
+      base::BindLambdaForTesting(pipeline_status_fn_.AsStdFunction()));
+  RunPendingTasks();
+
+  // This geometry change should be ignored by the geometry_change_handler_,
+  // which was never initialized with a player.
+  static_cast<mojom::VideoGeometrySetter*>(&geometry_setter_service_)
+      ->SetVideoGeometry(geometry, transform, plane_id_);
+  RunPendingTasks();
+}
+
+TEST_F(StarboardRendererTest, Initialize_Twice_Crashes) {
+  StarboardRenderer renderer(std::move(starboard_), task_runner_, plane_id_,
+                             /*enable_buffering=*/true,
+                             &geometry_setter_service_, &cast_metrics_helper_);
+
+  renderer.Initialize(
+      &media_resource_, &client_,
+      base::BindLambdaForTesting(pipeline_status_fn_.AsStdFunction()));
+  RunPendingTasks();
+
+  EXPECT_CHECK_DEATH(renderer.Initialize(
+      &media_resource_, &client_,
+      base::BindLambdaForTesting(pipeline_status_fn_.AsStdFunction())));
+}
+
 TEST_F(StarboardRendererTest, SetPlaybackRateReportsMetric) {
   // Ignore unrelated metrics calls.
   EXPECT_CALL(cast_metrics_helper_, RecordApplicationEvent(_))
diff --git a/media/mojo/clients/BUILD.gn b/media/mojo/clients/BUILD.gn
index 66e1242..9fa9618 100644
--- a/media/mojo/clients/BUILD.gn
+++ b/media/mojo/clients/BUILD.gn
@@ -157,6 +157,7 @@
     "//gpu/ipc/common:test_support",
     "//media:test_support",
     "//media/mojo:test_support",
+    "//mojo/public/cpp/test_support:test_utils",
     "//testing/gmock",
     "//testing/gtest",
   ]
diff --git a/media/mojo/clients/mojo_renderer_unittest.cc b/media/mojo/clients/mojo_renderer_unittest.cc
index 1814cf0..3723b2f 100644
--- a/media/mojo/clients/mojo_renderer_unittest.cc
+++ b/media/mojo/clients/mojo_renderer_unittest.cc
@@ -2,11 +2,14 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#include "media/mojo/clients/mojo_renderer.h"
+
 #include <stdint.h>
 
 #include <memory>
 
 #include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
 #include "base/memory/ptr_util.h"
 #include "base/memory/raw_ptr.h"
 #include "base/run_loop.h"
@@ -21,7 +24,6 @@
 #include "media/base/test_helpers.h"
 #include "media/cdm/clear_key_cdm_common.h"
 #include "media/cdm/default_cdm_factory.h"
-#include "media/mojo/clients/mojo_renderer.h"
 #include "media/mojo/common/media_type_converters.h"
 #include "media/mojo/mojom/content_decryption_module.mojom.h"
 #include "media/mojo/mojom/renderer.mojom.h"
@@ -33,6 +35,7 @@
 #include "mojo/public/cpp/bindings/receiver.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/bindings/self_owned_receiver.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "url/gurl.h"
 
@@ -232,6 +235,34 @@
   Initialize();
 }
 
+// Regression test for crbug.com/503617302.
+TEST_F(MojoRendererTest, Initialize_Twice) {
+  // Create a service directly to bypass client-side checks in MojoRenderer.
+  auto mock_renderer = std::make_unique<StrictMock<MockRenderer>>();
+  mojo::Remote<mojom::Renderer> remote;
+  auto receiver_ref = MojoRendererService::Create(
+      &mojo_cdm_service_context_, std::move(mock_renderer),
+      remote.BindNewPipeAndPassReceiver());
+
+  mojo::PendingAssociatedRemote<mojom::RendererClient> client_remote_1;
+  auto client_receiver_1 = client_remote_1.InitWithNewEndpointAndPassReceiver();
+  std::vector<mojo::PendingRemote<mojom::DemuxerStream>> streams;
+  mojo::PendingRemote<mojom::DemuxerStream> stream_remote;
+  auto stream_receiver = stream_remote.InitWithNewPipeAndPassReceiver();
+  streams.push_back(std::move(stream_remote));
+  remote->Initialize(std::move(client_remote_1), std::move(streams),
+                     base::DoNothing());
+
+  mojo::PendingAssociatedRemote<mojom::RendererClient> client_remote_2;
+  auto client_receiver_2 = client_remote_2.InitWithNewEndpointAndPassReceiver();
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  remote->Initialize(std::move(client_remote_2), std::nullopt,
+                     base::DoNothing());
+
+  EXPECT_EQ("MojoRendererService is already initialized",
+            bad_message_observer.WaitForBadMessage());
+}
 TEST_F(MojoRendererTest, Initialize_Failure) {
   CreateAudioStream();
   // Mojo Renderer only expects a boolean result, which will be translated
diff --git a/media/mojo/services/mojo_renderer_service.cc b/media/mojo/services/mojo_renderer_service.cc
index 90d4ae6..c0697a0 100644
--- a/media/mojo/services/mojo_renderer_service.cc
+++ b/media/mojo/services/mojo_renderer_service.cc
@@ -16,6 +16,7 @@
 #include "media/mojo/common/media_type_converters.h"
 #include "media/mojo/services/media_resource_shim.h"
 #include "media/mojo/services/mojo_cdm_service_context.h"
+#include "mojo/public/cpp/bindings/message.h"
 
 namespace media {
 
@@ -58,7 +59,11 @@
         streams,
     InitializeCallback callback) {
   DVLOG(1) << __func__;
-  DCHECK_EQ(state_, STATE_UNINITIALIZED);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chromecast/starboard/media/renderer/starboard_renderer_test.cc b/chromecast/starboard/media/renderer/starboard_renderer_test.cc
index 36cfdd1e..cca300bc 100644
--- a/chromecast/starboard/media/renderer/starboard_renderer_test.cc
+++ b/chromecast/starboard/media/renderer/starboard_renderer_test.cc
@@ -649,6 +649,56 @@
   EXPECT_EQ(renderer.GetMediaTime(), kMediaTime);
 }
 
+// Regression test for crbug.com/503617302.
+TEST_F(StarboardRendererTest, DoesNotSetSbPlayerOnInitializationFailure) {
+  const gfx::RectF geometry(0, 0, 1920, 1080);
+  const gfx::OverlayTransform transform =
+      gfx::OverlayTransform::OVERLAY_TRANSFORM_NONE;
+
+  // Set up the mock to fail player creation.
+  EXPECT_CALL(*starboard_, CreatePlayer(_, _)).WillOnce(Return(nullptr));
+
+  // The player's bounds should NOT be set after a failed initialization, even
+  // if geometry changes occur.
+  EXPECT_CALL(*starboard_, SetPlayerBounds(_, _, _, _, _, _)).Times(0);
+
+  StarboardRenderer renderer(std::move(starboard_), task_runner_, plane_id_,
+                             /*enable_buffering=*/true,
+                             &geometry_setter_service_, &cast_metrics_helper_);
+  RunPendingTasks();
+
+  EXPECT_CALL(
+      pipeline_status_fn_,
+      Call(HasStatusCode(
+          ::media::PipelineStatusCodes::PIPELINE_ERROR_INITIALIZATION_FAILED)))
+      .Times(1);
+  renderer.Initialize(
+      &media_resource_, &client_,
+      base::BindLambdaForTesting(pipeline_status_fn_.AsStdFunction()));
+  RunPendingTasks();
+
+  // This geometry change should be ignored by the geometry_change_handler_,
+  // which was never initialized with a player.
+  static_cast<mojom::VideoGeometrySetter*>(&geometry_setter_service_)
+      ->SetVideoGeometry(geometry, transform, plane_id_);
+  RunPendingTasks();
+}
+
+TEST_F(StarboardRendererTest, Initialize_Twice_Crashes) {
+  StarboardRenderer renderer(std::move(starboard_), task_runner_, plane_id_,
+                             /*enable_buffering=*/true,
+                             &geometry_setter_service_, &cast_metrics_helper_);
+
+  renderer.Initialize(
+      &media_resource_, &client_,
+      base::BindLambdaForTesting(pipeline_status_fn_.AsStdFunction()));
+  RunPendingTasks();
+
+  EXPECT_CHECK_DEATH(renderer.Initialize(
+      &media_resource_, &client_,
+      base::BindLambdaForTesting(pipeline_status_fn_.AsStdFunction())));
+}
+
 TEST_F(StarboardRendererTest, SetPlaybackRateReportsMetric) {
   // Ignore unrelated metrics calls.
   EXPECT_CALL(cast_metrics_helper_, RecordApplicationEvent(_))
diff --git a/media/mojo/clients/mojo_renderer_unittest.cc b/media/mojo/clients/mojo_renderer_unittest.cc
index 1814cf0..3723b2f 100644
--- a/media/mojo/clients/mojo_renderer_unittest.cc
+++ b/media/mojo/clients/mojo_renderer_unittest.cc
@@ -2,11 +2,14 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#include "media/mojo/clients/mojo_renderer.h"
+
 #include <stdint.h>
 
 #include <memory>
 
 #include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
 #include "base/memory/ptr_util.h"
 #include "base/memory/raw_ptr.h"
 #include "base/run_loop.h"
@@ -21,7 +24,6 @@
 #include "media/base/test_helpers.h"
 #include "media/cdm/clear_key_cdm_common.h"
 #include "media/cdm/default_cdm_factory.h"
-#include "media/mojo/clients/mojo_renderer.h"
 #include "media/mojo/common/media_type_converters.h"
 #include "media/mojo/mojom/content_decryption_module.mojom.h"
 #include "media/mojo/mojom/renderer.mojom.h"
@@ -33,6 +35,7 @@
 #include "mojo/public/cpp/bindings/receiver.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/bindings/self_owned_receiver.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "url/gurl.h"
 
@@ -232,6 +235,34 @@
   Initialize();
 }
 
+// Regression test for crbug.com/503617302.
+TEST_F(MojoRendererTest, Initialize_Twice) {
+  // Create a service directly to bypass client-side checks in MojoRenderer.
+  auto mock_renderer = std::make_unique<StrictMock<MockRenderer>>();
+  mojo::Remote<mojom::Renderer> remote;
+  auto receiver_ref = MojoRendererService::Create(
+      &mojo_cdm_service_context_, std::move(mock_renderer),
+      remote.BindNewPipeAndPassReceiver());
+
+  mojo::PendingAssociatedRemote<mojom::RendererClient> client_remote_1;
+  auto client_receiver_1 = client_remote_1.InitWithNewEndpointAndPassReceiver();
+  std::vector<mojo::PendingRemote<mojom::DemuxerStream>> streams;
+  mojo::PendingRemote<mojom::DemuxerStream> stream_remote;
+  auto stream_receiver = stream_remote.InitWithNewPipeAndPassReceiver();
+  streams.push_back(std::move(stream_remote));
+  remote->Initialize(std::move(client_remote_1), std::move(streams),
+                     base::DoNothing());
+
+  mojo::PendingAssociatedRemote<mojom::RendererClient> client_remote_2;
+  auto client_receiver_2 = client_remote_2.InitWithNewEndpointAndPassReceiver();
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  remote->Initialize(std::move(client_remote_2), std::nullopt,
+                     base::DoNothing());
+
+  EXPECT_EQ("MojoRendererService is already initialized",
+            bad_message_observer.WaitForBadMessage());
+}
 TEST_F(MojoRendererTest, Initialize_Failure) {
   CreateAudioStream();
   // Mojo Renderer only expects a boolean result, which will be translated
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Use-After-Free in Cast StarboardRenderer via double initialization

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. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A compromised renderer can trigger a Use-After-Free in the Cast browser process by initializing a StarboardRenderer twice via its Mojo interface. If the second initialization fails, the underlying player is freed but a dangling pointer remains in GeometryChangeHandler, which can later be dereferenced by submitting a specific compositor frame.

Affected files:

  • chromecast/starboard/media/renderer/starboard_renderer.cc
  • chromecast/starboard/media/renderer/geometry_change_handler.cc
  • chromecast/starboard/media/renderer/geometry_change_handler.h
  • chromecast/starboard/media/renderer/starboard_player_manager.cc
  • media/mojo/services/mojo_renderer_service.cc
  • chromecast/starboard/media/media/starboard_api_wrapper_base.cc

Estimated timestamp from git blame: 2025-10-06

Summary

A potential Use-After-Free (UAF) vulnerability exists in the Cast browser process on Starboard builds. The MojoRendererService uses a DCHECK to prevent re-initialization, which is compiled out in release builds. This allows a compromised renderer to call Initialize multiple times. If a subsequent initialization fails, StarboardRenderer destroys the existing StarboardPlayerManager (freeing the SbPlayer) but fails to clear the sb_player_ pointer in its GeometryChangeHandler. A subsequent geometry update via a VideoHoleDrawQuad will then write attacker-controlled bounds to the freed SbPlayer memory.

Vulnerability Details

  1. MojoRendererService::Initialize (in media/mojo/services/mojo_renderer_service.cc) uses a debug-only DCHECK_EQ(state_, STATE_UNINITIALIZED) to ensure it is only called once. In release builds, a compromised renderer can call this method multiple times on the same remote.
  2. When Initialize is called a second time, it eventually invokes StarboardRenderer::Initialize (in chromecast/starboard/media/renderer/starboard_renderer.cc).
  3. StarboardRenderer::Initialize attempts to create a new StarboardPlayerManager by calling StarboardPlayerManager::Create(...).
  4. If the attacker provides an unsupported media configuration (e.g., using AudioCodec::kAMR_NB), Create fails and returns nullptr.
  5. The assignment player_manager_ = StarboardPlayerManager::Create(...) evaluates to nullptr. Because player_manager_ is a std::unique_ptr, this destroys the old StarboardPlayerManager from the first initialization, which in turn calls SbPlayerDestroy to free the underlying vendor-allocated SbPlayer.
  6. Because player_manager_ is now null, StarboardRenderer::Initialize returns early with an error:
    if (!player_manager_) {
      // Early return on failure
      media_task_runner_->PostTask(..., PIPELINE_ERROR_INITIALIZATION_FAILED);
      return;
    }
    
  7. Crucially, the early return skips the subsequent call to geometry_change_handler_.SetSbPlayer(...). As a result, the GeometryChangeHandler retains a dangling raw_ptr<void> sb_player_ pointing to the freed memory.
  8. The attacker can then trigger GeometryChangeHandler::OnVideoGeometryChange by submitting a compositor frame from the renderer containing a VideoHoleDrawQuad with the original overlay_plane_id and attacker-controlled bounds.
  9. OnVideoGeometryChange calls SetPlayerBounds, which invokes the vendor Starboard API SbPlayerSetBounds with the dangling sb_player_ pointer and the attacker-controlled coordinates, resulting in a highly controllable Write-After-Free in the browser process.

Note: MiraclePtr (BackupRefPtr) does not mitigate this vulnerability because SbPlayer is allocated by an external vendor library, not PartitionAlloc.

Potential Reproduction Steps

Note: These are suggested steps for an attacker; we do not have a working proof of concept.

  1. From a compromised renderer, call media.mojom.InterfaceFactory::CreateCastRenderer with a generated overlay_plane_id to obtain a browser-process media.mojom.Renderer remote.
  2. Call Renderer::Initialize with a valid media configuration (e.g., AAC/H264) and wait for the success callback. This successfully allocates the SbPlayer.
  3. Call Renderer::Initialize again on the same remote, this time providing an invalid configuration (e.g., advertising AudioCodec::kAMR_NB). This frees the SbPlayer but leaves a dangling pointer in GeometryChangeHandler.
  4. Submit a compositor frame from the renderer containing a VideoHoleDrawQuad. Set its overlay_plane_id to match the one from Step 1, and set its bounds to arbitrary values payload.
  5. The Viz process will forward the bounds to the browser process, triggering a write to the freed SbPlayer memory.

Suggested Fix

There are two places where this should be addressed for defense-in-depth:

  1. Prevent re-initialization at the Mojo boundary: In MojoRendererService::Initialize, replace or supplement the DCHECK with a hard check that terminates the renderer if it attempts to initialize twice:
    if (state_ != STATE_UNINITIALIZED) {
      mojo::ReportBadMessage("MojoRendererService cannot be initialized multiple times");
      return;
    }
    
  2. Clear the dangling pointer on failure: In StarboardRenderer::Initialize, ensure the GeometryChangeHandler’s pointer is cleared if player creation fails:
    if (!player_manager_) {
      geometry_change_handler_.SetSbPlayer(nullptr); // ADD THIS LINE
      LOG(ERROR) << "Unable to create StarboardPlayerManager";
      // ...
      return;
    }
    

Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09


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