← All issues

[13] DeferredWorkTimer leaves stale ticket pointer in m_tasks after cancellation

Severity: Medium | Component: JavaScriptCore DeferredWorkTimer | e7c6375

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

void DeferredWorkTimer::cancelPendingWorkSafe(JSGlobalObject* globalObject)
{
for (Ref<TicketData> ticket : *globalObject->m_weakTickets) {
if (!ticket->isCancelled())
cancelPendingWork(ticket.ptr());
- m_tasks.append(std::make_tuple(ticket.ptr(), [](DeferredWorkTimer::Ticket) { }));
}
if (!isScheduled() && !m_currentlyRunningTask)
setTimeUntilFire(0_s);

제거된 것은 단 한 줄입니다. 소멸 중인 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 해제 이후에도 남아있었습니다.

DeferredWorkTimer는 두 개의 자료구조를 관리합니다. 첫 번째인 m_pendingTickets는 각 ticket의 keep-alive reference를 보유하는 HashSet<Ref<TicketData>>이고, 두 번째인 m_tasksTicket = 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들을 정리합니다. TicketDataWTF_MAKE_TZONE_ALLOCATED_IMPL로 할당됩니다. 해제된 인스턴스는 type별로 분리된 freelist로 반환되며, 다음 TicketData::create 호출이 동일한 주소를 재사용할 수 있습니다. HashSet<Ref<T>>::find(rawPtr)는 pointer 값 기반의 해싱/동등 비교를 사용하며, 멤버십 결정 시 입력 pointer를 역참조하지 않습니다.

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을 중복으로 처리하던 방어 코드의 잔재였습니다.