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 <yyanagisawa@chromium.org>
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 <yyanagisawa@chromium.org>
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Reviewed-by: Nidhi Jaju <nidhijaju@chromium.org>
Reviewed-by: David Schinazi <dschinazi@chromium.org>
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.