CVE-2026-15121
Overview
Background
- `RTCVideoDecoderAdapter::Impl`
- the media-thread-resident helper that owns the underlying
video_decoder_and drives its asynchronousInitialize()/Decode()operations for a WebRTC video stream. - `weak_decoder_this_`
- a weak pointer to the
Implinstance whose bound callbacks are silently dropped once theImpl(and itsvideo_decoder_) has been destroyed. - `CrossThreadUnretained`
- a cross-thread binding wrapper that captures a raw, unretained pointer with no lifetime guarantee, so the callee must independently ensure the pointee is still alive.
- Synchronous init timeout
- creation waits on
async_init_waiter_->TimedWait(...)(formerly a hardbase::Seconds(10)) and, on timeout, records latency and returnsfalse, tearing the adapter down while the postedInitialize()completion may still be pending.
Root Cause Analysis
The Initialize() completion callback was bound with CrossThreadUnretained(video_decoder_.get()), capturing a raw pointer to the Impl-owned decoder rather than a lifetime-safe reference. When the synchronous creation path timed out (if (!async_init_waiter_->TimedWait(base::Seconds(10))) then return false;), the adapter and its Impl — including video_decoder_ — could be destroyed while the media decoder’s asynchronous Initialize() was still in flight. If that pending completion later fired, the bound lambda executed *decoder_type = video_decoder->GetDecoderType() on the already-freed decoder, a use-after-free on the media thread.
The fix rebinds the completion to the new OnInitializeDone() method via CrossThreadBindOnce(..., weak_decoder_this_, ...), so a destroyed Impl causes the callback to be dropped instead of dereferencing dead memory; the decoder_type raw pointer is retained via CrossThreadUnretained only because the added comment documents that it aliases the adapter’s decoder_type_ member, which the adapter destructor guarantees to outlive Impl by blocking on the media thread during Release().
Impl left a dangling reference that the late-arriving Initialize() result dereferenced; routing the callback through weak_decoder_this_ makes the stale invocation a no-op instead of a UAF.Attack Path
- Start a slow decoder init
A page (via WebRTC) causes an
RTCVideoDecoderAdapterto be created, invoking the media decoder’s asynchronousInitialize()whose completion is bound withCrossThreadUnretained(video_decoder_.get()). - Force the sync-wait timeout
Initialization is delayed past the wait window so
async_init_waiter_->TimedWait(...)returns false; the creation path runsRecordInitializationLatency(...)andreturn false;, tearing down the adapter and itsImpl(and freeingvideo_decoder_). - Late completion fires
The still-pending
Initialize()callback runs on the media thread and executes*decoder_type = video_decoder->GetDecoderType()against the freedvideo_decoder_, a use-after-free. - Memory corruption from the stale access
The dereference of freed decoder memory yields a UAF read/write on the media thread (further exploitation primitives are not shown in the patch)
(inferred).
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc |
modified |
Files Changed
third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.ccthird_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.hthird_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc
Audit Directions
- Unretained pointers in async completionsAudit any
CrossThreadUnretained/base::Unretainedcapture of an object owned by a helper whose destruction is not synchronized with the pending callback; prefer weak pointers for the owner and lifetime-anchored aliases for members. - Timeout/early-return teardown with in-flight callbacksReview paths that time out or return early and destroy an object while an asynchronous operation it launched can still complete and call back into freed state.
- Cross-thread destruction orderingWhere a destructor blocks on another sequence to guarantee outlival relationships (as documented for
decoder_type_), verify that assumption holds for every pointer bound into cross-thread tasks.
Patch
From 18a41228c0a061c0a08d01c22e943e4862223f1e Mon Sep 17 00:00:00 2001 From: Erik Språng <[email protected]> Date: Thu, 25 Jun 2026 04:27:23 -0700 Subject: [PATCH] Add better handling of RtcVideoDecoderAdapter init timeout. This CL introduced as new private `OnInitializeDone()` callback method and uses the `weak_decoder_this_` instead of unreatined `video_decoder_.get()`. Bug: 523712556 Change-Id: Icc2f8272b948dbc4fa65d5175dbcf60cea033462 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7992498 Reviewed-by: Guido Urdaneta <[email protected]> Commit-Queue: Erik Språng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1652310} --- diff --git a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.cc b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.cc index 2a387ac..0fe5b9a 100644 --- a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.cc +++ b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.cc @@ -62,6 +62,8 @@ // Maximum number of buffers that we will queue in |pending_buffers_|. constexpr int32_t kMaxPendingBuffers = 8; +std::optional<base::TimeDelta> g_init_timeout_for_testing; + // Maximum number of timestamps that will be maintained in |decode_timestamps_|. // Really only needs to be a bit larger than the maximum reorder distance (which // is presumably 0 for WebRTC), but being larger doesn't hurt much. @@ -234,6 +236,9 @@ void DecodePendingBuffers(); void OnDecodeDone(media::DecoderStatus status); void OnOutput(scoped_refptr<media::VideoFrame> frame); + void OnInitializeDone(CrossThreadOnceFunction<void(bool)> init_cb, + media::VideoDecoderType* decoder_type, + media::DecoderStatus status); const raw_ptr<media::GpuVideoAcceleratorFactories> gpu_factories_; const scoped_refptr<WebRtcVideoFrameAdapter::SharedResources> @@ -291,22 +296,28 @@ media::VideoDecoder::OutputCB output_cb = ConvertToBaseRepeatingCallback(CrossThreadBindRepeating( &RTCVideoDecoderAdapter::Impl::OnOutput, weak_decoder_this_)); + // Safe to use CrossThreadUnretained(decoder_type) because `decoder_type` + // points to the `RTCVideoDecoderAdapter::decoder_type_` member variable, + // which is guaranteed to outlive `Impl` since the adapter's destructor + // blocks synchronously on the media thread while destroying `Impl` in + // `Release()`. video_decoder_->Initialize( - config, /*low_delay=*/true, - /*cdm_context=*/nullptr, - base::BindOnce( - [](base::OnceCallback<void(bool)> cb, - media::VideoDecoderType* decoder_type, - media::VideoDecoder* video_decoder, media::DecoderStatus status) { - *decoder_type = video_decoder->GetDecoderType(); - std::move(cb).Run(status.is_ok()); - }, - ConvertToBaseOnceCallback(std::move(init_cb)), - CrossThreadUnretained(decoder_type), - CrossThreadUnretained(video_decoder_.get())), + config, /*low_delay=*/true, /*cdm_context=*/nullptr, + ConvertToBaseOnceCallback(CrossThreadBindOnce( + &RTCVideoDecoderAdapter::Impl::OnInitializeDone, weak_decoder_this_, + std::move(init_cb), CrossThreadUnretained(decoder_type))), output_cb, base::DoNothing()); } +void RTCVideoDecoderAdapter::Impl::OnInitializeDone( + CrossThreadOnceFunction<void(bool)> init_cb, + media::VideoDecoderType* decoder_type, + media::DecoderStatus status) { + DCHECK_CALLED_ON_VALID_SEQUENCE(media_sequence_checker_); + *decoder_type = video_decoder_->GetDecoderType(); + std::move(init_cb).Run(status.is_ok()); +} + void RTCVideoDecoderAdapter::Impl::Decode( scoped_refptr<media::DecoderBuffer> buffer, base::WaitableEvent* waiter, @@ -628,8 +639,8 @@ weak_impl_, config, std::move(init_cb), start_time, CrossThreadUnretained(&decoder_type_)))) { - // TODO(crbug.com/1076817) Remove if a root cause is found. - if (!async_init_waiter_->TimedWait(base::Seconds(10))) { + if (!async_init_waiter_->TimedWait( + g_init_timeout_for_testing.value_or(base::Seconds(10)))) { RecordInitializationLatency(base::TimeTicks::Now() - start_time); return false; } @@ -946,4 +957,9 @@ g_num_decoders_--; } +void RTCVideoDecoderAdapter::SetInitializeSyncTimeoutForTesting( + std::optional<base::TimeDelta> timeout) { + g_init_timeout_for_testing = timeout; +} + } // namespace blink diff --git a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.h b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.h index f2ddb988..d4c522e 100644 --- a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.h +++ b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.h @@ -91,6 +91,8 @@ static int GetCurrentDecoderCountForTesting(); static void IncrementCurrentDecoderCountForTesting(); static void DecrementCurrentDecoderCountForTesting(); + static void SetInitializeSyncTimeoutForTesting( + std::optional<base::TimeDelta> timeout); static std::atomic<int> g_num_decoders_; diff --git a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc index 5bc83ddd..eb209540 100644 --- a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc +++ b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc @@ -21,6 +21,7 @@ #include "base/test/mock_callback.h" #include "base/test/scoped_feature_list.h" #include "base/test/task_environment.h" +#include "base/threading/platform_thread.h" #include "base/threading/thread.h" #include "base/time/time.h" #include "build/build_config.h" @@ -889,4 +890,32 @@ media_thread_.FlushForTesting(); } +TEST_F(RTCVideoDecoderAdapterTest, InitializeSyncTimeoutRace) { + RTCVideoDecoderAdapter::SetInitializeSyncTimeoutForTesting( + base::Milliseconds(1)); + + base::WaitableEvent initialize_called; + media::VideoDecoder::InitCB saved_init_cb; + + EXPECT_CALL(*video_decoder_, Initialize_) + .WillOnce(testing::WithArg<3>([&](media::VideoDecoder::InitCB& init_cb) { + saved_init_cb = std::move(init_cb); + initialize_called.Signal(); + })); + + ASSERT_FALSE(RTCVideoDecoderAdapterWrapper::Create( + &gpu_factories_, + webrtc::SdpVideoFormat( + webrtc::CodecTypeToPayloadString(webrtc::kVideoCodecVP9)), + true)); + + initialize_called.Wait(); + media_thread_.task_runner()->PostTask( + FROM_HERE, base::BindOnce(std::move(saved_init_cb), + media::DecoderStatus::Codes::kOk)); + media_thread_.FlushForTesting(); + + RTCVideoDecoderAdapter::SetInitializeSyncTimeoutForTesting(std::nullopt); +} + } // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc
index 5bc83ddd..eb209540 100644
--- a/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc
+++ b/third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc
@@ -21,6 +21,7 @@
#include "base/test/mock_callback.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
+#include "base/threading/platform_thread.h"
#include "base/threading/thread.h"
#include "base/time/time.h"
#include "build/build_config.h"
@@ -889,4 +890,32 @@
media_thread_.FlushForTesting();
}
+TEST_F(RTCVideoDecoderAdapterTest, InitializeSyncTimeoutRace) {
+ RTCVideoDecoderAdapter::SetInitializeSyncTimeoutForTesting(
+ base::Milliseconds(1));
+
+ base::WaitableEvent initialize_called;
+ media::VideoDecoder::InitCB saved_init_cb;
+
+ EXPECT_CALL(*video_decoder_, Initialize_)
+ .WillOnce(testing::WithArg<3>([&](media::VideoDecoder::InitCB& init_cb) {
+ saved_init_cb = std::move(init_cb);
+ initialize_called.Signal();
+ }));
+
+ ASSERT_FALSE(RTCVideoDecoderAdapterWrapper::Create(
+ &gpu_factories_,
+ webrtc::SdpVideoFormat(
+ webrtc::CodecTypeToPayloadString(webrtc::kVideoCodecVP9)),
+ true));
+
+ initialize_called.Wait();
+ media_thread_.task_runner()->PostTask(
+ FROM_HERE, base::BindOnce(std::move(saved_init_cb),
+ media::DecoderStatus::Codes::kOk));
+ media_thread_.FlushForTesting();
+
+ RTCVideoDecoderAdapter::SetInitializeSyncTimeoutForTesting(std::nullopt);
+}
+
} // namespace blink
Original Bug Report
Potential Use-After-Free in RTCVideoDecoderAdapter::InitializeSync via Timeout Race Condition
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A race condition exists in RTCVideoDecoderAdapter where initialization can time out, causing the adapter to be destroyed while initialization tasks remain pending. Because WTF::CrossThreadUnretained is used to capture raw pointers, subsequent asynchronous failure callbacks bypass MiraclePtr and access dangling pointers. This results in UAF virtual method calls and memory corruption, potentially leading to renderer Remote Code Execution (RCE).
Affected files:
third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.ccthird_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.h
Estimated timestamp from git blame: 2025-11-20
Summary
A race condition in RTCVideoDecoderAdapter::InitializeSync can lead to a heap use-after-free (UAF) in the renderer process. When initialization of the video decoder takes longer than the hardcoded 10-second timeout, InitializeSync returns false, causing the caller to destroy the RTCVideoDecoderAdapter object. However, initialization tasks previously posted to the media thread may still be pending and hold raw pointers to the adapter’s members and the underlying media::VideoDecoder. When these tasks eventually run, they access freed memory.
Vulnerability Details
In RTCVideoDecoderAdapter::InitializeSync (third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.cc:607), several members are captured via CrossThreadUnretained and passed to a task on the media thread:
auto init_cb = CrossThreadBindOnce(
&FinishWait, CrossThreadUnretained(async_init_waiter_.get()),
CrossThreadUnretained(&async_init_result_));
if (PostCrossThreadTask(
*media_task_runner_.get(), FROM_HERE,
CrossThreadBindOnce(&RTCVideoDecoderAdapter::Impl::Initialize,
weak_impl_, config, std::move(init_cb), start_time,
CrossThreadUnretained(&decoder_type_)))) {
if (!async_init_waiter_->TimedWait(base::Seconds(10))) {
// ...
return false; // Timeout occurs here
}
Because WTF::CrossThreadUnretained is used, these pointers decay to raw C++ pointers, completely bypassing MiraclePtr (BackupRefPtr) protection.
The UAF race condition occurs as follows:
- Task A Enqueued:
InitializeSyncpostsImpl::Initialize(Task A) to the media thread and blocks viaTimedWait(base::Seconds(10)). - Timeout: If the media thread is heavily backlogged, Task A does not execute within 10 seconds.
InitializeSyncreturnsfalse. - Destruction & Task B Enqueued: The caller (e.g.,
RTCVideoDecoderAdapter::Create) destroys the adapter. The destructor callsRelease(), which postsimpl.reset()(Task B) to the media thread queue and waits for it. The queue is now[Task A, Task B]. - Task A Executes & Enqueues Task C: The media thread eventually processes Task A. It instantiates a
media::VideoDecoderand calls itsInitialize()method, passing a lambda callback that bindsCrossThreadUnretained(video_decoder_.get()). If initialization fails synchronously (e.g., bad config), decoders likeMediaCodecVideoDecoderusebase::BindPostTaskToCurrentDefaultto post the completion callback asynchronously. This appends Task C to the queue, resulting in[Task B, Task C]. - Task B Executes: The media thread runs Task B (
impl.reset()). This destroys theImplobject, which in turn frees themedia::VideoDecoder. Task B signalsRelease()to complete, allowing theRTCVideoDecoderAdapterto be freed. - Task C Executes (UAF): The media thread runs Task C, the completion lambda. The lambda executes
video_decoder->GetDecoderType(), which is a UAF virtual method call on the freedVideoDecoderobject. - Further UAFs: The lambda continues to write to the dangling
decoder_type_pointer. Finally,FinishWaitcallswaiter->Signal()on the danglingasync_init_waiter_pointer.WaitableEvent::Signal()loops over an internal list of waiters and calls the pure virtual methodFire(), providing a secondary avenue for a UAF virtual method call.
Impact
This is a heap use-after-free vulnerability in the renderer process. An attacker could potentially exploit this race condition to achieve arbitrary Remote Code Execution (RCE) by reallocating the freed media::VideoDecoder or WaitableEvent memory with controlled data before Task C executes. Hijacking the virtual method calls (GetDecoderType() or Fire()) provides direct control over the instruction pointer.
Triggering the issue (Potential Steps)
Note: These are suggested steps; our tooling agent does not yet run code to provide a functional PoC.
- A malicious website initiates a WebRTC video stream.
- The attacker uses intensive JavaScript operations to intentionally spam and backlog the renderer’s media task runner.
- This forces the 10-second
TimedWaitinInitializeSyncto expire while initialization tasks are still pending. - The attacker provides an invalid video configuration to force the synchronous failure path in the underlying decoder, ensuring Task C is appended after Task B.
- The attacker uses standard JS heap manipulation techniques to reclaim the freed memory between the execution of Task B and Task C.
Suggested Fix
Remove the use of WTF::CrossThreadUnretained in RTCVideoDecoderAdapter::InitializeSync. Instead of capturing raw pointers to member variables, the completion callback should be bound to a base::WeakPtr of the adapter, or the RTCVideoDecoderAdapter should safely manage the lifetime of its initialization state such that pending callbacks are explicitly cancelled or ignored if the adapter is destroyed.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.