← All reports

[5] Complex text path did not retain the system fallbacks it used

MediumWebCore font renderingUAF

The complex text path never learned the rule the simple path follows.

0082e68

Medium입니다. Web에서 도달 가능하고 attacker가 타이밍을 조정할 수 있는 경로이지만, WTF weak pointer는 destruction 시점에 0으로 초기화되기 때문에 확인되는 영향은 stale-pointer read가 아니라 painting 중 발생하는 null-base fault입니다. DoS를 넘어선 확장이 성립하려면, purge 이전에 buffer로부터 raw Font alias를 미리 끌어내는 consumer가 필요합니다.

텍스트 렌더링은 shape된 run을 캐싱해서, 같은 문자열을 반복해서 그릴 때 re-shaping을 건너뛸 수 있도록 합니다. 이때 캐싱된 결과는 reference cycle을 피하기 위해 사용한 font를 약하게만 참조합니다. WebKit은 빠른 "simple" 경로를 사용하거나, 복잡한 script와 heavy kerning이 필요한 경우 ComplexTextController를 거쳐 텍스트를 shape하는데, Apple 플랫폼에서는 이 작업이 Core Text로 넘어갑니다. FontCascade 뒤에 있는 객체인 FontCascadeFonts는 shape된 텍스트 캐시와 함께 HashSet<Ref<Font>>를 갖고 있으며, 이 HashSet의 유일한 목적은 캐싱된 run이 참조하는 system fallback face를 계속 살아있게 유지하는 것입니다. Weak font reference가 안전하게 동작하려면, 캐싱된 glyph buffer에 Font를 기록하는 쪽이 반드시 그에 대한 strong owner도 함께 등록해야 한다는 계약이 지켜져야 합니다.

관전 포인트: 복잡한 script 텍스트를 캐싱된 run으로 렌더링하는 페이지는, 여전히 살아있는 캐싱된 run이 참조 중인 fallback face를 font cache가 회수하도록 유도할 수 있습니다. 이후 원하는 시점에 다시 그리게 만들어 renderer를 fault시킬 수 있습니다.

Commit message는 이 비대칭 구조를 직접적으로 짚습니다.

A GlyphBuffer cached in a FontCascadeFonts shaped text cache references its Fonts through weak pointers (308842@main). The simple text path keeps the system fallbacks it uses alive in FontCascadeFonts::glyphDataForSystemFallback, but the complex text path did not, so a Core Text fallback used only there had a single reference and FontCache::purgeInactiveFontData could destroy it while a cached shaped run still referenced it. Painting that run then dereferenced an expired weak pointer in FontCascade::drawGlyphBuffer.

Source/WebCore/platform/graphics/coretext/ComplexTextControllerCoreText.mm

FontPlatformData runFontPlatformData(runCTFont.get(), CTFontGetSize(runCTFont.get()));
 
- runFont = protect(FontCache::forCurrentThread())->fontForPlatformData(runFontPlatformData).ptr();
+ Ref systemFallbackFont = protect(FontCache::forCurrentThread())->fontForPlatformData(runFontPlatformData);
+ // 이 system fallback을 계속 살아있게 유지합니다. 캐싱된 shaped buffer는 이를 약하게만 참조합니다 (glyphDataForSystemFallback()과 동일한 방식).
+ RefPtr fonts = m_fontCascade->fonts();
+ fonts->addSystemFallbackFont(systemFallbackFont.copyRef());
+ runFont = systemFallbackFont.ptr();

Source/WebCore/platform/graphics/FontCascadeFonts.cpp

// Keep the system fallback fonts we use alive.
if (fallbackGlyphData.isValid())
 
- m_systemFallbackFontSet.add(systemFallbackFont.releaseNonNull());
+ addSystemFallbackFont(systemFallbackFont.releaseNonNull());
...
+void FontCascadeFonts::addSystemFallbackFont(Ref<Font>&& font)
+{
+ m_systemFallbackFontSet.add(WTF::move(font));
+}

Tools/TestWebKitAPI/Tests/WebCore/FontCascade.cpp

