CVE-2026-9948
Overview
Background
- Tracking rects / `NSToolTip`
- When a tooltip is registered, AppKit allocates an internal
NSToolTipstruct and calls the view’saddTrackingRect:owner:userData:override; the view caches the owner (_trackingRectOwner) and the AppKit-supplieduserDatapointer (_trackingRectUserData). - `_trackingRectUserData`
- A raw pointer into AppKit’s
NSToolTipstruct. It was declaredraw_ptr<void, DanglingUntriaged>— i.e. a known-dangling-tolerated raw pointer whose dangling state was explicitly exempted from the dangling-pointer detector. - Add/remove symmetry
- The remove overrides (
removeTrackingRect:,_removeTrackingRects:count:) cleared_trackingRectOwnerbut not_trackingRectUserData, breaking the invariant that the cached pointer is valid only while a tracking rect exists.
Root Cause Analysis
The violated invariant is that _trackingRectUserData must point at a live NSToolTip struct only for as long as the matching tracking rect is registered. The add overrides set _trackingRectUserData = data; the remove overrides reset _trackingRectOwner = nil but left _trackingRectUserData holding its old value. When a view is detached from its window and the tooltip/tracking rect is removed, AppKit frees the NSToolTip struct, yet _trackingRectUserData still pointed at that freed memory — a dangling raw pointer. The DanglingUntriaged annotation deliberately suppressed detection of exactly this state. On a later tooltip update (setToolTipAtMousePoint:), because self.window is nil AppKit skips re-creating the tracking rect (so addTrackingRect: does not refresh _trackingRectUserData), and the view’s _sendToolTipMouseEntered/_sendToolTipMouseExited paths construct a synthetic NSEvent from the stale _trackingRectUserData and hand it to NSToolTipManager, which dereferences the freed struct — a use-after-free in the browser process.
The patch makes removal clear the pointer: in removeTrackingRect: and _removeTrackingRects:count: it now sets _trackingRectUserData = nullptr; alongside _trackingRectOwner = nil;, restoring add/remove symmetry so no stale pointer survives tooltip teardown. Correspondingly the member’s type is tightened from raw_ptr<void, DanglingUntriaged> to raw_ptr<void> — the dangling exemption is removed because the pointer is now always cleared on removal, so the dangling-pointer detector will catch any regression. The remaining hunks are non-functional cleanup (DCHECK→DCHECK_EQ, pointer-style formatting, comment/Radar-link updates, brace style). The advisory’s UAF class is substantiated by the diff: the fix is exactly the “clear the dangling pointer on detach” change. (The advisory also suggested extra nil-checks in the _sendToolTip* paths; the diff does not add those, relying solely on clearing the pointer.)
NSToolTip pointer but the remove path cleared only the owner, leaving _trackingRectUserData dangling after the struct was freed — and a DanglingUntriaged annotation hid that dangling pointer from detection until a later tooltip update dereferenced it.Attack Path
- Compromised renderer Attacker has code execution in a renderer and can drive tooltip-update IPCs to the browser (precondition per advisory; IPC plumbing not in the diff — (inferred)).
- Register a tooltip
Cause a normal tooltip on a
ToolTipBaseView/RenderWidgetHostViewCocoa, so AppKit allocates theNSToolTipstruct and_trackingRectUserDatais cached. - Detach the view
Trigger window detachment (tab tearing / window close) so
self.windowbecomes nil; the tracking rect is removed and theNSToolTipstruct freed, but_trackingRectUserDataremains dangling. - Send a malicious update
From the renderer send
SetTooltipText/UpdateTooltipUnderCursorwith a non-empty string, routed tosetToolTipAtMousePoint:. Because the window is nil, AppKit does not refresh the stale pointer. - Dereference freed memory
_sendToolTipMouseEntered/_sendToolTipMouseExitedbuilds anNSEventfrom the dangling_trackingRectUserDataand passes it toNSToolTipManager, which reads the freedNSToolTip— the UAF. - Reclaim and escape (inferred)
Since
NSToolTiplives in a macOS system malloc zone outside PartitionAlloc-Everywhere, BackupRefPtr does not protect it; the attacker sprays the browser heap to control the reallocated memory and escalate to RCE in the browser process. Heap control and RCE are plausible exploitation, not shown by the diff.
Impact Assessment
NSToolTip struct via a raw pointer not cleared on detach. The gained primitive is a dangling-pointer dereference in the browser process; escalation to RCE / sandbox escape is (inferred), contingent on controlling the reallocated system-zone allocation, which the diff does not demonstrate. The removed DanglingUntriaged annotation indicates the dangling state had previously been accepted as benign, understating the risk.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifui/base/cocoa/tool_tip_base_view.mm |
modified | |
forui/base/cocoa/tool_tip_base_view.mm |
modified |
Files Changed
ui/base/cocoa/tool_tip_base_view.mm
Audit Directions
- `DanglingUntriaged` raw_ptr inventoryTreat every
raw_ptr<..., DanglingUntriaged>as a latent UAF; audit add/clear symmetry for each, since the annotation suppresses the very detection that would catch this class. - AppKit tracking-rect overridesReview other Cocoa views overriding
addTrackingRect:/removeTrackingRect:/_removeTrackingRects:(WebKit-derived code especially) for cached-pointer lifetime mismatches. - System-zone allocations and BRP gapsFlag cached pointers into AppKit/system-malloc-zone structs; PartitionAlloc-Everywhere does not cover them, so their UAFs are unprotected in the browser process.
- Detached-window tooltip/IPC pathsAudit the
setToolTipAtMousePoint:/_sendToolTipMouseEntered/_sendToolTipMouseExitedwindow-number safety checks for other states where a nil/zero window bypasses validation (e.g. Top Chrome WebUI bridge checks that only require the app to be active).
Patch
From c24bb170bc654b5db62de638e93a37dc38a900c0 Mon Sep 17 00:00:00 2001
From: Avi Drissman <avi@chromium.org>
Date: Tue, 19 May 2026 14:25:54 -0700
Subject: [PATCH] Clear danging pointers
When a tool tip is detached, clear out _trackingRectUserData.
Fixed: 503790201
Change-Id: I1d24cf8bd81cdb390397903f8df76e596a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7858673
Reviewed-by: Bryan Oltman <bryanoltman@google.com>
Auto-Submit: Avi Drissman <avi@chromium.org>
Commit-Queue: Bryan Oltman <bryanoltman@google.com>
Cr-Commit-Position: refs/heads/main@{#1633109}
---
diff --git a/ui/base/cocoa/tool_tip_base_view.mm b/ui/base/cocoa/tool_tip_base_view.mm
index dfe52a1a..a041eb2 100644
--- a/ui/base/cocoa/tool_tip_base_view.mm
+++ b/ui/base/cocoa/tool_tip_base_view.mm
@@ -5,11 +5,13 @@
#import "ui/base/cocoa/tool_tip_base_view.h"
#include "base/check.h"
+#include "base/check_op.h"
#include "base/compiler_specific.h"
#include "base/memory/raw_ptr.h"
#include "base/notreached.h"
// Below is the nasty tooltip stuff -- copied from WebKit's WebHTMLView.mm
+// https://github.com/WebKit/WebKit/blob/main/Source/WebKitLegacy/mac/WebView/WebHTMLView.mm
// with minor modifications for code style and commenting.
//
// The 'public' interface is -setToolTipAtMousePoint:. This differs from
@@ -54,7 +56,7 @@
@implementation ToolTipBaseView {
// These are part of the magic tooltip code from WebKit's WebHTMLView:
id __weak _trackingRectOwner;
- raw_ptr<void, DanglingUntriaged> _trackingRectUserData;
+ raw_ptr<void> _trackingRectUserData;
NSTrackingRectTag _lastToolTipTag;
NSString* __strong _toolTip;
}
@@ -67,37 +69,37 @@
// See above for rationale.
- (NSTrackingRectTag)addTrackingRect:(NSRect)rect
owner:(id)owner
- userData:(void *)data
+ userData:(void*)data
assumeInside:(BOOL)assumeInside {
- DCHECK(_trackingRectOwner == nil);
+ DCHECK_EQ(_trackingRectOwner, nil);
_trackingRectOwner = owner;
_trackingRectUserData = data;
return kTrackingRectTag;
}
-// Override of (apparently) a private NSView method(!) See above for rationale.
+// Override of a private NSView method. See above for rationale.
- (NSTrackingRectTag)_addTrackingRect:(NSRect)rect
owner:(id)owner
- userData:(void *)data
+ userData:(void*)data
assumeInside:(BOOL)assumeInside
useTrackingNum:(int)tag {
DCHECK(tag == 0 || tag == kTrackingRectTag);
- DCHECK(_trackingRectOwner == nil);
+ DCHECK_EQ(_trackingRectOwner, nil);
_trackingRectOwner = owner;
_trackingRectUserData = data;
return kTrackingRectTag;
}
-// Override of (apparently) a private NSView method(!) See above for rationale.
-- (void)_addTrackingRects:(NSRect *)rects
+// Override of a private NSView method. See above for rationale.
+- (void)_addTrackingRects:(NSRect*)rects
owner:(id)owner
- userDataList:(void **)userDataList
- assumeInsideList:(BOOL *)assumeInsideList
- trackingNums:(NSTrackingRectTag *)trackingNums
+ userDataList:(void**)userDataList
+ assumeInsideList:(BOOL*)assumeInsideList
+ trackingNums:(NSTrackingRectTag*)trackingNums
count:(int)count {
- DCHECK(count == 1);
+ DCHECK_EQ(count, 1);
DCHECK(trackingNums[0] == 0 || trackingNums[0] == kTrackingRectTag);
- DCHECK(_trackingRectOwner == nil);
+ DCHECK_EQ(_trackingRectOwner, nil);
_trackingRectOwner = owner;
_trackingRectUserData = userDataList[0];
trackingNums[0] = kTrackingRectTag;
@@ -106,11 +108,13 @@
// Override of a public NSView method, replacing the inherited functionality.
// See above for rationale.
- (void)removeTrackingRect:(NSTrackingRectTag)tag {
- if (tag == 0)
+ if (tag == 0) {
return;
+ }
if (tag == kTrackingRectTag) {
_trackingRectOwner = nil;
+ _trackingRectUserData = nullptr;
return;
}
@@ -121,19 +125,21 @@
}
// If any other tracking rect is being removed, we don't know how it was
- // created and it's possible there's a leak involved (see Radar 3500217).
+ // created and it's possible there's a leak involved (see rdar://3500217).
NOTREACHED();
}
-// Override of (apparently) a private NSView method(!)
-- (void)_removeTrackingRects:(NSTrackingRectTag *)tags count:(int)count {
+// Override of a private NSView method.
+- (void)_removeTrackingRects:(NSTrackingRectTag*)tags count:(int)count {
for (int i = 0; i < count; ++i) {
// SAFETY: count provided by caller.
NSTrackingRectTag tag = UNSAFE_BUFFERS(tags[i]);
- if (tag == 0)
+ if (tag == 0) {
continue;
- DCHECK(tag == kTrackingRectTag);
+ }
+ DCHECK_EQ(tag, kTrackingRectTag);
_trackingRectOwner = nil;
+ _trackingRectUserData = nullptr;
}
}
@@ -160,7 +166,7 @@
NSInteger windowNumber = self.window.windowNumber;
// Only send a fake mouse enter if the mouse is actually over the window,
- // versus over a window which overlaps it (see http://crbug.com/883269).
+ // versus over a window which overlaps it (see https://crbug.com/40092440).
if ([NSWindow windowNumberAtPoint:NSEvent.mouseLocation
belowWindowWithWindowNumber:0] != windowNumber) {
return;
@@ -207,7 +213,7 @@
// Sets the view's current tooltip, to be displayed at the current mouse
// location. (This does not make the tooltip appear -- as usual, it only
// appears after a delay.) Pass null to remove the tooltip.
-- (void)setToolTipAtMousePoint:(NSString *)string {
+- (void)setToolTipAtMousePoint:(NSString*)string {
NSString* toolTip = string.length == 0 ? nil : string;
if ((toolTip && _toolTip && [toolTip isEqualToString:_toolTip]) ||
(!toolTip && !_toolTip)) {
@@ -229,11 +235,11 @@
//
// By moving the call to -removeAllTooltips outside of the conditional,
// we can ensure any visible tooltip will be removed from the screen.
- // See crbug.com/1409942.
+ // See https://crbug.com/40889407.
//
// The strategy of removing all tooltips rather than the single one that
// was added comes from WebKit, like the rest of the code here. It
- // apparently works around some AppKit bug.
+ // apparently works around some AppKit bug, the same rdar://3500217 as above.
[self removeAllToolTips];
if (toolTip) {
NSRect wideOpenRect = NSMakeRect(-100000, -100000, 200000, 200000);
@@ -245,10 +251,10 @@
}
// NSView calls this to get the text when displaying the tooltip.
-- (NSString *)view:(NSView *)view
- stringForToolTip:(NSToolTipTag)tag
- point:(NSPoint)point
- userData:(void *)data {
+- (NSString*)view:(NSView*)view
+ stringForToolTip:(NSToolTipTag)tag
+ point:(NSPoint)point
+ userData:(void*)data {
return [_toolTip copy];
}
Original Bug Report
Potential Sandbox Escape via UAF in macOS ToolTipBaseView
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 go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A Use-After-Free (UAF) in macOS tooltips can potentially be exploited by a compromised renderer. When a view is detached, tooltip cleanup fails to clear an internal pointer, which can later be dereferenced when the renderer sends a malicious tooltip update.
Affected files:
ui/base/cocoa/tool_tip_base_view.mm
Estimated timestamp from git blame: 2015-05-19
Description
A Use-After-Free (UAF) vulnerability exists in ui/base/cocoa/tool_tip_base_view.mm on macOS. ToolTipBaseView caches a raw pointer (_trackingRectUserData) to an internal AppKit NSToolTip struct. When a view is detached from its window and the tooltip is removed, the _trackingRectUserData pointer is not cleared, leaving it dangling. A compromised renderer can force the dereference of this dangling pointer by sending subsequent tooltip update IPC messages, potentially leading to a Sandbox Escape in the Browser process.
Technical Details
- Allocation: When a standard tooltip is displayed, AppKit allocates an
NSToolTipstruct and calls the overriddenaddTrackingRect:...method onToolTipBaseView(or a subclass likeRenderWidgetHostViewCocoa). Chromium caches a pointer to this struct in_trackingRectUserData. - Detachment: The view is detached from its
NSWindow(e.g., during tab tearing or window closure), soself.windowbecomesnil. - Removal: The tooltip is removed (either automatically or triggered by
setToolTipAtMousePoint:). AppKit calls the overriddenremoveTrackingRect:or_removeTrackingRects:count:. These methods correctly set_trackingRectOwnertonilbut fail to clear_trackingRectUserData. AppKit then frees theNSToolTipmemory, leaving_trackingRectUserDatadangling. - Malicious Update: A compromised renderer sends a
SetTooltipText(orUpdateTooltipUnderCursor) IPC message with a non-empty string. (Note: For some views, like Top Chrome WebUI, the bridge check only requires the app to be active, not the window to be valid). - Execution Path: The browser routes this to
-[ToolTipBaseView setToolTipAtMousePoint:].- It first calls
[self removeAllToolTips]. - It then attempts to add the new tooltip. Because
self.windowisnil, AppKit defers tracking rect creation and bypassesaddTrackingRect:, leaving the dangling_trackingRectUserDatauntouched. - It then calls
_sendToolTipMouseEntered. If the mouse is over the desktop, a safety check (windowNumberAtPoint) evaluates to 0, matchingself.window.windowNumber(which is also 0), and bypasses the check. Otherwise, the attacker can send a second IPC update, triggering_sendToolTipMouseExited, which completely lacks a window check.
- It first calls
- UAF: Both paths construct a fake
NSEventusing the dangling_trackingRectUserDataand dispatch it toNSToolTipManager. AppKit dereferences the freed memory. - Exploitation: Because
NSToolTipis an AppKit struct, it is allocated in a macOS system malloc zone. PartitionAlloc-Everywhere (PA-E) only intercepts the default zone, meaning this allocation is likely unprotected by BackupRefPtr (BRP). An attacker controlling the renderer can spray the browser heap to control the reallocated memory, leading to RCE in the Browser process.
Suggested Fix
Ensure _trackingRectUserData is explicitly cleared alongside _trackingRectOwner.
// ui/base/cocoa/tool_tip_base_view.mm
- (void)removeTrackingRect:(NSTrackingRectTag)tag {
...
if (tag == kTrackingRectTag) {
_trackingRectOwner = nil;
_trackingRectUserData = nullptr; // FIX
return;
}
...
}
- (void)_removeTrackingRects:(NSTrackingRectTag *)tags count:(int)count {
...
DCHECK(tag == kTrackingRectTag);
_trackingRectOwner = nil;
_trackingRectUserData = nullptr; // FIX
}
}
Additionally, add robust nil-checks for self.window and _trackingRectUserData in _sendToolTipMouseEntered and _sendToolTipMouseExited.
Evaluated with Chrome root at commit: c1eba8ce379f5d218a2948fafbb5dd72cfa30529
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.
Raised in root component due to access or custom field issues on 1974931