Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebRTC
DescriptionUse after free in WebRTC
ComponentWebRTC
Bug ClassUAF
Tracker504586599
Fix commitc56abf1fe8e4 (chromium/src) +171/-61
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-28

Changed Functions

FunctionChangeNotes
VideoMetronomeWorker
third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
modified
if
third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
modified

Files Changed

  • third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
From c56abf1fe8e4cb61159d9eebcc6dac6bcd9db886 Mon Sep 17 00:00:00 2001
From: Tony Herre <toprice@chromium.org>
Date: Tue, 21 Apr 2026 05:45:58 -0700
Subject: [PATCH] Fix cross-thread UAF in RTCEncodedVideoStreamTransformerDelegate

The RTCEncodedVideoStreamTransformerDelegate was using base::WeakPtr
across multiple threads (worker, signaling, and main), which is not
thread-safe and led to use-after-free vulnerabilities.

This CL refactors the delegate to use a separate ref-counted worker
class, VideoMetronomeWorker. This worker handles metronome tasks
using webrtc::scoped_refptr to keep itself alive during cross-thread
callbacks, eliminating the need for WeakPtr.

The design also avoids circular references by decoupling the metronome
state from the delegate's lifecycle and ensures thread safety by
relying on sequence affinity for worker state.

A new unit test WorkerOutlivesDelegate verifies the fix.

Bug: 504586599
Change-Id: I935074a5c4f3efa772a764079ffc337ab9f30128
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7779865
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Commit-Queue: Tony Herre <toprice@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1618115}
---

diff --git a/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc b/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
index a0d07f2..0b5d4735 100644
--- a/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
+++ b/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
@@ -19,6 +19,7 @@
 #include "third_party/blink/renderer/platform/wtf/cross_thread_copier_base.h"
 #include "third_party/blink/renderer/platform/wtf/cross_thread_copier_std.h"
 #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
+#include "third_party/blink/renderer/platform/wtf/thread_safe_ref_counted.h"
 #include "third_party/webrtc/api/frame_transformer_interface.h"
 #include "third_party/webrtc/rtc_base/ref_counted_object.h"
 
@@ -36,6 +37,126 @@
 // shortcircuiting/setting transforms.
 const size_t kMaxBufferedFrames = 60;
 
