Overview

High
Severity
9
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Proxy
DescriptionUse after free in Proxy
ComponentProxy
Sub-componentFortify
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — 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.
Tracker533534913
Fix commit78fb4f541c61 (chromium/src) +146/-7
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-10 (per vendor advisory)
Fix landed2026-08-25 (public git)
Disclosed2026-09-01
Patch gap7 days — fix public in source before the advisory

Background

`ConfiguredProxyResolutionRequest`
A per-lookup object in Chromium’s network stack that tracks one in-flight proxy resolution and holds the consumer’s completion callback.
`pending_requests_`
The set on ConfiguredProxyResolutionService holding every request currently awaiting a proxy decision, iterated when the service re-dispatches work.
PAC (Proxy Auto-Config)
A JavaScript program the browser executes to decide which proxy to use for a URL, whose runtime can be terminated mid-execution (ERR_PAC_SCRIPT_TERMINATED).
Re-entrancy
The condition where a function is invoked again on the same object before an outer invocation on that object has returned.

Root Cause Analysis

The vulnerable path is ConfiguredProxyResolutionRequest::QueryComplete, which invoked QueryDidComplete (and through it DidFinishResolvingProxy) while this was still a member of the service’s pending_requests_ set, unlinking itself only afterward. The invariant violated is that a request must be removed from pending_requests_ before the service is notified of its completion, so that the service never re-dispatches a request that is already mid-completion. When PAC execution terminated (ERR_PAC_SCRIPT_TERMINATED), DidFinishResolvingProxy reset the proxy config and re-initialized the resolver; if that re-initialization failed or completed synchronously (for example falling back to DIRECT), SetReady synchronously re-dispatched the still-listed request, re-entering QueryComplete. The re-entrant call ran the consumer callback, which could destroy the request object while the outer QueryComplete frame was still executing on that freed memory, a classic use-after-free.

The fix calls service_->RemovePendingRequest(this) before QueryDidComplete, so the re-initialization can no longer see or re-dispatch this request, and it adds a CHECK(!was_completed()) to trap any residual double-completion. The companion change in DidFinishResolvingProxy switches the guard from pending_requests_.size() > 1 to !pending_requests_.empty(), matching the new ordering where the completing request is no longer counted.

Key insight
The single mistake was notifying the service of completion before unlinking the request, leaving a live entry in pending_requests_ that a synchronous re-initialization could re-dispatch re-entrantly; the fix removes the request from pending_requests_ first, eliminating the window in which it can be re-entered and freed.

Attack Path

  1. Start concurrent resolutions An attacker’s page or a controlled proxy configuration causes two or more proxy resolutions to be in flight simultaneously through the same ConfiguredProxyResolutionService.
  2. Terminate PAC execution One request’s PAC script execution is terminated (ERR_PAC_SCRIPT_TERMINATED), driving that request into QueryComplete while it remains in pending_requests_.
  3. Force synchronous re-init DidFinishResolvingProxy resets the config and re-initializes the resolver in a way that completes synchronously (re-init fails and falls back to DIRECT), causing SetReady to re-dispatch the still-listed request.
  4. Re-enter and free The re-entrant QueryComplete runs the consumer callback, which (as real consumers like HttpStreamFactory::JobController do) destroys the request object while the outer frame still holds it.
  5. Use freed object The outer QueryComplete frame continues operating on the freed ConfiguredProxyResolutionRequest, yielding the use-after-free.

Impact Assessment

An attacker gains a use-after-free on a heap-allocated ConfiguredProxyResolutionRequest in the process that runs the network stack (the browser or network service process), which can lead to memory corruption and potentially controlled execution. Preconditions are the ability to influence proxy configuration/PAC behavior so that a PAC termination coincides with a synchronous proxy re-initialization while multiple requests are pending, and a consumer that destroys its request inside the completion callback. Severity is rated high, consistent with a UAF reachable through proxy resolution.

Changed Functions

FunctionChangeNotes
if
net/proxy_resolution/configured_proxy_resolution_service.cc
modified
SyncFailProxyResolverFactory
net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
modified
async_factory_
net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
modified
if
net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
modified

Files Changed

  • net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
  • net/proxy_resolution/configured_proxy_resolution_request.cc
  • net/proxy_resolution/configured_proxy_resolution_service.cc

