← All issues

[2] GradientRendererCG: per-thread sampled gradient cache

Severity: Medium | Component: WebCore CoreGraphics gradient renderer | d6dbb47

Rated Medium because the diff removes unsynchronized concurrent mutation of a static LRU cache holding RetainPtr<CGGradientRef> entries, eliminating a race between eviction-driven release and concurrent reads. The race plausibly yields a UAF or refcount imbalance on a CoreGraphics handle reachable from any web content that drives concurrent gradient sampling, though the diff does not establish a concrete control primitive.

GradientRendererCG::makeGradientBySampling() used a static 8-entry TinyLRUCache shared across all threads. When multiple threads sample gradients concurrently, they evict each other's entries from the small cache, collapsing the hit rate and forcing repeated CGGradientRef rebuilds. The fix wraps the cache in WTF::ThreadSpecific so each thread gets its own 8-entry LRU; the per-thread working set fits cleanly in 8 entries, and the shared mutable structure is gone by construction.

Source/WebCore/platform/graphics/cg/GradientRendererCG.cpp

#include "SampledGradientBuilder.h"
#include <pal/spi/cg/CoreGraphicsSPI.h>
#include <wtf/HashMap.h>
+#include <wtf/ThreadSpecific.h>
#include <wtf/TinyLRUCache.h>
...
GradientRendererCG::Gradient GradientRendererCG::makeGradientBySampling(ColorInterpolationMethod colorInterpolationMethod, const GradientColorStops& stops) const
{
auto colorStops = stops.sorted().stops();
- static NeverDestroyed<TinyLRUCache<WTF::SampledGradientCacheKey, RetainPtr<CGGradientRef>, 8>> cache;
- RetainPtr gradient = cache.get().get({ colorInterpolationMethod, colorStops, m_colorSpace });
+ static NeverDestroyed<ThreadSpecific<TinyLRUCache<WTF::SampledGradientCacheKey, RetainPtr<CGGradientRef>, 8>>> cache;
+ RetainPtr gradient = cache.get()->get({ colorInterpolationMethod, colorStops, m_colorSpace });
return Gradient { WTF::move(gradient) };
}

The change is a single-line restructuring of the static cache declaration plus an accessor update. static NeverDestroyed<TinyLRUCache<...>> becomes static NeverDestroyed<ThreadSpecific<TinyLRUCache<...>>>; cache.get().get(...) becomes cache.get()->get(...) because ThreadSpecific<T>::operator->() returns a T* for the current thread's instance, lazily constructed on first access. <wtf/ThreadSpecific.h> is added. No locking is introduced and no other code path is touched.

Unsynchronized concurrent mutation of a shared static LRU cache holding refcounted handles.

GradientRendererCG builds CGGradientRefs for CSS, SVG, and Canvas gradients. The sampling path is used whenever the interpolation color space is non-sRGB or any color component is none, and is invoked from whichever thread is currently executing graphics work — the main thread, scrolling / display-list threads in the GPU process, or off-main-thread image decoders.

TinyLRUCache<K, V, N> is a fixed-capacity (N=8 here) least-recently-used cache stored in-place. get(key) returns the stored value on a hit; on a miss it calls the policy's createValueForKey to materialize a new value, inserts it, and evicts the least-recently-used entry. Both paths mutate the cache: hits update LRU ordering, misses replace a slot. NeverDestroyed<T> is WebKit's pattern for a function-local static constructed on first use and intentionally never destructed. WTF::ThreadSpecific<T> is WebKit's portable wrapper over pthread / Win32 thread-local storage. RetainPtr<CGGradientRef> calls CGGradientRetain / CGGradientRelease on assignment and destruction; assigning over a RetainPtr releases the prior value, which may be the last reference.

The bug is a data race on a shared mutable structure. Before the fix, every thread that called makeGradientBySampling reached the same process-wide TinyLRUCache instance. The commit message frames the symptom as thrashing — cross-thread eviction collapses the hit rate — but the underlying property the fix changes is sharing of a mutable structure across threads without synchronization. The cache exposes no internal locking and the call site holds none.

The concurrent mutation surface has two arms. On the LRU ordering side, threads racing through get() on a hit promote different entries to most-recently-used simultaneously, and the slot array's index-ordering metadata can be written by thread A while thread B is reading it. On the value side, an eviction on thread B drops the last RetainPtr<CGGradientRef> reference for a slot that thread A is still consuming. The window for the second arm is narrow but precise: if thread A has loaded a raw CGGradientRef* from the cache slot and is mid-call into a CoreGraphics routine, an eviction-driven CGGradientRelease on thread B can deallocate the underlying CG object while thread A's pointer is still live. The fact that the cache is small (8 entries) and the working set per thread is also small actually increases the eviction rate, making the race more reachable rather than less.

A WebContent process able to drive concurrent gradient sampling — multiple Canvas2D contexts, SVG documents with animated gradients across multiple compositor threads, or coordinated off-main-thread image decode — can therefore exercise the race from untrusted content. The downstream effect on a successful race is a UAF or refcount imbalance on a CoreGraphics handle inside the renderer or GPU process. The diff does not establish how the freed object's contents are reused — that depends on CoreGraphics' internal allocator placement and on the relative timing of release versus reuse — so the concrete read/write primitive is not directly observable from this patch.

This vulnerability weakens the memory-safety boundary inside the renderer and GPU process by violating the invariant that data structures shared across threads either carry internal synchronization or are confined to a single thread. The mitigation strategy is worth noting: the maintainers chose ThreadSpecific rather than introducing a lock, which suggests either the cache is hot enough that locking would regress performance, or that no safe locking discipline could be retrofitted to this site without rewriting TinyLRUCache itself.

Note: Some specifics — the absence of internal synchronization in TinyLRUCache, the exact set of off-main-thread call sites for makeGradientBySampling, the behavior of ThreadSpecific::operator->() — are inferred from surrounding code patterns rather than directly visible in the diff. The core race condition and its remediation are directly supported by the patch.