← 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. 함수 지역 static cache가 그래픽 작업이 main thread 밖에서 실행되기 시작한 순간 process 전체가 공유하는 mutable 구조체로 바뀌었습니다. 이 cache의 값은 refcounted CoreGraphics handle이며, eviction이 동시 reader 아래에서 마지막 reference를 떨어뜨릴 수 있습니다. crash를 넘어선 확장 가능성은 TinyLRUCache의 내부 slot layout에 달려 있는데, 이 layout이 torn read가 stale handle을 만들어내는지 garbage handle을 만들어내는지를 결정합니다.

함수 지역 static으로 선언된 작은 고정 크기 cache는 그래픽 코드에서 가장 흔한 형태 중 하나입니다. 겉보기엔 지역 최적화처럼 보이지만 실제로는 process 수명을 가지며 process 전체에 도달 범위를 갖습니다. GradientRendererCG는 CSS, SVG, canvas gradient를 위한 CoreGraphics gradient handle을 만드는 WebCore 객체이며, sampling 경로는 만들어진 handle을 memoize하여 같은 gradient를 반복해서 렌더링할 때 재구성을 건너뛰도록 합니다. 이런 cache가 의존하는 invariant는, 내부적으로 synchronization을 갖추고 있거나 모든 caller가 하나의 thread에서만 접근한다는 조건입니다.

관전 포인트: 동시에 gradient sampling을 유발하는 web content라면 같은 cache slot에서 eviction과 reader 간 race를 일으킬 수 있고, renderer나 GPU process 내부에서 다른 thread가 아직 사용 중인 CoreGraphics gradient handle의 마지막 reference를 떨어뜨릴 수 있습니다.

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) };
}

GradientRendererCG::makeGradientBySampling 내부의 static cache가 WTF::ThreadSpecific<>로 감싸지면서, 타입이 NeverDestroyed<TinyLRUCache<SampledGradientCacheKey, RetainPtr<CGGradientRef>, 8>>에서 NeverDestroyed<ThreadSpecific<TinyLRUCache<...>>>로 바뀌었습니다. 이에 맞춰 접근 방식도 cache.get().get(...)에서 cache.get()->get(...)으로 변경되었습니다. ThreadSpecific<T>가 객체 자체가 아니라 호출한 thread에 대한 T* proxy를 반환하기 때문입니다. <wtf/ThreadSpecific.h>가 include 목록에 추가되었습니다. cache 용량은 8 entry로 그대로 유지되며, 변경 내용은 process당 하나의 cache에서 thread당 하나의 cache로 바뀐 것뿐입니다. locking은 도입되지 않았고 다른 code path도 건드리지 않았습니다.

refcounted handle을 담고 있는 공유 static LRU cache에 대한 unsynchronized 동시 mutation.

Where this lives. GradientRendererCG는 WebCore의 CoreGraphics platform layer에 위치하며 CSS, SVG, canvas gradient를 위한 CGGradientRef 객체를 생성합니다. sampling 경로인 makeGradientBySampling은 interpolation color space가 non-sRGB이거나 color component 중 하나라도 none일 때 taken되며, makeGradient로부터 현재 그래픽 작업을 수행 중인 thread — main thread, GPU process의 display-list thread, 혹은 off-main-thread rasterizer와 image decoder — 어디에서든 도달할 수 있습니다.

TinyLRUCache. TinyLRUCache<K, V, N>는 in-place로 저장되는 작은 고정 용량의 least-recently-used cache이며, 여기서는 N = 8입니다. get(key)는 순수한 read가 아닙니다. hit이면 해당 entry를 most-recently-used로 승격시키고, miss이면 policy의 createValueForKey를 호출해 값을 생성한 뒤 삽입하고 least-recently-used slot을 evict합니다. 두 경우 모두 내부 slot 배열과 순서 metadata를 mutate합니다.

NeverDestroyedThreadSpecific. NeverDestroyed<T>는 첫 사용 시 생성되고 의도적으로 절대 destruct되지 않는, WebKit의 함수 지역 static 패턴입니다. process 종료 시점의 static destruction 순서 문제를 우회하기 위한 것입니다. WTF::ThreadSpecific<T>는 pthread/Win32 thread-local storage를 감싼 WebKit의 이식 가능한 wrapper이며, operator->()는 호출한 thread의 instance에 대한 pointer를 반환합니다. 이 instance는 해당 thread의 첫 접근 시점에 lazy하게 생성됩니다. 어떤 타입을 ThreadSpecific로 감싸면 하나의 공유 instance가 thread별 instance로 바뀌게 됩니다.