Audit Directions

  • Notify-before-unlink ordering
    Flag any completion path that runs consumer callbacks or re-dispatch logic while the completing object is still registered in a container the callback can iterate or re-enter.
  • Synchronous re-init after reset
    Review resolver/config reset paths (ResetProxyConfig, ApplyProxyConfigIfAvailable, SetReady) for synchronous completions that re-dispatch pending work re-entrantly rather than posting it.
  • Callback-triggered self-destruction
    Audit consumers that reset() or delete their request object inside the completion callback, and verify no outer frame touches the object after the callback returns.
From 78fb4f541c61dccfc75aab1ec6dc985bc68a8475 Mon Sep 17 00:00:00 2001
From: Zonghan Xu <[email protected]>
Date: Tue, 25 Aug 2026 16:50:33 -0700
Subject: [PATCH] [Fortify] [CPRS] Fix UAF in ConfiguredProxyResolutionRequest::QueryComplete

When ConfiguredProxyResolutionRequest::QueryComplete completes a
request, it invoked QueryDidComplete (which calls
DidFinishResolvingProxy) before removing `this` from
`pending_requests_`.

If PAC execution terminated (e.g. ERR_PAC_SCRIPT_TERMINATED),
DidFinishResolvingProxy resets the proxy configuration and attempts
to re-initialize the proxy resolver. If re-initialization fails or
completes synchronously (such as falling back to DIRECT), SetReady()
synchronously re-dispatched the request that was still present in
`pending_requests_`.

This led to a re-entrant execution of QueryComplete, invoking the
consumer callback and destroying the request object while outer frames
were still executing, causing a use-after-free (UAF).

This CL fixes the issue by removing `this` from `pending_requests_`
before calling QueryDidComplete, and updates the `pending_requests_`
emptiness check in DidFinishResolvingProxy.

Bug: 533534913
Change-Id: I8fc6b88c8d7fc4bf69098e82e829b5e235215adc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8287885
Reviewed-by: Sebastien Lalancette <[email protected]>
Commit-Queue: Zonghan Xu <[email protected]>
Reviewed-by: mmenke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1686034}
---
diff --git a/net/proxy_resolution/configured_proxy_resolution_request.cc b/net/proxy_resolution/configured_proxy_resolution_request.cc
index 388275c..e42676f 100644
--- a/net/proxy_resolution/configured_proxy_resolution_request.cc
+++ b/net/proxy_resolution/configured_proxy_resolution_request.cc
@@ -216,12 +216,16 @@
 
 // Callback for when the ProxyResolver request has completed.
 void ConfiguredProxyResolutionRequest::QueryComplete(int result_code) {
+  CHECK(!was_completed());
+
+  // Remove `this` from the service before notifying it of completion to
+  // prevent re-entrant dispatch if the service re-initializes synchronously.
+  service_->RemovePendingRequest(this);
+
   result_code = QueryDidComplete(result_code);
+  service_ = nullptr;
 
   CompletionOnceCallback callback = std::move(user_callback_);
-
-  service_->RemovePendingRequest(this);
-  service_ = nullptr;
   user_callback_.Reset();
   std::move(callback).Run(result_code);
 }
diff --git a/net/proxy_resolution/configured_proxy_resolution_service.cc b/net/proxy_resolution/configured_proxy_resolution_service.cc
index 22ce137..11520ac 100644
--- a/net/proxy_resolution/configured_proxy_resolution_service.cc
+++ b/net/proxy_resolution/configured_proxy_resolution_service.cc
@@ -1340,9 +1340,9 @@
     // This can be observed because the service destructor sets
     // `in_destruction_ = true` and synchronously calls
     // QueryComplete(ERR_ABORTED) on each pending request. QueryComplete
