← All issues

[4] TimingFunction reference-count race across the scrolling thread

An easing curve that two threads reference-counted by hand — until a lost increment drops the count to zero while a live reference is still using it.

Severity: Medium | Component: WebCore platform animation | c541bf0

Rated Medium because the diff converts a refcount that is provably touched from two threads to atomic, closing a data race that can drive the count to zero while a logical reference is live; reaching a usable UAF requires reliably winning the race on a renderer-reachable heap object, and the object's narrow attacker-controlled state keeps the immediate outcome a crash rather than a confirmed corruption primitive.

Because accelerated effects may be accessed from both the main thread and the scrolling thread on macOS, TimingFunction should use ThreadSafeRefCounted like the other ref-counted types used by AcceleratedEffect and AcceleratedEffectValues. The crash surfaced under ASan on an existing threaded-animations layout test; the commit notes the fix was suggested by an LLM during bug analysis and validated by the author.

Source/WebCore/platform/animation/TimingFunction.h

-#include <wtf/RefCounted.h>
+#include <wtf/ThreadSafeRefCounted.h>
...
-class TimingFunction : public RefCounted<TimingFunction> {
+class TimingFunction : public ThreadSafeRefCounted<TimingFunction> {

LayoutTests/webanimations/threaded-animations/timing-function-threading-check.html

+ const easing = "cubic-bezier(0.1, 0.7, 1.0, 0.1)";
+ const animations = [ target.animate(...{ duration, easing }), ... ];
+ await Promise.all(animations.map(animation => animationAcceleration(animation)));
+ // Both paths call AnimationEffectTiming::resolve() which protects the
+ // effect's TimingFunction; this must not trip the RefCounted threading check.
+ for (let i = 0; i < 50; ++i)
+ await UIHelper.remoteAnimationStackForElement(target);

The patch changes the base class of TimingFunction from RefCounted<TimingFunction> to ThreadSafeRefCounted<TimingFunction>, swapping the include accordingly. No logic in transformProgress, clone, or the subclasses (LinearTimingFunction, CubicBezierTimingFunction, StepsTimingFunction, SpringTimingFunction) is altered. The added test creates four accelerated animations with a cubic-bezier easing and repeatedly resolves the animation stack on the main thread while the scrolling thread concurrently applies effects, asserting the RefCounted threading check is not tripped.

Non-atomic reference counting on an object shared across threads, allowing the refcount to be corrupted by a data race.

RefCounted<T> is WTF's single-threaded reference-counted base: ref()/deref() mutate the count non-atomically and, in assertion-enabled builds, carry a thread-ownership check that traps if the count is touched from a thread other than the owner. ThreadSafeRefCounted<T> is the atomic counterpart, using atomic read-modify-write on the count so concurrent ref/deref from multiple threads is safe. Threaded (accelerated) animations on macOS run a copy of the animation effect data on the scrolling thread so scroll-driven and time-driven animations can be resolved without the main thread; AcceleratedEffect/AcceleratedEffectValues hold the effect's TimingFunction. TimingFunction::transformProgress() maps a linear progress value through the easing curve (e.g. cubic-bezier) and is called both from the main-thread keyframe interpolation path and the scrolling-thread scroll-animation path, each taking a transient ref on the shared TimingFunction for the duration of the call.

This is a data race on a non-thread-safe reference count, leading to use-after-free / double-free. Before the fix, TimingFunction derived from RefCounted, whose ref()/deref() perform plain non-atomic increments/decrements and embed a thread-ownership assertion. On macOS, accelerated animations make the same TimingFunction instance reachable from two threads simultaneously: the main thread resolves timing via AnimationEffectTiming::resolve() / KeyframeInterpolation while the scrolling thread applies effects via ScrollAnimationSmooth, with both contexts taking a transient RefPtr/protect() ref before calling transformProgress. When both threads take and drop these transient refs concurrently, the non-atomic read-modify-write races.

Two concurrent deref() operations can read the same count value and each decrement to the same result, losing one decrement so the object leaks; in the inverse interleaving, a lost increment lets the count hit zero while another thread still holds a logical reference, freeing the TimingFunction while it is being used (transformProgress is const but is invoked on a possibly-freed object). The debug RefCounted threading-check assertion fires precisely because deref/ref happen off the owning thread — which is what the ASan-detected test crash surfaced.

This vulnerability weakens memory safety within the WebContent process by violating the implicit invariant that a TimingFunction's lifetime is governed by a consistent reference count. Before the fix, an object shared between the main thread and the scrolling thread could have its refcount corrupted, so the assumption "a live, referenced object is not freed" could be broken under concurrent access. If an attacker reliably wins the race so the count prematurely reaches zero, they could obtain a use-after-free on a heap object reachable from web content — a building block toward renderer memory corruption.

This is a recurring WebKit pattern: a type originally designed as single-threaded (RefCounted) gets adopted by a later-added cross-thread subsystem (threaded animations) without auditing whether all of its dependencies are thread-safe. AcceleratedEffect/AcceleratedEffectValues already used ThreadSafeRefCounted, but the leaf dependency TimingFunction was missed — the fix is essentially "make the whole reachable object graph thread-safe."

Note: The transient-ref behavior of KeyframeInterpolation.cpp and ScrollAnimationSmooth.cpp, the existing thread-safe status of the parent effect classes, and the macOS-specific scrolling-thread interleaving are inferred from the caller context and test commentary rather than the diff itself. The non-atomic-refcount root cause and the fix are directly supported by the patch.