+// This class handles the metronome-related tasks for the transformer delegate.
+// It is ref-counted and decoupled from the delegate's lifecycle to avoid
+// circular references and ensure cross-thread safety.
+class VideoMetronomeWorker : public ThreadSafeRefCounted<VideoMetronomeWorker> {
+ public:
+  VideoMetronomeWorker(
+      std::unique_ptr<Metronome> metronome,
+      scoped_refptr<RTCEncodedVideoStreamTransformer::Broker>
+          transformer_broker,
+      scoped_refptr<base::SingleThreadTaskRunner> source_task_runner)
+      : source_task_runner_(source_task_runner),
+        transformer_broker_(std::move(transformer_broker)),
+        use_metronome_(!!metronome),
+        metronome_(std::move(metronome)) {}
+
+  void SetSourceTaskRunner(
+      scoped_refptr<base::SingleThreadTaskRunner> task_runner) {
+    base::AutoLock locker(source_task_runner_lock_);
+    source_task_runner_ = std::move(task_runner);
+  }
+
+  void RegisterTransformedFrameSinkCallback(
+      webrtc::scoped_refptr<webrtc::TransformedFrameCallback>
+          send_frame_to_sink_callback,
+      uint32_t ssrc) {
+    transformer_broker_->RegisterTransformedFrameSinkCallback(
+        std::move(send_frame_to_sink_callback), ssrc);
+  }
+
+  void UnregisterTransformedFrameSinkCallback(uint32_t ssrc) {
+    transformer_broker_->UnregisterTransformedFrameSinkCallback(ssrc);
+  }
+
+  void Transform(
+      std::unique_ptr<webrtc::TransformableVideoFrameInterface> frame) {
+    if (use_metronome_) {
+      bool should_schedule_tick = false;
+      {
+        base::AutoLock locker(metronome_lock_);
+        queued_frames_.emplace_back(std::move(frame));
+        if (!tick_scheduled_) {
+          tick_scheduled_ = true;
+          should_schedule_tick = true;
+        }
+      }
+
+      if (should_schedule_tick) {
+        // Using a lambda here instead of a OnceClosure as
+        // RequestCallOnNextTick() requires an absl::AnyInvocable.
+        metronome_->RequestCallOnNextTick(
+            [worker = scoped_refptr<VideoMetronomeWorker>(this)] {
+              worker->InvokeQueuedTransforms();
+            });
+      }
+      return;
+    }
+
+    scoped_refptr<base::SingleThreadTaskRunner> task_runner;
+    {
+      base::AutoLock locker(source_task_runner_lock_);
+      task_runner = source_task_runner_;
+    }
+    if (task_runner) {
+      PostCrossThreadTask(
+          *task_runner, FROM_HERE,
+          CrossThreadBindOnce(&RTCEncodedVideoStreamTransformer::Broker::
+                                  TransformFrameOnSourceTaskRunner,
+                              transformer_broker_, std::move(frame)));
+    }
+  }
+
+  void InvokeQueuedTransforms() {
+    Vector<std::unique_ptr<webrtc::TransformableVideoFrameInterface>> frames;
+    {
+      base::AutoLock locker(metronome_lock_);
+      tick_scheduled_ = false;
+      frames = std::move(queued_frames_);
+    }
+
+    scoped_refptr<base::SingleThreadTaskRunner> task_runner;
+    {
+      base::AutoLock locker(source_task_runner_lock_);
+      task_runner = source_task_runner_;
+    }
+    if (!task_runner) {
+      return;
+    }
+    for (std::unique_ptr<webrtc::TransformableVideoFrameInterface>& frame :
+         frames) {
+      PostCrossThreadTask(
+          *task_runner, FROM_HERE,
+          CrossThreadBindOnce(&RTCEncodedVideoStreamTransformer::Broker::
+                                  TransformFrameOnSourceTaskRunner,
+                              transformer_broker_, std::move(frame)));
+    }
+  }
+
+  void Disconnect() {
+    base::AutoLock locker(source_task_runner_lock_);
+    source_task_runner_.reset();
+  }
+
+ private:
+  friend class ThreadSafeRefCounted<VideoMetronomeWorker>;
+  ~VideoMetronomeWorker() = default;
+
+  base::Lock source_task_runner_lock_;
+  scoped_refptr<base::SingleThreadTaskRunner> source_task_runner_
+      GUARDED_BY(source_task_runner_lock_);
+
+  scoped_refptr<RTCEncodedVideoStreamTransformer::Broker> transformer_broker_;
+  const bool use_metronome_;
+  const std::unique_ptr<Metronome> metronome_;
+
+  base::Lock metronome_lock_;
+  bool tick_scheduled_ GUARDED_BY(metronome_lock_) = false;
+  Vector<std::unique_ptr<webrtc::TransformableVideoFrameInterface>>
+      queued_frames_ GUARDED_BY(metronome_lock_);
+};
+
 // This delegate class exists to work around the fact that
 // RTCEncodedVideoStreamTransformer cannot derive from webrtc::RefCountedObject
 // and post tasks referencing itself as an webrtc::scoped_refptr. Instead,
@@ -50,17 +171,18 @@
       scoped_refptr<RTCEncodedVideoStreamTransformer::Broker>
           transformer_broker,
       std::unique_ptr<Metronome> metronome)
