← All reports

ClipboardItemBindingsDataSource::clearItemTypeLoaders()에서 발생하는 Heap use-after-free를 분석하겠습니다.

Heap use-after-free in ClipboardItemBindingsDataSource::clearItemTypeLoaders()

HighWebCore async clipboard bindingsUAF

CVE: CVE-2026-43727 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A use-after-free issue was addressed with improved memory management. Credit: Tommy DeVoss from Braze Security Team (@thedawgyg), Gia Bui (@yabeow) from Calif.io, Gurpreet Shergill

c5036aa | Bugzilla 313691

High. 동일한 clipboard item에 대해 write()를 두 번 호출하는 것만으로도, completion handler가 자신을 호출한 caller가 순회 중인 바로 그 리스트를 무너뜨리게 만들 수 있습니다. 일반적인 웹 콘텐츠에서 도달 가능한, attacker가 유발하는 free에 해당합니다. crash 수준을 넘어서려면, script가 재진입할 수 없는 synchronous window 안에서 해제된 buffer를 재점유해야 합니다.

Async Clipboard API는 페이지가 브라우저에 promise 묶음을 건넬 수 있게 해줍니다. new ClipboardItem({...})는 각 MIME 타입을 promise에 매핑하고, navigator.clipboard.write()는 이 promise들이 모두 resolve된 뒤에야 하나의 byte라도 platform pasteboard에 도달하게 합니다. WebCore는 이를 item별 data source인 ClipboardItemBindingsDataSource로 모델링합니다. 이 객체는 타입마다 loader 하나를 생성하고, 각 promise가 settle될 때마다 카운트를 줄여나갑니다. 이 구조 전체를 지탱하는 invariant는 리팩터링 과정에서 놓치기 쉬운, 평범한 종류의 것입니다. 바로 loader 리스트가 그 리스트를 dispatch하는 콜백에서 접근 가능하면서 동시에 변경 가능한 상태로 남아 있으면 안 된다는 점입니다.

관전 포인트: 부분적으로만 settle된 item에 대해 clipboard write 진입점을 두 번 호출하면, renderer의 loader 리스트가 순회 도중 해제되고 stale pointer를 통해 virtual call이 dispatch될 수 있습니다.

Source/WebCore/Modules/async-clipboard/ClipboardItemBindingsDataSource.cpp

void ClipboardItemBindingsDataSource::clearItemTypeLoaders()
{
 
- for (auto& itemTypeLoader : m_itemTypeLoaders)
+ auto itemTypeLoaders = std::exchange(m_itemTypeLoaders, { });
+ for (auto& itemTypeLoader : itemTypeLoaders)
itemTypeLoader->invokeCompletionHandler();
-
 
- m_itemTypeLoaders.clear();
}

LayoutTests/editing/async-clipboard/clipboard-write-item-crash.html

+(async () => {
+ const item = new ClipboardItem({
+ "text/plain": new Promise(() => {}),
+ "text/html": Promise.resolve("x"),
+ "text/uri-list": Promise.resolve("http://a/")
+ });
+
+ navigator.clipboard.write([item]).catch(() => {});
+
+ await 0; // 마이크로태스크 드레인
+
+ navigator.clipboard.write([item]).catch(() => {});
+ ...
+})();

프로덕션 코드 변경은 하나뿐이며, 순 증감으로는 3줄입니다. 기존 clearItemTypeLoaders()는 멤버 벡터인 m_itemTypeLoaders를 참조로 직접 순회하며 각 원소에 invokeCompletionHandler()를 호출한 뒤, 루프가 끝나면 멤버를 clear()하는 방식이었습니다. 패치에서는 std::exchange(m_itemTypeLoaders, { })로 멤버의 내용을 스택 로컬 벡터로 옮겨 담고, 그 벡터를 대신 순회하도록 바뀌었습니다. 마지막의 clear() 호출은 사라졌는데, std::exchange가 이미 멤버를 기본 생성 상태로 남기기 때문에 더 이상 필요 없기 때문입니다.

