← All issues

[4] Use-after-free in CSSFontFace::setStatus via CSSFontFace::load

The observer loop that kept every client alive — but not registered

Severity: High | Component: WebCore CSS font loading | 5aedb82

diff에서 확인되는 membership 재검증 guard는 loop 도중 re-entrant style recalc이 observer callback을 분리할 수 있음을 보여줍니다. 이로 인해 웹에서 도달 가능한 use-after-free가 발생하므로 High로 평가됩니다. read/write primitive로의 확장은 해제된 관련 메모리를 재사용하는 조건이 추가로 필요하며, diff에서는 이 부분이 확인되지 않습니다.

CSS font-load client iteration 과정의 use-after-free가 수정되었습니다. iterateClients helper는 weak client 집합을 Vector<Ref<CSSFontFaceClient>>로 복사하고 모든 callback을 조건 없이 호출했습니다. 이번 patch는 callback 호출 전에 membership 재검증을 추가했습니다.

Source/WebCore/css/CSSFontFace.cpp

static void iterateClients(WeakHashSet<CSSFontFaceClient>& clients, NOESCAPE const Function<void(CSSFontFaceClient&)>& callback)
{
- for (auto& client : copyToVectorOf<Ref<CSSFontFaceClient>>(clients))
- callback(client);
+ for (auto& client : copyToVectorOf<Ref<CSSFontFaceClient>>(clients)) {
+ if (clients.contains(client))
+ callback(client);
+ }
}

LayoutTests/fonts/font-face-load-crash.html

+Object.defineProperty(FontFace.prototype, 'then', { get() {
+ document.getElementById('v').remove();
+ document.body.offsetHeight;
+}, configurable: true });
+face.load();

이번 patch는 file-static 함수인 iterateClients를 수정했습니다. 기존에는 copyToVectorOf를 통해 weak client 집합을 Vector<Ref<CSSFontFaceClient>>로 복사하고, 모든 항목에 대해 조건 없이 callback(client)를 호출했습니다. 수정 후에는 if (clients.contains(client)) callback(client); 형태로 callback 호출 전에 membership 검증이 추가되었습니다. observer에게 변경사항을 알리는 모든 mutator(setFamily, setWeight, 그리고 commit 제목에 명시된 setStatus/load 경로)가 이 helper를 통해 동작하므로, 이 guard는 모든 client-notification loop에 적용됩니다.

Iterate-then-callback loop에서 JavaScript re-entrancy 경계를 넘은 뒤 observer-set membership 재검증이 누락된 패턴.

CSSFontFaceClientCSSFontFace의 family/weight/loading 상태 변경을 통보받고자 하는 객체(CSS font selector, FontFace wrapper)가 구현하는 observer interface입니다. WeakHashSet<CSSFontFaceClient>는 이 client들에 대한 weak reference 집합으로, client가 소멸되면 항목이 자동으로 제거됩니다. copyToVectorOf<Ref<CSSFontFaceClient>>(clients)는 weak 집합을 strong reference로 복사합니다. 이를 통해 loop가 안정적인 목록을 갖게 되고, 각 client 객체가 iteration 동안 살아있는 상태로 유지됩니다.

여기서 re-entrancy란 native C++이 JavaScript를 호출하는 지점을 가리킵니다. FontFace.prototype.then을 참조하는 promise 해결 과정에서 script가 동기적으로 실행되고, 제어권이 돌아오기 전에 C++ 상태가 변경될 수 있습니다. offsetHeight처럼 layout에 의존하는 property를 읽어 강제 style recalculation이 발생하면, font face 집합이 재구성되고 client가 등록되거나 해제될 수 있습니다. 정상적인 흐름은 다음과 같습니다: FontFace.load()CSSFontFace::load()setStatus()iterateClients()가 snapshot을 순회하며 각 observer에게 loading 상태 변경을 전달합니다.

이 취약점은 observer iteration 도중 JavaScript re-entrancy에 의해 유발되는 stale observer 호출 기반의 use-after-free입니다. 수정 이전 iterateClients는 weak client 집합을 strong Ref<CSSFontFaceClient>의 vector로 복사하고, client가 여전히 등록된 상태인지 확인하지 않은 채 각 callback을 호출했습니다. Ref snapshot이 loop 동안 각 client 객체의 메모리를 살아있는 상태로 유지하므로, 이는 client를 향한 raw dangling pointer 버그가 아닙니다. 복사 자체가 client 객체의 해제를 방어하는 의도적인 보호 장치입니다.

