← Chrome — 2026-W38

fff1540a69201e2e32a551acfb8f4483ed431f47  [M144-LTS][WebRTC] Ensure WebRtcMediaStreamTrackAdapter is disposed on main thread

severity high class UAF CVSS 8.0 confidence 0.8 signal score 7.5 third_party/blink/renderer patch-now AI review CONFIRMED
Primitive: AdapterRef::~AdapterRef on the signaling thread runs main-thread disposal (MediaStreamVideoTrack::RemoveSink) unsynchronized -> data race and UAF.
Triage note: Detaching an iframe with an RTCPeerConnection during replaceTrack posts cross-thread tasks that dispose on the signaling thread; web-reachable via WebRTC. Race adds exploitation complexity. Bug:523296105.

The bug at a glance

This is a genuine use-after-free reachable from web content via the WebRTC API surface (RTCPeerConnection.replaceTrack combined with iframe detach), which merits a high rating. The CVSS of 8.0 rather than a critical score reflects that reliable exploitation is gated behind a cross-thread data race: the vulnerable destructor path only executes on the WebRTC signaling thread when a main-thread task runner is torn down mid-flight during replaceTrack. Winning that race and shaping the freed AdapterRef/adapter memory into a controllable primitive requires precise timing between the renderer main thread and the signaling thread, adding meaningful attack complexity and reducing determinism compared to a straightforward single-threaded UAF.

The interesting angle is that this is not a classic ‘free then reuse’ logic bug in a single thread, but a thread-affinity violation: an object whose entire lifecycle contract assumed main-thread destruction ends up being destroyed on the WebRTC signaling thread. The trigger is an ordinary web-facing sequence – an RTCPeerConnection in an iframe, a replaceTrack() call that hands work to the signaling thread, and the iframe being detached before the cross-thread reply lands. Once the main-thread task runner is shut down, the reply PostTask silently fails, and the last owner of the AdapterRef closure is destroyed wherever the signaling thread happens to drop it. The destructor then reached into main-thread-only teardown code (map mutation and MediaStreamVideoTrack::RemoveSink) with no synchronization, turning a lifetime edge case into a cross-thread data race and use-after-free.

Root cause

WebRtcMediaStreamTrackAdapterMap maps blink MediaStreamComponent/webrtc tracks to WebRtcMediaStreamTrackAdapter objects. Callers hold an AdapterRef, and the map is designed so that references can be created from any thread but must be destroyed on the renderer main thread. The pre-patch AdapterRef::~AdapterRef() encoded that assumption only as a DCHECK: ‘DCHECK(map_->main_thread_->BelongsToCurrentThread());’. In a release build that DCHECK is compiled out, so the destructor body ran on whatever thread invoked it. That body took map_->lock_, pulled the adapter out of local_track_adapters_/remote_track_adapters_ via EraseByPrimary, and – if this was the last reference – captured the removed adapter into removed_adapter and (in the tail of the function, below the diff hunk) called removed_adapter->Dispose(). WebRtcMediaStreamTrackAdapter::Dispose() likewise only asserted main-thread affinity with ‘DCHECK(main_thread_->BelongsToCurrentThread())’ and then ran the synchronous local/remote-video disposal paths, which include tearing down sinks – e.g. MediaStreamVideoTrack::RemoveSink – that are only safe to touch on the main thread. The web-reachable trigger: replaceTrack on an RTCPeerConnection hosted in an iframe posts work to the WebRTC signaling thread; the signaling thread posts results back to the main thread via cross-thread tasks that carry closures owning AdapterRef objects. If the iframe is detached while this is in flight, the main-thread task runner is shut down, so the PostCrossThreadTask back to main fails and the closure – the last owner of the AdapterRef – is destructed on the signaling thread. That runs ~AdapterRef() on the signaling thread: it mutates the map under lock_ and calls Dispose(), which walks main-thread-only teardown (RemoveSink and friends) concurrently with any main-thread activity that still races – an unsynchronized data race on the adapter/sink data structures and a use-after-free of objects whose owning thread has torn down or is concurrently touching them. The fix converts the thread-affinity DCHECKs into runtime redirects. ~AdapterRef() now checks ‘!map_->main_thread_->BelongsToCurrentThread()’; if true it copies the main_thread task runner and PostCrossThreadTask’s a new static WebRtcMediaStreamTrackAdapterMap::DisposeAdapterRef(map, type, adapter) to the main thread, moving ownership of the adapter and map refs into the cross-thread closure, then returns without touching the map on the current thread. DisposeAdapterRef re-asserts main-thread affinity and performs the original map-removal logic. Symmetrically, Dispose() now checks ‘!main_thread_->BelongsToCurrentThread()’ and, if off-thread, PostCrossThreadTask’s DisposeOnMainThread(WrapRefCounted(this)) to the main thread; the actual teardown body was renamed from Dispose to DisposeOnMainThread and keeps its DCHECK(main_thread_->BelongsToCurrentThread()). The net effect is that map removal and track/sink disposal always execute on the main thread regardless of which thread dropped the last reference, restoring the invariant that the destructor path previously only assumed.