이 재작성에서 중요한 성질은 두 가지이며, 둘 다 우연이 아닙니다. 첫째, completion handler가 실행되기 전에 멤버가 이미 비워지므로, handler가 접근할 수 있는 그 어떤 것도 현재 순회 중인 리스트를 더 이상 가리키지 않습니다. 둘째, 살아남은 리스트 사본은 heap에 할당된 객체가 아니라 caller의 스택 프레임에 존재하므로, 루프의 backing store가 자신을 소유했던 객체보다 더 오래 살아남게 됩니다. 인덱스 기반 루프나 매 반복마다 m_itemTypeLoaders.size()를 재확인하는 방식은 두 시나리오 중 더 약한 쪽만 해결했을 것입니다. 반면 스택 로컬 벡터를 쓰는 방식은 둘 다 해결합니다.

나머지 변경은 회귀 테스트입니다. 절대 settle되지 않는 promise 하나와 이미 resolve된 promise 두 개를 타입 맵에 담은 ClipboardItem을, 마이크로태스크 드레인을 사이에 두고 navigator.clipboard.write()로 두 번 제출합니다. 기대 출력은 ASAN 환경에서 단순히 PASS이며, 이는 crash 여부만 검증하는 테스트로서 관찰 가능한 script-visible 동작이 없는 lifetime 버그에서 흔히 나타나는 형태입니다.

commit trailer에서 짚어둘 부분이 하나 있습니다. 이 수정은 먼저 safari-7624-branch305413.812로 랜딩된 뒤 main으로 merge되었으며, 그래서 main 브랜치의 commit 날짜가 6월 29일 Safari 26.5.2 릴리스보다 뒤에 위치합니다.

Async Clipboard API. new ClipboardItem({ "text/plain": promise, ... })는 각 MIME 타입의 payload를 즉시 값이 아니라 JS promise로 공급받는 clipboard item을 생성합니다. navigator.clipboard.write([item])은 엔진에게 이 promise들을 모두 resolve하고, 그 결과를 취합하여 platform pasteboard에 단일 write로 전달하도록 요청합니다. promise는 임의의 이후 시점에 settle될 수 있으므로, 이 write는 본질적으로 여러 단계에 걸친 비동기 연산이며 그 상태는 마이크로태스크 경계를 넘나들며 WebCore 안에 존재하게 됩니다.

WebCore에서의 promise settlement. native 코드는 DOMPromise::whenSettledWithResult()를 통해 JS promise를 관찰합니다. 이 함수는 promise가 settle될 때 호출되는 콜백을 등록합니다. 이미 resolve된 promise는 다음 마이크로태스크 체크포인트에서 콜백을 발화시키지만, new Promise(() => {})로 생성된 promise는 결코 settle되지 않으며 등록된 콜백은 무기한 대기 상태로 남습니다.

Data source. ClipboardItemBindingsDataSource는 JS에서 생성된 ClipboardItem을 뒷받침하는 WebCore 객체입니다. script가 넘겨준 타입→promise 쌍인 m_itemPromises, write가 시작될 때 생성되는 타입별 helper 객체 m_itemTypeLoaders, 아직 미완료 상태인 타입 개수를 추적하는 카운터 m_numberOfPendingClipboardTypes, 그리고 write를 마무리하는 콜백인 m_completionHandler를 갖고 있습니다. 이 객체의 상태 머신은 한 번에 하나의 write만 진행 중이라고 가정하는데, collectDataForWriting()에서는 이 가정이 ASSERT(!m_completionHandler)라는 형태로 표현됩니다. 다만 이는 debug 빌드에서만 동작하는 검사이며 release 빌드에서는 컴파일에서 제외됩니다.

Type loader. ClipboardItemTypeLoaderClipboardItemTypeLoader::create(destination, type, callback)로 생성되는, 타입별 refcounted helper입니다. invokeCompletionHandler()는 생성 시점에 data source가 넘겨준 콜백을 실행합니다. data source는 이들을 Vector<Ref<ClipboardItemTypeLoader>>에 보관합니다.

Diff에 등장하는 WTF idiom. Ref<T>는 null이 될 수 없는 refcounted smart pointer로, 객체를 가리키는 마지막 Ref가 파괴되면 객체도 파괴됩니다. WTF::Vector는 원소들을 하나의 연속된 heap buffer에 저장하므로, 벡터를 clear하거나 재할당하거나 파괴하면 그 buffer가 해제됨과 동시에 그 안에 담겨 있던 Ref 원소들도 파괴됩니다. for (auto& x : container)x를 각 원소에 제자리에서 바인딩합니다. 루프가 진행되는 동안 iterator는 루프 시작 시점 컨테이너가 갖고 있던 backing store를 가리키는 raw pointer로 동작합니다. std::exchange(member, { })는 멤버의 현재 값을 새 객체로 읽어들이고 멤버 자체는 기본 생성 상태로 남깁니다. 벡터에 적용하면 backing buffer의 소유권을 이전하고 멤버는 빈 상태가 됩니다.

