CVE-2026-9887
Overview
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
Patch
From 69cbd069c58bc2d118605ef776a1570365d5c143 Mon Sep 17 00:00:00 2001 From: Kenichi Ishibashi <[email protected]> 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 <[email protected]> Commit-Queue: Kenichi Ishibashi <[email protected]> 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.