← All reports

[3] GradientRendererCG: process-wide LRU cache mutated from multiple threads

MediumWebCore CoreGraphics gradient rendererRace

It shipped as a hit-rate fix, on eight cache slots of live CoreGraphics handles.

d6dbb47

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-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.

Wrap the cache in WTF::ThreadSpecific so 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

#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 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.

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.

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.