← Chrome — 2026-W35

1e6fb5edb520453447c07ddd9c7b8512b230a8c6  [M150] [WebRTC] Ensure WebRtcMediaStreamTrackAdapter is disposed on main thread

severity high class UAF CVSS 8.8 signal score 13.85 third_party/blink/renderer/modules/ patch-now AI review CONFIRMED
Structural signals: smartptr_member_added (+4.87), security_assert (+6.47), area:third_party/blink/renderer/modules/ (+2.5)

The bug at a glance

M150 merge of a renderer memory-safety fix: AdapterRef destruction on the WebRTC signaling thread ran main-thread-only disposal (map erase, Dispose, MediaStreamVideoTrack::RemoveSink per the commit message) without synchronization, which the commit says led to data races and use-after-free. The trigger (iframe detach during replaceTrack) is plausibly reachable from ordinary web content, giving renderer-process memory corruption.

A page can race frame teardown against an in-flight RTCRtpSender.replaceTrack so that bound closures holding AdapterRefs are destroyed off the main thread, turning WebRTC’s cross-thread bookkeeping into a renderer UAF; the backport to a release branch signals it was treated as a shipped security issue.

Root cause

replaceTrack posts work to the signaling thread with closures that hold AdapterRef objects and post back to the main thread. If the iframe is detached, the main-thread task runner is shut down, the post-back fails, and the closures (and their AdapterRefs) are destroyed on the signaling thread. ~AdapterRef previously only DCHECKed main-thread affinity, then erased the adapter from the map and called Dispose() on that thread, executing main-thread disposal paths concurrently with main-thread users. The fix makes ~AdapterRef and WebRtcMediaStreamTrackAdapter::Dispose post DisposeAdapterRef / DisposeOnMainThread to the main thread when not already on it.

Key code

Off-main-thread AdapterRef destruction and Dispose now bounce to the main thread

WebRtcMediaStreamTrackAdapterMap::AdapterRef::~AdapterRef() {
-  DCHECK(map_->main_thread_->BelongsToCurrentThread());
+  if (!map_->main_thread_->BelongsToCurrentThread()) {
+    ...
+    PostCrossThreadTask(
+        *main_thread, FROM_HERE,
+        CrossThreadBindOnce(
+            &WebRtcMediaStreamTrackAdapterMap::DisposeAdapterRef,
+            std::move(map_), type_, std::move(adapter_)));
+    return;
+  }
...
 void WebRtcMediaStreamTrackAdapter::Dispose() {
+  if (!main_thread_->BelongsToCurrentThread()) {
+    PostCrossThreadTask(
+        *main_thread_, FROM_HERE,
+        CrossThreadBindOnce(&WebRtcMediaStreamTrackAdapter::DisposeOnMainThread,
+                            WrapRefCounted(this)));
+    return;
+  }

Patch walkthrough

  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_map.cc — ~AdapterRef no longer assumes the main thread (it was only a DCHECK, absent in release); off-thread it moves map_/adapter_ into a posted DisposeAdapterRef, which performs the locked refcount check, map erase and adapter disposal on the main thread.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_map.h — Declares the static DisposeAdapterRef helper and relaxes comments: references may now be destroyed on any thread.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter.cc — Dispose() becomes a thread-checking trampoline that posts DisposeOnMainThread (holding a ref via WrapRefCounted) when called off the main thread, so track sink removal and teardown run on the owning thread.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter.h — Adds DisposeOnMainThread and documents that disposal can be initiated from any thread.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_map_test.cc — New test destroys an AdapterRef on the signaling thread and verifies the adapter is removed from the map after the main loop runs.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_test.cc — New test calls Dispose() from the signaling thread to exercise the new posting path.

Background

WebRtcMediaStreamTrackAdapterMap / AdapterRef — Thread-safe refcounted map tying Blink MediaStreamComponents to webrtc tracks; the last AdapterRef destroyed removes and disposes the adapter.

WebRTC signaling thread — Separate thread on which libwebrtc PeerConnection operations run; Blink posts results back to the main thread via cross-thread tasks.

Task runner shutdown on frame detach — When an iframe is detached its main-thread task runner stops accepting tasks, so a failed PostCrossThreadTask destroys the bound closure on the posting thread.

Vulnerability window

  1. Reachability — Ordinary web content in a stock build: an iframe using RTCPeerConnection and replaceTrack, then detached by its embedder; whether a local track needs a permission prompt depends on the track source (inferred).
  2. Trigger — Detach the iframe while replaceTrack is mid-flight on the signaling thread so the post-back to the main thread fails and closures holding AdapterRefs are destroyed on the signaling thread.
  3. Primitive — Main-thread-only disposal (map erase, Dispose, MediaStreamVideoTrack::RemoveSink) runs on the signaling thread racing the main thread, yielding data races and UAF per the commit message.
  4. Escalation — Renderer-process memory corruption that could seed code execution inside the sandbox, requiring a separate sandbox escape for full compromise (inferred).

Proof of concept

Conceptually: in an iframe, set up an RTCPeerConnection with a sender and call replaceTrack; while that operation is pending on the signaling thread, have the parent remove the iframe so the main-thread task runner shuts down. The failed post-back destroys AdapterRef-holding closures on the signaling thread, running disposal off-thread. Winning the race reliably requires timing control; no code is provided.

Exploitation

  1. Race window — Repeatedly create and detach iframes performing replaceTrack to hit the window where the signaling-thread callback is destroyed (inferred).
  2. Memory corruption — Concurrent disposal on the signaling thread frees or mutates sink/track state still used on the main thread, giving a UAF (inferred from commit message).
  3. Renderer control — Heap grooming to reclaim the freed object could convert the UAF into renderer code execution (inferred).

Detection & hunting

For defenders and detection engineers:

  • TSan/ASan reports involving WebRtcMediaStreamTrackAdapter::Dispose, AdapterRef::~AdapterRef or MediaStreamVideoTrack::RemoveSink on a signaling thread* — Off-main-thread stacks in these functions indicate the pre-fix bug in unpatched builds.
  • grep -n ‘DisposeOnMainThread|DisposeAdapterRef’ third_party/blink/renderer/modules/peerconnection/ — Presence confirms the fix is in a given branch (here M150 branch 7871).
  • weggli for destructors that only DCHECK(BelongsToCurrentThread()) then mutate shared state — DCHECK-only thread affinity is compiled out in release, so these are candidate variants.

Audit directions

  • Other objects captured in CrossThreadBindOnce closures posted back to a frame’s main-thread task runner in peerconnection/ (e.g. transceiver/sender state, track refs) — Any whose destructor assumes the main thread can run on the signaling/network thread when the frame is detached and the post fails.
  • The new fallback path itself — If the main thread is already shut down, the posted DisposeAdapterRef/DisposeOnMainThread closures are also dropped on the signaling thread; verify that merely releasing the scoped_refptrs there cannot run main-thread-only teardown (inferred).
  • MediaStreamTrack sink add/remove paths (RemoveSink, audio/video sink adapters) — Check for other callers that can reach sink removal from non-main threads during frame teardown.

Before / after

Loading diff…