Low watch CVSS 8.1 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebRTC
DescriptionInappropriate implementation in WebRTC
ComponentWebRTC
Sub-componentBlink>WebRTC
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker448408148
Fix commitff084da0f568 (src) +152/-1
CWECWE-79 (Cross-site scripting)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedPhilipp Hancke
Reported2025-10-01 (per vendor advisory)
Fix landed2025-10-07 (public git)
Disclosed2025-12-02
Patch gap56 days — fix public in source before the advisory

Background

SDP offer/answer (RFC 3264)
the negotiation model where one peer sends an offer describing its media directions and the other returns an answer that must be direction-compatible with that offer.
`RtpTransceiverDirection`
the enum encoding a media section’s direction (kSendRecv, kSendOnly, kRecvOnly, kInactive), queried via RtpTransceiverDirectionHasSend/RtpTransceiverDirectionHasRecv.
`SetRemoteDescription` / `kAnswer`
the API path by which a peer applies a remote SDP; when source == CS_REMOTE and the type is kAnswer/kPrAnswer, the local side is consuming the answer to a prior local offer.
`WebRTC-EnforceTransceiverDirection`
the field-trial killswitch introduced by this patch that gates enforcement of the new direction check (enabled by default, disable-able via IsDisabled).

Root Cause Analysis

The vulnerable path was SetRemoteDescription applying a remote answer (kAnswer/kPrAnswer with source == CS_REMOTE) without checking that each media section’s direction was consistent with the direction the local peer offered. RFC 3264 §6.1 requires that the answer’s direction be the complement of the offer’s: if the local side did not offer to receive, the remote must not answer with send, and vice-versa. Because no such validation existed, a malicious or malformed remote answer could assert a send/recv direction the local offer never permitted, letting media flow in a direction the application and user never negotiated.

The fix adds VerifyDirectionsInAnswer, which walks each local_offer/remote_answer content pair and returns INVALID_PARAMETER when !RtpTransceiverDirectionHasRecv(local) && RtpTransceiverDirectionHasSend(remote) or !RtpTransceiverDirectionHasSend(local) && RtpTransceiverDirectionHasRecv(remote); this check is invoked from the remote-answer branch and, unless the killswitch disables it, rejects the description. This is a tightening: a class of previously-accepted answers is now rejected.

Key insight
The core mistake was omitting the RFC 3264 direction-compatibility check when applying a remote answer, so a peer could unilaterally impose an unnegotiated send/receive direction; the fix adds VerifyDirectionsInAnswer to enforce that the answer’s direction is a valid complement of the local offer’s.

Attack Path

  1. Establish negotiation A remote peer participates in a WebRTC session where the local side creates and sets a local offer with a constrained direction (e.g. kRecvOnly or kInactive on a transceiver).
  2. Craft an incompatible answer The remote peer returns an SDP answer whose media section direction contradicts the offer — e.g. answering sendrecv/sendonly to a section the local side offered as recvonly, as the regression test’s false-expected tuples demonstrate.
  3. Apply without validation (pre-patch) The local peer’s SetRemoteDescription accepts the answer with no direction check, establishing a transceiver state that violates the negotiated/RFC 3264 direction. (inferred)
  4. Unnegotiated media flow The inconsistent direction causes media to be sent or received contrary to what the application intended, an inappropriate-implementation logic flaw. (inferred)

Impact Assessment

A remote WebRTC peer can send a crafted SDP answer that forces a send/receive direction the local peer never offered, causing media to flow in an unnegotiated direction in the process hosting the peer connection (the renderer). The precondition is an active offer/answer exchange with a malicious answerer; the advisory classifies this as an inappropriate implementation in WebRTC and does not report exploitation in the wild, and the fix is guarded by the WebRTC-EnforceTransceiverDirection killswitch.

Changed Functions

FunctionChangeNotes
SdpOfferAnswerDirectionTest
pc/sdp_offer_answer_unittest.cc
modified
TEST_P
pc/sdp_offer_answer_unittest.cc
modified

