Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Proxy
DescriptionUse after free in Proxy
ComponentProxy
Bug ClassUAF
Tracker518006379
Fix commit399cb31703ea (chromium/src) +89/-22
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Changed Functions

FunctionChangeNotes
if
services/proxy_resolver/proxy_resolver_v8.cc
modified
switch
services/proxy_resolver/proxy_resolver_v8.cc
modified

Files Changed

  • services/proxy_resolver/proxy_resolver_v8.cc
From 399cb31703ea6c6ce6456db6f450599dc0ae713f Mon Sep 17 00:00:00 2001
From: Nidhi Jaju <nidhijaju@chromium.org>
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 <ricea@chromium.org>
Commit-Queue: Nidhi Jaju <nidhijaju@chromium.org>
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) {
Loading diff…

Original Bug Report

reported by vm...@google.com

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, the ProxyResolverFactoryImpl is instantiated as a static base::NoDestructor directly 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.

  1. Establish two active proxy resolver instances (Resolver B created first, Resolver A second) sharing the same isolate.
  2. Execute a PAC script in Resolver A that sets up a SharedArrayBuffer and registers an asynchronous wait via Atomics.waitAsync(int32, 0, 0, 100).then(() => { alert("trigger"); });.
  3. Trigger the destruction of Resolver A, which deletes the C++ ProxyResolverV8::Context object but leaves the task queued on Resolver B’s thread.
  4. Allow Resolver B to process its queued tasks. The timeout will trigger, executing ResolveAsyncWaiterPromisesTask on the shared isolate, which resolves the promise and queues the reaction microtask.
  5. Resolver B executes a proxy resolution request, causing V8 to drain the microtask queue. This executes the callback, which invokes AlertCallback and accesses the freed Context memory.

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.

View on issue tracker