RetainPtr over CoreFoundation handles. RetainPtr<CGGradientRef>는 대입과 소멸 시점에 CGGradientRetain/CGGradientRelease를 호출합니다. 살아있는 RetainPtr에 새 값을 대입하면 이전 값이 release되며, 이 release가 마지막 release가 될 수 있습니다.

commit은 증상을 cache thrashing으로 설명하고 있지만, 실제로 변경된 속성은 synchronization 없이 여러 thread가 mutable 구조체를 공유하던 방식 그 자체입니다.

  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

두 column 모두 같은 get()을 호출합니다. TinyLRUCache는 내부 synchronization을 전혀 제공하지 않고 call site 역시 lock을 걸지 않으므로, 두 thread가 같은 slot 배열과 같은 순서 metadata를 동시에 mutate하게 됩니다. 여기서 두 가지 서로 다른 hazard가 발생합니다. 첫 번째는 구조적인 문제입니다. thread A가 쓰고 있는 slot을 thread B가 쓰기 도중 상태로 관찰할 수 있어, LRU 자체의 bookkeeping이 torn 상태로 읽힐 수 있습니다. 두 번째는 위에서 보인 lifetime hazard입니다. eviction은 RetainPtr<CGGradientRef>에 새 값을 대입하면서 이전 handle을 release하는데, 다른 thread가 같은 slot에서 파생된 raw CGGradientRef를 들고 있다면 이 release가 마지막 reference에 대한 release가 될 수 있습니다.

선택된 대응 방식은 제약 조건을 짐작하게 해줍니다. ThreadSpecific는 각 thread에 자신만의 8-entry LRU를 부여하여 모든 접근을 구조적으로 single-threaded하게 만듭니다. race를 serialize한 것이 아니라 아예 제거한 것이며, thread끼리 서로의 entry를 evict하지 않게 되므로 thrashing 문제도 부수적으로 해결됩니다. lock을 사용했다면 race는 해결되었겠지만 hit-rate collapse는 해결되지 않았을 것이고, hot한 rendering 경로에 contention을 추가했을 것입니다. locking 대신 thread별 storage를 선택했다는 사실은, 이 경로가 locking을 적용하면 측정 가능한 수준으로 성능이 저하될 만큼 hot하거나, 혹은 이 call site에 안전하게 적용할 수 있는 locking 방식이 마땅치 않았음을 시사합니다.

content로부터의 reachability는 sampling 경로 자체의 precondition만으로도 충분히 straightforward합니다. 어떤 페이지든 non-sRGB interpolation method를 사용하는 gradient를 요청하거나 color component에 none을 지정하는 것만으로 makeGradientmakeGradientBySampling으로 라우팅되기 때문입니다. 이를 여러 thread에서 동시에 유발할 수 있는지는 어떤 rendering 구성이 활성화되어 있는지에 달려 있습니다. main thread painting만으로는 concurrency가 발생하지 않는 반면, GPU process의 display-list rendering이나 off-main-thread rasterization이 개입하면 발생할 수 있습니다. race가 발생한 eviction을 crash가 아니라 제어된 결과로 유도할 수 있는지는 TinyLRUCache의 내부 layout에 달려 있습니다. 이 layout이 torn slot read가 무엇을 만들어내는지를 결정하는데, 이 부분은 이번 변경만으로는 확인되지 않습니다.

이 vulnerability는 renderer / GPU process 내부의 memory-safety boundary를 약화시킵니다. 공유 cache에 대해 synchronization 없는 동시 mutation을 허용했기 때문입니다. 원래의 security model은 thread 간 공유되는 자료구조가 내부 synchronization을 갖추고 있거나 단일 thread로 제한된다는 전제를 가지고 있는데, sampled-gradient cache에서는 이 invariant가 깨져 있었습니다. web content로부터 동시 gradient sampling을 유발할 수 있는 attacker라면 CoreGraphics 객체에 대한 use-after-free나 refcount 불균형을 원론적으로 유발할 수 있고, 그 결과는 cache의 내부 layout에 따라 controlled crash 수준에서 heap-corruption primitive 수준까지 이어질 가능성이 있습니다.

Insight: 이 사례는 함수 지역 static cache anti-pattern의 교과서적인 예시에 해당합니다. non-static member 함수 내부에 선언된 NeverDestroyed<TinyLRUCache<...>>는 single-threaded 관점의 review에서는 무해한 최적화처럼 읽히지만, 주변 subsystem이 성장하여 multi-thread call site를 갖게 되는 순간 조용히 process 전체가 공유하는 mutable 구조체로 바뀝니다. 이 전환을 표시하는 diff도, review도, code 변경도 존재하지 않습니다. 그래픽, layout, image-decoding 경로에 있는 함수 지역 static은 높은 수확을 기대할 수 있는 audit 대상입니다. 버그가 다른 commit에 의해 도입되는 구조이기 때문입니다.