457776ec7c7642b111a3936e46ebe2684a7f997e [M150-LTS][Fortify] [CPRS] Fix UAF in ConfiguredProxyResolutionRequest::QueryComplete
Triage note: M150-LTS 'Fix UAF'; QueryComplete now RemovePendingRequest(this)+CHECK(!was_completed()) before QueryDidComplete so a synchronous SetReady()/re-dispatch on ERR_PAC_SCRIPT_TERMINATED can't re-enter and destroy the still-listed request. Attacker-triggerable via malicious PAC.
The bug at a glance
Re-entrant use-after-free in net/proxy_resolution’s ConfiguredProxyResolutionRequest, which runs in the browser process; it destroys a live request object while outer frames still use it. Backported as an M150-LTS Fortify fix, consistent with a security-relevant browser-process UAF (High).
A PAC/proxy configuration an attacker or network can influence (e.g. one that causes ERR_PAC_SCRIPT_TERMINATED and a synchronous DIRECT fallback) makes proxy resolution re-enter itself and free a request mid-completion, so a hostile or crash-prone PAC script becomes a memory-corruption primitive in the browser process.
Root cause
ConfiguredProxyResolutionRequest::QueryComplete originally called QueryDidComplete (-> DidFinishResolvingProxy on the service) while the request was still in the service’s pending_requests_, and only removed itself afterward. When PAC execution terminated, DidFinishResolvingProxy reset the proxy config and re-initialized the resolver; if that re-init failed or completed synchronously (falling back to DIRECT), SetReady() synchronously re-dispatched the still-registered request, re-entering QueryComplete. The inner QueryComplete ran the consumer callback (which, like HttpStreamFactory::JobController, destroys the request), so when the outer QueryComplete frame resumed it operated on a freed this — a UAF. The fix removes this from pending_requests_ (service_->RemovePendingRequest(this)) and adds CHECK(!was_completed()) before any notification, so the request is no longer reachable for synchronous re-dispatch; it then nulls service_ and runs the user callback last.
Key code
configured_proxy_resolution_request.cc — deregister before notifying, and assert single completion
void ConfiguredProxyResolutionRequest::QueryComplete(int result_code) {
CHECK(!was_completed());
// Remove `this` before notifying 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_);
user_callback_.Reset();
std::move(callback).Run(result_code);
}
Patch walkthrough
net/proxy_resolution/configured_proxy_resolution_request.cc— QueryComplete is reordered: it now opens with CHECK(!was_completed()), then calls service_->RemovePendingRequest(this) BEFORE QueryDidComplete, and sets service_ = nullptr right after. Because DidFinishResolvingProxy (reached via QueryDidComplete) can synchronously re-dispatch any request still in pending_requests_, removing this first makes re-entrant dispatch of this request impossible; the user callback is still run last.net/proxy_resolution/configured_proxy_resolution_service.cc— The re-initialization guard after ResetProxyConfig changes from ‘pending_requests_.size() > 1’ to ‘!pending_requests_.empty()’. Since the completing request is now removed from pending_requests_ before DidFinishResolvingProxy runs, the old count is off by one and would skip re-applying the config when exactly one other request remains.net/proxy_resolution/configured_proxy_resolution_service_unittest.cc— Adds ProxyResolverTerminatedDuringRequestWithSyncReinit, a regression test with a SyncFailProxyResolverFactory that fails re-initialization synchronously; both callbacks destroy their request inside the completion callback and expect DIRECT. Without the fix, destroying request1 inside its callback during the re-entrant dispatch is a UAF.
Background
ConfiguredProxyResolutionRequest — Per-resolve object tracked in the service’s pending_requests_; QueryComplete finalizes it, runs the consumer’s completion callback, and the consumer typically deletes the request from inside that callback.
DidFinishResolvingProxy / SetReady — Service-side path reached via QueryDidComplete; on a reset config it re-initializes the resolver and, when ready, synchronously re-dispatches pending requests.
ERR_PAC_SCRIPT_TERMINATED — Error from a PAC runtime crash/termination; it triggers the config reset and resolver re-initialization that opens the re-entrancy window, with DIRECT as the per-request fallback.
Re-entrant completion — Synchronous re-init lets the inner QueryComplete run and free the request while the outer QueryComplete is still on the stack — a self-referential UAF.
Vulnerability window
- Original fix — Landed on main as commit 78fb4f541c61 (CL 8287885).
- LTS backport — Cherry-picked as 457776ec7c (dated 2026-10-01) to the M150-LTS branch under the [Fortify]/[CPRS] label, CL 8396969.
- Tracking — The included regression test ties directly to the tracked bug (crbug.com/533534913).
Proof of concept
Grounded in the regression test ProxyResolverTerminatedDuringRequestWithSyncReinit. Two concurrent resolves are dispatched; the factory is set to fail re-initialization synchronously; completing url1 with ERR_PAC_SCRIPT_TERMINATED resets the config and synchronously re-dispatches, re-entering QueryComplete, while each callback destroys its own request. On the unpatched code the outer QueryComplete then touches freed memory. Mapping this to a remote/web trigger (serving a PAC that terminates mid-resolve) is (inferred).
// Condensed from the diff's regression test
service.ResolveProxy(url1, ..., callback1, &request1, ...); // ERR_IO_PENDING
service.ResolveProxy(url2, ..., callback2, &request2, ...); // ERR_IO_PENDING
factory->pending_requests()[0]->CompleteNowWithForwarder(OK, &resolver);
factory->set_synchronous_fail(true);
// callback1/callback2 destroy their request: request1.reset(); request2.reset();
jobs[url1]->CompleteNow(ERR_PAC_SCRIPT_TERMINATED); // re-entrant path
Exploitation
- Setup — Arrange a proxy configuration with a PAC script and at least two concurrent proxy resolutions in flight.
- Trigger — Cause ERR_PAC_SCRIPT_TERMINATED on one in-flight resolve so DidFinishResolvingProxy resets the config and re-initializes the resolver synchronously (DIRECT fallback), re-entering QueryComplete on a request still in pending_requests_.
- Free — The consumer callback destroys the request inside the re-entrant completion; control returns to the outer QueryComplete frame which uses the freed request object.
- Escalate — (inferred) Steering a remote PAC to terminate on demand, reclaiming the freed request allocation, and any corruption beyond the crash are not shown by the patch and are (inferred).
Detection & hunting
For defenders and detection engineers:
- CHECK(!was_completed()) aborts — Post-patch, a crash on the new CHECK(!was_completed()) in QueryComplete is a direct marker of an attempted double/re-entrant completion; monitor browser-process crash reports for it.
- PAC termination clusters — Hunt for bursts of ERR_PAC_SCRIPT_TERMINATED / ERR_PAC_SCRIPT_FAILED in NetLog or enterprise proxy telemetry, especially when followed by DIRECT fallback and proxy-resolution crashes.
- UAF in proxy_resolution frames — On ASAN builds, flag heap-use-after-free with ConfiguredProxyResolutionRequest::QueryComplete / ConfiguredProxyResolutionService::DidFinishResolvingProxy on the stack.
Audit directions
- Deregister-before-notify ordering — Audit all net/ request objects that live in a service-owned pending list and run a user callback on completion: ensure they remove themselves from the list before any notification that can synchronously re-dispatch.
- Synchronous resolver re-init paths — Review ResetProxyConfig / ApplyProxyConfigIfAvailable / SetReady and any resolver-factory that can complete or fail synchronously (DIRECT fallback) for re-entrancy into in-flight requests.
- Off-by-one container guards — Check other ‘pending_requests_.size() > N’ style guards that assume the completing request is still present; reordering removal invalidates such counts.