+TEST(FontCascadeTest, ComplexTextRetainsSystemFallbackFonts)
+{
+ FontCascadeDescription description;
+ description.setOneFamily("Times"_s);
+ ...
+ // Times가 렌더링할 수 없는 complex-script 문자로, Core Text system fallback을 강제로 유발합니다.
+ static constexpr std::array<char16_t, 8> characters { 0x06D8, 0x092D, 0x0B40, 0x0F96, 0x0DBD, 0x0EAF, 0xA86C, 0x0ACF };
+ ...
+ SingleThreadWeakHashSet<const Font> fallbackFonts;
+ fontCascade.width(run, &fallbackFonts);
+ for (auto& font : fallbackFonts) {
+ hasUsedFallbackFont = true;
+ EXPECT_FALSE(font.hasOneRef());
+ }

FontCascadeFonts.h/.cpp에 public 함수 void FontCascadeFonts::addSystemFallbackFont(Ref<Font>&&)가 추가되었습니다. 이 함수는 기존 HashSet<Ref<Font>> m_systemFallbackFontSet에 값을 삽입합니다. 기존 simple-path 호출 지점인 FontCascadeFonts::glyphDataForSystemFallback에서는 m_systemFallbackFontSet.add(systemFallbackFont.releaseNonNull()) 호출이 addSystemFallbackFont(systemFallbackFont.releaseNonNull())로 변경되었습니다. 동작은 동일한 순수 리팩터링입니다.

실질적인 fix는 ComplexTextController::collectComplexTextRunsForCharacters에 있습니다. 기존에는 Core Text run font를 temporary에서 나온 bare pointer 형태로 만들었습니다 — runFont = protect(FontCache::forCurrentThread())->fontForPlatformData(runFontPlatformData).ptr() — 이 부분이 이번 패치에서 바뀌었습니다. 먼저 Ref systemFallbackFont = ...fontForPlatformData(runFontPlatformData)로 바인딩하고, RefPtr fonts = m_fontCascade->fonts()를 얻은 뒤, fonts->addSystemFallbackFont(systemFallbackFont.copyRef())를 호출해 FontCascadeFonts 객체가 strong reference를 갖도록 합니다. runFont = systemFallbackFont.ptr()은 그 이후에야 설정됩니다. 추가된 API test는 "Times" cascade 아래에서 complex-script 코드 포인트로 이루어진 run을 shaping하고, fallback들을 SingleThreadWeakHashSet<const Font>에 수집한 뒤 각각에 대해 EXPECT_FALSE(font.hasOneRef())를 검증합니다. 즉 FontCache 자신의 entry 외에 다른 owner가 각 fallback을 붙잡고 있는지 확인하는 것입니다.

동일한 동작을 수행하는 두 구현 사이의 비대칭적인 lifetime 처리. 한쪽 경로는 weak cache에 publish하는 리소스를 retain하지만, 다른 경로는 그렇지 않습니다.

Simple vs. complex text path. WebKit은 텍스트를 빠른 "simple" 경로(WidthIterator, 문자 단위 glyph lookup)로 shaping하거나, full shaping이 필요한 run에 대해서는 ComplexTextController를 거칩니다. Apple 플랫폼에서는 이 경로가 Core Text typesetting에 위임됩니다.

System fallback font. 지정된 font family가 특정 code point를 렌더링할 수 없으면, 플랫폼이 대체 face를 선택합니다. Complex 경로에서는 Core Text가 이 cascade 과정을 직접 수행하고 run마다 CTFont를 반환하며, WebKit은 이를 FontCache::fontForPlatformData(FontPlatformData)로 감쌉니다.

Reference types. Ref<T>/RefPtr<T>는 WebKit의 strong reference-counting smart pointer입니다. .ptr()은 ownership을 넘기지 않은 채 raw pointer를 반환합니다. SingleThreadWeakPtr<T>는 zeroing weak pointer로, pointee를 살려두지 않으며 pointee가 파괴되면 이후의 읽기는 null을 반환합니다.

GlyphBuffer. shaping 결과를 담는 구조체로, glyph ID, advance, origin, string offset, per-glyph font reference를 병렬 배열 형태로 보유합니다. font reference는 GlyphBuffer.hVector<SingleThreadWeakPtr<const Font>> m_fonts로 선언되어 있으며, fontAt(index)*m_fonts[index]를 반환합니다.

FontCascadeFonts and the shaped text cache. FontCascadeFontsFontCascade 뒤에 있는 RefCounted 객체로, realized fallback range, glyph-page cache, HashSet<Ref<Font>> m_systemFallbackFontSet("사용 중인 system fallback font를 살려두기 위한 용도"), GlyphGeometryCache, 그리고 TextShapingResultAndDisplayList entry들로 구성된 ShapedTextCache를 보유합니다. FontCascadeFonts::getOrCreateCachedShapedText()는 text run을 key로 shaping 결과를 memoize합니다. ShapedTextCacheDefaults의 튜닝 상수는 Canvas의 fillText/strokeText를 대상으로 문서화되어 있으며, maxTextLength = 128, maxSize = 3000 값을 사용합니다.

FontCache and purging. FontCache::forCurrentThread()FontDataCache(HashMap<FontPlatformData, Ref<Font>>)와 purgeInactiveFontDataIfNeeded를 구동하는 m_purgeTimer를 소유합니다. cache 자신만 보유하고 있는 font는 memory pressure 상황에서 회수 대상으로 의도된 것입니다.

FontCascadeCache. font description과 selector를 key로 FontCascadeFonts entry에 매핑되며, clearShapedTextCaches(), pruneUnreferencedEntries()(fonts.get().hasOneRef()인 entry를 제거), pruneSystemFallbackFonts()(내부적으로 FontCascadeFonts::pruneSystemFallbacks()를 호출)를 제공합니다.

이 버그는 object-lifetime 위반에 해당합니다. cache된 구조체가 weak reference로 리소스를 참조하고 있는데, 이에 대응하는 strong owner가 없는 상태입니다.

  Simple path                        Complex path (pre-fix)
  ───────────                        ──────────────────────
  glyphDataForSystemFallback         collectComplexTextRunsForCharacters
    font = fontForPlatformData(..)     fontForPlatformData(..).ptr()
    m_systemFallbackFontSet.add ─┐        └─► Ref dropped immediately
                                 │
                    strong owner ┘      only owner: FontDataCache entry
                                                       │
                    GlyphBuffer.m_fonts[i] (weak) ──────┘
                                                       │
                       purgeInactiveFontData: hasOneRef ► destroy
                                                       │
                       repaint ► fontAt(i) ► *m_fonts[i]  ← cleared weak

GlyphBuffer.h는 font 배열을 Vector<SingleThreadWeakPtr<const Font>> m_fonts로 선언하고 있습니다. 즉 buffer는 자신의 Font들을 weak하게만 참조합니다. commit message는 이 설계를 이전 revision인 308842@main에서 비롯된 것으로 설명합니다. 이 구조가 만들어내는 invariant는, 어떤 Font를 cache된 GlyphBuffer에 넣는 쪽이라면 그 cache entry가 살아있는 동안 해당 Font를 붙잡아둘 strong owner를 반드시 마련해야 한다는 것입니다. simple text 경로는 이 규칙을 지킵니다. glyphDataForSystemFallback은 자신이 resolve하는 모든 system fallback을 m_systemFallbackFontSet에 추가합니다. 반면 complex 경로는 그렇지 않았습니다. FontCache::fontForPlatformData(...).ptr()을 호출한 뒤 반환된 Ref를 즉시 버렸기 때문에, Core Text가 선택한 fallback에 대한 유일한 strong reference는 FontCache 자신의 FontDataCache entry뿐이었습니다.

FontCache::purgeInactiveFontData는 남아 있는 reference가 cache 자신의 것뿐인 Font를 회수합니다. 추가된 test는 hasOneRef()를 통해 정확히 이 조건을 그대로 인코딩하고 있고, 새로 추가된 test 주석도 문제 상황을 직접 서술합니다("FontCache::purgeInactiveFontData가 font를 회수하면, cache된 shaped run의 weak Font reference가 dangling 상태가 된다"). complex 경로에서만 사용되는 fallback은 이 조건을 만족할 수 있었습니다. 이때 살아 있는 FontCascadeFonts가 여전히 해당 fallback을 가리키는 GlyphBuffer를 담은 shaped run을 붙잡고 있는 상태였는데도 그랬습니다. Font가 파괴되고 나면 zeroing weak slot들이 clear되고, 이후의 paint가 GlyphBuffer::fontAt()에 도달하게 됩니다 — ASSERT(m_fonts[index]); return *m_fonts[index]; 코드에서 이 assertion은 release 빌드에서 컴파일 단계에 제거되므로, painting 도중 clear된 weak pointer를 dereference하는 결과로 이어집니다.

한 가지 세부사항은 제공된 context만으로는 확정되지 않습니다. FontCascadeFonts::pruneSystemFallbacks()m_shapedTextCache.clear()로 끝나는데, 이름과 함께 붙어 있는 retention 관련 주석으로 미루어 볼 때 m_systemFallbackFontSet도 함께 clear할 가능성이 높습니다. 다만 전체 본문은 제공된 context가 잘려 있어 확인할 수 없습니다. FontCascadeCache::pruneSystemFallbackFonts()m_entries를 순회하며 이 함수를 호출하므로, 통상적인 purge 경로에서는 shaped cache도 함께 invalidate됩니다. 따라서 이 취약점이 발생하는 window는 purge가 m_entries를 통해 도달하지 못하는 FontCascadeFonts를 필요로 할 가능성이 높습니다. 예를 들어 pruneUnreferencedEntries()maximumEntries random eviction이 map entry를 제거한 뒤에도 살아 있는 FontCascade가 여전히 참조하고 있는 경우, 혹은 애초에 map에 들어간 적이 없는 createForPlatformFont instance인 경우입니다. 또는 purgeInactiveFontData 내부에서 cascade cache가 clear되기 전에 font가 먼저 해제되는 순서 문제일 수도 있습니다.

diff에 등장하는 코드를 기준으로, 이론적으로 가능한 trigger 순서는 다음과 같습니다.

  1. 대상 스크립트를 커버하지 못하는 font family로 element나 canvas context를 styling합니다(test에서는 "Times"에 U+06D8, U+092D, U+0B40, U+0F96, U+0DBD, U+0EAF, U+A86C, U+0ACF를 사용). 이렇게 하면 collectComplexTextRunsForCharacterseffectiveFont->isSystemFontFallbackPlaceholder() 분기를 타게 되고, 이 분기에서 Core Text가 run font를 선택합니다.
  2. 같은 짧은 run을 반복해서 getOrCreateCachedShapedText가 이를 memoize하도록 만듭니다. ShapedTextCacheDefaults의 주석은 Canvas fillText/strokeText를 튜닝 대상 workload로 명시하고 있고, initialInterval = -3이라는 값은 entry가 처음 등장하는 순간부터 cache된다는 뜻입니다. 이 결과로 만들어지는 GlyphBuffer는 fallback face를 weak하게만 기록합니다.
  3. FontCache::purgeInactiveFontData를 구동시킵니다. memory pressure를 유발하거나, 서로 다른 font description을 대량으로 churn시켜 cache를 age out시키는 방법을 쓸 수 있습니다. 이때 소유 중인 FontCascadeFontspruneSystemFallbackFonts()에 의해 도달되지 않는 시점이어야 합니다. 예를 들어 살아 있는 FontCascade가 여전히 Ref를 쥐고 있는 상태에서, pruneUnreferencedEntries()maximumEntries random eviction이 m_entries slot을 제거한 이후가 이에 해당합니다. 이 조건에서, fix 이전에는 유일한 strong reference였던 FontDataCache의 참조가 사라지면서 fallback이 파괴됩니다.
  4. cache된 run을 다시 paint하면, glyph-drawing 경로가 이미 clear된 weak pointer를 담은 GlyphBuffer slot을 읽게 됩니다.

3번 단계가 이 시나리오 전체를 지탱하는 동시에 가장 확인이 덜 된 부분입니다. 제공된 FontCache.cpp 발췌본은 purgeInactiveFontData 이전에서 잘려 있어서, shaped cache entry를 살려둔 채로 만드는 정확한 purge/prune 순서는 직접 읽은 것이 아니라 추론한 내용입니다.

Exploitability 관점에서 보면, 이 경로는 privileged API 없이 일반적인 웹 콘텐츠만으로 도달할 수 있습니다. 즉각적으로 관찰되는 영향은 painting 도중 clear된 SingleThreadWeakPtr<const Font>를 dereference하는 것입니다. fontAt()*m_fonts[index]를 반환하는데 release 빌드에서는 assertion이 컴파일 단계에서 제거되므로, 이어지는 member access가 null base에서 fault를 일으킵니다. 이는 stale-pointer read라기보다는 공격자가 스케줄링할 수 있는 renderer crash에 가깝습니다. WTF weak pointer는 파괴 시점에 0으로 초기화되기 때문에, cache된 GlyphBuffer 자체만으로는 흔히 보는 '해제된 slot을 재활용하는' 경로로 곧바로 이어지지는 않습니다. 더 강력한 primitive를 얻으려면, purge 이전에 const Font&를 buffer 밖으로 꺼내 raw alias(또는 CTFontRef/FontPlatformData 복사본)로 만들어두었다가 이후에 그 alias를 사용하는 consumer가 필요합니다. glyph-drawing 경로나 그 하위 호출에 그런 alias가 실제로 존재한다면, 그 경로는 Core Graphics glyph drawing에 platform data를 공급하는 Font에 대한 진짜 use-after-free로 이어질 가능성이 있고, 이 경우 heap grooming도 의미를 가지게 될 수 있습니다. 다만 제공된 context에는 FontCascade::drawGlyphBuffer의 본문이 포함되어 있지 않으므로, 이 확장 시나리오는 어디까지나 예상되는 방향입니다.

이 vulnerability는 WebContent process 내부의 memory safety를 약화시킵니다. 여기서 걸려 있는 security-model assumption은 GlyphBuffer가 weak Font reference를 보유하는 데 따라오는 ownership 계약입니다. cache된 shaped run에서 도달 가능한 모든 Font는 해당 cache entry보다 오래 살아남는 strong owner를 가져야 합니다. fix 이전에는 complex text 경로에서만 사용되는 Core-Text-선택 system fallback에 그런 owner가 없었습니다. 그 결과 평범한 페이지 콘텐츠와 font-cache purging이 결합되면 painting이 clear된 weak pointer를 dereference하는 상황으로 이어질 수 있었습니다. 이는 공격자가 작성한 콘텐츠가 임의로 유발할 수 있는 renderer 측 crash로, 안정적인 denial of service이자 crash-oracle/heap-state signal로도 활용될 수 있습니다.

이 케이스에서 흥미로운 지점은 'ownership 의무를 새로 만들어낸 mitigation'이라는 점입니다. GlyphBuffer::m_fonts를 weak로 만든 결정은 reference cycle을 없애고 buffer를 저렴하게 cache할 수 있게 해주었지만, 그 대가로 "GlyphBuffer에 Font를 쓰는 사람"이라는 규칙을 "GlyphBuffer에 Font를 쓰는 사람은 반드시 strong owner도 함께 등록해야 한다"는 규칙으로 조용히 바꿔놓았습니다. 두 개의 shaping 경로 중 이 규칙을 학습한 쪽은 하나뿐이었습니다. 패치가 추가한 주석("mirrors glyphDataForSystemFallback()") 자체가, 이 계약이 type system이 아니라 산문 형태로만 존재해왔다는 사실을 인정하는 셈입니다. 구조적으로 올바른 fix는 매 producer가 규칙을 기억하리라 믿는 대신, sink 쪽 — GlyphBuffer::add나 shaped-cache 삽입 지점 — 이 retention 책임을 지도록 만드는 것입니다. 한 가지 더 읽어둘 만한 layered-defense 신호가 있습니다. 기존에 있던 test PurgeInactiveFontDataClearsShapedTextCache는 팀이 이미 'purge가 cache를 함께 clear한다'는 것으로 충분한 보호라고 믿고 있었음을 보여줍니다. 이번 버그는 그 첫 번째 보장이 닿지 않는 경로에서, 실제로 지켜져야 했던 것이 더 약한 두 번째 보장이었을 때 벌어지는 일을 보여주는 사례입니다.