CVE-2026-78915
Overview
Background
- `ProxyConfigServiceWin`
- the Windows implementation of
ProxyConfigService. It derives fromPollingProxyConfigService, reads the system/IE proxy settings, and watches registry keys (keys_to_watch_) and network changes so it can re-check them. - `NetworkChangeNotifier` observers
- objects registered through
NetworkChangeNotifier::AddNetworkChangeObserver()getOnNetworkChanged()on the sequence they registered from, so the thread that registers decides where callbacks arrive. - `PollingProxyConfigService` Core binding
- per the commit message, the polling service’s internal
Corelazily binds its origin task runner on first use (for example, viaCheckForChangesNow()). After that it expects every observer operation to happen on that sequence, and its observer list is not thread-safe. - `SEQUENCE_CHECKER` / `DETACH_FROM_SEQUENCE`
- Chromium’s debug-time sequence-affinity check. Detaching it in the constructor makes the object bind to whichever sequence first uses it.
Root Cause Analysis
Before the patch, the ProxyConfigServiceWin constructor called NetworkChangeNotifier::AddNetworkChangeObserver(this) itself. That bound network-change delivery to the constructing thread. In some embedders, which the commit message names as the Windows Chrome Remote Desktop host binaries, the service is constructed on a UI/controller thread but used and destroyed on the Network thread. On the first network change, OnNetworkChanged() ran on the UI thread and called CheckForChangesNow() (per the commit message). That bound the PollingProxyConfigService Core origin task runner to the wrong thread. Later Network-thread operations such as AddObserver() then touched the same non-thread-safe observer list from a second thread. The commit message says this caused data races, memory corruption and use-after-free crashes during shutdown. The broken invariant is that all of the object’s work, including network-change callbacks, must run on one sequence: the one that actually uses it.
The fix removes the registration from the constructor and replaces it with DETACH_FROM_SEQUENCE(sequence_checker_). Registration now happens lazily in AddObserver(), guarded by the new registered_as_network_change_observer_ flag, so it occurs on the Network thread. The destructor unregisters only if registration actually happened, and DCHECK_CALLED_ON_VALID_SEQUENCE checks are added to every entry point. Note that the advisory files this under “Enterprise”, while the diff is entirely in net/proxy_resolution/win/. The only stated trigger is the CRD host’s threading, and the patch does not show a separate enterprise-policy code path.
AddObserver() call puts callbacks, polling-core binding and observer-list access all on one sequence.Attack Path
- Cross-thread construction
A Windows embedder (per the commit message, the Chrome Remote Desktop host) constructs
ProxyConfigServiceWinon its UI/controller thread and hands it to the Network thread. Before the fix, the constructor registered the network-change observer on the UI thread. - Network-thread use
The Network thread calls
AddObserver(), which starts registry watching and adds an observer to thePollingProxyConfigServiceobserver list. - Network change on the wrong thread
A connectivity change, such as connecting a VPN or dropping the network, makes
NetworkChangeNotifierdeliverOnNetworkChanged()on the UI thread. That callsCheckForChangesNow()and binds the pollingCoreto the UI thread. The trigger is a local network event, not web content. - Concurrent access and UAF
The UI and Network threads now race on the non-thread-safe observer list and the polling state, which the commit message says leads to memory corruption or UAF, especially during shutdown on the Network thread. Turning this into a controlled primitive is
(inferred)and not shown by the patch.
Impact Assessment
ProxyConfigServiceWin on Windows. The commit message identifies the affected configuration as the Chrome Remote Desktop host binaries, which build it on one thread and use it on another. The trigger is a system network-change event, so an attacker would at most influence timing through the host’s network conditions; ordinary web content has no direct way to reach it, and any controlled exploitation is (inferred). The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 7.5.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifnet/proxy_resolution/win/proxy_config_service_win.cc |
modified | |
TestProxyConfigObservernet/proxy_resolution/win/proxy_config_service_win_unittest.cc |
modified | |
expected_thread_id_net/proxy_resolution/win/proxy_config_service_win_unittest.cc |
modified | |
ifnet/proxy_resolution/win/proxy_config_service_win_unittest.cc |
modified | |
TESTnet/proxy_resolution/win/proxy_config_service_win_unittest.cc |
modified |
Files Changed
net/proxy_resolution/win/proxy_config_service_win.ccnet/proxy_resolution/win/proxy_config_service_win_unittest.ccnet/proxy_resolution/win/proxy_config_service_win.h
Audit Directions
- Observer registration in constructorsSearch for constructors that call
AddNetworkChangeObserver,AddIPAddressObserver,AddConnectionTypeObserveror similar thread-affine registrations on objects that may be handed to another thread. Check otherProxyConfigServiceplatform implementations, such as the Linux, Mac and ChromeOS variants, for the same construct-here/use-there pattern. - Lazily bound task runnersLook for classes like
PollingProxyConfigService::Corethat captureSequencedTaskRunner::GetCurrentDefault()on first use. Check whether an unexpected first caller, like a notifier callback, can bind them to the wrong sequence. - Embedders with non-standard threadingReview the remoting host, utility processes and other non-browser embedders of
net/that build network services on a UI thread and pass them to an IO/Network thread. Check that every object involved has aSEQUENCE_CHECKERand is detached appropriately.
Patch
From e015f284b9aebc418adcc3d5695efd5fc3ab722a Mon Sep 17 00:00:00 2001
From: Etienne Bergeron <etienneb@google.com>
Date: Thu, 16 Jul 2026 11:01:33 -0700
Subject: [PATCH] [net] Defer NetworkChangeNotifier registration in ProxyConfigServiceWin
ProxyConfigServiceWin was registering itself as a NetworkChangeObserver
during construction. In some Windows environments (e.g., Chrome Remote
Desktop host binaries), the service is constructed on the UI/controller
thread but is subsequently used and destroyed on the Network thread.
Because the observer was registered during construction on the UI
thread, NetworkChangeNotifier dispatched OnNetworkChanged callbacks back
to the UI thread. On the first network change, OnNetworkChanged called
CheckForChangesNow(), which lazily bound PollingProxyConfigService's
Core origin task runner to the UI thread instead of the Network thread.
Subsequent operations (such as AddObserver) called on the Network thread
would then fail sequence checks or cause concurrent thread access
(races) on the non-thread-safe observer list, leading to memory
corruption or Use-After-Free crashes during shutdown.
This CL resolves the issue by:
1. Deferring NetworkChangeNotifier registration until the first call to
AddObserver(), which runs on the correct Network thread.
2. Adding a SEQUENCE_CHECKER and detaching it on construction to enforce
proper sequence affinity on first use.
3. Adding a new unit test ThreadMismatchRegistration to verify
lazy thread binding.
Bug: 533418127
Test: net_unittests --gtest_filter=ProxyConfigServiceWinTest.ThreadMismatchRegistration
Change-Id: Iddd14cdc6e4a007838e99cef80de4e2a8a1a4859
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8083986
Commit-Queue: Etienne Bergeron <etienneb@chromium.org>
Reviewed-by: Adam Rice <ricea@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1663303}
---
diff --git a/net/proxy_resolution/win/proxy_config_service_win.cc b/net/proxy_resolution/win/proxy_config_service_win.cc
index fc238b5e..4004f97 100644
--- a/net/proxy_resolution/win/proxy_config_service_win.cc
+++ b/net/proxy_resolution/win/proxy_config_service_win.cc
@@ -48,11 +48,16 @@
base::Seconds(kPollIntervalSec),
base::BindRepeating(&ProxyConfigServiceWin::GetCurrentProxyConfig),
traffic_annotation) {
- NetworkChangeNotifier::AddNetworkChangeObserver(this);
+ DETACH_FROM_SEQUENCE(sequence_checker_);
}
ProxyConfigServiceWin::~ProxyConfigServiceWin() {
- NetworkChangeNotifier::RemoveNetworkChangeObserver(this);
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+
+ if (registered_as_network_change_observer_) {
+ NetworkChangeNotifier::RemoveNetworkChangeObserver(this);
+ }
+
// The registry functions below will end up going to disk. TODO: Do this on
// another thread to avoid slowing the current thread. http://crbug.com/61453
base::ScopedAllowBlocking scoped_allow_blocking;
@@ -60,15 +65,25 @@
}
void ProxyConfigServiceWin::AddObserver(Observer* observer) {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+
// Lazily-initialize our registry watcher.
StartWatchingRegistryForChanges();
+ // Lazily-register as network change observer on the correct thread.
+ if (!registered_as_network_change_observer_) {
+ NetworkChangeNotifier::AddNetworkChangeObserver(this);
+ registered_as_network_change_observer_ = true;
+ }
+
// Let the super-class do its work now.
PollingProxyConfigService::AddObserver(observer);
}
void ProxyConfigServiceWin::OnNetworkChanged(
NetworkChangeNotifier::ConnectionType type) {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+
// Proxy settings on Windows may change when the active connection changes.
// For instance, after connecting to a VPN, the proxy settings for the active
// connection will be that for the VPN. (And ProxyConfigService only reports
@@ -83,6 +98,8 @@
}
void ProxyConfigServiceWin::StartWatchingRegistryForChanges() {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+
if (!keys_to_watch_.empty()) {
return; // Already initialized.
}
@@ -135,6 +152,8 @@
}
void ProxyConfigServiceWin::OnObjectSignaled(base::win::RegKey* key) {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+
// Figure out which registry key signalled this change.
auto it = std::ranges::find(keys_to_watch_, key,
&std::unique_ptr<base::win::RegKey>::get);
diff --git a/net/proxy_resolution/win/proxy_config_service_win.h b/net/proxy_resolution/win/proxy_config_service_win.h
index 7597fe6..79fa43ca 100644
--- a/net/proxy_resolution/win/proxy_config_service_win.h
+++ b/net/proxy_resolution/win/proxy_config_service_win.h
@@ -14,6 +14,7 @@
#include "base/compiler_specific.h"
#include "base/gtest_prod_util.h"
+#include "base/sequence_checker.h"
#include "net/base/net_export.h"
#include "net/base/network_change_notifier.h"
#include "net/proxy_resolution/polling_proxy_config_service.h"
@@ -83,6 +84,10 @@
const WINHTTP_CURRENT_USER_IE_PROXY_CONFIG& ie_config);
std::vector<std::unique_ptr<base::win::RegKey>> keys_to_watch_;
+
+ bool registered_as_network_change_observer_ = false;
+
+ SEQUENCE_CHECKER(sequence_checker_);
};
} // namespace net
diff --git a/net/proxy_resolution/win/proxy_config_service_win_unittest.cc b/net/proxy_resolution/win/proxy_config_service_win_unittest.cc
index 40e68003..033b4341 100644
--- a/net/proxy_resolution/win/proxy_config_service_win_unittest.cc
+++ b/net/proxy_resolution/win/proxy_config_service_win_unittest.cc
@@ -6,9 +6,15 @@
#include <array>
+#include "base/run_loop.h"
+#include "base/test/task_environment.h"
+#include "base/threading/platform_thread.h"
+#include "base/threading/thread.h"
#include "net/base/net_errors.h"
+#include "net/base/network_change_notifier.h"
#include "net/proxy_resolution/proxy_config.h"
#include "net/proxy_resolution/proxy_config_service_common_unittest.h"
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace net {
@@ -224,4 +230,83 @@
}
}
+// An observer that quits a RunLoop when notified of a config change.
+class TestProxyConfigObserver : public ProxyConfigService::Observer {
+ public:
+ TestProxyConfigObserver(base::OnceClosure quit_closure,
+ base::PlatformThreadId expected_thread_id)
+ : quit_closure_(std::move(quit_closure)),
+ expected_thread_id_(expected_thread_id) {}
+ void OnProxyConfigChanged(
+ const ProxyConfigWithAnnotation& config,
+ ProxyConfigService::ConfigAvailability availability) override {
+ EXPECT_EQ(base::PlatformThread::CurrentId(), expected_thread_id_);
+ if (quit_closure_) {
+ std::move(quit_closure_).Run();
+ }
+ }
+
+ private:
+ base::OnceClosure quit_closure_;
+ base::PlatformThreadId expected_thread_id_;
+};
+
+TEST(ProxyConfigServiceWinTest, ThreadMismatchRegistration) {
+ base::test::TaskEnvironment task_environment(
+ base::test::TaskEnvironment::MainThreadType::UI);
+
+ // Initialize a mock NetworkChangeNotifier so we can dispatch test events.
+ std::unique_ptr<NetworkChangeNotifier> ncn(
+ NetworkChangeNotifier::CreateMockIfNeeded());
+
+ // Simulating Thread A (UI/Constructor thread) and Thread B (Network thread).
+ base::Thread network_thread("NetworkThread");
+ ASSERT_TRUE(network_thread.Start());
+ std::unique_ptr<ProxyConfigServiceWin> service;
+
+ // 1. Construct the ProxyConfigServiceWin on the UI thread (Thread A).
+ service =
+ std::make_unique<ProxyConfigServiceWin>(TRAFFIC_ANNOTATION_FOR_TESTS);
+
+ // Create a run loop to wait for the network change notification.
+ base::RunLoop network_change_run_loop;
+ TestProxyConfigObserver observer(network_change_run_loop.QuitClosure(),
+ network_thread.GetThreadId());
+
+ // 2. Add the observer on the Network thread (Thread B).
+ base::RunLoop init_run_loop;
+ network_thread.task_runner()->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ [](ProxyConfigServiceWin* service, TestProxyConfigObserver* observer,
+ base::OnceClosure quit_closure) {
+ service->AddObserver(observer);
+ std::move(quit_closure).Run();
+ },
+ base::Unretained(service.get()), base::Unretained(&observer),
+ init_run_loop.QuitClosure()));
+ init_run_loop.Run();
+
+ // 3. Trigger a network change event (CONNECTION_NONE) on the UI thread.
+ NetworkChangeNotifier::NotifyObserversOfNetworkChangeForTests(
+ NetworkChangeNotifier::CONNECTION_NONE);
+
+ // 4. Wait for the observer to be notified on the Network thread (Thread B).
+ // The quit closure is thread-safe and will wake up this loop on the UI
+ // thread.
+ network_change_run_loop.Run();
+
+ // 5. Cleanup the service on the Network thread.
+ base::RunLoop destruct_run_loop;
+ network_thread.task_runner()->PostTask(
+ FROM_HERE, base::BindOnce(
+ [](std::unique_ptr<ProxyConfigServiceWin> service,
+ base::OnceClosure quit_closure) {
+ service.reset();
+ std::move(quit_closure).Run();
+ },
+ std::move(service), destruct_run_loop.QuitClosure()));
+ destruct_run_loop.Run();
+}
+
} // namespace net
Regression Test / PoC
diff --git a/net/proxy_resolution/win/proxy_config_service_win_unittest.cc b/net/proxy_resolution/win/proxy_config_service_win_unittest.cc
index 40e68003..033b4341 100644
--- a/net/proxy_resolution/win/proxy_config_service_win_unittest.cc
+++ b/net/proxy_resolution/win/proxy_config_service_win_unittest.cc
@@ -6,9 +6,15 @@
#include <array>
+#include "base/run_loop.h"
+#include "base/test/task_environment.h"
+#include "base/threading/platform_thread.h"
+#include "base/threading/thread.h"
#include "net/base/net_errors.h"
+#include "net/base/network_change_notifier.h"
#include "net/proxy_resolution/proxy_config.h"
#include "net/proxy_resolution/proxy_config_service_common_unittest.h"
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace net {
@@ -224,4 +230,83 @@
}
}
+// An observer that quits a RunLoop when notified of a config change.
+class TestProxyConfigObserver : public ProxyConfigService::Observer {
+ public:
+ TestProxyConfigObserver(base::OnceClosure quit_closure,
+ base::PlatformThreadId expected_thread_id)
+ : quit_closure_(std::move(quit_closure)),
+ expected_thread_id_(expected_thread_id) {}
+ void OnProxyConfigChanged(
+ const ProxyConfigWithAnnotation& config,
+ ProxyConfigService::ConfigAvailability availability) override {
+ EXPECT_EQ(base::PlatformThread::CurrentId(), expected_thread_id_);
+ if (quit_closure_) {
+ std::move(quit_closure_).Run();
+ }
+ }
+
+ private:
+ base::OnceClosure quit_closure_;
+ base::PlatformThreadId expected_thread_id_;
+};
+
+TEST(ProxyConfigServiceWinTest, ThreadMismatchRegistration) {
+ base::test::TaskEnvironment task_environment(
+ base::test::TaskEnvironment::MainThreadType::UI);
+
+ // Initialize a mock NetworkChangeNotifier so we can dispatch test events.
+ std::unique_ptr<NetworkChangeNotifier> ncn(
+ NetworkChangeNotifier::CreateMockIfNeeded());
+
+ // Simulating Thread A (UI/Constructor thread) and Thread B (Network thread).
+ base::Thread network_thread("NetworkThread");
+ ASSERT_TRUE(network_thread.Start());
+ std::unique_ptr<ProxyConfigServiceWin> service;
+
+ // 1. Construct the ProxyConfigServiceWin on the UI thread (Thread A).
+ service =
+ std::make_unique<ProxyConfigServiceWin>(TRAFFIC_ANNOTATION_FOR_TESTS);
+
+ // Create a run loop to wait for the network change notification.
+ base::RunLoop network_change_run_loop;
+ TestProxyConfigObserver observer(network_change_run_loop.QuitClosure(),
+ network_thread.GetThreadId());
+
+ // 2. Add the observer on the Network thread (Thread B).
+ base::RunLoop init_run_loop;
+ network_thread.task_runner()->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ [](ProxyConfigServiceWin* service, TestProxyConfigObserver* observer,
+ base::OnceClosure quit_closure) {
+ service->AddObserver(observer);
+ std::move(quit_closure).Run();
+ },
+ base::Unretained(service.get()), base::Unretained(&observer),
+ init_run_loop.QuitClosure()));
+ init_run_loop.Run();
+
+ // 3. Trigger a network change event (CONNECTION_NONE) on the UI thread.
+ NetworkChangeNotifier::NotifyObserversOfNetworkChangeForTests(
+ NetworkChangeNotifier::CONNECTION_NONE);
+
+ // 4. Wait for the observer to be notified on the Network thread (Thread B).
+ // The quit closure is thread-safe and will wake up this loop on the UI
+ // thread.
+ network_change_run_loop.Run();
+
+ // 5. Cleanup the service on the Network thread.
+ base::RunLoop destruct_run_loop;
+ network_thread.task_runner()->PostTask(
+ FROM_HERE, base::BindOnce(
+ [](std::unique_ptr<ProxyConfigServiceWin> service,
+ base::OnceClosure quit_closure) {
+ service.reset();
+ std::move(quit_closure).Run();
+ },
+ std::move(service), destruct_run_loop.QuitClosure()));
+ destruct_run_loop.Run();
+}
+
} // namespace net