문제는 loop 앞쪽의 callback이 script를 다시 호출할 수 있다는 점입니다. load 경로는 promise를 해결하는 과정에서 JavaScript를 다시 호출하고, 공격자가 조작한 FontFace.prototype.then getter가 이 시점에 임의의 JS를 실행합니다. 그 JS가 style recalculation을 강제로 유발하고, client를 등록 해제하며 해당 callback이 의존하는 상태를 파괴합니다.

테스트는 FontFace.prototype.then에 getter를 주입합니다. 이 getter는 <style> 요소를 제거하고 document.body.offsetHeight를 읽어 동기적 style recalculation을 강제합니다. 이후 face.load()를 호출합니다. loop가 이미 stale 상태이지만 Ref로 살아있는 client에 도달해 callback을 호출하면, 해당 callback은 논리적으로 분리된 client에서 동작하게 됩니다. 이때 re-entrant recalc이 이미 파괴한 연관 객체(CSSFontFace/font-selector/wrapper 연결고리)를 역참조하게 됩니다. 깨진 불변 조건은 "loop 진입 시 snapshot에 포함된 client가 callback 호출 시점에도 여전히 살아있는 등록된 observer이어야 한다"는 것입니다.

이 취약점은 font-load client iteration에서 발생하는 웹에서 도달 가능한 UAF로, 항상 동일하게 renderer crash로 이어집니다. callback이 역참조하기 전에 공격자가 제어하는 heap 내용이 해제된 연관 상태를 재사용할 수 있다면, 이론적으로 controlled UAF primitive로 발전할 가능성이 있습니다. 이 최선의 시나리오를 실현하려면 해제된 슬롯에 공격자 데이터를 배치하는 heap grooming과 dangling linkage를 통해 읽거나 쓰는 callback 경로가 필요합니다.

이 취약점은 WebContent process 내부의 메모리 안전성을 약화시킵니다. 보안 모델은 CSSFontFace::iterateClients의 observer callback이 등록 상태를 유지하는 client에게만, 그리고 backing state가 notification loop 동안 살아있는 경우에만 전달된다고 가정합니다. 수정 이전에는 callback이 script를 다시 호출해 font/style graph를 변경할 때마다 이 가정이 깨졌습니다. 일반 웹 콘텐츠만으로도 UAF를 유발할 수 있으며, 더 강력한 primitive로 발전시키려면 renderer를 벗어나기 위한 별도의 sandbox escape가 필요합니다.

이 패턴은 WebKit re-entrancy의 전형적인 사례입니다. observer 집합에 대한 snapshot-then-iterate loop에서 snapshot이 객체의 생명주기를 (Ref를 통해) 보호하지만 논리적 membership은 보호하지 않습니다. Ref로 복사하면 객체 해제가 방어되므로 loop가 안전하다는 착각을 유발합니다. 그러나 실제 위험은 re-entrant callback이 observer를 분리하고 그 의존 상태를 파괴한다는 점입니다. 이번 fix에서 적용된 각 callback 전의 contains 재검증은 올바른 최소 패턴이며, WebCore 내 모든 iterateClients 스타일의 loop에 전파할 필요가 있습니다.

Note: load/setStatus 경로가 iterateClients를 통해 라우팅된다는 사실, 정확히 해제되는 객체와 dangling linkage, controlled primitive로의 확장 가능성은 diff에 직접 드러나지 않으며 commit 제목과 주변 코드로부터 추론된 내용입니다. re-entrancy trigger와 membership 재검증 fix는 patch와 테스트에 의해 직접 확인됩니다.

시 메시지를 거부하고, 일반적으로 비정상 동작 process를 종료시킵니다. WebProcessProxy::hasGrantedSandboxExtensionForFile()은 특정 WebContent process에 해당 경로에 대한 접근 권한이 사전에 부여된 적 있는지 조회합니다.

실행 흐름은 다음과 같습니다. 스크립트가 window.open을 호출하면 WebContent가 NavigationActionData를 포함한 CreateNewPage 메시지를 전송합니다. UI process의 createNewPage가 이를 처리한 뒤 loadRequest()를 통해 내비게이션을 재발행하는데, 이 과정에서 LoadParameters 직렬화가 sandboxExtensionHandles()를 실행합니다.