Files Changed

  • experiments/field_trials.py
  • pc/peer_connection_interface_unittest.cc
  • pc/sdp_offer_answer.cc
  • pc/sdp_offer_answer_unittest.cc

Audit Directions

  • Pattern
    Look for SDP/offer-answer application paths that consume attacker-controlled remote descriptions without cross-checking them against the locally negotiated state (directions, codecs, m-line counts, BUNDLE membership).
  • Pattern
    Audit places where RFC-mandated symmetry/complement rules (RFC 3264 direction, ICE roles, DTLS setup attributes) are assumed rather than validated on the receiving side.
  • Pattern
    Review new validations gated behind field-trial killswitches to confirm the default-enabled path actually rejects, and check for other transceiver-state transitions that trust remote input.
From ff084da0f5689bb2424d662671bab6ac24eefeeb Mon Sep 17 00:00:00 2001
From: Philipp Hancke <[email protected]>
Date: Fri, 03 Oct 2025 12:47:49 +0200
Subject: [PATCH] Improve validation of SDP direction in remote description

enabled by default and guarded by the killswitch
  WebRTC-EnforceTransceiverDirection

Bug: chromium:448408148
Change-Id: I15e98d371d1b398aff4b9cb88bf34a483b36fe1b
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/413340
Commit-Queue: Philipp Hancke <[email protected]>
Reviewed-by: Harald Alvestrand <[email protected]>
Reviewed-by: Henrik Boström <[email protected]>
Cr-Commit-Position: refs/heads/main@{#45847}
---

diff --git a/experiments/field_trials.py b/experiments/field_trials.py
index 5408330..a0c7918 100755
--- a/experiments/field_trials.py
+++ b/experiments/field_trials.py
@@ -95,6 +95,9 @@
     FieldTrial('WebRTC-EncoderDataDumpDirectory',
                296242528,
                date(2024, 4, 1)),
+    FieldTrial('WebRTC-EnforceTransceiverDirection',
+               448408148,
+               date(2026, 6, 1)),
     FieldTrial('WebRTC-ForceDtls13',
                383141571,
                date(2024,9,1)),
diff --git a/pc/peer_connection_interface_unittest.cc b/pc/peer_connection_interface_unittest.cc
index f77176f..7df4f6e 100644
--- a/pc/peer_connection_interface_unittest.cc
+++ b/pc/peer_connection_interface_unittest.cc
@@ -71,8 +71,10 @@
 #include "pc/media_stream.h"
 #include "pc/peer_connection.h"
 #include "pc/peer_connection_factory.h"
+#include "pc/rtp_media_utils.h"
 #include "pc/rtp_sender.h"
 #include "pc/rtp_sender_proxy.h"
+#include "pc/sdp_utils.h"
 #include "pc/session_description.h"
 #include "pc/stream_collection.h"
 #include "pc/test/fake_audio_capture_module.h"
@@ -998,8 +1000,18 @@
 
   void CreateOfferReceiveAnswer() {
     CreateOfferAsLocalDescription();
+    std::unique_ptr<SessionDescriptionInterface> offer =
+        CloneSessionDescription(pc_->local_description());
+    // Adapts the offer so that it can serve as an answer.
+    // This test does not use DTLS so direcetion does not have
+    // to be adapted in a similar way.
+    for (auto& content : offer->description()->contents()) {
+      MediaContentDescription* media_description = content.media_description();
+      media_description->set_direction(
+          RtpTransceiverDirectionReversed(media_description->direction()));
+    }
     std::string sdp;
-    EXPECT_TRUE(pc_->local_description()->ToString(&sdp));
+    EXPECT_TRUE(offer->ToString(&sdp));
     CreateAnswerAsRemoteDescription(sdp);
   }
 
diff --git a/pc/sdp_offer_answer.cc b/pc/sdp_offer_answer.cc
index f572c1a..8a70f7d 100644
--- a/pc/sdp_offer_answer.cc
+++ b/pc/sdp_offer_answer.cc
@@ -313,6 +313,38 @@
                                 const SessionDescription& desc2) {
   return desc1.contents().size() == desc2.contents().size();
 }
