[3] Cross-thread Font refcount race on display-list transfer
A recorded font stays safe on one thread — until a canvas hands it to two.
High. Non-atomic refcount에 대한 single-owner 원칙이 깨졌습니다. buffer transfer 이후 해당 객체가 서로 다른 스레드에서 동작하는 두 개의 rendering backend에서 동시에 도달 가능해졌기 때문입니다. 이 refcount race에서 이기면 GPU process 내에서 UAF가 발생합니다. 다만 controlled crash를 넘어서는 확장은 해제된 Font slot을 heap-grooming으로 재사용해야 가능하며, 이번 변경 자체가 공격자에게 그 수단을 제공하지는 않습니다.
WebKit GPU-process rendering pipeline에서 cross-thread reference counting은 보통 single-owner 원칙으로 안전하게 유지됩니다. non-atomic refcount를 사용하는 객체는 그것을 생성한 스레드에서만 다뤄진다는 원칙입니다. 하나의 WebContent client에 대한 rendering IPC를 처리하는 GPU-process 객체인 RemoteRenderingBackend는 각각 자신의 work queue를 소유하며, 그 큐 전용 스레드에서 drawing command를 실행합니다. 나중에 재생되는 drawing Item 시퀀스를 기록한 DisplayList 기반 ImageBuffer는 backend 사이에서 전달될 수 있지만, DrawGlyphs item이 참조하는 WebCore::Font는 single-thread RefCounted로 만들어져 있어 자신을 생성한 스레드에서만 안전합니다.
관전 포인트: canvas rendering을 구동하는 페이지가 display-list buffer를 두 개의 GPU-process backend 스레드 사이에서 옮기면, 공유된 Font에 대한 ref/deref가 race하게 됩니다. 그 결과 살아있는 reference가 남아있는 상태에서 Font가 해제될 수 있습니다. GPU process 내부에서 발생하는 use-after-free이며, GPU process는 graphics resource에 관한 한 WebContent보다 더 높은 권한을 갖습니다.
WebCore::Font는 thread-safe하지 않은 RefCounted를 사용합니다. DrawGlyphs display-list item은 Ref<const Font>를 보유하며, DisplayList 기반 ImageBuffer는 다른 RemoteRenderingBackend로 전달될 수 있는데, 이 backend는 자신만의 work-queue 스레드에서 동작합니다. 전달 이후에는 두 스레드가 동일한 Font에 대해 각각 ref와 deref를 실행하게 되고, non-atomic refcount가 race하면서 use-after-free로 이어집니다.
Font를 ThreadSafeRefCounted로 만들려면 single-thread weak pointer를 포기해야 합니다. 예를 들어 GlyphPage는 ownership cycle을 끊기 위해 자신의 Font를 weak하게 다시 가리키는데, 이를 strong reference로 바꾸면 Font의 lifetime이 달라져 더 이상 font를 purge할 수 없게 됩니다. 대신 이번 패치는 display list가 전달될 때, 참조된 각 Font를 재구성에 필요한 데이터(FontInternalAttributes와 FontPlatformData)로 대체하는 방식을 택했습니다. source 스레드에서 이 데이터로 치환한 뒤, destination 스레드에서 다시 조립하며, 중첩된 DrawDisplayList item에 대해서도 재귀적으로 처리합니다. Font는 자신을 생성한 스레드를 single-thread weak pointer에 stamp하기 때문에, destination에서 재조립하도록 하면 각 Font가 생성·사용·소멸 모두 하나의 스레드 안에서만 이루어지게 됩니다. 이와 별도로, ImageBufferDisplayListBackend::copyNativeImage는 main-thread 전용인 ControlFactory::singleton() 대신 backend 자신의 ControlFactory를 통해 재생하도록 바뀌었습니다.
Source/WebCore/platform/graphics/displaylists/DisplayListItems.h
Source/WebKit/GPUProcess/graphics/RemoteRenderingBackend.cpp
Source/WebCore/platform/graphics/FontCustomPlatformData.h
LayoutTests/ipc/move-to-image-buffer-cross-thread-font-crash.html
Patch Details
이번 변경은 display-list item 내부에 Font를 저장하는 방식을 다시 짜고, 그 재작성 hook을 backend transfer 메서드 전체에 연결하며, 두 개의 보조 타입을 thread-safe refcounting으로 승격시킵니다. DrawGlyphs::m_font는 Ref<const Font>에서 Variant<Ref<const Font>, FontRebuildData>로 바뀌는데, FontRebuildData는 Font를 재구성하는 데 필요한 FontInternalAttributes와 FontPlatformData를 묶은 구조체입니다. 여기에 새로운 연산 두 가지가 추가되었습니다. replaceFontWithRebuildData()는 공유된 Ref를 버리고 재구성 데이터를 저장하며, rebuildFont()는 Font::create(...)를 호출합니다.
이 연산들은 호출 스택 위쪽으로 연결됩니다. DisplayListRecorderImpl.cpp에 새로 추가된 static helper replaceFontsWithRebuildDataInItems/rebuildFontsInItems가 중첩된 DrawDisplayList item을 재귀적으로 순회하며, 이 기능은 RecorderImpl, ImageBufferDisplayListBackend, ImageBufferBackend, ImageBuffer를 통해 노출됩니다. RemoteRenderingBackend::moveToSerializedBuffer에서는 source 스레드가 hasOneRef()를 assert한 뒤 imageBuffer->replaceFontsWithRebuildData()를 호출하며, moveToImageBuffer에서는 destination 스레드가 imageBuffer->rebuildFonts()를 호출합니다.
마지막으로, 재구성 데이터가 스레드 경계를 안전하게 넘나들 수 있도록 FontCustomPlatformData와 (Skia에서는) SkiaHarfBuzzFont가 ThreadSafeRefCounted로 바뀌었고, ImageBufferDisplayListBackend::copyNativeImage는 backend 자신의 m_controlFactory를 통해 재생하도록 변경되었습니다.
Thread-safe하지 않은 reference-counted 객체를 스레드 경계 너머로 공유해서, 동시에 일어나는 refcount 변경이 race하며 아직 살아있는 객체를 해제시키는 패턴.
Background
Non-atomic refcounting과 thread-safe refcounting의 차이.
RefCounted<T>는 동기화 없이 일반 정수를 증감시키며, 모든 ref/deref가 하나의 스레드에서 일어날 때만 안전합니다. 반면 ThreadSafeRefCounted<T>는 atomic 연산을 사용합니다. Ref<T>/RefPtr<T>는 생성·소멸 시점에 T의 refcount를 관리하는 smart pointer입니다.
RemoteRenderingBackend와 그 스레드.
RemoteRenderingBackend는 WebContent process로부터의 rendering IPC를 처리하는 GPU-process 객체로, 인스턴스마다 자신의 work queue를 소유하고 그 큐의 전용 스레드에서 drawing command를 실행합니다. 이렇게 인스턴스별로 스레드가 나뉘어 있기 때문에, 한 backend에서 안전한 객체라고 해서 다른 backend에서도 자동으로 안전한 것은 아닙니다.
DisplayList와 DrawGlyphs.
display list는 나중에 재생되는 drawing Item 시퀀스를 기록한 것이며, DrawGlyphs는 텍스트를 그리는 item으로 사용된 Font에 대한 reference를 보유합니다.
ImageBuffer transfer.
MoveToSerializedBuffer는 ImageBuffer를 한 backend에서 분리해 serialized handle로 만들고, MoveToImageBuffer는 이를 다른 backend에서 — 경우에 따라 다른 스레드에서 — 다시 조립합니다.
GPU-process font cache.
CacheFont/CacheFontCustomPlatformData는 identifier를 key로 Font를 등록하며, 그 결과 여러 buffer에 걸친 여러 display-list item이 동일한 Font 인스턴스를 참조하게 됩니다. 바로 이 공유 구조 때문에 cross-thread move가 위험해집니다.
Analysis
이 버그는 non-atomic reference count에 대한 data race가 일으키는 use-after-free입니다.
Backend A thread (retained buffer) Backend B thread (moved buffer)
────────────────────────────────── ──────────────────────────────────
DrawGlyphs -> Font::ref() ──┐
│ CopyNativeImage replays list
│ DrawGlyphs -> Font::deref()
│ (non-atomic, interleaves with A)
Font::deref() ───────────────┘
refcount hits 0 mid-flight
~Font() frees the Font
next DrawGlyphs on A ──────► use freed Font <- UAF
하나의 Font 인스턴스가 GPU-process font cache를 통해 여러 display-list item에서 공유됩니다. 그래서 어떤 ImageBuffer 내부 display list가 참조하는 Font가 두 번째 backend로 이동한 뒤에도, 원래 backend에 남아있는 다른 ImageBuffer가 여전히 같은 Font를 참조하는 상황이 만들어질 수 있습니다. 전달이 끝나면 destination 스레드(copyNativeImage → drawGlyphs → Font ref/deref 경로로 이동된 buffer를 재생)와 source 스레드(같은 Font로 여전히 glyph를 그리는 중)가 동시에 Font의 non-atomic refcount를 변경합니다. 이 race에서 increment나 decrement 하나가 유실될 수 있고, 그 결과 살아있는 reference가 남아있는 상태에서 count가 0에 도달해 Font가 해제되거나, 다른 스레드가 한창 작업 중일 때 destructor가 실행될 수 있습니다.
regression test는 이 상황을 의도적으로 재현합니다. Font 하나를 캐시에 등록하고, backend A의 두 display-list buffer에 이를 참조하는 DrawGlyphs를 기록한 뒤, 그중 하나를 backend B로 옮깁니다. 이후 0x1000번 반복되는 tight loop 안에서 B에 대해 CopyNativeImage를 호출해(옮겨진 list를 재생) 동시에 A에 남은 buffer에 대해 DrawGlyphs를 호출함으로써, 두 스레드를 모두 공유된 non-atomic refcount로 몰아넣습니다. 이 test는 IPC 수준에서 최소한의 형태로 구성되어 있는데, display-list transfer 경로의 threading model을 추론해서 만든 재현 코드로 읽히기도 하고, Font refcount에 대한 TSan hit에서 비롯되었을 가능성도 동등하게 존재합니다.
race에서 이기면 GPU process 안에서 Font 객체에 대한 use-after-free를 얻게 되며, 이는 buffer-transfer IPC를 통해 WebContent에서 도달 가능합니다. controlled crash를 넘어 이를 무기화하려면, 공격자가 heap grooming을 통해 해제된 Font slot을 재확보해야 합니다. 즉 해제 직후 비슷한 크기의 객체를 다수 allocate해서, 재사용된 allocation이 참조될 때 dangling reference가 공격자가 영향을 미칠 수 있는 메모리를 가리키게 만드는 방식입니다. 이러한 확장은 Font의 tzone allocation에 대한 heap-spray 성공 여부와, 재사용된 allocation에 대한 제어 정도에 좌우되는 조건부 시나리오입니다. 이 primitive는 그 자체로 sandbox 안에 있는 GPU process에서 발생하므로, code execution까지 이어지는 전체 chain을 완성하려면 더 폭넓은 compromise를 위한 추가적인 escape가 필요합니다.
이 vulnerability는 GPU process 내부의 memory safety를 약화시킵니다. RefCounted 객체는 하나의 스레드에서만 다뤄진다는 모델상의 전제가 깨지면서, race 상황에서 refcount invariant가 무너질 수 있고 그 결과 여전히 참조 중인 Font가 해제될 수 있습니다.
이번 fix에서 선택된 방향 자체가 흥미로운 지점입니다. Font를 ThreadSafeRefCounted로 승격시키는 대신 — 그럴 경우 GlyphPage와의 single-thread weak-pointer 기반 cycle-breaking이 깨지고 font-purging semantics도 달라지게 됩니다 — 패치는 각 Font를 여전히 single-thread로 유지하면서 경계를 넘을 때 serialize하고 재조립하는 방식을 택했습니다. transferable display list에 담긴 객체 중 thread-safe refcounting이 아닌 것은 무엇이든 같은 버그의 후보가 됩니다.
Audit directions
- 스레드 경계를 넘어 이동할 수 있는 값에 담긴, thread-safe하지 않은 reference-counted 객체. invariant: 객체의 refcount 방식(atomic인지 non-atomic인지)은 그 객체를 참조할 수 있는 모든 스레드와 일치해야 합니다. 좁혀서 보면:
DisplayListItems.h에 있는 다른DisplayList::Itemvariant들 중 WebCore graphics 객체(image, gradient, filter, native image)에 대한Ref/RefPtr를 보유한 것들을 나열하고, 각각이 참조하는 타입이ThreadSafeRefCounted인지 확인해야 합니다. display list 전체가MoveToSerializedBuffer/MoveToImageBuffer로 이동 가능하다는 점을 감안하면 더욱 그렇습니다. match tell:Item멤버가Ref<Foo>/RefPtr<Foo>타입인데Foo가 일반RefCounted를 상속하는 경우입니다. - 다른 스레드를 위해 만들어진 객체를 주고받는, 인스턴스마다 work-queue를 갖는 서비스. invariant: 독립적으로 스레드가 나뉜 두 backend 사이에서 전달되는 것은 thread-safe refcounting을 갖거나, 받는 쪽 스레드에서 재구성되어야 합니다. 좁혀서 보면:
RemoteRenderingBackend의 transfer/sink 경로(moveToImageBuffer,moveToSerializedBuffer,sinkIntoImageBuffer,transferToNewContext)를 점검해서, 전달 이후에도 계속 공유 상태로 남는 다른 멤버가 있는지 확인해야 합니다. 넓혀서 보면: 살아있는 객체 그래프를 process/thread work queue 경계 너머로 serialize하는 다른 서브시스템에서도 같은 형태가 나타날 수 있습니다 —RemoteResourceCachehandoff,RemoteDisplayListRecorder, canvas/WebGL command buffer 등. 가장 넓게 보면: refcount atomicity가 맞지 않는 실행 컨텍스트 사이의 ownership handoff라는 일반적인 클래스이며, intrusive refcounting을 혼용하는 어떤 C++ 코드베이스에도 적용됩니다(Chromium의base::RefCountedvsRefCountedThreadSafe, 스레드를 벗어나는 Rust의RcvsArc등). match tell: 다른 스레드로 옮겨지는 값인데, 그 값이 transitively 참조하는 멤버들이 single-thread refcounting을 사용하는 경우입니다. - main-thread singleton을 참조하는 off-main-thread 코드. invariant: backend work queue 위에서 실행되는 코드는 main thread를 전제하는
::singleton()accessor가 아니라 backend별 state를 사용해야 합니다. 좁혀서 보면: GPU-process display-list replay 경로에서RemoteRenderingBackendwork queue로부터 도달 가능한ControlFactory::singleton()이나 다른::singleton()호출을 검색해야 합니다(이번 패치는 이미copyNativeImage를 고쳐m_controlFactory를 쓰도록 만들었습니다). match tell: main run loop가 아니라 work-queue 스레드에서 transitively 호출되는 메서드 안에::singleton()/sharedFoo()호출이 있는 경우입니다.