CVE-2026-11643
Overview
Background
- `ProxyResolverV8` / PAC execution
- the proxy service runs network-admin-supplied PAC (Proxy Auto-Config) JavaScript in a V8 context, exposing native bindings like
alert,dnsResolve, andmyIpAddress. - `Context`
- the C++ object representing one PAC execution context; it owns the
v8::Contextand provides the native binding implementations viajs_bindings(). - `v8::External` binding data
- each JS callback
v8::Functioncarries av8::Externalwhose value was the rawContext*, retrieved inside the callback to find the owning object. - Callback-outlives-context problem
- queued foreground tasks (
ResolveAsyncWaiterPromisesTask) and the isolate’s shared defaultMicrotaskQueue(e.g.Atomics.waitAsyncreactions) can strongly root thev8::Contextand itsv8::Functions so a callback fires after theContextis destroyed.
Root Cause Analysis
The native PAC bindings were installed with a v8::External pointing directly at the raw Context* (this), and each callback (AlertCallback, DnsResolveCallbackHelper, etc.) cast that External value straight back to Context* and dereferenced it. Because ContextDisposedNotification only prunes dirty FinalizationRegistries and does not cancel queued ResolveAsyncWaiterPromisesTask tasks or flush the shared default MicrotaskQueue, the v8::Context and its callback v8::Functions (and thus the v8::External) can outlive the C++ Context, so a later callback dereferenced a dangling Context* — a use-after-free.
The fix interposes a GC-managed ContextHolder between the v8::External and the Context: the External now points at the holder, whose raw_ptr<Context> context is nulled in ~Context() under the v8::Locker, and callbacks fetch the Context through ContextFromArgs() and bail if !context || !context->js_bindings(). This preserves the invariant that a callback never dereferences a destroyed Context, because once ~Context() runs the holder yields nullptr and the holder itself is reclaimed via the weak persistent v8_this / OnExternalGC only after V8 has garbage-collected the External.
Context* that could outlive the object through queued tasks/microtasks; the fix indirects through a GC-lifetime ContextHolder whose pointer is nulled in the destructor and null-checked in every callback, turning a dangling dereference into a safe early return.Attack Path
- Supply PAC script
An attacker or malicious/compromised proxy-config source provides PAC JavaScript that the proxy service executes in a
ProxyResolverV8Context. - Queue a deferred callback
The script arranges for a native binding (e.g.
dnsResolve,alert) or its reaction to run later, e.g. via aResolveAsyncWaiterPromisesTaskor anAtomics.waitAsyncmicrotask that keeps thev8::Contextand callbackv8::Functions rooted in the shared isolate queue. - Destroy the Context
Resolution completes and the C++
Contextis destroyed, butContextDisposedNotificationleaves the queued task/microtask and thev8::Externalalive. - Fire callback on freed Context
The deferred callback runs and, pre-patch, cast the stale
v8::Externalvalue toContext*and dereferenced it — a use-after-free in the proxy-resolver process. - Downstream effect
Any memory-corruption consequence beyond the dangling
Context*dereference the diff shows is(inferred).
Impact Assessment
Context when a deferred JS callback fires after context teardown, in the proxy-resolver service process. The vendor advisory rates this critical; the precondition is attacker-influenced PAC content plus a deferred callback (queued task or Atomics.waitAsync microtask) surviving context destruction, i.e. a specific PAC-execution and timing setup rather than arbitrary web content. Any escalation beyond the freed-then-used Context the patch addresses (controlled reuse, code execution) is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/proxy_resolver/proxy_resolver_v8.cc |
modified | |
switchservices/proxy_resolver/proxy_resolver_v8.cc |
modified |
Files Changed
services/proxy_resolver/proxy_resolver_v8.cc
Audit Directions
- Raw `this` in `v8::External` / callback dataAudit native V8 bindings that stash a raw C++
thisinv8::Externalor callback data and dereference it later; require a GC- or weak-managed holder with a null check. - Callbacks outliving their owner via queuesReview code assuming context disposal cancels pending work, since queued foreground tasks and the shared
MicrotaskQueue(Atomics.waitAsync) can keep contexts/functions alive past owner destruction. - Destructor vs. V8 object teardown orderingCheck that C++ destructors neutralize any indirection (under the appropriate
v8::Locker) before V8 may invoke lingering callbacks, rather than relying onReset()alone.
Patch
From 399cb31703ea6c6ce6456db6f450599dc0ae713f Mon Sep 17 00:00:00 2001 From: Nidhi Jaju <[email protected]> Date: Tue, 02 Jun 2026 06:23:48 -0700 Subject: [PATCH] Fix context lifetime in ProxyResolverV8 callbacks Safeguard JS callback functions (like alert and dnsResolve) in the PAC execution context. We now indirect the execution context pointer through a GC-managed wrapper and add null checks for active bindings, ensuring callbacks return safely if the context is no longer active. Bug: 518006379 Change-Id: Id0932b292b2ae1e09e6bccd8be07f613f64cfca0 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7885520 Reviewed-by: Adam Rice <[email protected]> Commit-Queue: Nidhi Jaju <[email protected]> Cr-Commit-Position: refs/heads/main@{#1640118} --- diff --git a/services/proxy_resolver/proxy_resolver_v8.cc b/services/proxy_resolver/proxy_resolver_v8.cc index cd25935..51f00f0 100644 --- a/services/proxy_resolver/proxy_resolver_v8.cc +++ b/services/proxy_resolver/proxy_resolver_v8.cc @@ -453,7 +453,24 @@ v8::ContextDependants::kNoDependants); } - v8_this_.Reset(); + // ContextDisposedNotification only prunes dirty FinalizationRegistries; it + // does not cancel queued ResolveAsyncWaiterPromisesTask foreground tasks + // or flush the isolate's shared default MicrotaskQueue, both of which can + // still strongly root this v8::Context (and the v8::Functions whose + // v8::External data points at the holder). Neutralize the indirection while + // holding the v8::Locker so any later callback observes a null Context* + // instead of a dangling one. + if (holder_) { + holder_->context = nullptr; + if (!holder_->v8_this.IsEmpty()) { + // The v8::External is still alive, so release it. The V8 weak callback + // will delete it when the v8::External is garbage collected. If + // `v8_this` is empty, the v8::External was already garbage collected, + // so we can delete the holder. + holder_.release(); + } + } + v8_context_.Reset(); } @@ -523,11 +540,13 @@ v8::Isolate::Scope isolate_scope(isolate_); v8::HandleScope scope(isolate_); - v8_this_.Reset( - isolate_, - v8::External::New(isolate_, this, gin::kProxyResolverV8ContextTag)); - v8::Local<v8::External> v8_this = - v8::Local<v8::External>::New(isolate_, v8_this_); + holder_ = std::make_unique<ContextHolder>(); + holder_->context = this; + v8::Local<v8::External> v8_holder = v8::External::New( + isolate_, holder_.get(), gin::kProxyResolverV8ContextTag); + holder_->v8_this.Reset(isolate_, v8_holder); + holder_->v8_this.SetWeak(holder_.get(), OnExternalGC, + v8::WeakCallbackType::kParameter); v8_context_.Reset(isolate_, v8::Context::New(isolate_)); @@ -539,25 +558,25 @@ // Attach the javascript bindings. global ->Set(context, ASCIILiteralToV8String(isolate_, "alert"), - v8::Function::New(context, &AlertCallback, v8_this, 0, + v8::Function::New(context, &AlertCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); global ->Set(context, ASCIILiteralToV8String(isolate_, "myIpAddress"), - v8::Function::New(context, &MyIpAddressCallback, v8_this, 0, + v8::Function::New(context, &MyIpAddressCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); global ->Set(context, ASCIILiteralToV8String(isolate_, "dnsResolve"), - v8::Function::New(context, &DnsResolveCallback, v8_this, 0, + v8::Function::New(context, &DnsResolveCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); global ->Set(context, ASCIILiteralToV8String(isolate_, "isPlainHostName"), - v8::Function::New(context, &IsPlainHostNameCallback, v8_this, 0, + v8::Function::New(context, &IsPlainHostNameCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); @@ -565,26 +584,26 @@ // Microsoft's PAC extensions: global ->Set(context, ASCIILiteralToV8String(isolate_, "dnsResolveEx"), - v8::Function::New(context, &DnsResolveExCallback, v8_this, 0, + v8::Function::New(context, &DnsResolveExCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); global ->Set(context, ASCIILiteralToV8String(isolate_, "myIpAddressEx"), - v8::Function::New(context, &MyIpAddressExCallback, v8_this, 0, + v8::Function::New(context, &MyIpAddressExCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); global ->Set(context, ASCIILiteralToV8String(isolate_, "sortIpAddressList"), - v8::Function::New(context, &SortIpAddressListCallback, v8_this, 0, - v8::ConstructorBehavior::kThrow) + v8::Function::New(context, &SortIpAddressListCallback, v8_holder, + 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); global ->Set(context, ASCIILiteralToV8String(isolate_, "isInNetEx"), - v8::Function::New(context, &IsInNetExCallback, v8_this, 0, + v8::Function::New(context, &IsInNetExCallback, v8_holder, 0, v8::ConstructorBehavior::kThrow) .ToLocalChecked()) .Check(); @@ -693,9 +712,10 @@ // V8 callback for when "alert()" is invoked by the PAC script. static void AlertCallback(const v8::FunctionCallbackInfo<v8::Value>& args) { - Context* context = - static_cast<Context*>(v8::External::Cast(*args.Data()) - ->Value(gin::kProxyResolverV8ContextTag)); + Context* context = ContextFromArgs(args); + if (!context || !context->js_bindings()) { + return; + } // Like firefox we assume "undefined" if no argument was specified, and // disregard any arguments beyond the first. @@ -742,9 +762,26 @@ static void DnsResolveCallbackHelper( const v8::FunctionCallbackInfo<v8::Value>& args, net::ProxyResolveDnsOperation op) { - Context* context = - static_cast<Context*>(v8::External::Cast(*args.Data()) - ->Value(gin::kProxyResolverV8ContextTag)); + Context* context = ContextFromArgs(args); + if (!context || !context->js_bindings()) { + // Each function handles resolution errors differently. + switch (op) { + case net::ProxyResolveDnsOperation::DNS_RESOLVE: + args.GetReturnValue().SetNull(); + return; + case net::ProxyResolveDnsOperation::DNS_RESOLVE_EX: + args.GetReturnValue().SetEmptyString(); + return; + case net::ProxyResolveDnsOperation::MY_IP_ADDRESS: + args.GetReturnValue().Set( + ASCIILiteralToV8String(args.GetIsolate(), "127.0.0.1")); + return; + case net::ProxyResolveDnsOperation::MY_IP_ADDRESS_EX: + args.GetReturnValue().SetEmptyString(); + return; + } + NOTREACHED(); + } std::string hostname; @@ -860,10 +897,40 @@ args.GetReturnValue().Set(IsPlainHostName(hostname_utf8)); } + // Indirection for the v8::External bound to the JS callback v8::Functions. + // The v8::External's value is immutable and the v8::Functions can outlive + // |this| (e.g. via Atomics.waitAsync reactions queued in the shared isolate + // MicrotaskQueue), so callbacks must go through a holder that ~Context() + // nulls under the v8::Locker. The holder is managed via a V8 weak persistent + // handle and is deleted in OnExternalGC once V8 has garbage-collected it. + struct ContextHolder { + // Nulled in ~Context(), so the raw_ptr never dangles. + raw_ptr<Context> context = nullptr; + + // This is actually a weak persistent. + v8::Persistent<v8::External> v8_this; + }; + + static Context* ContextFromArgs( + const v8::FunctionCallbackInfo<v8::Value>& args) { + auto* holder = static_cast<ContextHolder*>( + v8::External::Cast(*args.Data()) + ->Value(gin::kProxyResolverV8ContextTag)); + return holder ? holder->context : nullptr; + } + + static void OnExternalGC(const v8::WeakCallbackInfo<ContextHolder>& data) {
Original Bug Report
Potential Use-After-Free in ProxyResolverV8::Context via Atomics.waitAsync
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 potential Use-After-Free (UAF) vulnerability exists in ProxyResolverV8::Context due to an asynchronous task lifetime mismatch when calling Atomics.waitAsync. When a proxy resolver instance is destroyed, its C++ context is deleted, but pending promise resolution tasks remain queued in V8’s foreground runner on a surviving resolver’s thread. When these tasks subsequently execute, they resolve the promise and invoke callbacks within the disposed context, dereferencing a dangling C++ pointer.
Affected files:
services/proxy_resolver/proxy_resolver_v8.cc
Estimated timestamp from git blame: 2020-09-14
Detailed Description
A potential Use-After-Free (UAF) vulnerability exists in the ProxyResolverV8::Context class within services/proxy_resolver/proxy_resolver_v8.cc. The issue arises because disposing of a V8 context via v8::Isolate::ContextDisposedNotification does not cancel outstanding V8 foreground tasks (such as those scheduled by Atomics.waitAsync) or flush pending promise resolution jobs.
When a PAC script leverages Atomics.waitAsync, V8 registers a FutexWaitListNode and schedules a foreground ResolveAsyncWaiterPromisesTask to run on the isolate’s task runner. In Chromium, all ProxyResolverV8 instances share a single process-wide v8::Isolate (managed by SharedIsolateFactory). If multiple proxy resolvers are active, they execute on separate background threads but share the same isolate.
If the first-created proxy resolver (Resolver B) is active, the shared isolate’s foreground task runner is bound to Resolver B’s thread. If a second resolver (Resolver A) executes a PAC script that initiates an asynchronous wait via Atomics.waitAsync, the resulting ResolveAsyncWaiterPromisesTask is posted to the shared isolate’s task runner (Resolver B’s thread).
If Resolver A is subsequently destroyed, its destructor ProxyResolverV8::~ProxyResolverV8() synchronously deletes its C++ ProxyResolverV8::Context object, freeing its heap memory. However, the pending task on Resolver B’s thread remains active. When Resolver B’s thread executes the task, it retrieves the weak global handles to Resolver A’s native context and promise, resolves the promise, and places the reaction job (e.g., a .then handler calling alert()) into the shared microtask queue.
Draining the microtask queue invokes the callback within the context of the destroyed Resolver A. The callback (such as AlertCallback) retrieves the C++ context pointer from v8::External and attempts to perform virtual method calls on it, leading to a Use-After-Free:
static void AlertCallback(const v8::FunctionCallbackInfo<v8::Value>& args) {
Context* context =
static_cast<Context*>(v8::External::Cast(*args.Data())
->Value(gin::kProxyResolverV8ContextTag));
...
context->js_bindings()->Alert(message); // Virtual call on a freed object
}
Potential Impact and Process Context
- Android (
BUILDFLAG(IS_ANDROID): On Android, theProxyResolverFactoryImplis instantiated as a staticbase::NoDestructordirectly within the browser process (chrome/browser/net/chrome_mojo_proxy_resolver_factory.cc, lines 33–37). Consequently, this UAF occurs inside the highly privileged, unsandboxed browser process, potentially allowing direct Remote Code Execution (RCE). - Desktop: On desktop platforms, the proxy resolver runs within a highly restrictive sandboxed utility process.
- V8 Sandbox (V8 SBX): The V8 Sandbox does not mitigate or protect against this issue. The vulnerability is a standard Use-After-Free of a host C++ object on the PartitionAlloc heap, and the dangling pointer is retrieved and dereferenced through normal control flow without requiring any V8 heap corruption.
Suggested/Potential Steps to Trigger
Note: These steps are based on static analysis of the control flow and code structures; our tooling does not currently have the capability to execute proof-of-concept code.
- Establish two active proxy resolver instances (Resolver B created first, Resolver A second) sharing the same isolate.
- Execute a PAC script in Resolver A that sets up a
SharedArrayBufferand registers an asynchronous wait viaAtomics.waitAsync(int32, 0, 0, 100).then(() => { alert("trigger"); });. - Trigger the destruction of Resolver A, which deletes the C++
ProxyResolverV8::Contextobject but leaves the task queued on Resolver B’s thread. - Allow Resolver B to process its queued tasks. The timeout will trigger, executing
ResolveAsyncWaiterPromisesTaskon the shared isolate, which resolves the promise and queues the reaction microtask. - Resolver B executes a proxy resolution request, causing V8 to drain the microtask queue. This executes the callback, which invokes
AlertCallbackand accesses the freedContextmemory.
Recommended Fix
To resolve this issue, avoid passing raw, unmanaged C++ pointers to v8::External when creating callbacks. Instead, manage the lifetime of the binding state using a shared state object (such as std::shared_ptr<ContextState>) or utilize a weak pointer pattern (base::WeakPtr) that can be invalidated upon Context destruction. Alternatively, the callbacks can be unregistered or neutralized when ContextDisposedNotification is issued, ensuring that no further callbacks can access the deleted object.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.