Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebRTC
DescriptionUse after free in WebRTC
ComponentWebRTC
Sub-componentBlink>WebRTC
Bug ClassUAF
Tracker565742177
Fix commit82df4ce4226d (src) +68/-2
CISA KEVNot listed
Creditedxinyang
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
pc/sdp_offer_answer.cc
modified
for
pc/sdp_offer_answer.cc
modified
TEST_F
pc/sdp_offer_answer_unittest.cc
modified

Files Changed

  • pc/BUILD.gn
  • pc/sdp_offer_answer_unittest.cc
  • pc/sdp_offer_answer.cc
From 82df4ce4226de25e1682351d7ba2e9bf9a198790 Mon Sep 17 00:00:00 2001
From: Tommi <tommi@webrtc.org>
Date: Fri, 25 Sep 2026 11:12:56 +0200
Subject: [PATCH] Ensure stopped transceivers do not hold a channel

Make sure that a transceiver that has been stopped does not get a
channel during negotiation, and that any channel is cleared before a
stopped transceiver is removed from the transceiver list.

Bug: chromium:565742177
Change-Id: I63251adef97c03644d499ad95baa244033f6678e
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/505661
Commit-Queue: Tomas Gunnarsson <tommi@webrtc.org>
Reviewed-by: Stefan Holmer <stefan@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#48722}
---
diff --git a/pc/sdp_offer_answer.cc b/pc/sdp_offer_answer.cc
index 2b9bbc7..5cf98d1 100644
--- a/pc/sdp_offer_answer.cc
+++ b/pc/sdp_offer_answer.cc
@@ -2798,7 +2798,8 @@
       // direction.
       transceiver->set_current_direction(local_direction);
       // 2.2.8.1.11.[3-6]: Set the transport internal slots.
