[13] DeferredWorkTimer leaves stale ticket pointer in m_tasks after cancellation
Medium으로 평가된 이유는 다음과 같습니다. 이 diff는 m_tasks에서의 stale pointer dequeue를 수정합니다. 첫 번째 pop이 TicketData를 해제하면 중복 항목이 해제된(또는 TZone에서 재사용된) pointer를 역참조합니다. 이 window에 도달하려면 충분한 수의 in-flight ticket이 있는 상태에서 cross-realm teardown이 필요합니다. primitive의 강도는 TZone 재사용이 해제된 slot에서 발생하는지 여부에 달려 있습니다.
cancelPendingWorkSafe()는 소멸 중인 global의 모든 weak ticket에 대해 무조건 (ticket, noop) 항목을 m_tasks에 추가하고 있었습니다. 그러나 이는 불필요한 동작이었습니다. doWork()에는 이미 removeIf(isCancelled) 처리 단계가 있어 취소된 ticket을 m_pendingTickets에서 제거하고, setTimeUntilFire(0_s) 호출이 doWork() 실행을 이미 보장하기 때문입니다.
Source/JavaScriptCore/runtime/DeferredWorkTimer.cpp
Patch Details
제거된 것은 단 한 줄입니다. 소멸 중인 JSGlobalObject에 속한 모든 weak ticket에 대해 (ticket, noop) tuple을 m_tasks에 무조건 추가하던 코드입니다. 나머지 로직은 그대로 유지됩니다. global의 m_weakTickets를 순회하면서 취소되지 않은 각 ticket에 대해 cancelPendingWork를 호출하고, setTimeUntilFire(0_s)를 통해 doWork() 실행을 예약합니다. 취소된 ticket의 정리는 doWork() 마지막 단계에 이미 존재하는 m_pendingTickets.removeIf(isCancelled) 처리에 맡겨집니다. regression test는 child global 안에서 FinalizationRegistry 인스턴스를 다수 생성한 뒤, global을 해제하고 GC를 수행한 다음, timer 기반 deferred work를 대량으로 예약하는 방식으로 구성되었습니다.
Work queue 내 stale raw pointer 재사용: 소유자 측 cleanup이 중복 raw pointer 항목을 추가했고, 이 항목들이 consumer 측의 소유 Ref 해제 이후에도 남아있었습니다.
Background
DeferredWorkTimer는 두 개의 자료구조를 관리합니다. 첫 번째인 m_pendingTickets는 각 ticket의 keep-alive reference를 보유하는 HashSet<Ref<TicketData>>이고, 두 번째인 m_tasks는 Ticket = TicketData*가 raw pointer인 Deque<tuple<Ticket, Task>>입니다. JS가 deferred work를 요청할 때(Atomics.waitAsync, FinalizationRegistry cleanup, WebAssembly promise 연동) ticket이 추가되고, doWork()는 VM API lock 하에서 m_tasks를 FIFO 방식으로 pop합니다. TicketData::cancel()은 m_isCancelled 플래그를 설정하지만, 어느 컨테이너에서도 ticket을 제거하지 않습니다. 정리는 doWork() 내부에서 지연 처리됩니다. cancelPendingWorkSafe(JSGlobalObject*)는 JSGlobalObject::~JSGlobalObject에서 호출되어 realm이 소멸 중인 ticket들을 정리합니다. TicketData는 WTF_MAKE_TZONE_ALLOCATED_IMPL로 할당됩니다. 해제된 인스턴스는 type별로 분리된 freelist로 반환되며, 다음 TicketData::create 호출이 동일한 주소를 재사용할 수 있습니다. HashSet<Ref<T>>::find(rawPtr)는 pointer 값 기반의 해싱/동등 비교를 사용하며, 멤버십 결정 시 입력 pointer를 역참조하지 않습니다.
Analysis
cancelPendingWorkSafe는 소멸 중인 global의 모든 weak ticket에 대해, 이미 대기 중인 항목과 별개로 (ticket, noop) 항목을 m_tasks에 추가했습니다. m_tasks에서 Ticket은 raw TicketData*로 저장되고, keep-alive Ref는 m_pendingTickets에 있습니다. doWork()의 메인 루프는 첫 번째 m_tasks 항목에서 취소된 ticket을 발견하면 m_pendingTickets.remove(pendingTicket)을 호출합니다. 이 호출이 마지막 Ref를 해제하고 TicketData를 소멸시킵니다. cancelPendingWorkSafe가 동일한 ticket pointer를 두 번째로 큐에 추가했기 때문에, 다음 루프 반복에서 pop되는 (ticket, noop)의 ticket은 이미 dangling TicketData* 상태가 됩니다.
Heap 재사용이 발생하면, 동일한 주소에 재할당된 다른 live TicketData를 조회하게 됩니다. TicketData는 TZone 할당 방식이고 test가 수백 개의 FinalizationRegistry ticket을 생성하기 때문에, 이 재활용은 쉽게 일어납니다. 이 경우 find()를 통과하고 잘못된 객체의 m_isCancelled를 읽습니다. 가장 심각한 결과는 아직 살아있는 ticket에 대해 m_pendingTickets.take(pendingTicket)을 호출하는 것입니다. 이로 인해 해당 ticket의 keep-alive Ref가 끊어지고, noop closure가 그 ticket의 실제 작업인 것처럼 실행됩니다.
Exploit 구조는 다음과 같습니다. 먼저 in-flight deferred-work ticket을 다수 보유한 child realm을 생성합니다. 이어서 최소 하나의 ticket에 대해 실제 (ticket, real_task) 항목이 m_tasks에 이미 존재하는 상태를 만듭니다. 그 다음 child global을 해제하면 cancelPendingWorkSafe가 실행되어 두 번째 (ticket, noop) 항목이 추가됩니다. test의 패턴(FinalizationRegistry 대량 생성, Atomics.waitAsync + 다수의 setTimeout, 반복적인 gc() 호출)이 정확히 이 grooming 형태에 해당합니다.
이 vulnerability는 JSC 런타임 내부의 메모리 안전성을 약화시켰습니다. 패치 이전 코드는 m_tasks에서 pop된 TicketData*가 항상 m_pendingTickets에 참조되어 있거나, 존재하지 않아 무해하다는 불변 조건을 위반합니다. 동일한 ticket pointer가 두 번 큐에 추가된 상태에서 첫 번째 pop이 해제를 발생시키면, 두 번째 pop은 dangling pointer 역참조가 됩니다. TZone 재사용 조건에서는 이것이 잘못된 객체에 대한 스케줄링 혼동으로 나타납니다. 아직 살아있는 ticket이 keep-alive Ref를 잃고, 그 ticket의 실제 작업 대신 no-op task가 실행됩니다. 무조건적인 m_tasks.append는 이미 removeIf(isCancelled)가 수행하는 cleanup을 중복으로 처리하던 방어 코드의 잔재였습니다.
Audit directions
- 별도의 refcount 보유 컨테이너와 짝을 이루는 raw pointer work queue.
Deque<tuple<RawPtr, Task>>방식의 큐가 작업 backlog로 사용되고HashSet<Ref<T>>가 keep-alive 역할을 하는 다른 JSC 서브시스템을 점검합니다.Source/JavaScriptCore/runtime/DeferredWorkTimer.h,Source/JavaScriptCore/runtime/Microtask.*,JSPromisereaction 큐,VM::deferredWorkTimer사용 코드부터 시작하고, 어떤 producer 경로도 Ref 수명 내에 raw pointer를 두 번 이상 큐에 추가할 수 없는지 확인합니다. - 단일 후행 sweep에 의존하는 지연 취소 cleanup. JSC 런타임 코드 전반에서
removeIf(isCancelled)와m_isCancelled플래그를 검색합니다. 그 다음 어떤 호출 지점도 sweep과 병행하여 추가적인 sentinel/no-op 항목을 추가하지 않는지 확인합니다.JSGlobalObject,WaiterListManager,Microtaskcancellation 내의cancelPendingWorkSafe방식 teardown 헬퍼도 살펴봅니다. - Raw pointer freelist를 거쳐 살아남는 TZone/type-stable allocator UAF.
TicketData*,Microtask*또는 이와 유사한 TZone 관리 raw pointer를 key로 저장하는 컨테이너를 점검합니다. TZone 재사용 조건에서는 해제된 pointer의 두 번째 사용이 다른 live 인스턴스를 조용히 참조할 수 있습니다. 이러한 조회에 generation counter나WeakPtr을 적용해야 하는지 검토합니다. - 공유 런타임 큐에 접근하는 cross-realm teardown.
JSGlobalObject::~JSGlobalObject와JSGlobalObject::destroy에서 VM 전역 큐로 이어지는 모든 호출 경로를 추적합니다. 동일한 VM 위에서 아직 살아있는 다른 realm의addPendingWork/scheduleWorkSoon호출과 인터리빙되는 상황에서도 각 cleanup이 멱등성을 유지하는지 확인합니다.