CVE-2026-78966
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifnet/quic/quic_chromium_client_session.cc |
modified | |
ASSERT_TRUEnet/quic/quic_session_pool_test.cc |
modified | |
TEST_Pnet/quic/quic_session_pool_test.cc |
modified | |
TEST_Fnet/socket/udp_socket_unittest.cc |
modified |
Files Changed
net/quic/quic_chromium_client_session.ccnet/quic/quic_session_pool_test.ccnet/socket/udp_client_socket.ccnet/socket/udp_socket_unittest.cc
Patch
From 2b93b8b811b82a88bb480734b40f2b2673de54c2 Mon Sep 17 00:00:00 2001 From: Kenichi Ishibashi <[email protected]> Date: Mon, 29 Jun 2026 17:02:45 -0700 Subject: [PATCH] [net] Enforce port and address restrictions for QUIC and UDP sockets This CL ensures that QUIC connection migration and UDP socket connections correctly respect address and port restrictions. Bug: 497637694 Change-Id: I77474e8c4db5cfe04ba886ccb3cd95b1d1d87841 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017562 Commit-Queue: Kenichi Ishibashi <[email protected]> Reviewed-by: Stefano Duo <[email protected]> Cr-Commit-Position: refs/heads/main@{#1654479} --- diff --git a/net/quic/quic_chromium_client_session.cc b/net/quic/quic_chromium_client_session.cc index 7519477..3fd1deda 100644 --- a/net/quic/quic_chromium_client_session.cc +++ b/net/quic/quic_chromium_client_session.cc @@ -4355,6 +4355,17 @@ }); return; } + + if (ToIPAddress(connection()->peer_address().host()).IsPubliclyRoutable() && + !ToIPAddress(server_preferred_address.host()).IsPubliclyRoutable()) { + net_log_.AddEvent(NetLogEventType::QUIC_CONNECTION_MIGRATION_FAILURE, [&] { + return NetLogQuicMigrationFailureParams( + connection_id(), + "Ignored non-publicly routable server preferred address"); + }); + return; + } + if (!allow_server_preferred_address_) { return; } diff --git a/net/quic/quic_session_pool_test.cc b/net/quic/quic_session_pool_test.cc index b602068..bba0c1a 100644 --- a/net/quic/quic_session_pool_test.cc +++ b/net/quic/quic_session_pool_test.cc @@ -6008,6 +6008,69 @@ quic_data2.ExpectAllWriteDataConsumed(); } +TEST_P(QuicSessionPoolTest, + ServerPreferredAddressIgnoredWhenNotPubliclyRoutable) { + // Original peer is public. + host_resolver_->rules()->AddIPLiteralRule(kDefaultServerHostName, "9.9.9.9", + ""); + + // Preferred address is private. + IPEndPoint server_preferred_address = IPEndPoint(IPAddress(10, 0, 0, 1), 123); + FLAGS_quic_enable_chaos_protection = false; + quic_params_->allow_server_migration = true; + socket_factory_ = std::make_unique<TestPortMigrationSocketFactory>(); + Initialize(); + + ProofVerifyDetailsChromium verify_details = DefaultProofVerifyDetails(); + crypto_client_stream_factory_.AddProofVerifyDetails(&verify_details); + quic::QuicConfig config; + config.SetIPv4AlternateServerAddressToSend( + ToQuicSocketAddress(server_preferred_address)); + quic::test::QuicConfigPeer::SetPreferredAddressConnectionIdAndToken( + &config, kNewCID, quic::QuicUtils::GenerateStatelessResetToken(kNewCID)); + crypto_client_stream_factory_.SetConfig(config); + crypto_client_stream_factory_.set_handshake_mode( + MockCryptoClientStream::COLD_START_WITH_CHLO_SENT); + + int packet_number = 1; + MockQuicData quic_data1(version_); + quic_data1.AddReadPauseForever(); + quic_data1.AddWrite(ASYNC, + client_maker_.MakeDummyCHLOPacket(packet_number++)); + client_maker_.SetEncryptionLevel(quic::ENCRYPTION_FORWARD_SECURE); + quic_data1.AddWrite(SYNCHRONOUS, + ConstructInitialSettingsPacket(packet_number++)); + quic_data1.AddSocketDataToFactory(socket_factory_.get()); + + // Create request. + RequestBuilder builder(this); + EXPECT_EQ(ERR_IO_PENDING, builder.CallRequest()); + EXPECT_FALSE(HasActiveSession(kDefaultDestination)); + EXPECT_TRUE(HasActiveJob(kDefaultDestination, PRIVACY_MODE_DISABLED)); + ASSERT_TRUE(base::test::RunUntil([&]() { + return crypto_client_stream_factory_.last_stream() != nullptr; + })); + + crypto_client_stream_factory_.last_stream() + ->NotifySessionOneRttKeyAvailable(); + EXPECT_THAT(callback_.WaitForResult(), IsOk()); + ASSERT_TRUE(HasActiveSession(kDefaultDestination)); + EXPECT_FALSE(HasActiveJob(kDefaultDestination, PRIVACY_MODE_DISABLED)); + QuicChromiumClientSession* session = GetActiveSession(kDefaultDestination); + + const quic::QuicSocketAddress original_peer_address = session->peer_address(); + + // Since the preferred address is private, it should be ignored. + // Path validation should not be pending. + EXPECT_FALSE(session->connection()->HasPendingPathValidation()); + EXPECT_FALSE( + session->connection()->GetStats().server_preferred_address_validated); + EXPECT_EQ(session->peer_address(), original_peer_address); + + quic_data1.ExpectAllReadDataConsumed(); + quic_data1.ExpectAllWriteDataConsumed(); +} + TEST_P(QuicSessionPoolTest, FailedToValidateServerPreferredAddress) { IPEndPoint server_preferred_address = IPEndPoint(IPAddress(1, 2, 3, 4), 123); FLAGS_quic_enable_chaos_protection = false; diff --git a/net/socket/udp_client_socket.cc b/net/socket/udp_client_socket.cc index 6203284..ed7a174 100644 --- a/net/socket/udp_client_socket.cc +++ b/net/socket/udp_client_socket.cc @@ -92,6 +92,9 @@ int UDPClientSocket::ConnectUsingNetwork(handles::NetworkHandle network, const IPEndPoint& address) { CHECK(!connect_called_); + if (!IsPortAllowedForIpEndpoint(address)) { + return ERR_UNSAFE_PORT; + } connect_called_ = true; if (!NetworkChangeNotifier::AreNetworkHandlesSupported()) return ERR_NOT_IMPLEMENTED; @@ -118,6 +121,9 @@ int UDPClientSocket::ConnectUsingDefaultNetwork(const IPEndPoint& address) { CHECK(!connect_called_); + if (!IsPortAllowedForIpEndpoint(address)) { + return ERR_UNSAFE_PORT; + } connect_called_ = true; if (!NetworkChangeNotifier::AreNetworkHandlesSupported()) return ERR_NOT_IMPLEMENTED; diff --git a/net/socket/udp_socket_unittest.cc b/net/socket/udp_socket_unittest.cc index 7fa6075..b4d177c2 100644 --- a/net/socket/udp_socket_unittest.cc +++ b/net/socket/udp_socket_unittest.cc @@ -410,6 +410,50 @@ server_address.port(), 1); } +TEST_F(UDPSocketTest, ConnectUsingNetworkRestrictedPort) { + base::HistogramTester histogram_tester; + base::test::ScopedFeatureList feature_list; + UDPServerSocket server(NetLog::Get(), NetLogSource()); + server.AllowAddressReuse(); + ASSERT_THAT(server.Listen(IPEndPoint(IPAddress::IPv4Localhost(), 0)), IsOk()); + IPEndPoint server_address; + ASSERT_THAT(server.GetLocalAddress(&server_address), IsOk()); + feature_list.InitAndEnableFeatureWithParameters( + features::kRestrictAbusePortsOnLocalhost, + {{"localhost_restrict_ports", + base::NumberToString(server_address.port())}}); + ReloadLocalhostRestrictedPortsForTesting(); + + auto client = std::make_unique<UDPClientSocket>( + DatagramSocket::DEFAULT_BIND, NetLog::Get(), NetLogSource(), + handles::kInvalidNetworkHandle); + EXPECT_THAT(client->ConnectUsingNetwork(1234, server_address), + IsError(ERR_UNSAFE_PORT)); + histogram_tester.ExpectTotalCount("Net.RestrictedLocalhostPorts", 1); +} + +TEST_F(UDPSocketTest, ConnectUsingDefaultNetworkRestrictedPort) { + base::HistogramTester histogram_tester; + base::test::ScopedFeatureList feature_list; + UDPServerSocket server(NetLog::Get(), NetLogSource()); + server.AllowAddressReuse(); + ASSERT_THAT(server.Listen(IPEndPoint(IPAddress::IPv4Localhost(), 0)), IsOk()); + IPEndPoint server_address; + ASSERT_THAT(server.GetLocalAddress(&server_address), IsOk()); + feature_list.InitAndEnableFeatureWithParameters( + features::kRestrictAbusePortsOnLocalhost, + {{"localhost_restrict_ports", + base::NumberToString(server_address.port())}}); + ReloadLocalhostRestrictedPortsForTesting(); + + auto client = std::make_unique<UDPClientSocket>( + DatagramSocket::DEFAULT_BIND, NetLog::Get(), NetLogSource(), + handles::kInvalidNetworkHandle); + EXPECT_THAT(client->ConnectUsingDefaultNetwork(server_address), + IsError(ERR_UNSAFE_PORT)); + histogram_tester.ExpectTotalCount("Net.RestrictedLocalhostPorts", 1); +} + #if BUILDFLAG(IS_WIN) TEST_F(UDPSocketTest, ConnectNonBlocking) { ConnectTest(true, false);
Regression Test / PoC
diff --git a/net/quic/quic_session_pool_test.cc b/net/quic/quic_session_pool_test.cc
index b602068..bba0c1a 100644
--- a/net/quic/quic_session_pool_test.cc
+++ b/net/quic/quic_session_pool_test.cc
@@ -6008,6 +6008,69 @@
quic_data2.ExpectAllWriteDataConsumed();
}
+TEST_P(QuicSessionPoolTest,
+ ServerPreferredAddressIgnoredWhenNotPubliclyRoutable) {
+ // Original peer is public.
+ host_resolver_->rules()->AddIPLiteralRule(kDefaultServerHostName, "9.9.9.9",
+ "");
+
+ // Preferred address is private.
+ IPEndPoint server_preferred_address = IPEndPoint(IPAddress(10, 0, 0, 1), 123);
+ FLAGS_quic_enable_chaos_protection = false;
+ quic_params_->allow_server_migration = true;
+ socket_factory_ = std::make_unique<TestPortMigrationSocketFactory>();
+ Initialize();
+
+ ProofVerifyDetailsChromium verify_details = DefaultProofVerifyDetails();
+ crypto_client_stream_factory_.AddProofVerifyDetails(&verify_details);
+ quic::QuicConfig config;
+ config.SetIPv4AlternateServerAddressToSend(
+ ToQuicSocketAddress(server_preferred_address));
+ quic::test::QuicConfigPeer::SetPreferredAddressConnectionIdAndToken(
+ &config, kNewCID, quic::QuicUtils::GenerateStatelessResetToken(kNewCID));
+ crypto_client_stream_factory_.SetConfig(config);
+ crypto_client_stream_factory_.set_handshake_mode(
+ MockCryptoClientStream::COLD_START_WITH_CHLO_SENT);
+
+ int packet_number = 1;
+ MockQuicData quic_data1(version_);
+ quic_data1.AddReadPauseForever();
+ quic_data1.AddWrite(ASYNC,
+ client_maker_.MakeDummyCHLOPacket(packet_number++));
+ client_maker_.SetEncryptionLevel(quic::ENCRYPTION_FORWARD_SECURE);
+ quic_data1.AddWrite(SYNCHRONOUS,
+ ConstructInitialSettingsPacket(packet_number++));
+ quic_data1.AddSocketDataToFactory(socket_factory_.get());
+
+ // Create request.
+ RequestBuilder builder(this);
+ EXPECT_EQ(ERR_IO_PENDING, builder.CallRequest());
+ EXPECT_FALSE(HasActiveSession(kDefaultDestination));
+ EXPECT_TRUE(HasActiveJob(kDefaultDestination, PRIVACY_MODE_DISABLED));
+ ASSERT_TRUE(base::test::RunUntil([&]() {
+ return crypto_client_stream_factory_.last_stream() != nullptr;
+ }));
+
+ crypto_client_stream_factory_.last_stream()
+ ->NotifySessionOneRttKeyAvailable();
+ EXPECT_THAT(callback_.WaitForResult(), IsOk());
+ ASSERT_TRUE(HasActiveSession(kDefaultDestination));
+ EXPECT_FALSE(HasActiveJob(kDefaultDestination, PRIVACY_MODE_DISABLED));
+ QuicChromiumClientSession* session = GetActiveSession(kDefaultDestination);
+
+ const quic::QuicSocketAddress original_peer_address = session->peer_address();
+
+ // Since the preferred address is private, it should be ignored.
+ // Path validation should not be pending.
+ EXPECT_FALSE(session->connection()->HasPendingPathValidation());
+ EXPECT_FALSE(
+ session->connection()->GetStats().server_preferred_address_validated);
+ EXPECT_EQ(session->peer_address(), original_peer_address);
+
+ quic_data1.ExpectAllReadDataConsumed();
+ quic_data1.ExpectAllWriteDataConsumed();
+}
+
TEST_P(QuicSessionPoolTest, FailedToValidateServerPreferredAddress) {
IPEndPoint server_preferred_address = IPEndPoint(IPAddress(1, 2, 3, 4), 123);
FLAGS_quic_enable_chaos_protection = false;
diff --git a/net/socket/udp_socket_unittest.cc b/net/socket/udp_socket_unittest.cc
index 7fa6075..b4d177c2 100644
--- a/net/socket/udp_socket_unittest.cc
+++ b/net/socket/udp_socket_unittest.cc
@@ -410,6 +410,50 @@
server_address.port(), 1);
}
+TEST_F(UDPSocketTest, ConnectUsingNetworkRestrictedPort) {
+ base::HistogramTester histogram_tester;
+ base::test::ScopedFeatureList feature_list;
+ UDPServerSocket server(NetLog::Get(), NetLogSource());
+ server.AllowAddressReuse();
+ ASSERT_THAT(server.Listen(IPEndPoint(IPAddress::IPv4Localhost(), 0)), IsOk());
+ IPEndPoint server_address;
+ ASSERT_THAT(server.GetLocalAddress(&server_address), IsOk());
+ feature_list.InitAndEnableFeatureWithParameters(
+ features::kRestrictAbusePortsOnLocalhost,
+ {{"localhost_restrict_ports",
+ base::NumberToString(server_address.port())}});
+ ReloadLocalhostRestrictedPortsForTesting();
+
+ auto client = std::make_unique<UDPClientSocket>(
+ DatagramSocket::DEFAULT_BIND, NetLog::Get(), NetLogSource(),
+ handles::kInvalidNetworkHandle);
+ EXPECT_THAT(client->ConnectUsingNetwork(1234, server_address),
+ IsError(ERR_UNSAFE_PORT));
+ histogram_tester.ExpectTotalCount("Net.RestrictedLocalhostPorts", 1);
+}
+
+TEST_F(UDPSocketTest, ConnectUsingDefaultNetworkRestrictedPort) {
+ base::HistogramTester histogram_tester;
+ base::test::ScopedFeatureList feature_list;
+ UDPServerSocket server(NetLog::Get(), NetLogSource());
+ server.AllowAddressReuse();
+ ASSERT_THAT(server.Listen(IPEndPoint(IPAddress::IPv4Localhost(), 0)), IsOk());
+ IPEndPoint server_address;
+ ASSERT_THAT(server.GetLocalAddress(&server_address), IsOk());
+ feature_list.InitAndEnableFeatureWithParameters(
+ features::kRestrictAbusePortsOnLocalhost,
+ {{"localhost_restrict_ports",
+ base::NumberToString(server_address.port())}});
+ ReloadLocalhostRestrictedPortsForTesting();
+
+ auto client = std::make_unique<UDPClientSocket>(
+ DatagramSocket::DEFAULT_BIND, NetLog::Get(), NetLogSource(),
+ handles::kInvalidNetworkHandle);
+ EXPECT_THAT(client->ConnectUsingDefaultNetwork(server_address),
+ IsError(ERR_UNSAFE_PORT));
+ histogram_tester.ExpectTotalCount("Net.RestrictedLocalhostPorts", 1);
+}
+
#if BUILDFLAG(IS_WIN)
TEST_F(UDPSocketTest, ConnectNonBlocking) {
ConnectTest(true, false);
Original Bug Report
Bypass of Local Network Access and port restrictions via QUIC preferred_address
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A malicious QUIC server can supply a preferred_address transport parameter pointing to arbitrary internal IPs and restricted ports. On platforms where kMigrateSessionsOnNetworkChangeV2 is enabled (like Android), the resulting UDP socket bypasses IsPortAllowedForIpEndpoint checks. This allows attackers to send up to 20 bytes of arbitrary UDP payload to localhost or internal network services, and perform blind UDP port scanning.
Affected files:
net/quic/quic_chromium_client_session.ccnet/socket/udp_client_socket.ccnet/quic/quic_session_pool.ccnet/third_party/quiche/src/quiche/quic/core/quic_config.ccnet/third_party/quiche/src/quiche/quic/core/crypto/transport_parameters.cc
Estimated timestamp from git blame: 2024-10-15
Description
A potential vulnerability exists in Chrome’s handling of the IETF QUIC preferred_address transport parameter. A malicious QUIC server can provide an alternative IP address and port for the client to migrate its connection to. Chrome’s QUIC implementation attempts to validate this server-provided address by sending encrypted 1-RTT QUIC PATH_CHALLENGE packets.
However, the implementation fails to perform adequate validation on the target IP and port, bypassing both Local/Private Network Access (LNA/PNA) boundaries and Chrome’s restricted port list (e.g., ports 53, 111). This allows a remote attacker to trigger unsolicited UDP traffic to arbitrary internal or external targets, with partial control over the UDP payload.
Technical Details
-
Missing IP Validation: When the
preferred_addressis received,QuicChromiumClientSession::OnServerPreferredAddressAvailableis triggered (net/quic/quic_chromium_client_session.cc:4310). It checks if the connection is direct but fails to enforce any LNA/PNA policy or IP blocklist validations against the new server-provided IP address. -
Bypassed Port Restrictions: To send the probe,
QuicSessionPool::ConnectAndConfigureSocketis called. On platforms wherefeatures::kMigrateSessionsOnNetworkChangeV2is enabled (which defaults to true on Android pernet/base/features.cc:299), the code usesConnectUsingNetworkAsyncorConnectUsingDefaultNetworkAsyncinnet/socket/udp_client_socket.cc. Crucially, unlike the standardUDPClientSocket::Connectmethod, these specialized methods do not callIsPortAllowedForIpEndpoint. This allows the socket to bind and connect to restricted ports, including those on localhost. -
Payload Control (SSRF): The client sends
PATH_CHALLENGEpackets to the target. Because this is an established connection, a 1-RTT “Short Header” QUIC packet is used. The short header starts with a 1-byte header, immediately followed by the Destination Connection ID. The attacker fully controls the up to 20-byteconnection_idsupplied in thepreferred_addressparameter. This means the first ~21 bytes of the UDP payload are attacker-controlled (1 byte header + 20 bytes connection ID), enabling blind UDP SSRF. -
Internal Port Scanning Oracle: If the target internal port is closed, the victim’s OS may respond with an ICMP Port Unreachable message, causing an immediate failure of the path validation. If the port is open/filtered, the client will send repeated probes until a timeout occurs. The attacker’s server can observe this timing difference (e.g., via the client retiring the connection ID) to reliably scan the victim’s internal network for active UDP services.
Potential Reproduction Steps
Note: These are theoretical steps as we do not yet have a working PoC.
- Set up an HTTPS/3 (QUIC) server with a valid TLS certificate.
- Configure the server to include the
preferred_addresstransport parameter during the handshake, pointing to an internal target (e.g.,127.0.0.1:111or10.0.0.5:53). Set theconnection_idto the desired UDP payload prefix. - Navigate to the server using Chrome on Android (where
kMigrateSessionsOnNetworkChangeV2is enabled). - Observe via packet capture that Chrome attempts to connect and send UDP packets to the specified internal address and restricted port.
Suggested Fix
- Enforce Port Restrictions: Add a call to
IsPortAllowedForIpEndpoint(address)inUDPClientSocket::ConnectUsingNetworkandUDPClientSocket::ConnectUsingDefaultNetworkinnet/socket/udp_client_socket.ccto ensure restricted ports cannot be targeted, even during network migrations. - Enforce LNA/PNA Policies: Implement IP address validation in
QuicChromiumClientSession::OnServerPreferredAddressAvailableto block migrations to internal/private IP ranges if the original connection was to a public IP, adhering to Private Network Access guidelines.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.