ClipboardItemBindingsDataSource::clearItemTypeLoaders()에서 발생하는 Heap use-after-free를 분석하겠습니다.
Heap use-after-free in ClipboardItemBindingsDataSource::clearItemTypeLoaders()
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
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
LayoutTests/editing/async-clipboard/clipboard-write-item-crash.html
Patch Details
프로덕션 코드 변경은 하나뿐이며, 순 증감으로는 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-branch에 305413.812로 랜딩된 뒤 main으로 merge되었으며, 그래서 main 브랜치의 commit 날짜가 6월 29일 Safari 26.5.2 릴리스보다 뒤에 위치합니다.
Background
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. ClipboardItemTypeLoader는 ClipboardItemTypeLoader::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가 그 콜백이 무엇에 접근할 수 있는지를 결정하기 때문입니다.
Analysis
이는 전형적인 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 작업의 흔적입니다. 람다가 Ref로 m_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에 도달할 방법이 있어야 합니다. 회귀 테스트가 구성하는 시나리오가 정확히 이 형태입니다.
- 절대 settle되지 않는 promise 하나와 이미 resolve된 promise 두 개, 총 세 타입으로
ClipboardItem을 구성합니다. navigator.clipboard.write([item])을 호출합니다.collectDataForWriting()이 loader 세 개를 채우고 pending count를 3으로 설정합니다.await 0으로 마이크로태스크 체크포인트가 실행되게 하면, resolve된 두 promise가 settle되면서 pending count가 1로 줄어듭니다.- 동일한 item에 대해
navigator.clipboard.write([item])을 다시 호출합니다. 두 번째 write는clearItemTypeLoaders()로 시작하는데, 이는 첫 번째 write의 loader 리스트를 순회하기 시작합니다. - 첫 번째 loader의 handler가 남은 pending type을 0으로 줄이면서 data source의 completion 경로로 재진입하고, 이때 실행 중인 루프 아래에서 리스트가 무너집니다.
- 루프는 해제된 메모리로 계속 진행됩니다.
이 시퀀스가 성립하는 데 특별한 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를 쥐고 있습니다.
Insight
핵심은 이렇습니다. 루프 본문에서 저장된 completion handler를 호출하는 clear 계열 메서드라면, 먼저 std::exchange(member, { })로 스택 로컬에 옮겨 담아야 합니다. 인덱스 기반 루프나 size 재확인은 컨테이너가 변경되는 상황에는 버티지만, 콜백이 this를 파괴하는 상황까지 버티는 것은 오직 스택이 소유한 사본뿐입니다.
Audit directions
-
자신이 dispatch하는 콜백에서 접근 가능한 컨테이너. 좁게 보면: WebCore에서
m_접두사가 붙은Vector/HashMap멤버를 range-for로 순회하면서 본문에서invokeCompletionHandler,->callback(),handler(),dispatchEvent를 호출하는 지점을 검색하십시오.Source/WebCore/Modules/async-clipboard/, 인접한ClipboardItemDataSource서브클래스, 그리고FileReaderLoader의 client-notification 경로부터 시작하는 것이 좋습니다. 좁은 범위에서의 판별 기준은 명확합니다. 멤버 컨테이너에 대한 range-for 안에서 콜백이 호출되고, 루프 이후 그 동일한 멤버에 대한clear()나 재할당이 뒤따르는 패턴입니다. 넓게 보면: pending-operation registry를 제자리에서 비워내는 곳이면 어디든 같은 클래스의 문제가 나타날 수 있습니다.CompletionHandler벡터,Vector<Ref<PendingScript>>, promise-settlement 콜백 리스트,HashMap<..., CompletionHandler>teardown 루프 등이 해당합니다. 코드 검색 결과에서의 판별 기준은, callee가 순회 중인 객체를 가리키는this나protectedThis를 capture하는 drain 루프입니다. 가장 넓게 보면: 이는 콜백을 제자리에서 dispatch하는 모든 언어에서 성립하는 일반적인 reentrant-iterator-invalidation 클래스입니다. Chromium의base::ObserverList는 정확히 이 문제를 해결하기 위해 존재하고, .NET은 같은 이유로 multicast delegate의 invocation list를 스냅샷으로 뜨며, Rust의 borrow checker는 이런 형태를 컴파일 타임에 거부합니다. 이식 가능한 invariant는 다음과 같습니다. 컬렉션을 볼 수 있는 무언가를 호출하기 전에, 그 컬렉션을 스냅샷 뜨거나 분리해두라. -
this를 해제시킬 수 있는 콜백. 좁게 보면:ClipboardItemBindingsDataSource.cpp와Clipboard.cpp의 나머지 부분에서,m_completionHandler(...)나invokeCompletionHandler()를 호출한 뒤에도 멤버를 계속 건드리는 메서드를 점검할 필요가 있습니다. 각 메서드가 먼저 로컬Ref { *this }/protectedThis를 확보하는지 확인해야 합니다. 여기서 loader lambda들은 이미m_item을 보호하고 있어, 해당 패턴이 절반만 적용된 상태입니다. 좁은 범위에서의 단서는, completion handler 호출 이후의 코드 라인에서 ownership anchor 없이 멤버를 읽거나 쓰는 부분입니다. 넓게 보면: 이 형태는 completion handler를 외부 caller가 소유하는 WebCore 클래스 전반에서 반복됩니다.FileReaderLoaderclient,ResourceLoader의 completion 경로,DeferredPromiseresolution 지점, media element의 async callback 등이 해당합니다.CompletionHandler::operator()를 non-terminal 위치에서 호출하는 caller들을 따라가며 점검할 수 있습니다. 가장 넓게 보면: dispatch된 callback을 통한 self-destruction은 Qt의 signal/slot과deleteLater, delegator를 해제하는 Objective-C delegate callback, emitter를 dispose하는 NodeEventEmitterhandler 등에서도 동일하게 나타납니다. 이런 패턴에 공통되는 불변 원칙은 다음과 같습니다: callback이 dispatcher를 해제할 수 있다면, dispatcher는 반드시 self-reference를 유지하거나 dispatch를 마지막 statement로 만들어야 합니다. -
assertion만으로 보호되는 단발성 async state machine. 좁게 보면:
collectDataForWriting()의ASSERT(!m_completionHandler)가 두 번째navigator.clipboard.write()호출과m_completionHandler/m_numberOfPendingClipboardTypes가 조용히 덮어써지는 상황 사이를 막아주는 유일한 장치입니다. 다만 이 assertion은 release build에서는 컴파일 과정에서 제거됩니다. 두 번째 write 호출이 이제 값을 덮어쓰는 대신 거부되거나 순차 처리되는지 확인할 필요가 있습니다. 단서가 되는 패턴은ASSERT(!m_someHandler)바로 다음에 같은 handler에 대한 할당이 이어지는 코드입니다. 넓게 보면: 두 개의 겹치는 async consumer에게 전달되는 모든 DOM object에서 동일한 패턴이 나타날 수 있습니다. 두 번의FileReaderread에 넘겨지는Blob/File, 두 reader에 lock되는ReadableStream, decoding 도중 transfer되는ImageBitmap등이 해당합니다. 멤버가 비어 있는지 먼저 확인하지 않고CompletionHandler를 멤버에 저장하는 WebIDL entry point를 따라가며 점검할 수 있습니다. 가장 넓게 보면: "단발성 리소스가 동시에 재사용되는" 일반적인 클래스에 해당합니다. event loop에 두 번 제출된 file handle, 두 task에서 poll되는 future, in-flight request 사이에서 재사용되는 connection object 등이 여기에 속합니다. 이식 가능한 불변 원칙은 다음과 같습니다: state machine에 busy 상태가 존재한다면, 그 상태에 두 번 진입하는 시도는 debug에서 assert로 처리할 것이 아니라 runtime에서 거부되어야 합니다.