CVE-2026-9886
Overview
Background
- Pasteboard observation
- macOS has no public notification for
NSPasteboardchanges, so Chromium swizzles (overrides) an internal AppKit pasteboard method to learn when the general pasteboard changes. - Method swizzling / IMP
- replacing an Objective-C method implementation (
IMP) with a block so Chromium’s code runs whenever the internal pasteboard-change method is invoked. - Internal CFPasteboard dispatch queue
- the swizzled method is invoked by AppKit on arbitrary internal threads/queues, not on Chromium’s main thread.
- `GetCallbackList().Notify()`
- runs the registered pasteboard-changed callbacks, which execute non-thread-safe Chromium code.
Root Cause Analysis
In base/mac/pasteboard_changed_observation.mm, the swizzled implementation block (installed within the function at the @@ -77,15 hunk, whose enclosing name is not shown — it is the swizzle-installation routine) called GetCallbackList().Notify() directly from inside the override, which AppKit invokes on “random internal AppKit threads” per the commit message. Only the secondary “dirty the cache” step was hopped to the main thread via dispatch_async; the actual callback notification ran on whatever internal CFPasteboard thread fired, even though the notified Chromium code is not thread-safe. Running that non-thread-safe code concurrently with main-thread activity is a data race, and the advisory classifies the resulting defect as a use-after-free in Base.
The fix wraps both the Notify() call and the cache-dirtying read of NSPasteboard.generalPasteboard.changeCount in a single base::BindOnce task posted via base::SingleThreadTaskRunner::GetMainThreadDefault()->PostTask, so all Chromium processing happens on the main thread. It also adds a CHECK that RegisterPasteboardChangedCallback is called on the main thread and drops the previous per-callback base::BindPostTask(SequencedTaskRunner::GetCurrentDefault(), callback) wrapper, since notification is now already marshaled to the main thread.
The restored invariant is that all pasteboard-change Chromium processing — registration and notification — occurs on the main thread.
GetCallbackList().Notify()) directly on AppKit’s internal pasteboard threads; the fix posts all of that processing to the main thread via GetMainThreadDefault()->PostTask and CHECKs that registration also happens there.Attack Path
- Establish pasteboard observation
Chromium swizzles the internal AppKit pasteboard method and registers callbacks via
RegisterPasteboardChangedCallback; the swizzled override will fire on internal CFPasteboard threads whenever the general pasteboard changes. - Cause pasteboard changes
Any process on the system changing
NSPasteboard.generalPasteboard(including this one) causes AppKit to invoke the swizzled method on an arbitrary internal thread. - Run non-thread-safe code off-main-thread
Pre-patch, the override called
GetCallbackList().Notify()on that internal thread, executing non-thread-safe Chromium callbacks concurrently with main-thread work, producing a data race. - Use-after-free (inferred mechanism)
The concurrent access to non-thread-safe state yields the use-after-free the advisory reports; the diff shows the thread-safety violation but not a specific freed object or downstream primitive, which remain
(inferred).
Impact Assessment
Base. Preconditions are the macOS platform with pasteboard observation active and a pasteboard change (which any process, including Chrome itself, can cause) arriving on an internal thread concurrently with main-thread activity. The diff establishes only the off-thread execution and race; the exact freed object and any exploitation primitive beyond a crash are (inferred), and the advisory does not state in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
imp_implementationWithBlockbase/mac/pasteboard_changed_observation.mm |
modified |
Files Changed
base/mac/pasteboard_changed_observation.hbase/mac/pasteboard_changed_observation.mm
Audit Directions
- Swizzled/overridden system methodsAudit any Objective-C method swizzling that runs Chromium code inside the override, confirming the override marshals to the correct sequence before touching non-thread-safe state, since OS frameworks may call it on arbitrary threads.
- Partial thread hopsFlag code that posts only part of its work to the right thread (here, only the cache-dirty step) while running other callbacks inline; the thread-affinity guarantee must cover every Chromium call in the handler.
- Callback lists invoked from framework threadsReview
CallbackList/observer notifications triggered by OS events (pasteboard, power, network, display changes) to ensureNotify()runs on the sequence the subscribers expect.
Patch
From 4492ca1c2c11ea98174d0433c699adb8d2040b32 Mon Sep 17 00:00:00 2001
From: Avi Drissman <avi@chromium.org>
Date: Tue, 05 May 2026 15:24:05 -0700
Subject: [PATCH] Do pasteboard observation on the main thread
When tracking pasteboard changes, the implementation overrides internal
pasteboard implementation methods that get called on random internal
AppKit threads. Be sure to do all Chromium processing back on the main
thread, as the code called isn't necessarily thread-safe.
Fixed: 508456788
Link: https://chromium-review.googlesource.com/id/I3e494418430189cc821f1c14fd1cfa466a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7806661
Reviewed-by: Mark Mentovai <mark@chromium.org>
Commit-Queue: Avi Drissman <avi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1625756}
---
diff --git a/base/mac/pasteboard_changed_observation.h b/base/mac/pasteboard_changed_observation.h
index bea89ba..2d80ef5 100644
--- a/base/mac/pasteboard_changed_observation.h
+++ b/base/mac/pasteboard_changed_observation.h
@@ -14,6 +14,9 @@
// Registers a callback that will be called whenever the
// NSPasteboard.generalPasteboard has been changed by any process on the system
// (including this one).
+//
+// This registration call must be made on the main thread, and the callback will
+// be done on the main thread.
BASE_EXPORT CallbackListSubscription
RegisterPasteboardChangedCallback(RepeatingClosure callback);
diff --git a/base/mac/pasteboard_changed_observation.mm b/base/mac/pasteboard_changed_observation.mm
index 04c0d35..9868a87 100644
--- a/base/mac/pasteboard_changed_observation.mm
+++ b/base/mac/pasteboard_changed_observation.mm
@@ -11,10 +11,11 @@
#include <string_view>
#include "base/callback_list.h"
+#include "base/functional/bind.h"
#include "base/functional/callback.h"
+#include "base/location.h"
#include "base/no_destructor.h"
-#include "base/task/bind_post_task.h"
-#include "base/task/sequenced_task_runner.h"
+#include "base/task/single_thread_task_runner.h"
// There is no notification API on macOS for changes to the pasteboard (unlike
// on iOS where there is UIPasteboardChangedNotification). However...
@@ -77,15 +78,17 @@
IMP new_imp =
imp_implementationWithBlock(^(id object_self, int change_count) {
- GetCallbackList().Notify();
+ // Hop to the main thread, as the cache is processed on an internal
+ // CFPasteboard dispatch queue.
+ base::SingleThreadTaskRunner::GetMainThreadDefault()->PostTask(
+ FROM_HERE, base::BindOnce([] {
+ GetCallbackList().Notify();
- // Dirty the app's pasteboard cache to ensure an invalidation callback
- // for the next pasteboard change that occurs in other apps. Hop to the
- // main thread, as the cache is processed on an internal CFPasteboard
- // dispatch queue.
- dispatch_async(dispatch_get_main_queue(), ^{
- std::ignore = NSPasteboard.generalPasteboard.changeCount;
- });
+ // Dirty the app's pasteboard cache to ensure an invalidation
+ // callback for the next pasteboard change that occurs in other
+ // apps.
+ std::ignore = NSPasteboard.generalPasteboard.changeCount;
+ }));
g_old_imp(object_self, selector, change_count);
});
@@ -100,6 +103,9 @@
CallbackListSubscription RegisterPasteboardChangedCallback(
RepeatingClosure callback) {
+ CHECK(base::SingleThreadTaskRunner::GetMainThreadDefault()
+ ->BelongsToCurrentThread());
+
static bool swizzle_internal_class [[maybe_unused]] = SwizzleInternalClass();
// Intentionally DCHECK so that in the field it doesn't rely on that specific
// internal class (as listening for pasteboard changes isn't critical), but
@@ -107,8 +113,7 @@
// ever change it will be noticed.
DCHECK(swizzle_internal_class);
- return GetCallbackList().Add(
- base::BindPostTask(SequencedTaskRunner::GetCurrentDefault(), callback));
+ return GetCallbackList().Add(callback);
}
} // namespace base
Original Bug Report
Potential Use-After-Free in Browser via base::mac::PasteboardChangedObserver
Flapjack, 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 (UAF) exists in the Browser process due to a thread-safety violation in base::mac::PasteboardChangedObserver. Concurrent access to an unsynchronized CallbackList from the UI thread and a macOS background dispatch queue corrupts underlying memory. This could allow a full sandbox escape and arbitrary Remote Code Execution (RCE).
Affected files:
base/mac/pasteboard_changed_observation.mm
Estimated timestamp from git blame: 2025-06-23
Conclusion: This report details a potential Use-After-Free (UAF) vulnerability in the Browser process, yielding a highly probable sandbox escape and Remote Code Execution (RCE).
Analysis:
The initial logic and parameters regarding DOM-based clipboard event handling and Mojo IPC propagation to the Browser process are validated. ClipboardHostImpl correctly manages observer registration via base::RegisterPasteboardChangedCallback on the UI thread. Concurrently, macOS system pasteboard invalidations correctly trigger the swizzled _CFPasteboardCache setChangeCount: method on an internal background CFPasteboard dispatch queue.
Leaping directly to the final memory transformation: the concurrent execution of UI thread CancelCallback mutations and background thread Notify() iterations fundamentally corrupts the unsynchronized std::list within CallbackListBase. Because the std::list nodes and scoped_refptr<BindStateBase> utilize RAW_PTR_EXCLUSION, MiraclePtr (BackupRefPtr) is completely bypassed. Dereferencing the dangling iterator grants the attacker direct control over the hijacked polymorphic_invoke_ function pointer upon Run(), enabling instantaneous RCE in the Browser process.
Suggested Steps to Trigger (Theoretical): Note: These are potential steps as our tooling agent does not yet have the ability to run code.
- The attacker script rapidly cycles
navigator.clipboard.addEventListenerandremoveEventListener. - Standard processing applied for propagating listener state to the Browser UI thread.
- Standard processing applied for ambient pasteboard invalidations triggering the macOS background queue.
- Concurrent
std::listdestruction and iteration overlap triggers the UAF, leading to execution hijacking viapolymorphic_invoke_.
Suggested Fix:
Move the GetCallbackList().Notify() call inside the existing dispatch_async(dispatch_get_main_queue(), ...) block in base/mac/pasteboard_changed_observation.mm to ensure all list accesses remain synchronized on the main thread.
Evaluated with Chrome root at commit: cc901875d53bf4e4fe0e01f02843871da4106e70
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.