Key code

AdapterRef destructor and Dispose(): before (DCHECK-only affinity) vs after (runtime post to main thread)

// BEFORE: webrtc_media_stream_track_adapter_map.cc
WebRtcMediaStreamTrackAdapterMap::AdapterRef::~AdapterRef() {
  DCHECK(map_->main_thread_->BelongsToCurrentThread());  // compiled out in release
  scoped_refptr<blink::WebRtcMediaStreamTrackAdapter> removed_adapter;
  {
    base::AutoLock scoped_lock(map_->lock_);
    blink::WebRtcMediaStreamTrackAdapter* adapter = adapter_.get();
    adapter_ = nullptr;
    if (adapter->HasOneRef()) {
      removed_adapter = adapter;
      if (type_ == Type::kLocal)
        map_->local_track_adapters_.EraseByPrimary(adapter->track()->UniqueId());
      else
        map_->remote_track_adapters_.EraseByPrimary(adapter->webrtc_track().get());
    }
  }
  // ... removed_adapter->Dispose() ran here on WHATEVER thread destroyed the ref
}

// BEFORE: webrtc_media_stream_track_adapter.cc
void WebRtcMediaStreamTrackAdapter::Dispose() {
  DCHECK(main_thread_->BelongsToCurrentThread());  // compiled out in release
  // ... local/remote-video teardown: MediaStreamVideoTrack::RemoveSink, etc.
}

// AFTER: destructor redirects off-thread destruction to the main thread
WebRtcMediaStreamTrackAdapterMap::AdapterRef::~AdapterRef() {
  if (!map_->main_thread_->BelongsToCurrentThread()) {
    scoped_refptr<base::SingleThreadTaskRunner> main_thread = map_->main_thread_;
    PostCrossThreadTask(
        *main_thread, FROM_HERE,
        CrossThreadBindOnce(
            &WebRtcMediaStreamTrackAdapterMap::DisposeAdapterRef,
            std::move(map_), type_, std::move(adapter_)));
    return;
  }
  DisposeAdapterRef(std::move(map_), type_, std::move(adapter_));
}

// AFTER: Dispose() reposts to main thread; body renamed DisposeOnMainThread
void WebRtcMediaStreamTrackAdapter::Dispose() {
  if (!main_thread_->BelongsToCurrentThread()) {
    PostCrossThreadTask(
        *main_thread_, FROM_HERE,
        CrossThreadBindOnce(&WebRtcMediaStreamTrackAdapter::DisposeOnMainThread,
                            WrapRefCounted(this)));
    return;
  }
  DisposeOnMainThread();
}

