Medium patch-now CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in WebRTC
DescriptionType Confusion in WebRTC
ComponentWebRTC
Bug ClassType Confusion
Action tierpatch-now — corruption primitive, web-reachable in default config (unverified)
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.
Tracker498753456
Fix commitfaec1cd9c75f (chromium/src) +16/-12
CWECWE-843 (Type confusion)
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-04-02 (per vendor advisory)
Fix landed2026-04-02 (public git)
Disclosed2026-05-05
Patch gap33 days — fix public in source before the advisory

Background

`Persistent` vs `CrossThreadPersistent`
a Persistent<T> is a Blink Oilpan garbage-collection root tied to the thread that created it, whereas CrossThreadPersistent<T> (produced by WrapCrossThreadPersistent) is the variant whose bookkeeping is safe to hold and dereference on another thread.
`CrossThreadOnceFunction` vs `base::OnceCallback`
CrossThreadOnceFunction<Sig> is WTF’s thread-safe callback wrapper that enforces cross-thread-safe bound arguments, while a plain base::OnceCallback makes no such guarantee.
`CrossThreadBindOnce` / `WrapCrossThreadPersistent`
the WTF helpers that bind a functor and wrap a GC pointer specifically for passing to another thread (here, the WebRTC signaling thread).
`RTCVoidRequest`
a garbage-collected Blink object (request) used to deliver the completion result of ReplaceTrack/SetParameters back to script.

Root Cause Analysis

In RTCRtpSenderImpl::ReplaceTrack and RTCRtpSenderImpl::SetParameters, the completion callback was built with BindOnce(&OnReplaceTrackCompleted, WrapPersistent(request)) and BindOnce(&OnSetParametersCompleted, WrapPersistent(request)). That callback is ultimately handed to the internal RTCRtpSenderInternal methods, which PostCrossThreadTask it to the WebRTC signaling thread. A main-thread Persistent<RTCVoidRequest> carried across a thread boundary violates the invariant that a Persistent handle and its referenced Oilpan object are only valid on their owning thread, so the garbage collector’s cross-thread accounting for request is incorrect. The inner methods compounded this by taking a base::OnceCallback and re-wrapping it with CrossThreadBindOnce(std::move(callback)) at the post site, layering a cross-thread bind over a callback whose own bound state was not cross-thread-safe.

The fix changes the inner ReplaceTrack/SetParameters signatures to accept a CrossThreadOnceFunction, builds the callback at the public entry points with CrossThreadBindOnce + WrapCrossThreadPersistent(request), and drops the redundant inner CrossThreadBindOnce, so the GC pointer and functor are cross-thread-correct end to end.

Key insight
The single mistake was wrapping a garbage-collected RTCVoidRequest with WrapPersistent (a same-thread handle) in a callback destined for the signaling thread; the fix swaps it to WrapCrossThreadPersistent and threads a CrossThreadOnceFunction through, so the object is referenced under the correct thread-safe persistence type.

Attack Path

  1. Invoke a cross-thread sender operation Script on a page using WebRTC calls RTCRtpSender.replaceTrack() or setParameters(), reaching RTCRtpSenderImpl::ReplaceTrack/SetParameters on the main thread.
  2. Bind a same-thread handle for another thread The callback is created with WrapPersistent(request), producing a main-thread Persistent<RTCVoidRequest> that is then posted to the WebRTC signaling thread.
  3. Mishandle the GC reference cross-thread When the operation completes on the signaling thread, the improperly-typed persistent reference to request is used off its owning thread, breaking Oilpan’s per-thread persistence invariant (type confusion of the handle/object as reported by the advisory).
  4. Memory-safety consequence (inferred) Dereferencing or collecting the mis-persisted object across threads could lead to use of an inconsistent object, but the specific sink and any primitive are not shown in the diff.

Impact Assessment