-      : source_task_runner_(realm_task_runner),
-        transformer_broker_(std::move(transformer_broker)),
-        metronome_(std::move(metronome)) {
-    DCHECK(source_task_runner_->BelongsToCurrentThread());
-    DETACH_FROM_SEQUENCE(metronome_sequence_checker_);
+      : metronome_worker_(base::MakeRefCounted<VideoMetronomeWorker>(
+            std::move(metronome),
+            std::move(transformer_broker),
+            realm_task_runner)) {}
+
+  ~RTCEncodedVideoStreamTransformerDelegate() override {
+    metronome_worker_->Disconnect();
   }
 
   void SetSourceTaskRunner(
       scoped_refptr<base::SingleThreadTaskRunner> task_runner) {
-    base::AutoLock locker(source_task_runner_lock_);
-    source_task_runner_ = std::move(task_runner);
+    metronome_worker_->SetSourceTaskRunner(std::move(task_runner));
   }
 
   // webrtc::FrameTransformerInterface
@@ -68,72 +190,23 @@
       webrtc::scoped_refptr<webrtc::TransformedFrameCallback>
           send_frame_to_sink_callback,
       uint32_t ssrc) override {
-    transformer_broker_->RegisterTransformedFrameSinkCallback(
+    metronome_worker_->RegisterTransformedFrameSinkCallback(
         std::move(send_frame_to_sink_callback), ssrc);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer_test.cc b/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer_test.cc
index 8460eb6..6aa2827 100644
--- a/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer_test.cc
+++ b/third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer_test.cc
@@ -287,4 +287,41 @@
           CrossThreadUnretained(&mock_transformer_callback_holder_)));
 }
 