Re-entrancy. 메서드는, 자신이 동일한 객체로 다시 synchronous하게 콜백해서 return 전에 상태를 변경할 수 있는 콜백에게 제어권을 넘기는 지점에서 re-entrant하다고 봅니다. 다른 객체 안에 저장된 completion handler는 이런 지점의 대표적인 원천인데, callee의 capture가 그 콜백이 무엇에 접근할 수 있는지를 결정하기 때문입니다.

이는 전형적인 re-entrant iterator invalidation입니다. 루프 본문이 자신이 순회 중인 컨테이너를 파괴할 수 있는 콜백을 dispatch합니다.

  clearItemTypeLoaders()             m_itemTypeLoaders (heap buffer)
  ---------------------------------  ------------------------------
  it = &m_itemTypeLoaders[0]  ─────► [ L0 ][ L1 ][ L2 ]
  L0->invokeCompletionHandler()
    └─ --m_numberOfPendingClipboardTypes == 0
         └─ invokeCompletionHandler()      ◄── re-entry into the data source
              └─ write completes; loaders released / owner torn down
                                     [ freed ]
  ++it; it->invokeCompletionHandler()  ◄── Ref loaded from freed memory,
                                           virtual call through stale pointer

loader의 completion handler는 그냥 놀고 있는 콜백이 아닙니다. collectDataForWriting()에서 생성될 때 각각 data source와 item을 capture합니다.

auto itemTypeLoader = ClipboardItemTypeLoader::create(destination, type,
    [this, protectedItem = Ref { m_item.get() }] {
        ASSERT(m_numberOfPendingClipboardTypes);
        if (!--m_numberOfPendingClipboardTypes)
            invokeCompletionHandler();
    });

이 부분이 다이어그램에서 re-entry로 표시한 화살표에 해당합니다. L0의 handler를 실행하면 data source의 pending-type 카운터가 감소하고, 카운터가 0에 도달하는 순간 handler는 ClipboardItemBindingsDataSource::invokeCompletionHandler() — 즉 객체 자신의 write 완료 경로 — 를 곧바로 호출합니다. 이때 호출 지점은 여전히 m_itemTypeLoaders를 가리키는 raw iterator를 쥐고 있는 루프 내부입니다. write가 완료되면 loader 리스트는 무너집니다. m_itemTypeLoaders를 아예 비우거나 재할당하는 방식이거나, 혹은 감싸고 있는 data source의 마지막 참조가 사라지면서 벡터 자체가 객체와 함께 파괴되는 방식일 수 있습니다. 이 capture 리스트 자체도 이전에 이뤄진 lifetime 작업의 흔적입니다. 람다가 Refm_item을 보호하고 있다는 점에서, 콜백이 자신의 owner보다 오래 살아남는 위험은 이미 ClipboardItem에 대해서는 저자들이 인지하고 있었던 셈입니다. 다만 data source 자신의 순회 상태에 대해서는 그렇지 못했습니다.

두 teardown 경로 모두 range-for의 pointer가 여전히 가리키고 있는 상태에서 벡터의 연속된 backing buffer를 해제합니다. 이후 루프는 계속 진행되어 해제된 메모리에서 다음 Ref<ClipboardItemTypeLoader>를 읽어오고, 그 슬롯을 현재 차지하고 있는 임의의 pointer에 대해 ->invokeCompletionHandler()를 수행합니다. buffer가 여전히 매핑되어 있는 온건한 경우라 하더라도, 중첩된 teardown 과정에서 이미 그 안에 담겨 있던 Ref 원소들이 파괴된 상태이므로, 루프는 이미 파괴된 loader를 대상으로 virtual call을 dispatch하는 셈이 됩니다. 기존 코드의 마지막 m_itemTypeLoaders.clear() 호출은 이 stale 원소들에 대해 참조를 한 번 더 해제함으로써 피해를 가중시킵니다.