-    // synchronously calls QueryDidComplete, which in turn calls
-    // DidFinishResolvingProxy on this service before the request removes itself
-    // from `pending_requests_` and runs the user callback.
+    // removes the request from `pending_requests_` and synchronously calls
+    // QueryDidComplete, which in turn calls DidFinishResolvingProxy on this
+    // service, and then runs the user callback.
     if (resolver_error == ERR_ABORTED && in_destruction_ && !config_) {
       result_code = ERR_MANDATORY_PROXY_CONFIGURATION_FAILED;
       net_log.EndEvent(NetLogEventType::PROXY_RESOLUTION_SERVICE);
@@ -1368,8 +1368,9 @@
 
     if (reset_config) {
       ResetProxyConfig(false);
-      if (pending_requests_.size() > 1)
+      if (!pending_requests_.empty()) {
         ApplyProxyConfigIfAvailable();
+      }
     } else if (enable_pac_runtime_backoff_ &&
                resolver_error == ERR_PAC_SCRIPT_FAILED) {
       HandlePacScriptLoadError();
diff --git a/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc b/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
index b43ff3c..078ef0f 100644
--- a/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
+++ b/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
@@ -1881,6 +1881,140 @@
   EXPECT_EQ("[foopy_valid:8080]", info.proxy_chain().ToDebugString());
 }
 
+// Test what happens when the ProxyResolver fails with
+// ERR_PAC_SCRIPT_TERMINATED while multiple requests are in progress, and the
+// subsequent proxy re-initialization completes synchronously (e.g. fails and
+// falls back to DIRECT). Make sure no use-after-free occurs.
+TEST_F(ConfiguredProxyResolutionServiceTest,
+       ProxyResolverTerminatedDuringRequestWithSyncReinit) {
+  class SyncFailProxyResolverFactory : public ProxyResolverFactory {
+   public:
+    explicit SyncFailProxyResolverFactory(bool resolvers_expect_pac_bytes)
+        : ProxyResolverFactory(resolvers_expect_pac_bytes),
+          async_factory_(resolvers_expect_pac_bytes) {}
+
+    int CreateProxyResolver(const scoped_refptr<PacFileData>& pac_script,
+                            std::unique_ptr<ProxyResolver>* resolver,
+                            CompletionOnceCallback callback,
+                            std::unique_ptr<ProxyResolverFactory::Request>*
+                                request_handle) override {
+      if (synchronous_fail_) {
+        return ERR_PAC_SCRIPT_FAILED;
+      }
+      return async_factory_.CreateProxyResolver(
+          pac_script, resolver, std::move(callback), request_handle);
+    }
+
+    void set_synchronous_fail(bool synchronous_fail) {
+      synchronous_fail_ = synchronous_fail;
+    }
+
+    const MockAsyncProxyResolverFactory::RequestsList& pending_requests()
+        const {
+      return async_factory_.pending_requests();
+    }
+
+   private:
+    bool synchronous_fail_ = false;
+    MockAsyncProxyResolverFactory async_factory_;
+  };
+
+  auto config_service =
+      std::make_unique<MockProxyConfigService>("http://foopy/proxy.pac");
+
+  MockAsyncProxyResolver resolver;
+  auto resolver_factory = std::make_unique<SyncFailProxyResolverFactory>(false);
+  auto* resolver_factory_ptr = resolver_factory.get();
+
+  ConfiguredProxyResolutionService service(std::move(config_service),
+                                           std::move(resolver_factory),
+                                           mock_host_resolver_.get(), nullptr,
+                                           /*quick_check_enabled=*/true);
+
+  // Start two resolve requests.
+  GURL url1("http://www.google.com/");
+  GURL url2("https://www.google.com/");
+  ProxyInfo info1, info2;
+  std::unique_ptr<ProxyResolutionRequest> request1, request2;
+
+  int callback1_result = ERR_IO_PENDING;
+  base::RunLoop run_loop1;
+  auto callback1 = base::BindLambdaForTesting([&](int rv) {
+    callback1_result = rv;
+    // Destroy the request inside the completion callback, simulating the
+    // behavior of consumers like HttpStreamFactory::JobController.
+    request1.reset();
+    run_loop1.Quit();
+  });
+
+  int callback2_result = ERR_IO_PENDING;
+  base::RunLoop run_loop2;
+  auto callback2 = base::BindLambdaForTesting([&](int rv) {
+    callback2_result = rv;
+    request2.reset();
+    run_loop2.Quit();
+  });
+
+  // Request 1: In-flight resolve request that will encounter
+  // ERR_PAC_SCRIPT_TERMINATED and trigger proxy re-initialization.
+  int rv1 = service.ResolveProxy(url1, std::string(), NetworkAnonymizationKey(),
+                                 handles::kInvalidNetworkHandle, &info1,
+                                 std::move(callback1), &request1,
+                                 NetLogWithSource(), DEFAULT_PRIORITY);
+
+  // Request 2: Concurrent in-flight resolve request that will be paused in
+  // `pending_requests_` and restarted upon re-initialization.
+  int rv2 = service.ResolveProxy(url2, std::string(), NetworkAnonymizationKey(),
+                                 handles::kInvalidNetworkHandle, &info2,
+                                 std::move(callback2), &request2,
+                                 NetLogWithSource(), DEFAULT_PRIORITY);
+
+  // Both requests should be waiting for PAC compilation now.
+  EXPECT_THAT(rv1, IsError(ERR_IO_PENDING));
+  EXPECT_THAT(rv2, IsError(ERR_IO_PENDING));
+
+  // The service must first create a ProxyResolver by fetching and compiling
+  // the PAC script. We should have one PAC factory creation request for
+  // "http://foopy/proxy.pac".
+  ASSERT_EQ(1u, resolver_factory_ptr->pending_requests().size());
+  EXPECT_EQ(GURL("http://foopy/proxy.pac"),
+            resolver_factory_ptr->pending_requests()[0]->script_data()->url());
+
+  // Complete PAC script compilation now so the service becomes ready and
+  // dispatches `url1` and `url2` to the resolver.
+  resolver_factory_ptr->pending_requests()[0]->CompleteNowWithForwarder(
+      OK, &resolver);
+
+  // Both `url1` and `url2` are now running as in-flight jobs in the resolver.
+  JobMap jobs = GetPendingJobsForURLs(resolver, url1, url2);
+
+  // Configure the next CreateProxyResolver call (when the service attempts to
+  // reset and re-initialize the PAC resolver after `url1` crashes) to fail
+  // synchronously.
+  resolver_factory_ptr->set_synchronous_fail(true);
+
+  // Simulate a PAC script crash on `url1`. DidFinishResolvingProxy() will then
+  // reset the configuration, attempt re-initialization (which fails
+  // synchronously), and fall back to DIRECT.
+  //
+  // Note: this covers the edge case reported in crbug.com/533534913 and
+  // confirms that destroying `request1` inside its completion callback does not
+  // cause a use-after-free.
+  jobs[url1]->CompleteNow(ERR_PAC_SCRIPT_TERMINATED);
+
+  // `request1` fell back to DIRECT per-request as the error fallback for
+  // PAC runtime script crash (ERR_PAC_SCRIPT_TERMINATED).
+  run_loop1.Run();
+  EXPECT_THAT(callback1_result, IsOk());
+  EXPECT_TRUE(info1.is_direct());
+
+  // PAC re-initialization failed, so the entire service entered DIRECT fallback
+  // mode and restarted `request2` against this new DIRECT configuration.
+  run_loop2.Run();
+  EXPECT_THAT(callback2_result, IsOk());
+  EXPECT_TRUE(info2.is_direct());
+}
+
 TEST_F(ConfiguredProxyResolutionServiceTest,
        PacFileFetcherFailsDownloadingMandatoryPac) {
   // Test what happens when the ProxyResolver fails to download a mandatory PAC
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc b/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
index b43ff3c..078ef0f 100644
--- a/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
+++ b/net/proxy_resolution/configured_proxy_resolution_service_unittest.cc
@@ -1881,6 +1881,140 @@
   EXPECT_EQ("[foopy_valid:8080]", info.proxy_chain().ToDebugString());
 }
 
+// Test what happens when the ProxyResolver fails with
+// ERR_PAC_SCRIPT_TERMINATED while multiple requests are in progress, and the
+// subsequent proxy re-initialization completes synchronously (e.g. fails and
+// falls back to DIRECT). Make sure no use-after-free occurs.
+TEST_F(ConfiguredProxyResolutionServiceTest,
+       ProxyResolverTerminatedDuringRequestWithSyncReinit) {
+  class SyncFailProxyResolverFactory : public ProxyResolverFactory {
+   public:
+    explicit SyncFailProxyResolverFactory(bool resolvers_expect_pac_bytes)
+        : ProxyResolverFactory(resolvers_expect_pac_bytes),
+          async_factory_(resolvers_expect_pac_bytes) {}
+
+    int CreateProxyResolver(const scoped_refptr<PacFileData>& pac_script,
+                            std::unique_ptr<ProxyResolver>* resolver,
+                            CompletionOnceCallback callback,
+                            std::unique_ptr<ProxyResolverFactory::Request>*
+                                request_handle) override {
+      if (synchronous_fail_) {
+        return ERR_PAC_SCRIPT_FAILED;
+      }
+      return async_factory_.CreateProxyResolver(
+          pac_script, resolver, std::move(callback), request_handle);
+    }
+
+    void set_synchronous_fail(bool synchronous_fail) {
+      synchronous_fail_ = synchronous_fail;
+    }
+
+    const MockAsyncProxyResolverFactory::RequestsList& pending_requests()
+        const {
+      return async_factory_.pending_requests();
+    }
+
+   private:
+    bool synchronous_fail_ = false;
+    MockAsyncProxyResolverFactory async_factory_;
+  };
+
+  auto config_service =
+      std::make_unique<MockProxyConfigService>("http://foopy/proxy.pac");
+
+  MockAsyncProxyResolver resolver;
+  auto resolver_factory = std::make_unique<SyncFailProxyResolverFactory>(false);
+  auto* resolver_factory_ptr = resolver_factory.get();
+
+  ConfiguredProxyResolutionService service(std::move(config_service),
+                                           std::move(resolver_factory),
+                                           mock_host_resolver_.get(), nullptr,
+                                           /*quick_check_enabled=*/true);
+
+  // Start two resolve requests.
+  GURL url1("http://www.google.com/");
+  GURL url2("https://www.google.com/");
+  ProxyInfo info1, info2;
+  std::unique_ptr<ProxyResolutionRequest> request1, request2;
+
+  int callback1_result = ERR_IO_PENDING;
+  base::RunLoop run_loop1;
+  auto callback1 = base::BindLambdaForTesting([&](int rv) {
+    callback1_result = rv;
+    // Destroy the request inside the completion callback, simulating the
+    // behavior of consumers like HttpStreamFactory::JobController.
+    request1.reset();
+    run_loop1.Quit();
+  });
+
+  int callback2_result = ERR_IO_PENDING;
+  base::RunLoop run_loop2;
+  auto callback2 = base::BindLambdaForTesting([&](int rv) {
+    callback2_result = rv;
+    request2.reset();
+    run_loop2.Quit();
+  });
+
+  // Request 1: In-flight resolve request that will encounter
+  // ERR_PAC_SCRIPT_TERMINATED and trigger proxy re-initialization.
+  int rv1 = service.ResolveProxy(url1, std::string(), NetworkAnonymizationKey(),
+                                 handles::kInvalidNetworkHandle, &info1,
+                                 std::move(callback1), &request1,
+                                 NetLogWithSource(), DEFAULT_PRIORITY);
+
+  // Request 2: Concurrent in-flight resolve request that will be paused in
+  // `pending_requests_` and restarted upon re-initialization.
+  int rv2 = service.ResolveProxy(url2, std::string(), NetworkAnonymizationKey(),
+                                 handles::kInvalidNetworkHandle, &info2,
+                                 std::move(callback2), &request2,
+                                 NetLogWithSource(), DEFAULT_PRIORITY);
+
+  // Both requests should be waiting for PAC compilation now.
+  EXPECT_THAT(rv1, IsError(ERR_IO_PENDING));
+  EXPECT_THAT(rv2, IsError(ERR_IO_PENDING));
+
+  // The service must first create a ProxyResolver by fetching and compiling
+  // the PAC script. We should have one PAC factory creation request for
+  // "http://foopy/proxy.pac".
+  ASSERT_EQ(1u, resolver_factory_ptr->pending_requests().size());
+  EXPECT_EQ(GURL("http://foopy/proxy.pac"),
+            resolver_factory_ptr->pending_requests()[0]->script_data()->url());
+
+  // Complete PAC script compilation now so the service becomes ready and
+  // dispatches `url1` and `url2` to the resolver.
+  resolver_factory_ptr->pending_requests()[0]->CompleteNowWithForwarder(
+      OK, &resolver);
+
+  // Both `url1` and `url2` are now running as in-flight jobs in the resolver.
+  JobMap jobs = GetPendingJobsForURLs(resolver, url1, url2);
+
+  // Configure the next CreateProxyResolver call (when the service attempts to
+  // reset and re-initialize the PAC resolver after `url1` crashes) to fail
+  // synchronously.
+  resolver_factory_ptr->set_synchronous_fail(true);
+
+  // Simulate a PAC script crash on `url1`. DidFinishResolvingProxy() will then
+  // reset the configuration, attempt re-initialization (which fails
+  // synchronously), and fall back to DIRECT.
+  //
+  // Note: this covers the edge case reported in crbug.com/533534913 and
+  // confirms that destroying `request1` inside its completion callback does not
+  // cause a use-after-free.
+  jobs[url1]->CompleteNow(ERR_PAC_SCRIPT_TERMINATED);
+
+  // `request1` fell back to DIRECT per-request as the error fallback for
+  // PAC runtime script crash (ERR_PAC_SCRIPT_TERMINATED).
+  run_loop1.Run();
+  EXPECT_THAT(callback1_result, IsOk());
+  EXPECT_TRUE(info1.is_direct());
+
+  // PAC re-initialization failed, so the entire service entered DIRECT fallback
+  // mode and restarted `request2` against this new DIRECT configuration.
+  run_loop2.Run();
+  EXPECT_THAT(callback2_result, IsOk());
+  EXPECT_TRUE(info2.is_direct());
+}
+
 TEST_F(ConfiguredProxyResolutionServiceTest,
        PacFileFetcherFailsDownloadingMandatoryPac) {
   // Test what happens when the ProxyResolver fails to download a mandatory PAC
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free in ConfiguredProxyResolutionRequest via re-entrant SetReady

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A re-entrancy flaw in ConfiguredProxyResolutionRequest::QueryComplete can lead to a potential Use-After-Free in the unsandboxed network service. If a PAC script terminates and proxy initialization fails synchronously due to a negative DNS cache hit, the completing request is re-dispatched, freed nestedly, and then accessed upon stack unwind. An attacker can deterministically trigger the required synchronous failure via DNS manipulation and the PacFileDeciderPoller.

Affected files:

  • net/proxy_resolution/configured_proxy_resolution_request.cc
  • net/proxy_resolution/configured_proxy_resolution_service.cc
  • net/http/http_stream_factory_job_controller.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

1. Summary of the Issue (Meant for Human Triage)

A potential Use-After-Free (UAF) vulnerability exists in Google Chrome’s proxy resolution architecture, specifically within ConfiguredProxyResolutionRequest::QueryComplete. The flaw occurs because a completing request invokes the proxy resolution service (DidFinishResolvingProxy) before removing itself from the list of pending requests (pending_requests_).

If the PAC script terminates unexpectedly (e.g., due to a crash), the service attempts to reset and re-initialize the proxy configuration. If this re-initialization fails synchronously, the service immediately calls SetReady(). Because the completing request is still in pending_requests_ and has had its state marked as not started, SetReady() synchronously re-dispatches it. This results in a nested execution of QueryComplete that executes the consumer callback and frees the request object. As the execution stack unwinds to the outer QueryDidComplete frame, it accesses members of the now-freed request object, leading to a UAF.

While proxy initialization is typically asynchronous, a local network attacker (A-NETCONF) can force a synchronous failure on Linux, Mac, or Android. By manipulating DNS to return NXDOMAIN for the wpad host, the attacker causes the background PacFileDeciderPoller to populate the HostCache with a negative entry keyed to the service’s persistent transient NetworkAnonymizationKey. If the attacker then crashes the PAC resolver, the resulting Mojo disconnect (processed as a high-priority I/O event) triggers the re-entrancy flaw. The re-initialization hits the primed negative DNS cache synchronously, satisfying all conditions for the UAF. As the Network Service is unsandboxed on Linux, Mac, ChromeOS, and Android, this UAF can potentially be exploited for remote code execution.

2. Proof-of-Concept & Detailed Execution Flow

Root Cause Analysis

The ordering flaw resides in ConfiguredProxyResolutionRequest::QueryComplete:

// net/proxy_resolution/configured_proxy_resolution_request.cc:218-227
void ConfiguredProxyResolutionRequest::QueryComplete(int result_code) {
  result_code = QueryDidComplete(result_code);         // [1] Calls service, resets state
  CompletionOnceCallback callback = std::move(user_callback_);
  service_->RemovePendingRequest(this);               // [2] Only removed here, too late!
  service_ = nullptr;
  user_callback_.Reset();
  std::move(callback).Run(result_code);
}

At [1], QueryDidComplete is called, which invokes Reset(), clearing resolve_job_ (setting is_started() to false). It then calls into the service:

// net/proxy_resolution/configured_proxy_resolution_request.cc:163-175
int ConfiguredProxyResolutionRequest::QueryDidComplete(int result_code) {
  CHECK(!was_completed());
  if (is_started()) { Reset(); }                       // resolve_job_.reset() -> is_started() == false
  int rv = service_->DidFinishResolvingProxy(url_, ..., result_code, ...);

During DidFinishResolvingProxy, when the result code is ERR_PAC_SCRIPT_TERMINATED, reset_config is set to true:

// net/proxy_resolution/configured_proxy_resolution_service.cc:1338-1341
if (reset_config) {
  ResetProxyConfig(false);
  if (pending_requests_.size() > 1)        // [3] still counted since RemovePendingRequest has not run
    ApplyProxyConfigIfAvailable();
}

ApplyProxyConfigIfAvailable() initiates a re-resolution via InitializeUsingLastFetchedConfig(), which sets up InitProxyResolver and calls init_proxy_resolver_->Start(). If this completes synchronously:

// net/proxy_resolution/configured_proxy_resolution_service.cc:1614-1623
int rv = init_proxy_resolver_->Start(...);
if (rv != ERR_IO_PENDING)
  OnInitProxyResolverComplete(rv);         // -> SetReady()

SetReady() iterates through a copy of pending_requests_:

// net/proxy_resolution/configured_proxy_resolution_service.cc:1153-1167
auto pending_requests_copy = pending_requests_;
for (ConfiguredProxyResolutionRequest* req : pending_requests_copy) {
  if (!ContainsPendingRequest(req)) continue;
  if (!req->is_started()) {
    req->StartAndCompleteCheckingForSynchronous();   // [4] Synch-dispatches outer `this`!

Because req->is_started() is false, StartAndCompleteCheckingForSynchronous() is executed on the outer request currently executing QueryComplete. Inside StartAndCompleteCheckingForSynchronous():

// net/proxy_resolution/configured_proxy_resolution_request.cc:139-149
void ConfiguredProxyResolutionRequest::StartAndCompleteCheckingForSynchronous() {
  int rv = service_->TryToCompleteSynchronously(
      url_, /*bypass_override_rules=*/false, net_log_, results_);
  ...
  if (rv != ERR_IO_PENDING) {
    QueryComplete(rv);                     // [5] Nested QueryComplete
  }
}

If TryToCompleteSynchronously succeeds (e.g., fallback configuration resolves synchronously to DIRECT), a nested QueryComplete(OK) runs. This nested invocation consumes user_callback_, calls RemovePendingRequest(this), and invokes the user callback, which frees the request immediately:

// net/http/http_stream_factory_job_controller.cc:852-855
int HttpStreamFactory::JobController::DoResolveProxyComplete(int rv) {
  DCHECK_NE(ERR_IO_PENDING, rv);
  proxy_resolve_request_ = nullptr;        // [6] Deletes and frees `this`

When control unwinds back to the outer QueryDidComplete:

// net/proxy_resolution/configured_proxy_resolution_request.cc:179-195
  results_->set_proxy_resolve_start_time(creation_time_);   // [7] UAF read/write!
  results_->set_proxy_resolve_end_time(base::TimeTicks::Now());
  ...
  if (!results_->traffic_annotation().is_valid())
    results_->set_traffic_annotation(traffic_annotation_);  // [8] UAF write!
  ...
  traffic_annotation_.reset();                              // [9] UAF write!

Accessing members via the freed this pointer constitutes a critical Use-After-Free.

Potential Trigger Steps

While proxy initialization is typically asynchronous, an attacker can reliably trigger the synchronous failure path:

  1. Initial State: The victim (on Linux, Mac, or Android) is configured with WPAD auto-detect. The attacker serves a valid PAC script. Chrome initializes the proxy resolver and creates a PacFileDeciderPoller (configured_proxy_resolution_service.cc:1207). The poller is given the service’s pac_file_fetcher_, which holds a persistent, transient NetworkAnonymizationKey (NAK) (pac_file_fetcher.h:79).
  2. Concurrent Requests: The victim navigates to a page that triggers at least two concurrent network requests, creating two ConfiguredProxyResolutionRequests that are added to pending_requests_.
  3. DNS Manipulation & Polling: The attacker manipulates the local DNS to return NXDOMAIN for wpad. The PacFileDeciderPoller triggers a background poll (DoPoll). It tries WPAD_DHCP (which synchronously returns ERR_NOT_IMPLEMENTED on these platforms) and falls back to WPAD_DNS, calling DoQuickCheck().
  4. Negative Cache Priming: DoQuickCheck() issues a HostResolver::CreateRequest using the fetcher’s persistent transient NAK. The HostResolver receives the NXDOMAIN response and populates the HostCache with a negative entry keyed to this specific NAK. The poller finishes and posts a notification task (NotifyProxyResolutionServiceOfChange) to the thread’s message loop queue (configured_proxy_resolution_service.cc:738).
  5. Triggering the Race: Before the posted notification task executes, the attacker crashes the out-of-process PAC resolver (e.g., via a malformed PAC response causing an OOM). The OS closes the Mojo pipe. Chromium’s event loop (MessagePumpEpoll) processes this Mojo disconnect as a high-priority I/O event, executing the disconnect handler (ProxyResolverMojo::Job::OnMojoDisconnect) before the queued poller task.
  6. Synchronous Re-initialization: The disconnect handler calls CompleteRequest(ERR_PAC_SCRIPT_TERMINATED), triggering the outer QueryComplete on the completing request. DidFinishResolvingProxy resets the config (destroying the poller and safely canceling its pending notification task) and calls ApplyProxyConfigIfAvailable -> InitializeUsingLastFetchedConfig -> InitProxyResolver::Start.
  7. Synchronous Failure: InitProxyResolver::Start tries WPAD_DHCP (sync ERR_NOT_IMPLEMENTED) and falls back to WPAD_DNS. DoQuickCheck() issues a HostResolver request using the same persistent transient NAK. ResolveLocally() performs a cache lookup, hits the negative entry cached in step 4, and synchronously returns ERR_NAME_NOT_RESOLVED.
  8. UAF Triggered: Because InitProxyResolver::Start fails synchronously, SetReady() is called synchronously. It re-dispatches the completing request, invoking the nested QueryComplete, which executes the consumer callback and frees the request object. Unwinding the stack to the outer QueryDidComplete results in the UAF.

Suggested Fix

Move service_->RemovePendingRequest(this) in ConfiguredProxyResolutionRequest::QueryComplete to occur before the call to QueryDidComplete. This ensures the request is no longer counted in pending_requests_ when DidFinishResolvingProxy executes, preventing it from satisfying pending_requests_.size() > 1 (if it was the last request) and preventing it from being re-dispatched by SetReady().

3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

> Critic model verdict (2026-07-10 13:38:30): > * Severity: High (S1) > * Brief Notes / Reasoning: > The report correctly identifies a structural re-entrancy flaw where QueryComplete can be invoked while this is still in pending_requests_. If InitProxyResolver::Start() completes synchronously, SetReady() is called, which synchronously re-dispatches the request, invoking the user callback (which frees this) and causing an outer-frame UAF write (results_->set_traffic_annotation(...)). >
> The researcher assessed this as latent/unreachable, assuming InitProxyResolver::Start() is always async. However, an attacker (A-NETCONF) can deterministically trigger a synchronous failure on Linux/Mac/Android (where WPAD_DHCP fails synchronously): > 1. The attacker serves a valid WPAD IP, initializing the proxy resolver. > 2. The attacker changes wpad DNS to NXDOMAIN. > 3. The attacker forces Chrome to resolve wpad (e.g., the PAC script returns DIRECT for http://wpad/), populating the host resolver’s negative cache. > 4. The attacker triggers a crash in the PAC script (e.g., via OOM). > 5. Chrome processes ERR_PAC_SCRIPT_TERMINATED, re-initializes, and hits the synchronous negative cache for wpad. > 6. InitProxyResolver::Start fails synchronously, SetReady() is called, and the UAF is triggered. >
> MiraclePtr does not mitigate this since the freed object is accessed via the on-stack this pointer. By grooming the heap, the attacker can control the freed object, gaining an arbitrary 32-bit write primitive in the network service. Since the network service is unsandboxed on Linux and Android, this is a sandbox escape. Rated S1 (High) per guidelines (memory corruption in unsandboxed network process requiring a groom).

Double-Check Validator Notes: The prior Critic’s general UAF logic holds, but the specific trigger mechanism required correction. The Critic asserted the attacker could prime the negative DNS cache via a PAC script. This is incorrect because PacFileDecider::QuickCheck uses the PacFileFetcher’s IsolationInfo::CreateTransient(). Chrome’s HostCache is partitioned by the NetworkAnonymizationKey (NAK), meaning the transient NAK guarantees a cache miss against entries populated by general web requests or the PAC script itself.

However, synchronous failure IS reachable via the PacFileDeciderPoller. The poller uses the exact same pac_file_fetcher_ instance as the main service (configured_proxy_resolution_service.cc:1212), which retains a persistent IsolationInfo and transient NAK (pac_file_fetcher.h:79-80). When the poller executes, its wpad DNS check caches a negative entry under this specific transient NAK. If a Mojo disconnect occurs while the poller’s completion task is queued on the message loop, the event loop (e.g., MessagePumpEpoll) processes the Mojo disconnect as an I/O event prior to executing queued tasks. This causes ProxyResolverMojo::Job::OnMojoDisconnect to run before the poller task. The resulting proxy re-initialization then issues a wpad DNS query using the identical transient NAK, hitting the negative cache synchronously (HostResolverManager::ResolveLocally). This completes the synchronous loop to SetReady() and triggers the UAF as described. MiraclePtr (BRP) does not protect accesses made directly from a freed this pointer traversing to member variables (results_, creation_time_, traffic_annotation_, service_), resulting in an exploitable UAF in the unsandboxed network service (S1).

Evaluated with Chrome root at commit: f4cb78b4ec077b7f51b504af9350cdc166d10c2f


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker
Links in the report