Patch walkthrough

  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter.cc — Splits the disposal logic. Dispose() gains a front guard: if not on the main thread it PostCrossThreadTask’s DisposeOnMainThread(WrapRefCounted(this)) to main_thread_ and returns, taking a self-ref so the object survives until the posted task runs. The original synchronous teardown body (with its DCHECK(main_thread_->BelongsToCurrentThread()) and is_disposed_ guard) is moved into the new DisposeOnMainThread() method. This guarantees the main-thread-only teardown paths (including MediaStreamVideoTrack::RemoveSink) never execute on the signaling thread.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter.h — Declares DisposeOnMainThread() as a private method and updates the contract comments: Dispose()’s ‘Invoke on the main thread’ becomes ‘Can be called from any thread’, and the disposal-flow comment now says the logic starts/finishes on the main thread but can be initiated from any thread. These are the documented API-contract changes that match the runtime redirect.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_map.cc — Replaces the ~AdapterRef() DCHECK-only main-thread assumption with a runtime check. Off-thread, it copies map_->main_thread_ and PostCrossThreadTask’s the new static DisposeAdapterRef(std::move(map_), type_, std::move(adapter_)) to the main thread, then returns. The original map-removal body (lock_, HasOneRef check, EraseByPrimary for kLocal/remote) is refactored into the static DisposeAdapterRef, which re-asserts DCHECK(map->main_thread_->BelongsToCurrentThread()) and operates on passed-in parameters instead of member state. Making it static and moving map_/adapter_ into the closure keeps the objects alive across the thread hop.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_map.h — Declares the private static DisposeAdapterRef(map, type, adapter). Updates the AdapterRef and Get*TrackAdapter doc comments: the old ‘references must be destroyed on the main thread’ constraint is removed and replaced with ‘If invoked on another thread, disposal is posted to the main thread’, codifying that destroying an AdapterRef from any thread is now safe.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_map_test.cc — Adds DestroyAdapterRefOnSignalingThread: creates a local track adapter (count 1), then moves the AdapterRef into a closure posted to the signaling thread where it is destroyed. After RunMessageLoopsUntilIdle it asserts GetLocalTrackCount()==0 and the adapter is gone – proving the destructor now correctly reposts map removal to the main thread rather than racing it off-thread.
  • third_party/blink/renderer/modules/peerconnection/webrtc_media_stream_track_adapter_test.cc — Adds DisposeOnSignalingThread: builds a local audio track adapter, then posts a task to the WebRTC signaling task runner that calls adapter->Dispose() directly on that thread. RunMessageLoopsUntilIdle then drives the reposted disposal to completion – exercising the new off-thread Dispose() redirect.

Background

WebRtcMediaStreamTrackAdapter — The Blink object that bridges a blink MediaStreamComponent (and MediaStreamVideoTrack/audio track) to a webrtc::MediaStreamTrackInterface. Its Dispose() tears down the bridge, including removing sinks; historically this had to run on the renderer main thread.

WebRtcMediaStreamTrackAdapterMap / AdapterRef — A ThreadSafeRefCounted registry keyed by track identity that hands out AdapterRef handles. References can be acquired from any thread, but the pre-patch contract required each AdapterRef to be destroyed on the main thread; the map protects its local_track_adapters_/remote_track_adapters_ tables with lock_.

WebRTC signaling thread vs renderer main thread — PeerConnection operations like replaceTrack marshal work to the WebRTC signaling thread and post results back to the Blink main thread via PostCrossThreadTask. Many Blink objects (sinks, the adapter’s teardown) are only safe to mutate on the main thread; touching them on the signaling thread is a thread-affinity violation.

DCHECK vs runtime check — DCHECK(main_thread_->BelongsToCurrentThread()) documents and enforces an invariant only in debug/DCHECK-enabled builds. In shipping release builds it is a no-op, so the buggy code path executed unchecked – the crux of why the affinity assumption was silently violated in production.

Task runner shutdown on frame detach — When an iframe is detached, its associated main-thread task runner is shut down, causing subsequent PostTask/PostCrossThreadTask targeting it to fail. This is what strands the cross-thread reply closure on the signaling thread, causing the AdapterRef to be dropped there. (inferred: the exact ‘main thread task runner is shut down’ failure is stated in the commit message, not observable line-by-line in the diff)