An attacker-controlled page exercising WebRTC sender APIs can trigger the mishandled cross-thread passing of a GC object between the Blink main thread and the WebRTC signaling thread, matching the advisory’s reported Type Confusion in WebRTC at medium severity. The flaw lives in the renderer process and requires the victim to run a page that calls replaceTrack/setParameters. Any escalation beyond the type confusion itself is (inferred) and not demonstrated by the patch.

Files Changed

  • third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc
  • third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.h
  • third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc

Audit Directions

  • `WrapPersistent` in cross-thread binds
    grep for WrapPersistent/BindOnce whose resulting callback is later handed to PostCrossThreadTask or stored by code running on another thread; these should use WrapCrossThreadPersistent/CrossThreadBindOnce.
  • Callback-type boundaries
    audit interfaces that accept base::OnceCallback but post the callback to a different sequence/thread; prefer CrossThreadOnceFunction so bound arguments are checked for cross-thread safety.
  • PeerConnection internal/platform split
    review other RTCRtpSenderInternal/RTCRtpSender*Impl methods (GetStats, SetStreams, DTMF) that bridge the Blink main thread and the WebRTC signaling thread for similarly mis-typed GC references.
From faec1cd9c75ffca4e10584ed555177c6e2141e07 Mon Sep 17 00:00:00 2001
From: Tony Herre <[email protected]>
Date: Thu, 02 Apr 2026 02:47:36 -0700
Subject: [PATCH] Fix cross-thread passing of Persistent bound params in RTCrtpSenderImpl

Use WrapCrossThreadPersistent to bind params which are eventually
passed cross-thread inside closures.

Bug: 498753456
Change-Id: Ida9b2f23754bc3af0c89d06478b816d09233a7a6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7725121
Commit-Queue: Tony Herre <[email protected]>
Auto-Submit: Tony Herre <[email protected]>
Reviewed-by: Palak Agarwal <[email protected]>
Commit-Queue: Palak Agarwal <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1609069}
---

diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc
index 26cd378..3ed0885 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc
@@ -213,7 +213,7 @@
   }
 
   void ReplaceTrack(MediaStreamComponent* with_track,
-                    base::OnceCallback<void(bool)> callback) {
+                    CrossThreadOnceFunction<void(bool)> callback) {
     DCHECK(main_task_runner_->BelongsToCurrentThread());
     std::unique_ptr<blink::WebRtcMediaStreamTrackAdapterMap::AdapterRef>
         track_ref;
@@ -228,7 +228,7 @@
                                 ReplaceTrackOnSignalingThread,
                             WrapRefCounted(this), std::move(track_ref),
                             CrossThreadUnretained(webrtc_track),
-                            CrossThreadBindOnce(std::move(callback))));
+                            std::move(callback)));
   }
 
   std::unique_ptr<blink::RtcDtmfSenderHandler> GetDtmfSender() const {
@@ -250,7 +250,7 @@
   void SetParameters(
       Vector<webrtc::RtpEncodingParameters> encodings,
       std::optional<webrtc::DegradationPreference> degradation_preference,
-      base::OnceCallback<void(webrtc::RTCError)> callback) {
+      CrossThreadOnceFunction<void(webrtc::RTCError)> callback) {
     DCHECK(main_task_runner_->BelongsToCurrentThread());
 
     webrtc::RtpParameters new_parameters = parameters_;
@@ -285,7 +285,7 @@
         CrossThreadBindOnce(&RTCRtpSenderImpl::RTCRtpSenderInternal::
                                 SetParametersOnSignalingThread,
                             WrapRefCounted(this), std::move(new_parameters),
-                            CrossThreadBindOnce(std::move(callback))));
+                            std::move(callback)));
   }
 
   void GetStats(RTCStatsReportCallback callback) {
@@ -495,7 +495,8 @@
 void RTCRtpSenderImpl::ReplaceTrack(MediaStreamComponent* with_track,
                                     RTCVoidRequest* request) {
   internal_->ReplaceTrack(
-      with_track, BindOnce(&OnReplaceTrackCompleted, WrapPersistent(request)));
+      with_track, CrossThreadBindOnce(&OnReplaceTrackCompleted,
+                                      WrapCrossThreadPersistent(request)));
 }
 
 std::unique_ptr<blink::RtcDtmfSenderHandler> RTCRtpSenderImpl::GetDtmfSender()