이 상태에 도달하려면 loader가 두 개 이상 있어야 하고, 루프 도중 카운트가 0에 도달할 방법이 있어야 합니다. 회귀 테스트가 구성하는 시나리오가 정확히 이 형태입니다.

  1. 절대 settle되지 않는 promise 하나와 이미 resolve된 promise 두 개, 총 세 타입으로 ClipboardItem을 구성합니다.
  2. navigator.clipboard.write([item])을 호출합니다. collectDataForWriting()이 loader 세 개를 채우고 pending count를 3으로 설정합니다.
  3. await 0으로 마이크로태스크 체크포인트가 실행되게 하면, resolve된 두 promise가 settle되면서 pending count가 1로 줄어듭니다.
  4. 동일한 item에 대해 navigator.clipboard.write([item])을 다시 호출합니다. 두 번째 write는 clearItemTypeLoaders()로 시작하는데, 이는 첫 번째 write의 loader 리스트를 순회하기 시작합니다.
  5. 첫 번째 loader의 handler가 남은 pending type을 0으로 줄이면서 data source의 completion 경로로 재진입하고, 이때 실행 중인 루프 아래에서 리스트가 무너집니다.
  6. 루프는 해제된 메모리로 계속 진행됩니다.

이 시퀀스가 성립하는 데 특별한 API surface나 이례적인 설정은 필요 없습니다. Async Clipboard API가 활성화되어 있고, 배포 중인 write 경로가 적용하는 user-activation gate 정도만 있으면 됩니다. 실제로 테스트는 test-runner flag로 AsyncClipboardAPIEnabled=true를 명시적으로 설정합니다. 아울러 4단계는 조용히 한 가지를 더 드러냅니다. "한 번에 하나의 write만"이라는 계약이 오직 ASSERT(!m_completionHandler)로만 강제되고 있다는 점입니다. 그래서 release 빌드에서는 두 번째 write가 거부되지 않고, 진행 중이던 handler와 counter를 그대로 덮어써버립니다.

이번 패치는 증상이 아니라 근본에서 invariant를 복원합니다. std::exchange가 첫 handler 실행 전에 멤버를 비워두므로, re-entrant한 completion 경로가 마주하는 m_itemTypeLoaders는 이미 비어 있는 상태이고 더 이상 무너뜨릴 대상이 없습니다. 또한 살아남은 사본이 스택에 있으므로, 같은 경로가 data source 자체를 파괴하더라도 그 사본은 유효하게 남습니다. 이 취약점은 JS에서 접근 가능한 진입점이 WebCore가 실제로 순회 중인 리스트의 lifetime을 제어할 수 있게 함으로써 WebContent process 내부의 메모리 안전성을 약화시켰습니다. 패치 이후에는 그 리스트의 lifetime이 순회를 수행하는 스택 프레임에 귀속됩니다. 패치 이전 상태에서 현실적으로 기대할 수 있는 최소한의 결과는 일반 웹 콘텐츠에서 attacker가 유발하는 renderer crash이며, 이는 Apple이 명시한 impact와 일치합니다. 만약 해제된 buffer가 stale 슬롯이 역참조되기 전에 attacker가 영향을 미칠 수 있는 할당으로 재점유된다면, stale Ref 로드와 그것을 통한 호출은 controlled-pointer dispatch와 attacker가 영향을 미치는 주소에서의 refcount 감소로 이어질 가능성이 있습니다. 다만 이는 여전히 sandbox 처리된 WebContent process 내부에 국한되므로, 시스템까지 도달하려면 별도의 sandbox escape가 필요합니다.

두 번 겹치는 clipboard write가 completion handler로 하여금 loader 벡터를, 혹은 그 owner를 파괴하게 만들 수 있습니다. 이 시점에 clearItemTypeLoaders()는 여전히 그 backing store를 가리키는 raw iterator를 쥐고 있습니다.

핵심은 이렇습니다. 루프 본문에서 저장된 completion handler를 호출하는 clear 계열 메서드라면, 먼저 std::exchange(member, { })스택 로컬에 옮겨 담아야 합니다. 인덱스 기반 루프나 size 재확인은 컨테이너가 변경되는 상황에는 버티지만, 콜백이 this를 파괴하는 상황까지 버티는 것은 오직 스택이 소유한 사본뿐입니다.