CVE-2026-7988
Overview
Background
- `Persistent` vs `CrossThreadPersistent`
- a
Persistent<T>is a Blink Oilpan garbage-collection root tied to the thread that created it, whereasCrossThreadPersistent<T>(produced byWrapCrossThreadPersistent) 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 plainbase::OnceCallbackmakes 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 ofReplaceTrack/SetParametersback 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.
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
- Invoke a cross-thread sender operation
Script on a page using WebRTC calls
RTCRtpSender.replaceTrack()orsetParameters(), reachingRTCRtpSenderImpl::ReplaceTrack/SetParameterson the main thread. - Bind a same-thread handle for another thread
The callback is created with
WrapPersistent(request), producing a main-threadPersistent<RTCVoidRequest>that is then posted to the WebRTC signaling thread. - Mishandle the GC reference cross-thread
When the operation completes on the signaling thread, the improperly-typed persistent reference to
requestis used off its owning thread, breaking Oilpan’s per-thread persistence invariant (type confusion of the handle/object as reported by the advisory). - 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
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.ccthird_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl.hthird_party/blink/renderer/modules/peerconnection/rtc_rtp_sender_impl_test.cc
Audit Directions
- `WrapPersistent` in cross-thread bindsgrep for
WrapPersistent/BindOncewhose resulting callback is later handed toPostCrossThreadTaskor stored by code running on another thread; these should useWrapCrossThreadPersistent/CrossThreadBindOnce. - Callback-type boundariesaudit interfaces that accept
base::OnceCallbackbut post the callback to a different sequence/thread; preferCrossThreadOnceFunctionso bound arguments are checked for cross-thread safety. - PeerConnection internal/platform splitreview other
RTCRtpSenderInternal/RTCRtpSender*Implmethods (GetStats,SetStreams, DTMF) that bridge the Blink main thread and the WebRTC signaling thread for similarly mis-typed GC references.
Patch
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,
Regression Test / PoC
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,
Original Bug Report
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.ccthird_party/blink/renderer/modules/peerconnection/rtc_rtp_sender.ccv8/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.
- In an attacker-controlled iframe, create an
RTCPeerConnectionand a sender viaaddTransceiver. - Execute
sender.setParameters(...). - While the task is processing in libwebrtc, detach the iframe (e.g.,
iframe.remove()). This shuts down the Blink task runner. - When the async operation completes, the signaling thread attempts to post back to the main thread but fails, dropping and destroying the
Persistenthandle off-thread. - Concurrently, loop the rapid creation of GC objects (e.g.,
ScriptPromiseResolvercreations) on the main thread to collide with the background thread’sFreeNodeexecution, corrupting thecppgcfree-list. - 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.