CVE-2026-11199
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fp2p/base/turn_port_unittest.cc |
modified |
Files Changed
p2p/base/stun_request.ccp2p/base/turn_port_unittest.cc
Patch
From aa54326daa32892de997bb6fd60701b8e03c718a Mon Sep 17 00:00:00 2001 From: Harald Alvestrand <[email protected]> Date: Wed, 22 Apr 2026 20:16:27 +0000 Subject: [PATCH] Discard unauthenticated TURN responses to unauthenticated requests In StunRequestManager::CheckResponse, if a TURN request was sent without authentication (initial ALLOCATE), we now explicitly discard any success response. We also restrict allowed error responses to 401 Unauthorized and 300 Try Alternate. This prevents an authentication bypass (b/504572664) where an attacker could hijack a TURN session by forging a success response to the initial unauthenticated request. Add regression test for unauthenticated TURN ALLOCATE success. Bug: chromium:504572664 Change-Id: Ie5619e10b6b6dccac3221661632e9f1d6312f760 Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/466100 Commit-Queue: Harald Alvestrand <[email protected]> Reviewed-by: Danil Chapovalov <[email protected]> Cr-Commit-Position: refs/heads/main@{#47529} --- diff --git a/p2p/base/stun_request.cc b/p2p/base/stun_request.cc index 3afabe1..1a18d2b 100644 --- a/p2p/base/stun_request.cc +++ b/p2p/base/stun_request.cc @@ -32,6 +32,7 @@ #include "rtc_base/checks.h" #include "rtc_base/logging.h" #include "rtc_base/string_encode.h" +#include "rtc_base/string_utils.h" namespace webrtc { @@ -122,8 +123,28 @@ bool skip_integrity_checking = (request->msg()->integrity() == StunMessage::IntegrityStatus::kNotSet); if (!request->AuthenticationRequired()) { - // This is a STUN_BINDING to from stun_port.cc or - // the initial (unauthenticated) TURN_ALLOCATE_REQUEST. + if (request->type() != STUN_BINDING_REQUEST) { + if (msg->type() == GetStunSuccessResponseType(request->type())) { + RTC_LOG(LS_WARNING) + << "Discarding unauthenticated success response (0x" + << ToHex(msg->type()) << ") to TURN request of type 0x" + << ToHex(request->type()) + << ", id=" << hex_encode(msg->transaction_id()); + return false; + } + if (msg->type() == GetStunErrorResponseType(request->type())) { + int error_code = msg->GetErrorCodeValue(); + if (error_code != STUN_ERROR_UNAUTHORIZED && + error_code != STUN_ERROR_TRY_ALTERNATE) { + RTC_LOG(LS_WARNING) + << "Discarding unauthenticated error response with code " + << error_code << " to TURN request of type 0x" + << ToHex(request->type()) + << ", id=" << hex_encode(msg->transaction_id()); + return false; + } + } + } } else if (skip_integrity_checking) { // TODO(chromium:1177125): Remove below! // This indicates lazy test writing (not adding integrity attribute). diff --git a/p2p/base/turn_port_unittest.cc b/p2p/base/turn_port_unittest.cc index 8a53aae..b23f33a 100644 --- a/p2p/base/turn_port_unittest.cc +++ b/p2p/base/turn_port_unittest.cc @@ -1948,6 +1948,65 @@ unsigned int* attr_counter_ = nullptr; }; +// Test that an unauthenticated TURN ALLOCATE success response is NOT accepted +// if integrity is expected but not present. +// This is a regression test for b/504572664. +TEST_F(TurnPortTest, TestUnauthenticatedAllocateSuccessRejected) { + SocketAddress fake_server_addr("99.99.99.99", 3478); + CreateTurnPort(kTurnUsername, kTurnPassword, + ProtocolAddress(fake_server_addr, PROTO_UDP)); + + std::unique_ptr<AsyncPacketSocket> server_socket = + socket_factory()->CreateUdpSocket(env_, fake_server_addr, 0, 0); + + std::string transaction_id; + server_socket->RegisterReceivedPacketCallback( + [&](AsyncPacketSocket* /* socket */, const ReceivedIpPacket& packet) { + ByteBufferReader reader(packet.payload()); + TurnMessage msg; + if (msg.Read(&reader) && msg.type() == STUN_ALLOCATE_REQUEST) { + transaction_id = msg.transaction_id(); + } + }); + + turn_port_->PrepareAddress(); + + // Wait for the request to reach the server. + ASSERT_TRUE( + WaitUntil([&] { return !transaction_id.empty(); }, + {.timeout = kSimulatedRtt, .clock = &time_controller_})); + + TurnMessage forged_success(STUN_ALLOCATE_RESPONSE, transaction_id); + + // Add required attributes for ALLOCATE success. + SocketAddress relayed_addr("198.51.100.99", 6666); + SocketAddress mapped_addr("203.0.113.77", 5555); + + forged_success.AddAttribute(std::make_unique<StunXorAddressAttribute>( + STUN_ATTR_XOR_RELAYED_ADDRESS, relayed_addr)); + forged_success.AddAttribute(std::make_unique<StunXorAddressAttribute>( + STUN_ATTR_XOR_MAPPED_ADDRESS, mapped_addr)); + forged_success.AddAttribute( + std::make_unique<StunUInt32Attribute>(STUN_ATTR_LIFETIME, 300)); + + ByteBufferWriter buf; + forged_success.Write(&buf); + + // Send the forged response to the TurnPort. + SocketAddress local_addr = turn_port_->socket()->GetLocalAddress(); + AsyncSocketPacketOptions local_options; + server_socket->SendTo(buf.Data(), buf.Length(), local_addr, local_options); + + // Wait a bit for the packet to be processed. + time_controller_.AdvanceTime(kSimulatedRtt); + + // If vulnerable, turn_ready_ would be true because it accepted the forged + // success. The correct behavior is to reject it and eventually fail or + // receive the 401. + EXPECT_FALSE(turn_ready_) + << "Vulnerability present: Unauthenticated ALLOCATE success accepted!"; +} + // Do a TURN allocation, establish a TLS connection, and send some data. // Add customizer and check that it get called. TEST_F(TurnPortTest, TestTurnCustomizerCount) {
Regression Test / PoC
diff --git a/p2p/base/turn_port_unittest.cc b/p2p/base/turn_port_unittest.cc
index 8a53aae..b23f33a 100644
--- a/p2p/base/turn_port_unittest.cc
+++ b/p2p/base/turn_port_unittest.cc
@@ -1948,6 +1948,65 @@
unsigned int* attr_counter_ = nullptr;
};
+// Test that an unauthenticated TURN ALLOCATE success response is NOT accepted
+// if integrity is expected but not present.
+// This is a regression test for b/504572664.
+TEST_F(TurnPortTest, TestUnauthenticatedAllocateSuccessRejected) {
+ SocketAddress fake_server_addr("99.99.99.99", 3478);
+ CreateTurnPort(kTurnUsername, kTurnPassword,
+ ProtocolAddress(fake_server_addr, PROTO_UDP));
+
+ std::unique_ptr<AsyncPacketSocket> server_socket =
+ socket_factory()->CreateUdpSocket(env_, fake_server_addr, 0, 0);
+
+ std::string transaction_id;
+ server_socket->RegisterReceivedPacketCallback(
+ [&](AsyncPacketSocket* /* socket */, const ReceivedIpPacket& packet) {
+ ByteBufferReader reader(packet.payload());
+ TurnMessage msg;
+ if (msg.Read(&reader) && msg.type() == STUN_ALLOCATE_REQUEST) {
+ transaction_id = msg.transaction_id();
+ }
+ });
+
+ turn_port_->PrepareAddress();
+
+ // Wait for the request to reach the server.
+ ASSERT_TRUE(
+ WaitUntil([&] { return !transaction_id.empty(); },
+ {.timeout = kSimulatedRtt, .clock = &time_controller_}));
+
+ TurnMessage forged_success(STUN_ALLOCATE_RESPONSE, transaction_id);
+
+ // Add required attributes for ALLOCATE success.
+ SocketAddress relayed_addr("198.51.100.99", 6666);
+ SocketAddress mapped_addr("203.0.113.77", 5555);
+
+ forged_success.AddAttribute(std::make_unique<StunXorAddressAttribute>(
+ STUN_ATTR_XOR_RELAYED_ADDRESS, relayed_addr));
+ forged_success.AddAttribute(std::make_unique<StunXorAddressAttribute>(
+ STUN_ATTR_XOR_MAPPED_ADDRESS, mapped_addr));
+ forged_success.AddAttribute(
+ std::make_unique<StunUInt32Attribute>(STUN_ATTR_LIFETIME, 300));
+
+ ByteBufferWriter buf;
+ forged_success.Write(&buf);
+
+ // Send the forged response to the TurnPort.
+ SocketAddress local_addr = turn_port_->socket()->GetLocalAddress();
+ AsyncSocketPacketOptions local_options;
+ server_socket->SendTo(buf.Data(), buf.Length(), local_addr, local_options);
+
+ // Wait a bit for the packet to be processed.
+ time_controller_.AdvanceTime(kSimulatedRtt);
+
+ // If vulnerable, turn_ready_ would be true because it accepted the forged
+ // success. The correct behavior is to reject it and eventually fail or
+ // receive the 401.
+ EXPECT_FALSE(turn_ready_)
+ << "Vulnerability present: Unauthenticated ALLOCATE success accepted!";
+}
+
// Do a TURN allocation, establish a TLS connection, and send some data.
// Add customizer and check that it get called.
TEST_F(TurnPortTest, TestTurnCustomizerCount) {
Original Bug Report
TURN session hijack via forged unauthenticated ALLOCATE success response
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic flaw in WebRTC’s STUN/TURN handling allows an on-path attacker to forge a success response to an initial, unauthenticated TURN ALLOCATE request. This bypasses the normal 401 Unauthorized challenge, leaving the port’s credential hash empty for all subsequent requests. The attacker can then impersonate the TURN relay indefinitely by using an empty key to compute MESSAGE-INTEGRITY hashes.
Affected files:
third_party/webrtc/p2p/base/stun_request.ccthird_party/webrtc/p2p/base/turn_port.ccthird_party/webrtc/api/transport/stun.ccthird_party/webrtc/rtc_base/message_digest.cc
Estimated timestamp from git blame: 2023-11-15
Summary
A logic flaw exists in StunRequestManager::CheckResponse that allows an active on-path attacker to bypass integrity checks for TURN ALLOCATE success responses. By winning a race against the legitimate TURN server’s response to an initial unauthenticated request, an attacker can supply forged relay and mapped addresses. Because the authentication challenge is bypassed, subsequent TURN requests use an empty HMAC key for MESSAGE-INTEGRITY, allowing the attacker to fully impersonate the TURN server for the duration of the session.
Root Cause Analysis
In WebRTC, a TurnPort starts by sending an unauthenticated TURN ALLOCATE request to discover the required realm and nonce. This request is marked with SetAuthenticationRequired(false) because no credentials have been negotiated yet.
In third_party/webrtc/p2p/base/stun_request.cc, the CheckResponse function determines whether to skip integrity validation based on the outgoing request:
bool skip_integrity_checking =
(request->msg()->integrity() == StunMessage::IntegrityStatus::kNotSet);
For the initial unauthenticated request, skip_integrity_checking is set to true and AuthenticationRequired() is false. When an attacker sends a forged Allocate Success (0x0103) response, the code skips the ValidateMessageIntegrity block and evaluates:
} else if (msg->type() == GetStunSuccessResponseType(request->type())) {
if (!msg->IntegrityOk() && !skip_integrity_checking) {
return false;
}
// ...
owned_request->OnResponse(msg);
return true;
}
Because skip_integrity_checking is true, the success response is accepted without a valid MESSAGE-INTEGRITY attribute. The pending request is then erased, meaning the legitimate server’s subsequent 401 Unauthorized challenge will be silently dropped.
Because the 401 challenge is dropped, TurnPort::set_realm and TurnPort::UpdateHash are never called, leaving TurnPort::hash_ empty. In Release builds (NDEBUG), the RTC_DCHECK(!hash_.empty()) in TurnPort::AddRequestAuthInfo is compiled out. Subsequent TURN requests (Refresh, CreatePermission) will compute their MESSAGE-INTEGRITY HMAC using this empty key, which ComputeHmac handles by zero-padding.
Potential Exploitation Steps
(Note: These are potential steps based on code analysis; a working PoC has not been executed.)
- Intercept: An active on-path attacker observes the cleartext UDP
TURN ALLOCATErequest and extracts its 12-byte Transaction ID. - Forge: The attacker races the legitimate TURN server by sending a spoofed UDP
STUN Allocate Successresponse (0x0103) containing the intercepted Transaction ID and attacker-controlledXOR-RELAYED-ADDRESSvalues. - Bypass: WebRTC processes the response, bypassing the integrity check because the original request was unauthenticated. The application surfaces the attacker’s relay candidate to the remote peer.
- Hijack: Subsequent TURN Refresh requests sent by the browser use an empty key for their
MESSAGE-INTEGRITYattribute. The attacker observes these, validates the empty-key HMAC, and responds with successful responses using the same empty key, maintaining the hijacked session indefinitely.
Impact
An active on-path attacker can force the client to use an attacker-controlled TURN relay. This allows the attacker to intercept, drop, or manipulate the routing of peer-to-peer traffic. While media content remains protected by end-to-end DTLS-SRTP encryption, this breaks the security guarantees of the TURN protocol itself.
Suggested Fix
The system should strictly enforce state transitions for TURN allocations. Specifically:
- If a
TurnAllocateRequestis sent without credentials but theTurnPorthas been configured with a username and password, it should explicitly rejectAllocate Successresponses and only accept a401 Unauthorizedchallenge. - Alternatively,
StunRequestManager::CheckResponseshould be hardened so thatskip_integrity_checkingdoes not automatically bypass validation for Success responses if the protocol dictates that a Success response must always be authenticated once a session is established.
Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.
Raised in root component due to access or custom field issues on 1565889