+
+// Checks that the remote answer follows the rules from
+// https://datatracker.ietf.org/doc/html/rfc3264#section-6.1
+RTCError VerifyDirectionsInAnswer(const SessionDescription* local_offer,
+                                  const SessionDescription* remote_answer) {
+  RTC_DCHECK(local_offer);
+  RTC_DCHECK(remote_answer);
+
+  const ContentInfos& local_contents = local_offer->contents();
+  const ContentInfos& remote_contents = remote_answer->contents();
+  RTC_DCHECK(local_contents.size() == remote_contents.size());
+
+  for (size_t i = 0; i < local_contents.size(); i++) {
+    RtpTransceiverDirection local_direction =
+        local_contents[i].media_description()->direction();
+    RtpTransceiverDirection remote_direction =
+        remote_contents[i].media_description()->direction();
+
+    if (!RtpTransceiverDirectionHasRecv(local_direction) &&
+        RtpTransceiverDirectionHasSend(remote_direction)) {
+      LOG_AND_RETURN_ERROR(RTCErrorType::INVALID_PARAMETER,
+                           "Incompatible receive direction");
+    }
+    if (!RtpTransceiverDirectionHasSend(local_direction) &&
+        RtpTransceiverDirectionHasRecv(remote_direction)) {
+      LOG_AND_RETURN_ERROR(RTCErrorType::INVALID_PARAMETER,
+                           "Incompatible send direction");
+    }
+  }
+  return RTCError::OK();
+}
+
 // Checks that each non-rejected content has a DTLS
 // fingerprint, unless it's in a BUNDLE group, in which case only the
 // BUNDLE-tag section (first media section/description in the BUNDLE group)
@@ -3885,6 +3917,17 @@
     if (!error.ok()) {
       return error;
     }
+
+    if (source == CS_REMOTE &&
+        (type == SdpType::kPrAnswer || type == SdpType::kAnswer)) {
+      RTC_DCHECK(local_description());
+      error = VerifyDirectionsInAnswer(local_description()->description(),
+                                       sdesc->description());
+      if (!error.ok() && !env_.field_trials().IsDisabled(
+                             "WebRTC-EnforceTransceiverDirection")) {
+        return error;
+      }
+    }
   }
 
   return RTCError::OK();
diff --git a/pc/sdp_offer_answer_unittest.cc b/pc/sdp_offer_answer_unittest.cc
index b077223..9189c3b 100644
--- a/pc/sdp_offer_answer_unittest.cc
+++ b/pc/sdp_offer_answer_unittest.cc
@@ -14,6 +14,7 @@
 #include <memory>
 #include <optional>
 #include <string>
+#include <tuple>
 #include <utility>
 #include <vector>
 
@@ -47,6 +48,7 @@
 #include "media/base/media_constants.h"
 #include "media/base/stream_params.h"
 #include "pc/peer_connection_wrapper.h"
+#include "pc/session_description.h"
 #include "pc/test/fake_audio_capture_module.h"
 #include "pc/test/integration_test_helpers.h"
 #include "pc/test/mock_peer_connection_observers.h"
@@ -1744,4 +1746,95 @@
   EXPECT_EQ(codecs[1].id, av1.id);
 }
 