Vulnerability window

  1. Latent introduction — The AdapterRef/Dispose design enforced main-thread destruction only via DCHECKs, leaving release builds unprotected against off-thread destruction. (inferred: the diff shows the DCHECK-only state being replaced but does not date its origin)
  2. Discovery — Tracked as Chromium bug 523296105. The failure mode – iframe detached during replaceTrack, reply task runner shut down, AdapterRef destroyed on the signaling thread – points to a race/UAF report. (inferred: how it was found is not in the diff)
  3. Trunk fix — Landed on main as commit 5643da4a3d7eca3d0b9b523647c7e14e37f294c4 (Cr-Original-Commit-Position refs/heads/main@{#1656585}) by Guido Urdaneta, reviewed by Tove Petersson.
  4. LTS backport — Cherry-picked as this commit (fff1540a69…) into the M144-LTS branch (refs/branch-heads/7559@{#5307}), reviewed by Achuith Bhandarkar with owners override, dated 2026-09-14.

Proof of concept

The diff ships two C++ regression tests that reconstruct the trigger deterministically by forcing off-thread destruction. DestroyAdapterRefOnSignalingThread (map test) creates a local track adapter, moves the AdapterRef into a closure posted to the signaling thread, and lets it be destroyed there; it then asserts the map count dropped to 0, proving disposal was reposted to the main thread. DisposeOnSignalingThread (adapter test) posts a task to the WebRTC signaling task runner that calls adapter->Dispose() directly. Web-reachable reproduction (a race, not deterministic from JS): in a child iframe, create an RTCPeerConnection, add a sender with a track, begin sender.replaceTrack(newTrack) so the operation is marshalled to the signaling thread, and from the parent synchronously detach/remove the iframe (or navigate it away) during that window so the child’s main-thread task runner is torn down before the signaling thread’s reply lands. The reply PostTask then fails and the closure owning the AdapterRef is destroyed on the signaling thread, exercising the vulnerable ~AdapterRef()/Dispose() path. Reliability depends on hitting the narrow interval between replaceTrack dispatch and its cross-thread reply, so many rapid iterations are needed; no exploit code is provided here.

// C++ regression test from the diff (webrtc_media_stream_track_adapter_map_test.cc)
TEST_F(WebRtcMediaStreamTrackAdapterMapTest, DestroyAdapterRefOnSignalingThread) {
  MediaStreamComponent* track = CreateLocalTrack("local_track");
  std::unique_ptr<blink::WebRtcMediaStreamTrackAdapterMap::AdapterRef>
      adapter_ref = map_->GetOrCreateLocalTrackAdapter(track);
  EXPECT_TRUE(adapter_ref->is_initialized());
  EXPECT_EQ(1u, map_->GetLocalTrackCount());
  // Destroying the AdapterRef on the signaling thread should post the removal
  // and disposal to the main thread.
  PostCrossThreadTask(
      *signaling_thread(), FROM_HERE,
      CrossThreadBindOnce(
          [](std::unique_ptr<
              blink::WebRtcMediaStreamTrackAdapterMap::AdapterRef> ref) {
            // Destroyed when going out of scope on the signaling thread.
          },
          std::move(adapter_ref)));
  RunMessageLoopsUntilIdle();
  EXPECT_EQ(0u, map_->GetLocalTrackCount());
  EXPECT_EQ(nullptr, map_->GetLocalTrackAdapter(track));
}

// Sketch of the web-reachable trigger (race; illustrative, not an exploit)
// parent.html
const iframe = document.body.appendChild(document.createElement('iframe'));
iframe.srcdoc = `<script>
  (async () => {
    const pc = new RTCPeerConnection();
    const s1 = (await navigator.mediaDevices.getUserMedia({video:true})).getVideoTracks()[0];
    const sender = pc.addTrack(s1);
    const s2 = (await navigator.mediaDevices.getUserMedia({video:true})).getVideoTracks()[0];
    parent.postMessage('go','*');
    sender.replaceTrack(s2); // marshals to signaling thread
  })();
<\/script>`;
window.addEventListener('message', () => {
  // Detach the iframe during the replaceTrack round-trip so the child's
  // main-thread task runner is shut down before the signaling reply lands.
  iframe.remove();
});

Exploitation

  1. Reach the code — From ordinary web content, open a cross-frame RTCPeerConnection in an iframe and drive replaceTrack so an AdapterRef is owned by a closure in flight between the signaling thread and the frame’s main thread.
  2. Win the race — Detach the iframe in the exact window where the signaling->main reply cannot be delivered (task runner shut down), forcing the AdapterRef closure to be destructed on the signaling thread. This is timing-sensitive and typically requires many attempts. (inferred: the precise timing window is described in the commit message, not measurable from the diff)
  3. Corrupt state — The off-thread ~AdapterRef()/Dispose() mutates the map and runs main-thread-only teardown (e.g. MediaStreamVideoTrack::RemoveSink) concurrently with the main thread, producing a data race and a use-after-free of the adapter/sink objects. (inferred: which specific object is freed and reused is not spelled out in the diff)
  4. Weaponize — Convert the freed-object reuse into a controlled read/write by reoccupying the freed allocation with attacker-influenced data and steering a subsequent dereference. Given the multithreaded, non-deterministic nature, reliability would be low without heap-grooming and repeated triggering. (inferred: no exploit primitive is demonstrated in the diff)

Detection & hunting

For defenders and detection engineers:

  • Thread-affinity assertion failures — In DCHECK/ASAN builds, watch for DCHECK(main_thread_->BelongsToCurrentThread()) firing inside WebRtcMediaStreamTrackAdapter or ~AdapterRef – the pre-patch code would trip these when destruction occurred off the main thread.
  • ASAN/TSAN reports in peerconnection teardown — Heap-use-after-free or data-race stacks that include WebRtcMediaStreamTrackAdapterMap::AdapterRef::~AdapterRef, WebRtcMediaStreamTrackAdapter::Dispose, or MediaStreamVideoTrack::RemoveSink running on the WebRTC signaling thread are strong indicators of this bug.
  • Crash telemetry correlated with replaceTrack + frame detach — Renderer crashes clustering around RTCPeerConnection.replaceTrack usage in iframes that are removed/navigated mid-operation. (inferred: no telemetry appears in the diff)
  • Fuzzing during frame lifecycle transitions — A WebRTC fuzzer that repeatedly starts replaceTrack and tears down the hosting frame under ASAN/TSAN can surface this class of cross-thread lifetime bug.

Audit directions

  • DCHECK-only thread-affinity contracts — Audit destructors and Dispose/teardown methods across Blink peerconnection/mediastream code that assert BelongsToCurrentThread() only via DCHECK. Any such method that can be reached by an object owned in a cross-thread closure is a candidate for the same off-thread-destruction UAF and should redirect to the correct thread at runtime.
  • Cross-thread closures owning ref-counted Blink objects — Review PostCrossThreadTask sites whose closures carry AdapterRef-like handles or scoped_refptrs to main-thread-affine objects. Confirm what happens when the target task runner is shut down (frame detach): where does the closure – and thus the object – get destroyed?
  • Frame-detach / task-runner-shutdown races — Enumerate WebRTC operations that marshal to the signaling thread and post back (replaceTrack, setLocalDescription, addTrack, ICE callbacks) and verify each tolerates the reply failing due to a shut-down main-thread task runner without dropping ownership on the wrong thread.
  • MediaStreamVideoTrack sink lifecycle — Verify that AddSink/RemoveSink and related sink teardown are always invoked on the main thread from every disposal path, now that disposal can be initiated off-thread and reposted.

Before / after

Loading diff…