-      if (transceiver->mid()) {
+      // A stopped transceiver has no channel and therefore no transport.
+      if (transceiver->mid() && !transceiver->stopped()) {
         auto it = dtls_transports_by_mid.find(*transceiver->mid());
         RTC_DCHECK(it != dtls_transports_by_mid.end());
         transceiver->SetTransport(it->second.transport,
@@ -4899,7 +4900,8 @@
   TRACE_EVENT0("webrtc", "SdpOfferAnswerHandler::UpdateTransceiverChannel");
   RTC_DCHECK(IsUnifiedPlan());
   RTC_DCHECK(transceiver);
-  if (content.rejected) {
+  // A stopped transceiver must not have a channel.
+  if (content.rejected || transceiver->internal()->stopped()) {
     if (transceiver->internal()->HasChannel()) {
       network_teardown_tasks.Add(
           transceiver->internal()->GetClearChannelNetworkTask());
@@ -5993,6 +5995,10 @@
   }
   // Traverse a copy of the transceiver list.
   auto transceiver_list = transceivers()->List();
+  // Batched channel teardown for removed transceivers. The tasks run at the
+  // end of this function while `transceiver_list` still holds references.
+  ScopedOperationsBatcher network_teardown_tasks(context_->network_thread());
+  ScopedOperationsBatcher worker_tasks(context_->worker_thread());
   for (const auto& transceiver : transceiver_list) {
     // 3.2.10.1.1: If transceiver is stopped, associated with an m= section
     //             and the associated m= section is rejected in
@@ -6018,8 +6024,19 @@
       RTC_LOG(LS_INFO)
           << "Dropping stopped transceiver that was never associated";
     }
+    // Make sure the channel is cleared before the transceiver is removed.
+    if (transceiver->internal()->HasChannel()) {
+      network_teardown_tasks.Add(
+          transceiver->internal()->GetClearChannelNetworkTask());
+      worker_tasks.Add(transceiver->internal()->GetDeleteChannelWorkerTask(
+          /*stop_senders=*/false));
+    }
     transceivers()->Remove(transceiver);
   }
+  RTCError error = network_teardown_tasks.Run();
+  RTC_DCHECK(error.ok());
+  error = worker_tasks.Run();
+  RTC_DCHECK(error.ok());
 }
 
 void SdpOfferAnswerHandler::RemoveUnusedChannels(
diff --git a/pc/sdp_offer_answer_unittest.cc b/pc/sdp_offer_answer_unittest.cc
index 562cb0a..82bda92 100644
--- a/pc/sdp_offer_answer_unittest.cc
+++ b/pc/sdp_offer_answer_unittest.cc
@@ -48,8 +48,10 @@
 #include "media/base/codec.h"
 #include "media/base/media_constants.h"
 #include "media/base/stream_params.h"
+#include "p2p/base/p2p_constants.h"
 #include "pc/peer_connection_wrapper.h"
 #include "pc/rtp_transceiver.h"
+#include "pc/sdp_utils.h"
 #include "pc/session_description.h"
 #include "pc/test/fake_audio_capture_module.h"
 #include "pc/test/integration_test_helpers.h"
@@ -2341,6 +2343,51 @@
   EXPECT_FALSE(pc1->SetRemoteDescription(std::move(answer3)));
 }
 
+// Verifies that a stopped transceiver does not get a channel.
+TEST_F(SdpOfferAnswerTest, StoppedTransceiverHasNoChannel) {
+  auto caller = CreatePeerConnection();
+  auto callee = CreatePeerConnection();
+
+  caller->AddTransceiver(MediaType::AUDIO);
+  scoped_refptr<RtpTransceiverInterface> stopped_transceiver =
+      caller->AddTransceiver(MediaType::AUDIO);
+  ASSERT_TRUE(stopped_transceiver);
+
+  auto offer = caller->CreateOfferAndSetAsLocal();
+  ASSERT_THAT(offer, NotNull());
+  ASSERT_TRUE(callee->SetRemoteDescription(std::move(offer)));
+  auto answer = callee->CreateAnswerAndSetAsLocal();
+  ASSERT_THAT(answer, NotNull());
+
+  auto pranswer =
+      CloneSessionDescriptionAsType(answer.get(), SdpType::kPrAnswer);
+  ASSERT_THAT(pranswer, NotNull());
+  SessionDescription* pranswer_desc = pranswer->description();
+  ASSERT_THAT(pranswer_desc->contents(), SizeIs(2));
+  ContentInfo& rejected_content = pranswer_desc->contents()[1];
+  rejected_content.rejected = true;
+  const ContentGroup* bundle = pranswer_desc->GetGroupByName(GROUP_TYPE_BUNDLE);
+  ASSERT_THAT(bundle, NotNull());
+  ContentGroup new_bundle = *bundle;
+  new_bundle.RemoveContentName(rejected_content.mid());
+  pranswer_desc->RemoveGroupByName(GROUP_TYPE_BUNDLE);
+  pranswer_desc->AddGroup(new_bundle);
+
+  ASSERT_TRUE(caller->SetRemoteDescription(std::move(pranswer)));
+  EXPECT_TRUE(stopped_transceiver->stopped());
+
+  ASSERT_TRUE(caller->SetRemoteDescription(std::move(answer)));
+  EXPECT_TRUE(stopped_transceiver->stopped());
+
+  bool has_channel = signaling_thread_->BlockingCall([&] {
+    return static_cast<RtpTransceiverProxyWithInternal<RtpTransceiver>*>(
+               stopped_transceiver.get())
+        ->internal()
+        ->HasChannel();
+  });
+  EXPECT_FALSE(has_channel);
+}
+
 TEST_F(SdpOfferAnswerTest, SubsequentOfferDoesNotAddSctpInit) {
   auto pc1 = CreatePeerConnection("WebRTC-Sctp-Snap/Enabled/");
   auto pc2 = CreatePeerConnection("WebRTC-Sctp-Snap/Enabled/");
diff --git a/pc/BUILD.gn b/pc/BUILD.gn
index 9f715cb..e71364f 100644
--- a/pc/BUILD.gn
+++ b/pc/BUILD.gn
@@ -3815,6 +3815,7 @@
       ":pc_test_utils",
       ":peerconnection_wrapper",
       ":rtp_transceiver",
+      ":sdp_utils",
       ":session_description",
       "../api:create_peerconnection_factory",
       "../api:jsep",
@@ -3841,6 +3842,7 @@
       "../media:codec",
       "../media:media_constants",
       "../media:stream_params",
+      "../p2p:p2p_constants",
       "../rtc_base:ssl_adapter",
       "../rtc_base:threading",
       "../system_wrappers:metrics",
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/pc/sdp_offer_answer_unittest.cc b/pc/sdp_offer_answer_unittest.cc
index 562cb0a..82bda92 100644
--- a/pc/sdp_offer_answer_unittest.cc
+++ b/pc/sdp_offer_answer_unittest.cc
@@ -48,8 +48,10 @@
 #include "media/base/codec.h"
 #include "media/base/media_constants.h"
 #include "media/base/stream_params.h"
+#include "p2p/base/p2p_constants.h"
 #include "pc/peer_connection_wrapper.h"
 #include "pc/rtp_transceiver.h"
+#include "pc/sdp_utils.h"
 #include "pc/session_description.h"
 #include "pc/test/fake_audio_capture_module.h"
 #include "pc/test/integration_test_helpers.h"
@@ -2341,6 +2343,51 @@
   EXPECT_FALSE(pc1->SetRemoteDescription(std::move(answer3)));
 }
 
+// Verifies that a stopped transceiver does not get a channel.
+TEST_F(SdpOfferAnswerTest, StoppedTransceiverHasNoChannel) {
+  auto caller = CreatePeerConnection();
+  auto callee = CreatePeerConnection();
+
+  caller->AddTransceiver(MediaType::AUDIO);
+  scoped_refptr<RtpTransceiverInterface> stopped_transceiver =
+      caller->AddTransceiver(MediaType::AUDIO);
+  ASSERT_TRUE(stopped_transceiver);
+
+  auto offer = caller->CreateOfferAndSetAsLocal();
+  ASSERT_THAT(offer, NotNull());
+  ASSERT_TRUE(callee->SetRemoteDescription(std::move(offer)));
+  auto answer = callee->CreateAnswerAndSetAsLocal();
+  ASSERT_THAT(answer, NotNull());
+
+  auto pranswer =
+      CloneSessionDescriptionAsType(answer.get(), SdpType::kPrAnswer);
+  ASSERT_THAT(pranswer, NotNull());
+  SessionDescription* pranswer_desc = pranswer->description();
+  ASSERT_THAT(pranswer_desc->contents(), SizeIs(2));
+  ContentInfo& rejected_content = pranswer_desc->contents()[1];
+  rejected_content.rejected = true;
+  const ContentGroup* bundle = pranswer_desc->GetGroupByName(GROUP_TYPE_BUNDLE);
+  ASSERT_THAT(bundle, NotNull());
+  ContentGroup new_bundle = *bundle;
+  new_bundle.RemoveContentName(rejected_content.mid());
+  pranswer_desc->RemoveGroupByName(GROUP_TYPE_BUNDLE);
+  pranswer_desc->AddGroup(new_bundle);
+
+  ASSERT_TRUE(caller->SetRemoteDescription(std::move(pranswer)));
+  EXPECT_TRUE(stopped_transceiver->stopped());
+
+  ASSERT_TRUE(caller->SetRemoteDescription(std::move(answer)));
+  EXPECT_TRUE(stopped_transceiver->stopped());
+
+  bool has_channel = signaling_thread_->BlockingCall([&] {
+    return static_cast<RtpTransceiverProxyWithInternal<RtpTransceiver>*>(
+               stopped_transceiver.get())
+        ->internal()
+        ->HasChannel();
+  });
+  EXPECT_FALSE(has_channel);
+}
+
 TEST_F(SdpOfferAnswerTest, SubsequentOfferDoesNotAddSctpInit) {
   auto pc1 = CreatePeerConnection("WebRTC-Sctp-Snap/Enabled/");
   auto pc2 = CreatePeerConnection("WebRTC-Sctp-Snap/Enabled/");
Loading diff…

Original Bug Report

reported by xi...@anthropic.com

Heap-use-after-free in webrtc::BaseChannel::SendPacket

Summary

A page can make WebRTC destroy the bundle DtlsSrtpTransport while a stopped transceiver’s channel still points to it. That channel’s next RTCP report then makes a virtual call through the freed transport. This happens in the renderer (thread WebRTC_W_and_N) with no user interaction and no camera or microphone permission.

Environment

  • Chrome for Testing 154.0.8037.57 (Stable), 155.0.8059.12 (Beta), 156.0.8072.0 (Canary); code unchanged on main b7c07f35773a
  • Ubuntu 24.04, x86-64
  • Evidence build: ASan, Chromium 155.0.8038.0 (webrtc 17c6ed323a)

Reproduction

chrome --user-data-dir=/tmp/profile --no-first-run poc.html
  1. Launch with a fresh profile.
  2. Wait; zero interaction beyond loading the page. The renderer crashes after about 3 s.

Rate: Stable 4/4, Beta 3/3, Canary 5/5. On DCHECK builds, RTC_DCHECK(!stopped_) (rtp_transceiver.cc:483) fires first.

Evidence Renderer process. Full report: asan.txt.

heap-use-after-free READ of size 8, 0 bytes inside 816-byte region
  #0 webrtc::BaseChannel::SendPacket  pc/channel.cc:413
freed:     DtlsSrtpTransport::~DtlsSrtpTransport <- JsepTransportCollection::CommitTransports
allocated: JsepTransportController::MaybeCreateJsepTransport
MiraclePtr Status: NOT PROTECTED

Stock Stable/Beta: the load of the freed object’s first word returns 0x0000414141410000, a value from the page’s DataChannel messages, just before a CFI-checked call *0x40(%rax).

Vulnerable code (third_party/webrtc)

pc/channel.cc  BaseChannel::SendPacket()
413  if (!rtp_transport_ || !rtp_transport_->IsWritable(rtcp)) {   [1]
pc/sdp_offer_answer.cc  RemoveStoppedTransceivers()
5957     transceivers()->Remove(transceiver);                     [2]

[1] Virtual call through the freed transport. [2] A stopped transceiver is removed while its channel stays alive, so later transport changes no longer reach it. How it gets there: a remote pranswer rejects an m-section, then the answer accepts it again and gives the stopped transceiver a new channel.

Impact Any web page, no interaction or permission. The renderer makes a virtual call through a freed object. Reporter’s estimate: High (S1).

Discovery and validation Found by AI-assisted source review and dynamic testing (Claude).

Attachments: poc.html, asan.txt, report.md (full write-up)

View on issue tracker