CVE-2025-31238
Overview
Impact:
Description: Processing maliciously crafted web content may lead to memory corruption
Researchers: wac working with Trend Micro Zero Day Initiative
Key insight. A C++ object (ScrollerMac) was referenced by AppKit-owned Objective-C helpers (delegate and animations) whose lifetimes it did not control, via raw back-pointers; re-attaching the scroller without invalidating the old delegate left those helpers dangling. The fix pairs lifetime-checked CheckedPtr back-pointers with an explicit invalidate() on delegate replacement.
Patch Details
diff --git a/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txt b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txt
new file mode 100644
index 000000000000..f374b04343a9
--- /dev/null
+++ b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txt
@@ -0,0 +1,2 @@
+This test passes if it doesn't crash
+
diff --git a/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html
new file mode 100644
index 000000000000..abf3e8f800c8
--- /dev/null
+++ b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+
+<style>
+ *:root {margin-right: 100%;}
+ *:last-child {direction: rtl;}
+</style>
+
+<script>
+ function main() {
+ ele1.scrollIntoViewIfNeeded();
+ document.onpointerlockerror = func;
+ ele1.requestPointerLock();
+ ele2.submit();
+ if (window.testRunner)
+ testRunner.dumpAsText();
+ }
+
+ function func() {
+ document.styleSheets[0].media.appendMedium("Sample");
+ }
+
+</script>
+<body onload="main()">
+This test passes if it doesn't crash
+<time id="ele1" onfocusin="main()">
+</time>
+<form id="ele2">
+</form>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/Source/WebCore/page/scrolling/mac/ScrollerMac.h b/Source/WebCore/page/scrolling/mac/ScrollerMac.h
index 42db17fb11e1..49cf880f9ab3 100644
--- a/Source/WebCore/page/scrolling/mac/ScrollerMac.h
+++ b/Source/WebCore/page/scrolling/mac/ScrollerMac.h
@@ -40,7 +40,9 @@ namespace WebCore {
class FloatPoint;
class ScrollerPairMac;
-class ScrollerMac {
+class ScrollerMac final : public CanMakeCheckedPtr<ScrollerMac> {
+ WTF_MAKE_FAST_ALLOCATED;
+ WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR(ScrollerMac);
friend class ScrollerPairMac;
public:
ScrollerMac(ScrollerPairMac&, ScrollbarOrientation);
diff --git a/Source/WebCore/page/scrolling/mac/ScrollerMac.mm b/Source/WebCore/page/scrolling/mac/ScrollerMac.mm
index bd2f3e02e9e5..765e3522b3a2 100644
--- a/Source/WebCore/page/scrolling/mac/ScrollerMac.mm
+++ b/Source/WebCore/page/scrolling/mac/ScrollerMac.mm
@@ -47,7 +47,7 @@ enum class FeatureToAnimate {
};
@interface WebScrollbarPartAnimationMac : NSAnimation {
- WebCore::ScrollerMac* _scroller;
+ CheckedPtr<WebCore::ScrollerMac> _scroller;
FeatureToAnimate _featureToAnimate;
CGFloat _startValue;
CGFloat _endValue;
@@ -129,7 +129,7 @@ enum class FeatureToAnimate {
@end
@interface WebScrollerImpDelegateMac : NSObject<NSAnimationDelegate, NSScrollerImpDelegate> {
- WebCore::ScrollerMac* _scroller;
+ CheckedPtr<WebCore::ScrollerMac> _scroller;
RetainPtr<WebScrollbarPartAnimationMac> _knobAlphaAnimation;
RetainPtr<WebScrollbarPartAnimationMac> _trackAlphaAnimation;
@@ -223,7 +223,7 @@ enum class FeatureToAnimate {
RetainPtr scrollerImp = _scroller->scrollerImp();
CGFloat currentAlpha = featureToAnimate == FeatureToAnimate::KnobAlpha ? [scrollerImp.get() knobAlpha] : [scrollerImp.get() trackAlpha];
- scrollbarPartAnimation = adoptNS([[WebScrollbarPartAnimationMac alloc] initWithScroller:_scroller
+ scrollbarPartAnimation = adoptNS([[WebScrollbarPartAnimationMac alloc] initWithScroller:_scroller.get()
featureToAnimate:featureToAnimate
animateFrom:currentAlpha
animateTo:newAlpha
@@ -261,7 +261,7 @@ enum class FeatureToAnimate {
[scrollerImp setUiStateTransitionProgress:1 - [scrollerImp uiStateTransitionProgress]];
if (!_uiStateTransitionAnimation) {
- _uiStateTransitionAnimation = adoptNS([[WebScrollbarPartAnimationMac alloc] initWithScroller:_scroller
+ _uiStateTransitionAnimation = adoptNS([[WebScrollbarPartAnimationMac alloc] initWithScroller:_scroller.get()
featureToAnimate:FeatureToAnimate::UIStateTransition
animateFrom:[scrollerImp uiStateTransitionProgress]
animateTo:1.0
@@ -286,7 +286,7 @@ enum class FeatureToAnimate {
[scrollerImp setExpansionTransitionProgress:1 - [scrollerImp expansionTransitionProgress]];
if (!_expansionTransitionAnimation) {
- _expansionTransitionAnimation = adoptNS([[WebScrollbarPartAnimationMac alloc] initWithScroller:_scroller
+ _expansionTransitionAnimation = adoptNS([[WebScrollbarPartAnimationMac alloc] initWithScroller:_scroller.get()
featureToAnimate:FeatureToAnimate::ExpansionTransition
animateFrom:[scrollerImp expansionTransitionProgress]
animateTo:1.0
@@ -334,6 +334,7 @@ ScrollerMac::~ScrollerMac()
void ScrollerMac::attach()
{
RefPtr pair = m_pair.get();
+ [m_scrollerImpDelegate invalidate];
m_scrollerImpDelegate = adoptNS([[WebScrollerImpDelegateMac alloc] initWithScroller:this]);
setScrollerImp([NSScrollerImp scrollerImpWithStyle:nsScrollerStyle(pair->scrollbarStyle()) controlSize:nsControlSizeFromScrollbarWidth(pair->scrollbarWidthStyle()) horizontal:m_orientation == ScrollbarOrientation::Horizontal replacingScrollerImp:nil]);
[m_scrollerImp setDelegate:m_scrollerImpDelegate.get()];
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2025-31238 |
| Bugzilla | 289653 |
| Component | WebCore |
| Bug Class | UAF |
| Severity | medium |
| Commit | a23df0dfbec0c9df… |
| Advisory | Apple Advisory |
Terminology
- ScrollerMac — The WebCore C++ object representing a single macOS scrollbar (one orientation), owning the platform NSScrollerImp and its Objective-C delegate.
- CheckedPtr / CanMakeCheckedPtr — A WebKit smart-pointer scheme where the pointee counts outstanding CheckedPtrs and aborts (rather than silently continuing) if it is destroyed while references remain or if a dangling pointer is used, turning UAFs into detectable crashes.
- NSScrollerImpDelegate / NSAnimation retention — AppKit objects: an NSScrollerImp retains its delegate and running NSAnimations retain themselves, so these can outlive the WebCore object that created them if not explicitly invalidated.
- adoptNS — A WebKit RAII helper that takes ownership of a +1-retained Objective-C object into a RetainPtr, tying its release to C++ scope.
- invalidate (delegate teardown) — A method that severs a delegate’s back-references and stops its animations so lingering AppKit callbacks no longer touch the (soon-to-be-freed) C++ owner.
Root Cause Analysis
On macOS, scrollbar appearance animations are driven by AppKit through two Objective-C helper classes, WebScrollbarPartAnimationMac (an NSAnimation subclass) and WebScrollerImpDelegateMac (an NSScrollerImpDelegate), both of which held a raw C++ back-pointer ‘WebCore::ScrollerMac* _scroller’. These Objective-C objects have lifetimes controlled by AppKit and by pending animations: an NSScrollerImp retains its delegate, and running animations retain their animation objects, so they can outlive the C++ ScrollerMac that owns them. ScrollerMac::attach() re-creates the delegate: it did ’m_scrollerImpDelegate = adoptNS([[WebScrollerImpDelegateMac alloc] initWithScroller:this])’ and installed it on a fresh NSScrollerImp. If attach() ran while a previous delegate (and/or its part animations) was still alive and still referencing the old ScrollerMac, or if the ScrollerMac was subsequently destroyed while an AppKit-retained delegate/animation lingered, later NSScrollerImpDelegate/NSAnimation callbacks would dereference the stale raw _scroller pointer — a use-after-free. The bundled LayoutTest reaches this by churning scrollbar/scroller state during layout and focus/pointer-lock/form activity (‘scrollIntoViewIfNeeded’, ‘requestPointerLock’, form ‘submit’, and an appendMedium() style mutation), which can tear down and re-attach scrollers while animations/delegates are outstanding. The fix has two parts. First, ScrollerMac is made ‘final’ and derives from CanMakeCheckedPtr<ScrollerMac> with WTF_MAKE_FAST_ALLOCATED and WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR(ScrollerMac), and the two Objective-C classes’ raw ‘ScrollerMac* _scroller’ members become ‘CheckedPtrWebCore::ScrollerMac _scroller’ (with call sites updated to pass _scroller.get()). CheckedPtr participates in ScrollerMac’s checked-pointer accounting so that a dangling access is detected/aborted rather than silently reading freed memory, and the overridden delete enforces that no CheckedPtrs outlive the object. Second, ScrollerMac::attach() now calls ‘[m_scrollerImpDelegate invalidate]’ before replacing the delegate, so the outgoing delegate is explicitly torn down (its reference to the scroller cleared and its animations stopped/detached) instead of being left alive holding a pointer to a scroller that is being re-attached. Together these restore the invariant that no AppKit-retained delegate or animation ever dereferences a ScrollerMac after that scroller has stopped owning it. The exact body of WebScrollerImpDelegateMac’s invalidate method and the animation callbacks are not shown in the diff, so the precise clearing steps invalidate performs are inferred from the surrounding pattern.
Attack Path
1. Create scrollable content with custom scrollbars
Load a page that produces overlay/animated scrollbars on macOS (the test uses root margin and rtl direction to force scrollbar geometry), instantiating ScrollerMac objects with WebScrollerImpDelegateMac delegates and part animations.
2. Provoke scroller re-attach
Trigger operations that rebuild scroller state while animations/delegates are in flight — the test uses scrollIntoViewIfNeeded(), requestPointerLock() with an error handler, a form submit(), and a stylesheet media appendMedium() mutation — so ScrollerMac::attach() runs (pre-patch, without invalidating the old delegate).
3. Outlive the C++ owner
Because AppKit’s NSScrollerImp retains the delegate and running NSAnimations retain themselves, a delegate/animation persists after its ScrollerMac is freed, leaving a stale raw _scroller pointer.
4. Fire a stale callback
A subsequent AppKit animation tick or NSScrollerImpDelegate callback dereferences the freed ScrollerMac (e.g. calling _scroller->scrollerImp()), reading and acting on freed heap memory — the use-after-free.
5. (Background, separate) escalate the UAF
Standard exploitation would groom the heap to reclaim the freed ScrollerMac allocation with attacker-controlled data before the stale callback fires, turning the dangling dereference into a controlled read/write toward code execution; this is generic technique, not something the commit itself demonstrates.
Impact Assessment
The bug is a use-after-free of a heap-allocated ScrollerMac reachable from AppKit-retained scrollbar delegates/animations, giving a dangling-object dereference that fires from asynchronous animation/delegate callbacks — a strong UAF primitive because the reclamation window (a running or pending animation) is attacker-influenceable. It lives in the WebContent process’s rendering/scrolling code and is therefore confined to the WebContent sandbox, matching the ‘memory corruption’ description; realistic escalation is heap-grooming the freed slot to gain a controlled read/write toward code execution within that process, though the checked-pointer conversion means post-patch such accesses abort as controlled crashes rather than corrupting memory. On its own it does not cross the sandbox boundary.
Audit Directions
Other raw back-pointers in ScrollerMac.mm
Verify every _scroller / m_pair / owner reference in ScrollerMac.mm and ScrollerPairMac is now a CheckedPtr/WeakPtr and that all replace-and-reinstall sites (like attach()) invalidate the prior delegate before reassigning m_scrollerImpDelegate / m_scrollerImp.
Objective-C delegates holding raw C++ pointers
Grep the mac scrolling/rendering code for Objective-C @interface ivars typed as bare ‘WebCore::Something*’ (especially NSAnimation, NSScrollerImpDelegate, CALayer delegate, and NSObject subclasses stored via adoptNS) and check each has a matching invalidate-on-teardown and a checked/weak pointer.
adoptNS reassignment without invalidate
Search for ’m_…Delegate = adoptNS(’ or similar delegate/animation reassignments that are not preceded by an [oldObject invalidate] or setDelegate:nil, since a replaced-but-still-retained AppKit object with a back-pointer is the exact pattern fixed here.
CanMakeCheckedPtr adoption gaps
Look for WebCore classes referenced from platform Objective-C callback objects that do NOT yet derive from CanMakeCheckedPtr/CanMakeWeakPtr (no WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR), as these remain candidates for the same cross-language lifetime UAF.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
ScrollerMac (class declaration) |
Source/WebCore/page/scrolling/mac/ScrollerMac.h |
modified | Made ‘final’, now inherits CanMakeCheckedPtr<ScrollerMac>, and adds WTF_MAKE_FAST_ALLOCATED and WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR(ScrollerMac) so the object supports checked-pointer lifetime accounting and detects dangling references. |
WebScrollbarPartAnimationMac (ivar _scroller) |
Source/WebCore/page/scrolling/mac/ScrollerMac.mm |
modified | Changed the raw ‘WebCore::ScrollerMac* _scroller’ instance variable to ‘CheckedPtr<WebCore::ScrollerMac> _scroller’ so an animation callback can no longer silently use a freed scroller. |
WebScrollerImpDelegateMac (ivar _scroller) |
Source/WebCore/page/scrolling/mac/ScrollerMac.mm |
modified | Changed the raw ‘WebCore::ScrollerMac* _scroller’ instance variable to ‘CheckedPtr<WebCore::ScrollerMac> _scroller’; call sites that construct WebScrollbarPartAnimationMac now pass _scroller.get(). |
ScrollerMac::attach |
Source/WebCore/page/scrolling/mac/ScrollerMac.mm |
modified | Added ‘[m_scrollerImpDelegate invalidate]’ before allocating and installing a new WebScrollerImpDelegateMac, so the previous delegate (and its animations) is torn down and no longer references the scroller being re-attached. |
Files Changed
Source Files
Source/WebCore/page/scrolling/mac/ScrollerMac.hSource/WebCore/page/scrolling/mac/ScrollerMac.mm
Test Files
LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txtLayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txt b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txt
new file mode 100644
index 000000000000..f374b04343a9
--- /dev/null
+++ b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash-expected.txt
@@ -0,0 +1,2 @@
+This test passes if it doesn't crash
+
diff --git a/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html
new file mode 100644
index 000000000000..abf3e8f800c8
--- /dev/null
+++ b/LayoutTests/fast/scrolling/mac/scrollbars/scrollbar-crash.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+
+<style>
+ *:root {margin-right: 100%;}
+ *:last-child {direction: rtl;}
+</style>
+
+<script>
+ function main() {
+ ele1.scrollIntoViewIfNeeded();
+ document.onpointerlockerror = func;
+ ele1.requestPointerLock();
+ ele2.submit();
+ if (window.testRunner)
+ testRunner.dumpAsText();
+ }
+
+ function func() {
+ document.styleSheets[0].media.appendMedium("Sample");
+ }
+
+</script>
+<body onload="main()">
+This test passes if it doesn't crash
+<time id="ele1" onfocusin="main()">
+</time>
+<form id="ele2">
+</form>
+</body>
+
+</html>
\ No newline at end of file