[3] GradientRendererCG: process-wide LRU cache mutated from multiple threads
It shipped as a hit-rate fix, on eight cache slots of live CoreGraphics handles.
Medium. A function-local static cache became a process-wide shared mutable structure the moment graphics work started running off the main thread, and its values are refcounted CoreGraphics handles whose eviction can drop the last reference under a concurrent reader. Escalation past a crash depends on TinyLRUCache's internal slot layout, which decides whether a torn read yields a stale handle or a garbage one.
A small fixed-size cache declared as a function-local static is one of the most common shapes in graphics code: it looks like a local optimization but has process lifetime and process-wide reach. GradientRendererCG is the WebCore object that builds CoreGraphics gradient handles for CSS, SVG, and canvas gradients, and its sampling path memoizes the built handle so repeated renders of the same gradient skip a rebuild. The invariant a cache like this depends on is that either it carries internal synchronization or every caller reaches it from one thread.
The angle: web content that drives concurrent gradient sampling can race an eviction against a reader on the same cache slot, dropping the last reference to a CoreGraphics gradient handle that another thread is still consuming inside the renderer or GPU process.
From the commit message:
GradientRendererCG::makeGradientBySampling()uses a static 8-entryTinyLRUCacheshared 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 repeatedCGGradientRefrebuilds.Wrap the cache in
WTF::ThreadSpecificso each thread gets its own 8-entry LRU. The per-thread working set fits cleanly in 8 entries, so hit rates recover without growing the cache size.
Source/WebCore/platform/graphics/cg/GradientRendererCG.cpp
Patch Details
The static cache inside GradientRendererCG::makeGradientBySampling is wrapped in a WTF::ThreadSpecific<>, changing its type from NeverDestroyed<TinyLRUCache<SampledGradientCacheKey, RetainPtr<CGGradientRef>, 8>> to NeverDestroyed<ThreadSpecific<TinyLRUCache<...>>>. The accessor changes accordingly from cache.get().get(...) to cache.get()->get(...), because ThreadSpecific<T> yields a T* proxy for the calling thread rather than the object itself. <wtf/ThreadSpecific.h> is added to the includes. Cache capacity is unchanged at 8 entries — the change is one-cache-per-process to one-cache-per-thread. No locking is introduced and no other code path is touched.
Unsynchronized concurrent mutation of a shared static LRU cache holding refcounted handles.
Background
Where this lives.
GradientRendererCG sits in WebCore's CoreGraphics platform layer and builds CGGradientRef objects for CSS, SVG, and canvas gradients. Its sampling path — makeGradientBySampling — is taken whenever the interpolation color space is non-sRGB or any color component is none, and is reached from makeGradient on whichever thread is currently doing graphics work: the main thread, display-list threads in the GPU process, or off-main-thread rasterizers and image decoders.
TinyLRUCache.
TinyLRUCache<K, V, N> is a small fixed-capacity least-recently-used cache stored in place, with N = 8 here. Its get(key) is not a pure read: on a hit it promotes the entry to most-recently-used, and on a miss it calls the policy's createValueForKey to materialize a value, inserts it, and evicts the least-recently-used slot. Both outcomes mutate the internal slot array and its ordering metadata.
NeverDestroyed and ThreadSpecific.
NeverDestroyed<T> is WebKit's pattern for a function-local static constructed on first use and deliberately never destructed — it sidesteps static-destruction-order problems at process exit. WTF::ThreadSpecific<T> is WebKit's portable wrapper over pthread/Win32 thread-local storage; operator->() returns a pointer to the calling thread's instance, lazily constructed on that thread's first access. Wrapping a type in ThreadSpecific converts a single shared instance into one instance per thread.
RetainPtr over CoreFoundation handles.
RetainPtr<CGGradientRef> calls CGGradientRetain/CGGradientRelease on assignment and destruction. Assigning over a live RetainPtr releases the prior value, and that release can be the last one.
Analysis
The commit frames the symptom as cache thrashing, but the property it changes is sharing of a mutable structure across threads without synchronization.
Thread A (main) Thread B (display list)
────────────────── ────────────────────────
cache.get(keyA)
miss → createValueForKey
write slot[3] ──┐
│ cache.get(keyB)
│ miss → evict LRU == slot[3]
│ RetainPtr<CGGradientRef> overwritten
│ └─► CGGradientRelease (last ref?)
┌───────────────┘
└─► read slot[3] / hold raw CGGradientRef
└─► consumes a released handle ← UAF candidate
Both columns call the same get(). TinyLRUCache exposes no internal synchronization and the call site holds no lock, so the two threads mutate the same slot array and the same ordering metadata concurrently. Two distinct hazards fall out. The first is structural: a slot being written by thread A can be observed mid-write by thread B, so the LRU's own bookkeeping can be read torn. The second is the lifetime hazard shown above: eviction assigns over a RetainPtr<CGGradientRef>, releasing the prior handle, and if another thread holds a raw CGGradientRef derived from that same slot the release can be the last reference.
The chosen mitigation is informative about the constraints. ThreadSpecific gives each thread its own 8-entry LRU, making every access single-threaded by construction — the race is removed rather than serialized, and the thrashing is fixed as a side effect since threads no longer evict each other. A lock would have fixed the race but not the hit-rate collapse, and would have added contention on a hot rendering path. Choosing per-thread storage over locking suggests either that the path is hot enough for locking to regress measurably, or that no safe locking discipline was retrofittable to this call site.
Reachability from content is straightforward at the sampling path's own precondition: any page can request a gradient with a non-sRGB interpolation method or with none color components, which is exactly what routes makeGradient into makeGradientBySampling. Driving that from multiple threads simultaneously depends on which rendering configuration is active — main-thread painting alone would not produce concurrency, while GPU-process display-list rendering and off-main-thread rasterization would. Whether a racing eviction can be steered into a controlled outcome rather than a crash depends on TinyLRUCache's internal layout, which decides what a torn slot read yields; that determination is not settled by this change.
This vulnerability weakens the memory-safety boundary inside the renderer / GPU process by allowing concurrent unsynchronized mutation of a shared cache whose values are refcounted CoreGraphics handles. The security model assumes data structures shared across threads either carry internal synchronization or are confined to a single thread; that invariant was violated for the sampled-gradient cache. An attacker able to drive concurrent gradient sampling from web content could plausibly induce a use-after-free or refcount imbalance on a CoreGraphics object, with the consequence ranging from a controlled crash to a heap-corruption primitive depending on the cache's internal layout.
Insight: this is a textbook function-local static cache anti-pattern. A NeverDestroyed<TinyLRUCache<...>> declared inside a non-static member function reads as a benign optimization under single-threaded review, and silently becomes a process-wide shared mutable structure as soon as the surrounding subsystem grows multi-thread call sites — with no diff, no review, and no code change marking the transition. Function-local statics inside graphics, layout, and image-decoding paths are a high-yield audit target precisely because the bug is introduced by other commits.
Audit directions
- Function-local
static NeverDestroyed<...>caches reachable from multiple threads without synchronization. Grep WebCore forstatic NeverDestroyed<followed byTinyLRUCache,HashMap,HashSet, orLRUCacheinside non-static member functions, then check whether the enclosing function is invoked off the main thread (graphics, image decoding, font/shaping, layout workers). Start withSource/WebCore/platform/graphics/andSource/WebCore/platform/image-decoders/. The review tell is short:static NeverDestroyed<inside a member function body, with no lock in the same scope. - Shared caches whose values are CoreGraphics / CoreFoundation
RetainPtrs, where eviction drops the last reference. Audit anyTinyLRUCache<K, RetainPtr<...>>andHashMap<K, RetainPtr<...>>in graphics code for thread-safety: a race between eviction-release and a concurrent reader of the same slot is a UAF candidate even when the cache itself looks "small and safe". GrepRetainPtr<CG,RetainPtr<CF,RetainPtr<CTinside cache types underSource/WebCore/platform/graphics/cg/andSource/WebCore/platform/graphics/cocoa/. - Per-thread caches as a stand-in for missing synchronization. Review other uses of
WTF::ThreadSpecificin WebKit to see whether each one is paired with an underlying mutable structure that was previously shared; those sites document, by their git history, prior races that may still have variants elsewhere. Rungit log -S 'ThreadSpecific' -- Source/WebCore/platform/graphics/and inspect the prior implementations. - Investigate
TinyLRUCacheitself for documented thread-safety expectations. If the template carries no contract, every existing instantiation across WebKit is an audit candidate; enumerate them withgrep -rn 'TinyLRUCache<' Source/and verify each call site is either single-threaded or externally locked.