Low watch CVSS 7.5 chrome Race 🔧 Commit mapped

Overview

Low
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Enterprise
DescriptionRace condition in Enterprise
ComponentEnterprise
Sub-componentNet
Bug ClassRace
Action tierwatch — corruption primitive but gated (local) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker533418127
Fix commite015f284b9ae (chromium/src) +111/-2
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:A/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-10 (per vendor advisory)
Fix landed2026-07-16 (public git)
Disclosed2026-08-25
Patch gap40 days — fix public in source before the advisory

Background

`ProxyConfigServiceWin`
the Windows implementation of ProxyConfigService. It derives from PollingProxyConfigService, 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() get OnNetworkChanged() 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 Core lazily binds its origin task runner on first use (for example, via CheckForChangesNow()). 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.

Key insight
The service registered for network-change callbacks in its constructor, so callbacks went to the construction thread instead of the thread that actually used the object. Moving registration to the first AddObserver() call puts callbacks, polling-core binding and observer-list access all on one sequence.

Attack Path

  1. Cross-thread construction A Windows embedder (per the commit message, the Chrome Remote Desktop host) constructs ProxyConfigServiceWin on 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.
  2. Network-thread use The Network thread calls AddObserver(), which starts registry watching and adds an observer to the PollingProxyConfigService observer list.
  3. Network change on the wrong thread A connectivity change, such as connecting a VPN or dropping the network, makes NetworkChangeNotifier deliver OnNetworkChanged() on the UI thread. That calls CheckForChangesNow() and binds the polling Core to the UI thread. The trigger is a local network event, not web content.
  4. 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

The bug is a data race that can lead to memory corruption or use after free in the process hosting 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

FunctionChangeNotes
if
net/proxy_resolution/win/proxy_config_service_win.cc
modified
TestProxyConfigObserver
net/proxy_resolution/win/proxy_config_service_win_unittest.cc
modified
expected_thread_id_
net/proxy_resolution/win/proxy_config_service_win_unittest.cc
modified
if
net/proxy_resolution/win/proxy_config_service_win_unittest.cc
modified
TEST
net/proxy_resolution/win/proxy_config_service_win_unittest.cc
modified

Files Changed

  • net/proxy_resolution/win/proxy_config_service_win.cc
  • net/proxy_resolution/win/proxy_config_service_win_unittest.cc
  • net/proxy_resolution/win/proxy_config_service_win.h

Audit Directions

  • Observer registration in constructors
    Search for constructors that call AddNetworkChangeObserver, AddIPAddressObserver, AddConnectionTypeObserver or similar thread-affine registrations on objects that may be handed to another thread. Check other ProxyConfigService platform implementations, such as the Linux, Mac and ChromeOS variants, for the same construct-here/use-there pattern.
  • Lazily bound task runners
    Look for classes like PollingProxyConfigService::Core that capture SequencedTaskRunner::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 threading
    Review 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 a SEQUENCE_CHECKER and is detached appropriately.
From e015f284b9aebc418adcc3d5695efd5fc3ab722a Mon Sep 17 00:00:00 2001
From: Etienne Bergeron <[email protected]>
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 <[email protected]>
Reviewed-by: Adam Rice <[email protected]>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.