121d4383143bbeb64a571faddba463d673cf514d [M144-LTS][Fortify] [CPRS] Fix UAF in ConfiguredProxyResolutionRequest::QueryComplete
Triage note: Real browser-process UAF but gated on a configured PAC/proxy and PAC-script termination, not web-content-reachable in a default profile. Bug:533534913.
The bug at a glance
This is a genuine, understood use-after-free in the network service, not just hardening: the CL ships a regression test that reproduces the re-entrant destruction. It is rated medium rather than patch-now for several reasons. The trigger requires a specific and largely non-attacker-chosen sequence: a PAC script must terminate at runtime (ERR_PAC_SCRIPT_TERMINATED) while multiple resolve requests are in flight, and proxy re-initialization must complete synchronously (fail and fall back to DIRECT). PAC scripts are typically administrator- or network-controlled (WPAD/policy), not directly renderer-controlled, so the input surface is constrained. The freed object is a heap-allocated request whose vtable/callback is invoked during a re-entrant frame; that is a real control-flow-relevant UAF, hence a 6.2 band / 5.0 CVSS, but the difficulty of steering the timing and the limited attacker control over PAC keep it out of the exploitable tier in this triage. It is an M144-LTS Fortify backport, underlining that the maintainers consider it a real fix worth shipping to stable.
The core question for any completion routine is: does the object remove itself from every container that could re-dispatch it BEFORE it runs callbacks that might synchronously re-enter the subsystem? Here it did not. QueryComplete called QueryDidComplete -> DidFinishResolvingProxy while this was still enrolled in the service’s pending_requests_. On PAC termination, DidFinishResolvingProxy resets the config and tries to re-init the resolver; if that completes synchronously (e.g. falls back to DIRECT), SetReady() synchronously re-dispatches every still-listed pending request — including the one already mid-completion. That second, re-entrant QueryComplete runs the consumer callback, and consumers (e.g. HttpStreamFactory::JobController) commonly delete the request inside their callback. The outer QueryComplete frame then resumes and touches freed member state — a classic re-entrancy UAF.
Root cause
The fix reorders QueryComplete so the request unlists itself first. It adds CHECK(!was_completed()) to catch double-completion, then calls service_->RemovePendingRequest(this) BEFORE QueryDidComplete, and sets service_ = nullptr immediately after QueryDidComplete. Because this is no longer in pending_requests_ when DidFinishResolvingProxy runs, the synchronous SetReady()/ApplyProxyConfigIfAvailable re-dispatch can no longer pick this request up and re-enter QueryComplete. The user callback is still moved out and run last. Correspondingly, in configured_proxy_resolution_service.cc the post-ResetProxyConfig re-dispatch guard is changed from ‘pending_requests_.size() > 1’ to ‘!pending_requests_.empty()’ — the ‘> 1’ was a fragile compensation for the fact that the completing request was still counted in the list; once requests unlist before completion, the correct condition is simply ‘any pending request remains’. A comment block is updated to describe the new ordering (remove, then QueryDidComplete, then run callback).
Key code
QueryComplete now removes this from the service before notifying completion, closing the re-entrant re-dispatch window.
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);
}
Patch walkthrough
net/proxy_resolution/configured_proxy_resolution_request.cc— The heart of the fix. Adds CHECK(!was_completed()) as a double-completion tripwire. Moves RemovePendingRequest(this) and service_ = nullptr to before QueryDidComplete is called (well, service_ is nulled right after RemovePendingRequest and QueryDidComplete). The critical reorder is that the request is off pending_requests_ before DidFinishResolvingProxy can trigger a synchronous re-dispatch, so QueryComplete can no longer be re-entered on the same object. The user callback is still the last thing run, preserving consumer behavior including the consumer deleting the request in its callback.net/proxy_resolution/configured_proxy_resolution_service.cc— Updates the descriptive comment about destructor-driven QueryComplete(ERR_ABORTED) to reflect the new ordering, and changes the re-dispatch guard after ResetProxyConfig(false) from ‘if (pending_requests_.size() > 1)’ to ‘if (!pending_requests_.empty())’. The old ‘> 1’ silently assumed the still-completing request was counted; with early removal the in-flight request is gone, so the honest condition is whether any other request still needs the newly re-applied config. This also fixes a latent functional bug where a single remaining request could fail to be restarted.net/proxy_resolution/configured_proxy_resolution_service_unittest.cc— Adds ProxyResolverTerminatedDuringRequestWithSyncReinit, a regression test that starts two concurrent resolves, completes PAC compilation, arms a SyncFailProxyResolverFactory to fail re-init synchronously, then crashes request1 with ERR_PAC_SCRIPT_TERMINATED. Both callbacks delete their request in-callback (mimicking JobController). The test asserts both fall back to DIRECT with no UAF — it would fail/ASAN-crash on the pre-patch code.
Background
ConfiguredProxyResolutionRequest — Per-resolve request object owned via unique_ptr by the consumer; tracks one ResolveProxy call, holds a raw pointer to the service and a completion callback, and is enrolled in the service’s pending_requests_ set while in flight.
PAC script termination — ERR_PAC_SCRIPT_TERMINATED / ERR_PAC_SCRIPT_FAILED occur when the JS PAC interpreter is torn down or the script errors at runtime. It triggers config reset and resolver re-initialization, which can complete synchronously when it fails (fallback to DIRECT).
SetReady / ApplyProxyConfigIfAvailable — Service routines that, once a resolver is ready (or DIRECT fallback is chosen), synchronously walk pending_requests_ and re-dispatch each — the mechanism that re-enters QueryComplete on a still-listed request.
Consumer deletes-in-callback — Standard Chromium network idiom: HttpStreamFactory::JobController and similar reset the ProxyResolutionRequest unique_ptr inside the completion callback, which is what frees the object mid-frame.
[M144-LTS][Fortify] — Cherry-pick (from 78fb4f541c61) into the M144 long-term-support branch (branch-heads/7559), tagged Fortify — a security-hardening backport program. Confirms this is treated as a real security fix.
Vulnerability window
- root cause — QueryComplete historically removed the request from pending_requests_ after running QueryDidComplete, leaving a synchronous re-dispatch window.
- 2026-09-14 fix authored — Zonghan Xu reorders removal-before-completion; original landed main@{#1686034} (78fb4f54).
- backport — Cherry-picked to M144-LTS branch-heads/7559@{#5306} under the Fortify program; reviewed by mmenke, seblalancette, achuith.
- verification — New unittest reproduces the sync-reinit re-entrancy and confirms no UAF.
Proof of concept
A real regression test ships in the CL and is reproduced below verbatim in shape. It is a functional/ASAN regression test, not a weaponized exploit: it drives the exact re-entrant sequence (two in-flight resolves, PAC termination on one, synchronous re-init failure, request-delete-in-callback) and asserts DIRECT fallback with no use-after-free. On the pre-patch build this test would trip the UAF (ASAN heap-use-after-free) inside the outer QueryComplete frame. No memory-corruption exploit is demonstrated; steering the freed-object contents to achieve control flow is left unaddressed and non-trivial given the constrained PAC input surface.
// From configured_proxy_resolution_service_unittest.cc (shipped test)
TEST_F(ConfiguredProxyResolutionServiceTest,
ProxyResolverTerminatedDuringRequestWithSyncReinit) {
// ... SyncFailProxyResolverFactory returns ERR_PAC_SCRIPT_FAILED
// synchronously once set_synchronous_fail(true).
// Start two resolves (url1, url2); both go IO_PENDING.
// Complete PAC compilation so both dispatch to the resolver.
resolver_factory_ptr->set_synchronous_fail(true);
// Crash url1's job: config reset + sync re-init failure -> DIRECT.
jobs[url1]->CompleteNow(ERR_PAC_SCRIPT_TERMINATED);
// callback1/callback2 each do request.reset() in-callback,
// mimicking HttpStreamFactory::JobController.
run_loop1.Run();
EXPECT_THAT(callback1_result, IsOk());
EXPECT_TRUE(info1.is_direct());
run_loop2.Run();
EXPECT_THAT(callback2_result, IsOk());
EXPECT_TRUE(info2.is_direct());
}
Exploitation
- setup — Victim uses a PAC-based proxy config (WPAD/policy). Attacker influence over PAC is usually indirect (network position, malicious WPAD); renderer cannot freely author PAC.
- trigger — Cause a runtime PAC termination while >=1 other resolve is in flight, and make re-init complete synchronously (fail -> DIRECT). This re-enters QueryComplete on a still-listed request.
- primitive — Consumer deletes the request in its callback; the outer QueryComplete frame then uses freed request state (member access / callback machinery) — a browser/network-service UAF.
- escalation — To weaponize would require heap grooming of the freed request and control of re-entrancy timing; the CL does not demonstrate this and the constrained, timing-sensitive trigger makes reliable control-flow hijack hard.
Detection & hunting
For defenders and detection engineers:
- ASAN heap-use-after-free in QueryComplete/QueryDidComplete — On unpatched builds, fuzz/stress proxy resolution with abrupt PAC termination under multiple concurrent requests; UAF surfaces in the outer completion frame.
- CHECK(!was_completed()) failures — Post-patch, hits on this CHECK indicate an attempted double-completion / re-entrancy path — a strong signal to investigate the caller.
- Anomalous DIRECT fallback bursts — Repeated ERR_PAC_SCRIPT_TERMINATED followed by mass DIRECT fallback in NetLog may indicate someone probing the re-init path.
Audit directions
- Completion routines in net/ — Audit other request types that run consumer callbacks before fully unlisting themselves from owning containers; the same remove-before-notify ordering flaw pattern applies to any object that can be re-dispatched synchronously.
- Synchronous re-dispatch paths — Review ApplyProxyConfigIfAvailable / SetReady and analogous ‘apply-then-restart-all-pending’ loops for re-entrancy safety when a pending entry is mid-completion.
- The ‘> 1’ guard idiom — Search for size()>1 / count-based guards that implicitly assume the current object is still enrolled; these are fragile and often mask lifetime assumptions.