+class SdpOfferAnswerDirectionTest
+    : public SdpOfferAnswerTest,
+      public testing::WithParamInterface<
+          std::tuple<RtpTransceiverDirection, RtpTransceiverDirection, bool>> {
+ public:
+  SdpOfferAnswerDirectionTest() : SdpOfferAnswerTest() {}
+};
+
+TEST_P(SdpOfferAnswerDirectionTest, IncompatibleDirection) {
+  auto caller = CreatePeerConnection();
+  auto callee = CreatePeerConnection();
+
+  auto transceiver = caller->AddTransceiver(MediaType::VIDEO);
+  EXPECT_TRUE(transceiver->SetDirectionWithError(std::get<0>(GetParam())).ok());
+
+  auto offer = caller->CreateOfferAndSetAsLocal();
+  EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer)));
+
+  ASSERT_THAT(callee->pc()->GetTransceivers(), SizeIs(1));
+  auto callee_transceiver = callee->pc()->GetTransceivers()[0];
+  EXPECT_TRUE(callee_transceiver
+                  ->SetDirectionWithError(RtpTransceiverDirection::kInactive)
+                  .ok());
+  auto answer = callee->CreateAnswerAndSetAsLocal();
+  // Modify the answer.
+  ASSERT_THAT(answer->description()->contents(), SizeIs(1));
+  ContentInfo& content = answer->description()->contents()[0];
+  EXPECT_EQ(content.media_description()->direction(),
+            RtpTransceiverDirection::kInactive);
+  content.media_description()->set_direction(std::get<1>(GetParam()));
+
+  EXPECT_EQ(caller->SetRemoteDescription(std::move(answer)),
+            std::get<2>(GetParam()));
+}
+
+INSTANTIATE_TEST_SUITE_P(SdpOfferAnswerDirectionTest,
+                         SdpOfferAnswerDirectionTest,
+                         ::testing::Values(
+                             // sendrecv.
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kSendRecv,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kSendOnly,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kRecvOnly,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/pc/peer_connection_interface_unittest.cc b/pc/peer_connection_interface_unittest.cc
index f77176f..7df4f6e 100644
--- a/pc/peer_connection_interface_unittest.cc
+++ b/pc/peer_connection_interface_unittest.cc
@@ -71,8 +71,10 @@
 #include "pc/media_stream.h"
 #include "pc/peer_connection.h"
 #include "pc/peer_connection_factory.h"
+#include "pc/rtp_media_utils.h"
 #include "pc/rtp_sender.h"
 #include "pc/rtp_sender_proxy.h"
+#include "pc/sdp_utils.h"
 #include "pc/session_description.h"
 #include "pc/stream_collection.h"
 #include "pc/test/fake_audio_capture_module.h"
@@ -998,8 +1000,18 @@
 
   void CreateOfferReceiveAnswer() {
     CreateOfferAsLocalDescription();
+    std::unique_ptr<SessionDescriptionInterface> offer =
+        CloneSessionDescription(pc_->local_description());
+    // Adapts the offer so that it can serve as an answer.
+    // This test does not use DTLS so direcetion does not have
+    // to be adapted in a similar way.
+    for (auto& content : offer->description()->contents()) {
+      MediaContentDescription* media_description = content.media_description();
+      media_description->set_direction(
+          RtpTransceiverDirectionReversed(media_description->direction()));
+    }
     std::string sdp;
-    EXPECT_TRUE(pc_->local_description()->ToString(&sdp));
+    EXPECT_TRUE(offer->ToString(&sdp));
     CreateAnswerAsRemoteDescription(sdp);
   }
diff --git a/pc/sdp_offer_answer_unittest.cc b/pc/sdp_offer_answer_unittest.cc
index b077223..9189c3b 100644
--- a/pc/sdp_offer_answer_unittest.cc
+++ b/pc/sdp_offer_answer_unittest.cc
@@ -14,6 +14,7 @@
 #include <memory>
 #include <optional>
 #include <string>
+#include <tuple>
 #include <utility>
 #include <vector>
 
@@ -47,6 +48,7 @@
 #include "media/base/media_constants.h"
 #include "media/base/stream_params.h"
 #include "pc/peer_connection_wrapper.h"
+#include "pc/session_description.h"
 #include "pc/test/fake_audio_capture_module.h"
 #include "pc/test/integration_test_helpers.h"
 #include "pc/test/mock_peer_connection_observers.h"
@@ -1744,4 +1746,95 @@
   EXPECT_EQ(codecs[1].id, av1.id);
 }
 
+class SdpOfferAnswerDirectionTest
+    : public SdpOfferAnswerTest,
+      public testing::WithParamInterface<
+          std::tuple<RtpTransceiverDirection, RtpTransceiverDirection, bool>> {
+ public:
+  SdpOfferAnswerDirectionTest() : SdpOfferAnswerTest() {}
+};
+
+TEST_P(SdpOfferAnswerDirectionTest, IncompatibleDirection) {
+  auto caller = CreatePeerConnection();
+  auto callee = CreatePeerConnection();
+
+  auto transceiver = caller->AddTransceiver(MediaType::VIDEO);
+  EXPECT_TRUE(transceiver->SetDirectionWithError(std::get<0>(GetParam())).ok());
+
+  auto offer = caller->CreateOfferAndSetAsLocal();
+  EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer)));
+
+  ASSERT_THAT(callee->pc()->GetTransceivers(), SizeIs(1));
+  auto callee_transceiver = callee->pc()->GetTransceivers()[0];
+  EXPECT_TRUE(callee_transceiver
+                  ->SetDirectionWithError(RtpTransceiverDirection::kInactive)
+                  .ok());
+  auto answer = callee->CreateAnswerAndSetAsLocal();
+  // Modify the answer.
+  ASSERT_THAT(answer->description()->contents(), SizeIs(1));
+  ContentInfo& content = answer->description()->contents()[0];
+  EXPECT_EQ(content.media_description()->direction(),
+            RtpTransceiverDirection::kInactive);
+  content.media_description()->set_direction(std::get<1>(GetParam()));
+
+  EXPECT_EQ(caller->SetRemoteDescription(std::move(answer)),
+            std::get<2>(GetParam()));
+}
+
+INSTANTIATE_TEST_SUITE_P(SdpOfferAnswerDirectionTest,
+                         SdpOfferAnswerDirectionTest,
+                         ::testing::Values(
+                             // sendrecv.
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kSendRecv,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kSendOnly,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kRecvOnly,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendRecv,
+                                             RtpTransceiverDirection::kInactive,
+                                             true),
+                             // sendonly.
+                             std::make_tuple(RtpTransceiverDirection::kSendOnly,
+                                             RtpTransceiverDirection::kSendRecv,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kSendOnly,
+                                             RtpTransceiverDirection::kSendOnly,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kSendOnly,
+                                             RtpTransceiverDirection::kRecvOnly,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kSendOnly,
+                                             RtpTransceiverDirection::kInactive,
+                                             true),
+                             // recvonly.
+                             std::make_tuple(RtpTransceiverDirection::kRecvOnly,
+                                             RtpTransceiverDirection::kSendRecv,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kRecvOnly,
+                                             RtpTransceiverDirection::kSendOnly,
+                                             true),
+                             std::make_tuple(RtpTransceiverDirection::kRecvOnly,
+                                             RtpTransceiverDirection::kRecvOnly,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kRecvOnly,
+                                             RtpTransceiverDirection::kInactive,
+                                             true),
+                             // inactive.
+                             std::make_tuple(RtpTransceiverDirection::kInactive,
+                                             RtpTransceiverDirection::kSendRecv,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kInactive,
+                                             RtpTransceiverDirection::kSendOnly,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kInactive,
+                                             RtpTransceiverDirection::kRecvOnly,
+                                             false),
+                             std::make_tuple(RtpTransceiverDirection::kInactive,
+                                             RtpTransceiverDirection::kInactive,
+                                             true)));
+
 }  // namespace webrtc
Loading diff…

Original Bug Report

reported by [email protected]

SetRemoteDescription does not validate transceiver direction properly

VULNERABILITY DETAILS https://jsfiddle.net/e7t531oc/ shows that setRemoteDescription allows the remote SDP to control whether the local side receives and processes packets. See https://issues.webrtc.org/issues/42221095 for previous issues preventing that.

Firefox rejects this. VERSION Chrome Version: 140.0.0.0 + [stable, beta, and dev] Operating System: all

REPRODUCTION CASE https://jsfiddle.net/e7t531oc/

CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: Philipp Hancke

View on issue tracker