+TEST_P(RTCEncodedVideoStreamTransformerTest, WorkerOutlivesDelegate) {
+  if (!GetParam()) {
+    return;
+  }
+
+  MockTransformerCallbackHolder lifecycle_callback_holder;
+  scoped_refptr<base::SingleThreadTaskRunner> main_runner =
+      blink::scheduler::GetSingleThreadTaskRunnerForTesting();
+  auto* mock_metronome = new NiceMock<MockMetronome>();
+  // Using AnyInvocable as that's what the libwebrtc Metronome
+  // interface requires.
+  absl::AnyInvocable<void() &&> metronome_callback;
+  EXPECT_CALL(*mock_metronome, RequestCallOnNextTick)
+      .WillOnce([&](absl::AnyInvocable<void() &&> c) {
+        metronome_callback = std::move(c);
+      });
+
+  auto transformer = std::make_unique<RTCEncodedVideoStreamTransformer>(
+      main_runner, absl::WrapUnique(mock_metronome));
+  transformer->SetTransformerCallback(CrossThreadBindRepeating(
+      &MockTransformerCallbackHolder::OnEncodedFrame,
+      CrossThreadUnretained(&lifecycle_callback_holder)));
+
+  // Send a frame to schedule a tick.
+  transformer->Delegate()->Transform(CreateMockFrame());
+  ASSERT_TRUE(metronome_callback);
+
+  // Destroy the transformer. This should call Disconnect() on the delegate's
+  // worker.
+  transformer.reset();
+
+  // Now fire the metronome tick. It should NOT crash and should NOT call
+  // the callback (since the transformer is gone).
+  EXPECT_CALL(lifecycle_callback_holder, OnEncodedFrame).Times(0);
+  std::move(metronome_callback)();
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by vm...@google.com

Renderer UAF in RTCEncodedVideoStreamTransformerDelegate via cross-thread WeakPtr usage

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 https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential use-after-free vulnerability exists in RTCEncodedVideoStreamTransformerDelegate due to a Time-of-Check Time-of-Use (TOCTOU) race condition when resolving a base::WeakPtr across different threads. In release builds, bypassed sequence checks allow the object to be destroyed while a callback is simultaneously executing on a different thread. An attacker could potentially reclaim the freed memory to achieve arbitrary code execution via a hijacked virtual function call.

Affected files:

  • third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc

Estimated timestamp from git blame: 2024-01-09

Summary

A potential Use-After-Free (UAF) vulnerability has been identified in RTCEncodedVideoStreamTransformerDelegate within the Blink renderer. The issue arises from a base::WeakPtr being captured in a callback that executes on the WebRTC worker thread, while the delegate object itself is destroyed on a different thread (typically the WebRTC signaling thread). Because base::WeakPtr sequence affinity checks are disabled in release builds, a Time-of-Check Time-of-Use (TOCTOU) race condition allows the worker thread to successfully resolve the WeakPtr and execute methods on the delegate after its memory has been freed.

Root Cause Analysis

When an encoded video stream is transformed, RTCEncodedVideoStreamTransformerDelegate::Transform() executes on the WebRTC worker thread. If a metronome is active, it schedules a callback for the next tick, capturing a base::WeakPtr to itself:

// third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc
metronome_->RequestCallOnNextTick(
    [delegate = weak_factory_.GetWeakPtr()] {
      if (delegate) {
        delegate->InvokeQueuedTransforms();
      }
    });

The delegate is a thread-safe ref-counted object (webrtc::RefCountedObject) owned jointly by a Blink-managed RTCEncodedVideoStreamTransformer (destroyed on the main thread) and a native WebRTC receiver proxy. During teardown, the final reference is typically released when the native WebRTC receiver proxy completes its destruction on the WebRTC signaling thread.

The vulnerability occurs due to a race condition between the metronome tick executing on the worker thread and the delegate destructor executing on the signaling thread:

  1. On the worker thread, the lambda evaluates if (delegate). This reads the atomic validity flag inside internal::WeakReference::Flag::IsValid().
  2. Concurrently, the signaling thread begins destroying the delegate. It reaches base::WeakPtrFactory::~WeakPtrFactory(), which calls Invalidate() to mark the atomic flag as invalid.
  3. In release builds (DCHECK_IS_ON() is false), Invalidate() skips the DCHECK_CALLED_ON_VALID_SEQUENCE check. The cross-thread invalidation succeeds without crashing the process.
  4. If the worker thread reads the atomic flag just before the signaling thread marks it invalid, if (delegate) passes. The subsequent delegate-> check also passes.
  5. The delegate is fully destroyed on the signaling thread, and the memory is returned to the allocator.
  6. The worker thread proceeds to call InvokeQueuedTransforms() on the now-dangling this pointer.

Impact

This vulnerability bypasses MiraclePtr (BackupRefPtr) protections because base::WeakPtr explicitly uses RAW_PTR_EXCLUSION T* ptr_ for its internal pointer to avoid keeping allocations in quarantine.

If an attacker can precisely time the teardown and perform heap spraying to reclaim the freed memory chunk, they can control the execution context of InvokeQueuedTransforms():

  1. The function begins with base::AutoLock locker(source_task_runner_lock_);. By filling the reclaimed memory with zeros, the underlying platform mutex will appear unlocked, allowing the lock acquisition to succeed without crashing.
  2. The function iterates over queued_frames_ (a WTF::Vector). An attacker can manipulate the vector’s metadata in the reclaimed memory to point to arbitrary locations.
  3. Inside the loop, it calls PostCrossThreadTask(*source_task_runner_, ...). If the attacker controls the source_task_runner_ pointer, this leads to a virtual function call (PostTask) on attacker-controlled memory.

This vtable hijack can be leveraged to achieve Arbitrary Code Execution (RCE) within the sandboxed Renderer process.

Potential Reproduction Steps

Note: These are suggested steps to trigger the vulnerability; a working proof-of-concept has not been verified via tooling yet.

  1. In JavaScript, negotiate a WebRTC connection with a video stream.
  2. Attach an RTCRtpScriptTransform to the receiver to force the creation of the RTCEncodedVideoStreamTransformerDelegate.
  3. Send a burst of encoded video frames to ensure the Transform method schedules the metronome callback on the worker thread.
  4. Just before the metronome tick is expected to fire, aggressively tear down the connection (e.g., pc.close() and drop references) to trigger destruction of the delegate on the signaling thread.
  5. Concurrently, perform heap spraying (e.g., via a Web Worker) with objects of the exact size of the delegate, filling the payload with zeros for the lock and attacker-controlled addresses for the source_task_runner_ pointer.
  6. If the race is won, the worker thread will execute InvokeQueuedTransforms() on the attacker’s payload.

Suggested Fix

To prevent this vulnerability, RTCEncodedVideoStreamTransformerDelegate must guarantee that any scheduled callbacks are either safely canceled or executed on valid memory.

One approach is to avoid capturing a WeakPtr bound to a different sequence than where destruction occurs. Instead, the class could implement a tear-down method that synchronously cancels the metronome task or invalidates the WeakPtrFactory on the worker thread before allowing the final reference count to drop and the destructor to run. Alternatively, a separate ref-counted state object could be created exclusively for the metronome callback to hold the queued frames, decoupling the callback’s lifecycle from the delegate’s lifecycle.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


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