High watch ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebRTC
DescriptionUse after free in WebRTC
ComponentWebRTC
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — revised 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 (tier revised from patch-now)
Reviewer notes: Mechanism matches diff and regression test. But trigger needs hardware-decoder (gpu_factories) init exceeding 10s timeout plus decoder invoking init callback after destruction; not attacker-controlled 'default'. 'Requiring only' understates this.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker523712556
Fix commit18a41228c0a0 (chromium/src) +61/-14
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
CreditedGoogle
Reported2026-06-14 (per vendor advisory)
Fix landed2026-06-25 (public git)
Disclosed2026-07-08
Patch gap13 days — fix public in source before the advisory

Background

`RTCVideoDecoderAdapter::Impl`
the media-thread-resident helper that owns the underlying video_decoder_ and drives its asynchronous Initialize()/Decode() operations for a WebRTC video stream.
`weak_decoder_this_`
a weak pointer to the Impl instance whose bound callbacks are silently dropped once the Impl (and its video_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 hard base::Seconds(10)) and, on timeout, records latency and returns false, tearing the adapter down while the posted Initialize() 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().

Key insight
The completion callback captured the decoder by an unretained raw pointer, so a timeout-driven teardown of 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

  1. Start a slow decoder init A page (via WebRTC) causes an RTCVideoDecoderAdapter to be created, invoking the media decoder’s asynchronous Initialize() whose completion is bound with CrossThreadUnretained(video_decoder_.get()).
  2. Force the sync-wait timeout Initialization is delayed past the wait window so async_init_waiter_->TimedWait(...) returns false; the creation path runs RecordInitializationLatency(...) and return false;, tearing down the adapter and its Impl (and freeing video_decoder_).
  3. Late completion fires The still-pending Initialize() callback runs on the media thread and executes *decoder_type = video_decoder->GetDecoderType() against the freed video_decoder_, a use-after-free.
  4. 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

An attacker able to drive WebRTC video decoding can race decoder initialization against adapter teardown to trigger a use-after-free of the media decoder object in the renderer/media-thread context, requiring only that initialization be slow enough to hit the synchronous timeout. The advisory classifies this as a use-after-free in WebRTC; it does not state in-the-wild exploitation. Escalation from the freed-object access to a controlled read/write or code execution is not demonstrated by the patch and is (inferred).

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc
modified

Files Changed

  • third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.cc
  • third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter.h
  • third_party/blink/renderer/platform/peerconnection/rtc_video_decoder_adapter_test.cc

Audit Directions

  • Unretained pointers in async completions
    Audit any CrossThreadUnretained/base::Unretained capture 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 callbacks
    Review 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 ordering
    Where 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.
From 18a41228c0a061c0a08d01c22e943e4862223f1e Mon Sep 17 00:00:00 2001
From: Erik Språng <sprang@chromium.org>
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 <guidou@chromium.org>
Commit-Queue: Erik Språng <sprang@chromium.org>
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
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by rj...@google.com

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.cc
  • third_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:

  1. Task A Enqueued: InitializeSync posts Impl::Initialize (Task A) to the media thread and blocks via TimedWait(base::Seconds(10)).
  2. Timeout: If the media thread is heavily backlogged, Task A does not execute within 10 seconds. InitializeSync returns false.
  3. Destruction & Task B Enqueued: The caller (e.g., RTCVideoDecoderAdapter::Create) destroys the adapter. The destructor calls Release(), which posts impl.reset() (Task B) to the media thread queue and waits for it. The queue is now [Task A, Task B].
  4. Task A Executes & Enqueues Task C: The media thread eventually processes Task A. It instantiates a media::VideoDecoder and calls its Initialize() method, passing a lambda callback that binds CrossThreadUnretained(video_decoder_.get()). If initialization fails synchronously (e.g., bad config), decoders like MediaCodecVideoDecoder use base::BindPostTaskToCurrentDefault to post the completion callback asynchronously. This appends Task C to the queue, resulting in [Task B, Task C].
  5. Task B Executes: The media thread runs Task B (impl.reset()). This destroys the Impl object, which in turn frees the media::VideoDecoder. Task B signals Release() to complete, allowing the RTCVideoDecoderAdapter to be freed.
  6. 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 freed VideoDecoder object.
  7. Further UAFs: The lambda continues to write to the dangling decoder_type_ pointer. Finally, FinishWait calls waiter->Signal() on the dangling async_init_waiter_ pointer. WaitableEvent::Signal() loops over an internal list of waiters and calls the pure virtual method Fire(), 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.

  1. A malicious website initiates a WebRTC video stream.
  2. The attacker uses intensive JavaScript operations to intentionally spam and backlog the renderer’s media task runner.
  3. This forces the 10-second TimedWait in InitializeSync to expire while initialization tasks are still pending.
  4. 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.
  5. 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.

View on issue tracker