CVE-2025-13639
Overview
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 viaRtpTransceiverDirectionHasSend/RtpTransceiverDirectionHasRecv. - `SetRemoteDescription` / `kAnswer`
- the API path by which a peer applies a remote SDP; when
source == CS_REMOTEand the type iskAnswer/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.
VerifyDirectionsInAnswer to enforce that the answer’s direction is a valid complement of the local offer’s.Attack Path
- 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.
kRecvOnlyorkInactiveon a transceiver). - Craft an incompatible answer
The remote peer returns an SDP answer whose media section direction contradicts the offer — e.g. answering
sendrecv/sendonlyto a section the local side offered asrecvonly, as the regression test’sfalse-expected tuples demonstrate. - Apply without validation (pre-patch)
The local peer’s
SetRemoteDescriptionaccepts the answer with no direction check, establishing a transceiver state that violates the negotiated/RFC 3264 direction. (inferred) - 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
WebRTC-EnforceTransceiverDirection killswitch.Changed Functions
| Function | Change | Notes |
|---|---|---|
SdpOfferAnswerDirectionTestpc/sdp_offer_answer_unittest.cc |
modified | |
TEST_Ppc/sdp_offer_answer_unittest.cc |
modified |
Files Changed
experiments/field_trials.pypc/peer_connection_interface_unittest.ccpc/sdp_offer_answer.ccpc/sdp_offer_answer_unittest.cc
Audit Directions
- PatternLook 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).
- PatternAudit places where RFC-mandated symmetry/complement rules (RFC 3264 direction, ICE roles, DTLS setup attributes) are assumed rather than validated on the receiving side.
- PatternReview 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.
Patch
From ff084da0f5689bb2424d662671bab6ac24eefeeb Mon Sep 17 00:00:00 2001
From: Philipp Hancke <philipp.hancke@googlemail.com>
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 <philipp.hancke@googlemail.com>
Reviewed-by: Harald Alvestrand <hta@webrtc.org>
Reviewed-by: Henrik Boström <hbos@webrtc.org>
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,
Regression Test / PoC
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
Original Bug Report
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