@@ -513,7 +514,8 @@
     blink::RTCVoidRequest* request) {
   internal_->SetParameters(
       std::move(encodings), degradation_preference,
-      BindOnce(&OnSetParametersCompleted, WrapPersistent(request)));
+      CrossThreadBindOnce(&OnSetParametersCompleted,
+                          WrapCrossThreadPersistent(request)));
 }
 
 void RTCRtpSenderImpl::GetStats(RTCStatsReportCallback callback) {
@@ -524,8 +526,9 @@
   internal_->SetStreams(stream_ids);
 }
 
-void RTCRtpSenderImpl::ReplaceTrack(MediaStreamComponent* with_track,
-                                    base::OnceCallback<void(bool)> callback) {
+void RTCRtpSenderImpl::ReplaceTrack(
+    MediaStreamComponent* with_track,
+    CrossThreadOnceFunction<void(bool)> callback) {
   internal_->ReplaceTrack(with_track, std::move(callback));
 }
 
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.h b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.h
index d31dcce4..7f91018 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.h
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.h
@@ -17,6 +17,7 @@
 #include "third_party/blink/renderer/platform/peerconnection/rtc_rtp_sender_platform.h"
 #include "third_party/blink/renderer/platform/peerconnection/rtc_rtp_transceiver_platform.h"
 #include "third_party/blink/renderer/platform/peerconnection/rtc_stats.h"
+#include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
 #include "third_party/webrtc/api/peer_connection_interface.h"
 #include "third_party/webrtc/api/rtp_sender_interface.h"
 #include "third_party/webrtc/api/scoped_refptr.h"
@@ -160,7 +161,7 @@
   // ReplaceTrack() without having a blink::RTCVoidRequest, which can only be
   // constructed inside of blink.
   void ReplaceTrack(MediaStreamComponent* with_track,
-                    base::OnceCallback<void(bool)> callback);
+                    CrossThreadOnceFunction<void(bool)> callback);
   // Removes this sender's track from its PeerConnection. Only used in Plan B.
   bool RemoveFromPeerConnection(webrtc::PeerConnectionInterface* pc);
 
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
index c7ad3b6..39ad055a 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
@@ -114,9 +114,9 @@
     // and the |run_loop| quit.
     sender_->ReplaceTrack(
         component,
-        blink::BindOnce(&RTCRtpSenderImplTest::CallbackOnComplete,
-                        Unretained(this), Unretained(result_holder.get()),
-                        blink::Unretained(run_loop.get())));
+        CrossThreadBindOnce(&RTCRtpSenderImplTest::CallbackOnComplete,
+                            Unretained(this), Unretained(result_holder.get()),
+                            blink::Unretained(run_loop.get())));
     // When the resulting callback is invoked, waits for |run_loop| to complete
     // and returns |*result_holder|.
     return base::BindOnce(&RTCRtpSenderImplTest::RunLoopAndReturnResult,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
index c7ad3b6..39ad055a 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
@@ -114,9 +114,9 @@
     // and the |run_loop| quit.
     sender_->ReplaceTrack(
         component,
-        blink::BindOnce(&RTCRtpSenderImplTest::CallbackOnComplete,
-                        Unretained(this), Unretained(result_holder.get()),
-                        blink::Unretained(run_loop.get())));
+        CrossThreadBindOnce(&RTCRtpSenderImplTest::CallbackOnComplete,
+                            Unretained(this), Unretained(result_holder.get()),
+                            blink::Unretained(run_loop.get())));
     // When the resulting callback is invoked, waits for |run_loop| to complete
     // and returns |*result_holder|.
     return base::BindOnce(&RTCRtpSenderImplTest::RunLoopAndReturnResult,
Loading diff…

Original Bug Report

reported by [email protected]

Off-thread cppgc::Persistent destruction in RTCRtpSender leads to heap corruption

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 security team.

Overview: RTCRtpSenderImpl::SetParameters incorrectly passes a thread-affine cppgc::Persistent handle to a WebRTC background thread. If the frame is detached, the callback is destroyed off-thread, causing an unlocked data race in the cppgc free-list. This leads to heap corruption and potential renderer RCE.

Affected files:

  • third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc
  • third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender.cc
  • v8/include/cppgc/internal/persistent-node.h

Estimated timestamp from git blame: 2025-08-26

Summary

A vulnerability exists in RTCRtpSenderImpl::SetParameters where a thread-affine cppgc::Persistent handle is illicitly transferred to and potentially destroyed on a background thread. This off-thread destruction bypasses cppgc thread-safety expectations, creating an unlocked data race on the PersistentRegion free-list, leading to memory corruption in the renderer process.

Technical Details

When RTCRtpSender::setParameters is called, it allocates a SetParametersRequest on the garbage-collected heap. In RTCRtpSenderImpl::SetParameters (third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.cc), this request is wrapped using WrapPersistent(request) and bound into a base::OnceCallback.

To pass this callback to the WebRTC signaling thread, the code wraps the callback using CrossThreadBindOnce(std::move(callback)). CrossThreadBindOnce normally employs CheckGCedTypeRestrictions to prevent thread-unsafe types (like Persistent) from crossing thread boundaries. However, because the Persistent handle is embedded inside the functor (base::OnceCallback) rather than passed as a bound argument, the compile-time checks trivially pass, and the handle is successfully wrapped into a CrossThreadOnceFunction.

On the WebRTC signaling thread, this function is captured in a lambda and passed to webrtc_sender_->SetParametersAsync. Under normal conditions, the lambda posts the callback back to the main thread upon completion. However, if the frame’s task runner has been shut down (e.g., due to iframe detachment), PostCrossThreadTask returns false. The task is dropped and destroyed inline on the background thread, invoking the ~BasicPersistent() destructor off-thread.

Impact

PersistentRegionBase::FreeNode is not thread-safe. When invoked from a background thread, it performs a lock-free update of free_list_head_ (node->InitializeAsFreeNode(free_list_head_); free_list_head_ = node; nodes_in_use_--;).

Concurrently, allocations on the main thread will call TryAllocateNodeFromFreeList, which also reads and writes the free-list head without locks. This data race corrupts the free-list, leading to a double-allocation of a PersistentNode. Because PersistentNode is a tagged union (overlapping the owner_ pointer and next_ pointer), an attacker can cause type-confusion and inject controlled owner_ pointers. During the next garbage collection, the GC’s Iterate phase will follow these corrupted pointers, resulting in arbitrary memory read/write and potential Remote Code Execution (RCE) in the renderer process.

Potential Reproduction Steps

Note: These are suggested steps for reproducing the vulnerability based on static analysis, as our tooling agent cannot yet run a live proof of concept.

  1. In an attacker-controlled iframe, create an RTCPeerConnection and a sender via addTransceiver.
  2. Execute sender.setParameters(...).
  3. While the task is processing in libwebrtc, detach the iframe (e.g., iframe.remove()). This shuts down the Blink task runner.
  4. When the async operation completes, the signaling thread attempts to post back to the main thread but fails, dropping and destroying the Persistent handle off-thread.
  5. Concurrently, loop the rapid creation of GC objects (e.g., ScriptPromiseResolver creations) on the main thread to collide with the background thread’s FreeNode execution, corrupting the cppgc free-list.
  6. Observe heap corruption or a crash during the next GC cycle.

Suggested Fix

Update RTCRtpSenderImpl::SetParameters to use WrapCrossThreadPersistent(request) instead of WrapPersistent(request). CrossThreadPersistent safely handles off-thread destruction by locking the region’s free-list operations. Alternatively, the blink layer should correctly type-check nested functors in CrossThreadBindOnce to prevent base::OnceCallback wrappers from obscuring thread-affine arguments.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


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