CVE-2026-17733
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pnet/quic/quic_chromium_client_session_test.cc |
modified |
Files Changed
net/quic/quic_chromium_client_session.ccnet/quic/quic_chromium_client_session_peer.ccnet/quic/quic_chromium_client_session_peer.hnet/quic/quic_chromium_client_session_test.cc
Patch
From 11c8b6b1891e205cf1c7fa5fd8896f28d6c99452 Mon Sep 17 00:00:00 2001 From: Yoshisato Yanagisawa <[email protected]> Date: Thu, 18 Jun 2026 01:46:42 -0700 Subject: [PATCH] Bugfix: This CL fixes a bug reported in crbug.com/495793059 [analysis & reasoning] https://docs.google.com/document/d/13ReqU8edwhUsVs3Vh8hvh81yUhzfaNVKqVAeIGefSv4 Bug: 495793059 Change-Id: I4398d24f45edbca8f4624e3e8cdffacfce496c08 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7953930 Commit-Queue: Yoshisato Yanagisawa <[email protected]> Reviewed-by: Kenichi Ishibashi <[email protected]> Reviewed-by: Nidhi Jaju <[email protected]> Reviewed-by: David Schinazi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1648872} --- diff --git a/net/quic/quic_chromium_client_session.cc b/net/quic/quic_chromium_client_session.cc index f2c7262..25e7b5c 100644 --- a/net/quic/quic_chromium_client_session.cc +++ b/net/quic/quic_chromium_client_session.cc @@ -4105,6 +4105,7 @@ // confirmed if the session is not created on the default network. if (migrate_session_on_network_change_v2_ && default_network_ != handles::kInvalidNetworkHandle && + session_key_.proxy_chain().is_direct() && GetCurrentNetwork() != default_network_) { current_migration_cause_ = ON_MIGRATE_BACK_TO_DEFAULT_NETWORK; StartMigrateBackToDefaultNetworkTimer( diff --git a/net/quic/quic_chromium_client_session_peer.cc b/net/quic/quic_chromium_client_session_peer.cc index 04d6853..c765dbb 100644 --- a/net/quic/quic_chromium_client_session_peer.cc +++ b/net/quic/quic_chromium_client_session_peer.cc @@ -68,4 +68,16 @@ session->default_network_ = network; } +// static +bool QuicChromiumClientSessionPeer::IsMigrateBackToDefaultNetworkTimerRunning( + QuicChromiumClientSession* session) { + return session->migrate_back_to_default_timer_.IsRunning(); +} + +// static +void QuicChromiumClientSessionPeer::OnCryptoHandshakeComplete( + QuicChromiumClientSession* session) { + session->OnCryptoHandshakeComplete(); +} + } // namespace net::test diff --git a/net/quic/quic_chromium_client_session_peer.h b/net/quic/quic_chromium_client_session_peer.h index 68ca551..4c12204c 100644 --- a/net/quic/quic_chromium_client_session_peer.h +++ b/net/quic/quic_chromium_client_session_peer.h @@ -39,6 +39,11 @@ static void SetDefaultNetwork(QuicChromiumClientSession* session, handles::NetworkHandle network); + + static bool IsMigrateBackToDefaultNetworkTimerRunning( + QuicChromiumClientSession* session); + + static void OnCryptoHandshakeComplete(QuicChromiumClientSession* session); }; } // namespace test diff --git a/net/quic/quic_chromium_client_session_test.cc b/net/quic/quic_chromium_client_session_test.cc index 4d4ee587..c51b358 100644 --- a/net/quic/quic_chromium_client_session_test.cc +++ b/net/quic/quic_chromium_client_session_test.cc @@ -2930,6 +2930,35 @@ MIGRATION_STATUS_ALREADY_MIGRATED, 1); } +TEST_P(QuicChromiumClientSessionTest, NoMigrationForProxiedSessionOnHandshake) { + ProxyChain proxy_chain(ProxyServer::SCHEME_HTTPS, + HostPortPair("proxy.example.com", 443)); + session_key_ = QuicSessionKey( + kServerHostname, kServerPort, PRIVACY_MODE_DISABLED, proxy_chain, + SessionUsage::kDestination, SocketTag(), NetworkAnonymizationKey(), + SecureDnsPolicy::kAllow, /*require_dns_https_alpn=*/false, + /*disable_cert_verification_network_fetches=*/false, + handles::kInvalidNetworkHandle); + + // Initialize with kInvalidNetworkHandle so the test socket gets bound to it + // (simulating QuicProxyDatagramClientSocket's behavior). + default_network_ = handles::kInvalidNetworkHandle; + Initialize(/*migrate_session_on_network_change_v2=*/true); + + // Now set the session's default_network_ to a valid handle, simulating the + // physical user's network. This creates the exact mismatch condition for the + // bug. + QuicChromiumClientSessionPeer::SetDefaultNetwork( + session_.get(), handles::kInvalidNetworkHandle + 1); + + QuicChromiumClientSessionPeer::OnCryptoHandshakeComplete(session_.get()); + + // The timer MUST NOT be running for a proxied session. + EXPECT_FALSE( + QuicChromiumClientSessionPeer::IsMigrateBackToDefaultNetworkTimerRunning( + session_.get())); +} + TEST_P(QuicChromiumClientSessionTest, GoingAwaySessionDoesNotKeepAlive) { MockQuicData quic_data(version_); quic_data.AddWrite(SYNCHRONOUS, client_maker_.MakeInitialSettingsPacket(1));
Regression Test / PoC
diff --git a/net/quic/quic_chromium_client_session_test.cc b/net/quic/quic_chromium_client_session_test.cc
index 4d4ee587..c51b358 100644
--- a/net/quic/quic_chromium_client_session_test.cc
+++ b/net/quic/quic_chromium_client_session_test.cc
@@ -2930,6 +2930,35 @@
MIGRATION_STATUS_ALREADY_MIGRATED, 1);
}
+TEST_P(QuicChromiumClientSessionTest, NoMigrationForProxiedSessionOnHandshake) {
+ ProxyChain proxy_chain(ProxyServer::SCHEME_HTTPS,
+ HostPortPair("proxy.example.com", 443));
+ session_key_ = QuicSessionKey(
+ kServerHostname, kServerPort, PRIVACY_MODE_DISABLED, proxy_chain,
+ SessionUsage::kDestination, SocketTag(), NetworkAnonymizationKey(),
+ SecureDnsPolicy::kAllow, /*require_dns_https_alpn=*/false,
+ /*disable_cert_verification_network_fetches=*/false,
+ handles::kInvalidNetworkHandle);
+
+ // Initialize with kInvalidNetworkHandle so the test socket gets bound to it
+ // (simulating QuicProxyDatagramClientSocket's behavior).
+ default_network_ = handles::kInvalidNetworkHandle;
+ Initialize(/*migrate_session_on_network_change_v2=*/true);
+
+ // Now set the session's default_network_ to a valid handle, simulating the
+ // physical user's network. This creates the exact mismatch condition for the
+ // bug.
+ QuicChromiumClientSessionPeer::SetDefaultNetwork(
+ session_.get(), handles::kInvalidNetworkHandle + 1);
+
+ QuicChromiumClientSessionPeer::OnCryptoHandshakeComplete(session_.get());
+
+ // The timer MUST NOT be running for a proxied session.
+ EXPECT_FALSE(
+ QuicChromiumClientSessionPeer::IsMigrateBackToDefaultNetworkTimerRunning(
+ session_.get()));
+}
+
TEST_P(QuicChromiumClientSessionTest, GoingAwaySessionDoesNotKeepAlive) {
MockQuicData quic_data(version_);
quic_data.AddWrite(SYNCHRONOUS, client_maker_.MakeInitialSettingsPacket(1));
Original Bug Report
Potential MASQUE proxy bypass and IP leak via QUIC connection migration
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: Proxied QUIC sessions incorrectly trigger connection migration back to the default network upon handshake completion due to a network handle mismatch. This sends a direct UDP probe to the proxy, leaking the client’s real IP address. A non-fatal DUMP_WILL_BE_CHECK then allows the session to fully migrate to an unencapsulated socket, bypassing the proxy tunnel entirely.
Affected files:
net/quic/quic_chromium_client_session.ccnet/quic/quic_session_pool.cc
Estimated timestamp from git blame: 2025-04-18
Description
There is a potential privacy leak and encapsulation bypass in Chromium’s QUIC client when using proxied sessions, such as those used for MASQUE or IP Protection. The vulnerability stems from a logical flaw where proxied sessions incorrectly trigger connection migration, leading them to send unencapsulated UDP packets directly to the proxy server.
In a multi-hop proxy configuration (like IP Protection), this flaw causes the client’s real IP address to be exposed to the second-hop proxy, violating the split-knowledge privacy model. Furthermore, the session fully migrates out of the proxy tunnel, sending all subsequent traffic unencapsulated.
Root Cause Analysis
The vulnerability is a chain of logic bugs across session initialization, migration triggering, and non-fatal assertions:
-
Network Handle Mismatch during Initialization: When
QuicSessionPool::CreateSessionOnProxyStreamcreates a proxied session, it intentionally sets thenetworkhandle tohandles::kInvalidNetworkHandleto prevent migration. However, inQuicSessionPool::CreateSessionHelper(net/quic/quic_session_pool.cc:2091), the pool passes its globally tracked validdefault_network_to theQuicChromiumClientSessionconstructor instead of the explicitly invalidnetworkparameter. -
Spurious Migration Trigger: Upon successful completion of the cryptographic handshake,
QuicChromiumClientSession::OnCryptoHandshakeComplete()checks if it needs to migrate back to the default network (net/quic/quic_chromium_client_session.cc:4081):if (migrate_session_on_network_change_v2_ && default_network_ != handles::kInvalidNetworkHandle && GetCurrentNetwork() != default_network_)For a proxied session,
GetCurrentNetwork()invokesGetBoundNetwork()on theQuicProxyDatagramClientSocket, which correctly returnskInvalidNetworkHandle. Because the session was erroneously constructed with a validdefault_network_(e.g., the Wi-Fi interface handle), the conditionGetCurrentNetwork() != default_network_evaluates to true. The session then incorrectly schedules a migration back to the default network. -
Client IP Leak via Probing: When the migration timer fires (after
kMinRetryTimeForDefaultNetworkSecs),StartProbingcreates a direct, unproxiedDatagramClientSocketbound to the physicaldefault_network_. It sends a raw QUICPATH_CHALLENGEto the session’speer_address(). In the context of a proxied session,peer_address()is the proxy’s IP address. This sends a raw UDP packet from the client’s real IP directly to the proxy, exposing the client’s location/identity. -
Encapsulation Bypass via Non-Fatal Check: Because the proxy is a standard QUIC server, it responds to the
PATH_CHALLENGEwith aPATH_RESPONSE. This validates the direct path, triggeringQuicChromiumClientSession::MigrateToSocket(). InsideMigrateToSocket(net/quic/quic_chromium_client_session.cc:4228), there is a safeguard:// Sessions carried via a proxy should never migrate... DUMP_WILL_BE_CHECK(session_key_.proxy_chain().is_direct());Crucially,
DUMP_WILL_BE_CHECKis non-fatal in official release builds (DCHECK_IS_ON()is false). It merely generates a crash dump and allows execution to continue. The session proceeds toMigratePath, permanently swapping its proxy-encapsulated writer for a direct raw UDP writer. All subsequent QUIC packets bypass the CONNECT-UDP tunnel entirely.
Potential Reproduction Steps
Although not yet verified with a live exploit, an attacker or researcher could potentially observe this issue using the following steps:
- On an Android device or a client with
kMigrateSessionsOnNetworkChangeV2enabled, configure Chromium to route QUIC traffic through a CONNECT-UDP proxy (e.g., an IP Protection proxy). - Initiate an HTTP/3 connection to any target website.
- Monitor the network traffic on the client’s physical network interface (e.g., using Wireshark or tcpdump).
- Observe the initial QUIC handshake completing over the proxy tunnel.
- Wait approximately 1 second (the duration of
kMinRetryTimeForDefaultNetworkSecs). - Observe a raw UDP
PATH_CHALLENGEpacket sent directly from the client’s real IP to the proxy’s IP. - If the proxy relays or responds to the probe, observe that subsequent QUIC packets for the session are sent as raw UDP packets directly to the proxy, completely bypassing the HTTP/3 encapsulation of the MASQUE tunnel.
Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8
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. Please feel free to reach out to me if you have concerns or feedback.