CVE-2026-7341
Overview
Background
- `base::WeakPtr`
- a non-thread-safe weak reference whose invalidation and dereference must happen on the same sequence; using it across threads is a documented data race.
- `ThreadSafeRefCounted` / `scoped_refptr`
- reference-counting machinery whose refcount is atomic, so an object can be safely kept alive while callbacks run on other threads.
- Metronome
- a WebRTC scheduling primitive that batches work onto periodic “ticks” via
RequestCallOnNextTick, firing a callback on a potentially different thread later in time. - `RTCEncodedVideoStreamTransformerDelegate`
- the Blink object that bridges the WebRTC frame-transform callback interface to the encoded-video transform, posting frames across the worker, signaling, and main threads.
Root Cause Analysis
RTCEncodedVideoStreamTransformerDelegate held its state (source_task_runner_, transformer_broker_, metronome_) directly and relied on base::WeakPtr to reference itself across the worker, signaling, and main threads. Because base::WeakPtr is not thread-safe, a metronome tick scheduled via RequestCallOnNextTick could fire on one thread and race the delegate’s destruction on another, dereferencing a freed delegate — a cross-thread use-after-free.
The fix extracts all metronome/queue state into a new ThreadSafeRefCounted helper, VideoMetronomeWorker; the tick lambda captures scoped_refptr<VideoMetronomeWorker>(this), so the worker is guaranteed alive for the duration of the deferred callback even if the delegate is gone. The delegate’s new destructor calls metronome_worker_->Disconnect(), which takes source_task_runner_lock_ and resets source_task_runner_, so any later InvokeQueuedTransforms observes a null runner and returns without posting to the dead source. The shipped WorkerOutlivesDelegate test destroys the transformer before firing the stored tick and asserts no crash and zero callbacks, confirming the worker safely outlives the delegate.
base::WeakPtr to the delegate across multiple threads for metronome-deferred work; the fix replaces that lifetime model with a ref-counted VideoMetronomeWorker that callbacks keep alive via scoped_refptr, plus a Disconnect() that severs the source runner under a lock.Attack Path
- Set up an encoded transform
A page configures a WebRTC
RTCRtpScriptTransformon a video sender/receiver so frames flow throughRTCEncodedVideoStreamTransformerDelegatewith a metronome in play. - Schedule a tick
Transforming a frame enqueues it and calls
RequestCallOnNextTick, registering a deferred callback that, pre-patch, referenced the delegate via a cross-threadbase::WeakPtr. - Race destruction The delegate/transformer is torn down (e.g. the transform or peer connection is closed) on one thread while the metronome tick is pending or firing on another.
- Use-after-free
The tick callback dereferences the
base::WeakPtrwhose validity was mutated on a different sequence, accessing the freed delegate — the cross-thread UAF. - Escalation
Reclaiming and grooming the freed allocation to achieve a read/write primitive or code execution is
(inferred); the diff only demonstrates the lifetime race and its fix.
Impact Assessment
(inferred); the patch establishes only the cross-thread lifetime bug.Changed Functions
| Function | Change | Notes |
|---|---|---|
VideoMetronomeWorkerthird_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc |
modified | |
ifthird_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
Audit Directions
- Cross-thread `base::WeakPtr`Grep peerconnection/media code for
WeakPtr/GetWeakPtrcaptured intoPostCrossThreadTask/CrossThreadBindOnceor metronome callbacks; weak pointers must be bound and dereferenced on one sequence only. - Deferred-callback lifetimesAudit every
RequestCallOnNextTick/ timer / posted-task callback that references an owning object, confirming the callee is kept alive withscoped_refptror that aDisconnect-style teardown runs under the same lock before the object is freed. - Delegate/broker teardown orderingReview classes that split state between a delegate and a ref-counted broker/worker for a destructor that fails to sever outstanding task runners, leaving tasks able to post to a half-destroyed object.
Patch
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);
Regression Test / PoC
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
Original Bug Report
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:
- On the worker thread, the lambda evaluates
if (delegate). This reads the atomic validity flag insideinternal::WeakReference::Flag::IsValid(). - Concurrently, the signaling thread begins destroying the delegate. It reaches
base::WeakPtrFactory::~WeakPtrFactory(), which callsInvalidate()to mark the atomic flag as invalid. - In release builds (
DCHECK_IS_ON()is false),Invalidate()skips theDCHECK_CALLED_ON_VALID_SEQUENCEcheck. The cross-thread invalidation succeeds without crashing the process. - If the worker thread reads the atomic flag just before the signaling thread marks it invalid,
if (delegate)passes. The subsequentdelegate->check also passes. - The delegate is fully destroyed on the signaling thread, and the memory is returned to the allocator.
- The worker thread proceeds to call
InvokeQueuedTransforms()on the now-danglingthispointer.
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():
- 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. - The function iterates over
queued_frames_(aWTF::Vector). An attacker can manipulate the vector’s metadata in the reclaimed memory to point to arbitrary locations. - Inside the loop, it calls
PostCrossThreadTask(*source_task_runner_, ...). If the attacker controls thesource_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.
- In JavaScript, negotiate a WebRTC connection with a video stream.
- Attach an
RTCRtpScriptTransformto the receiver to force the creation of theRTCEncodedVideoStreamTransformerDelegate. - Send a burst of encoded video frames to ensure the
Transformmethod schedules the metronome callback on the worker thread. - 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. - 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. - 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.