근본 원인은 sandbox escape로 이어지는 IPC capability 검사 누락입니다. 즉 confused-deputy 패턴에 해당하는데, 패치 이전 createNewPage는 WebContent에서 전송된 NavigationActionData.request httpBody 내 파일 경로(EncodedFileData 타입의 FormData element)를 별도 검증 없이 신뢰했습니다. 전송 process가 해당 경로에 대한 sandbox extension을 보유하는지는 전혀 확인하지 않은 셈입니다.

UI process가 loadRequest()를 통해 공격자가 제공한 요청을 재전송하면, LoadParameters 직렬화 과정에서 FormDataReference::sandboxExtensionHandles()가 실행됩니다. 각 EncodedFileData 파일명에 대해 SandboxExtension::createHandle()이 호출되는데, UI process는 host filesystem에 대한 sandbox 제한이 없으므로 해당 경로의 live com.apple.app-sandbox.read 토큰이 발급됩니다. 이 토큰은 WebContent process에 전달되고, 접근 권한이 없던 파일을 읽는 데 사용됩니다.

누락된 불변 조건은 다음과 같습니다. UI process는 사전에 접근 권한이 부여되지 않은 경로에 대해 WebContent process를 대신해 sandbox extension을 발급해서는 안 됩니다. 권한 부여의 예로는 파일 선택 대화상자나 drag-and-drop이 있습니다.

이 취약점은 second-stage sandbox escape로 활용 가능합니다. 이미 침해된 WebContent process에서 NavigationActionData.request httpBody에 목표 경로를 담은 EncodedFileData element를 포함한 CreateNewPage 메시지를 조작하면 됩니다. 테스트에서는 /private/etc/passwd를 사용합니다. UI process가 해당 요청을 재전송하고 live read 토큰을 발급해 WebContent에 전달하면 파일을 읽을 수 있게 되고, 경로를 반복 지정하면 임의의 host 파일 read가 이론적으로 가능합니다.

다만 이 공격은 사전에 WebContent가 침해된 상태를 전제로 합니다. CreateNewPage IPC는 renderer에서 전송하며, 공격 payload에 도달하려면 기존 renderer RCE가 있거나 테스트처럼 IPC 테스트 API를 이용해야 합니다. memory corruption primitive가 아닌, capability 및 authorization을 직접 우회하는 방식입니다.

공격 표면을 살펴보면, CreateNewPage IPC 진입점은 기존에도 존재했으나 rdar://174662982에서 decidePolicyForNavigationAction()에 추가된 파일 경로 권한 검증 게이트가 없었습니다. 이 도달 가능한 IPC 경로는 request body의 파일 경로가 전송 process에 이미 권한 부여된 것이라고 가정만 했을 뿐, 실제로 강제하지는 않았습니다. 이 가정이 깨지면 UI process가 임의 경로에 대한 filesystem read 토큰을 발급하게 됩니다.

이 취약점은 WebContent sandbox 경계를 약화시킵니다. WebKit의 보안 모델은 UI process가 명시적 사용자 승인 이후에만 WebContent에 파일 sandbox extension을 부여한다고 가정합니다. 패치 이전에는 침해된 renderer가 CreateNewPage request body에 임의 경로를 삽입함으로써 이 가정을 우회할 수 있었습니다. 결과적으로 UI process가 live read 토큰을 전달하게 되고, 침해된 renderer 내에서 임의의 host filesystem read가 가능해집니다. /private/etc/passwd, 쿠키, 다른 사용자 데이터 탈취 등 read에 대한 full sandbox escape에 해당합니다.

이 취약점은 rdar://174662982 패치의 명시적인 variant입니다. WebContent가 제공한 요청을 파일 권한 재검증 없이 FormDataReference::sandboxExtensionHandles()를 직렬화하는 코드 경로로 전달하는 UI process IPC 핸들러 패턴은 반복적으로 나타나는 취약 유형입니다. 토큰 발급은 sandboxExtensionHandles()에 집중되어 있는 반면, 권한 검사는 핸들러별로 분산되어 있어 누락되기 쉽기 때문입니다.

Note: 정확한 fix 본문, loadRequest()/sandboxExtensionHandles()를 통한 토큰 발급 메커니즘, 이전 패치와의 variant 관계, com.apple.app-sandbox.read class 이름은 commit 메시지에서 가져온 정보입니다. diff에서 직접 확인되는 내용은 #include 추가 한 줄뿐이며, confused-deputy 구조와 테스트의 거부 assertion은 직접적으로 뒷받침됩니다.

Let me check memory for relevant context before translating.