← All reports

[6] DeferredWorkTimer queued duplicate raw tickets during realm teardown

MediumJSC runtimeOther

A teardown path that added work to the queue it was supposed to drain

e7c6375

Medium 등급입니다. 제거된 라인 자체는 순수한 중복이었습니다. drain loop 마지막에 있는 cancelled-ticket sweep이 이미 같은 일을 하고 있었기 때문입니다. 다만 이 라인은 이미 유일한 소유자가 해제된 주소를 가리키는 두 번째 queue entry를 남겨두었습니다. 확장을 위해서는 stale entry가 drain되기 전에 해제된 slot이 다른 ticket으로 다시 채워져야 합니다.

작업 항목을 raw address로 식별하는 queue는, 다른 무언가가 해당 객체를 queue entry 이상으로 계속 살려두는 동안에만 안전합니다. DeferredWorkTimer는 JavaScript engine에서 지연된 작업을 처리하는 run-loop timer로, WebAssembly compile promise, Atomics.waitAsync resolution, FinalizationRegistry cleanup 등을 담당합니다. 이 작업은 소유 강도가 다른 두 개의 container로 나뉘어 관리됩니다. 하나는 strong reference로 ticket을 소유하고, 다른 하나는 실행할 callable과 함께 bare pointer로 ticket을 큐잉합니다. 여기서 암묵적인 invariant는, queue에 들어있는 모든 raw ticket이 소유 측 set에 여전히 살아있는 ticket에 의해 뒷받침되어야 하고, 각 ticket은 살아있는 owner당 최대 한 번만 그곳에 등장해야 한다는 것입니다.

관전 포인트: realm teardown 시점에 ticket allocator를 조작한 페이지가 있다면, work timer가 살아있는 무관한 ticket을 소비하고 파괴하도록 유도할 수 있습니다. 그 결과 해당 ticket 자신의 queue entry가 dangling 상태로 남게 됩니다.

cancelPendingWorkSafe()는 죽어가는 global의 모든 weak ticket에 대해 (ticket, noop) entry를 조건 없이 m_tasks에 append하고 있었습니다. 이는 불필요한 동작인데, doWork() 마지막 부분에 이미 removeIf(isCancelled) pass가 있어 m_tasks entry 없이도 m_pendingTickets에서 cancel된 ticket을 제거하기 때문입니다. 또한 setTimeUntilFire(0_s)가 이미 doWork()가 실행되어 해당 cleanup을 수행하도록 보장합니다. 원래 305413.677@safari-7624-branch로 반영되었습니다.

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);
}

Source/JavaScriptCore/runtime/DeferredWorkTimer.cpp (context, unchanged — doWork())

while (!m_tasks.isEmpty()) {
auto [ticket, task] = m_tasks.takeFirst();
auto pendingTicket = m_pendingTickets.find(ticket); // raw TicketData* used as key
if (pendingTicket == m_pendingTickets.end())
continue;
ASSERT(ticket == pendingTicket->ptr());
if (ticket->isCancelled()) {
m_pendingTickets.remove(pendingTicket); // drops the last Ref<TicketData>
continue;
}
...
}
m_pendingTickets.removeIf([] (auto& ticket) { return ticket->isCancelled(); });

JSTests/wasm/stress/deferred-work-timer-cancel-duplicate-ticket.js

+function setupChildGlobal() {
+ var childGlobal = createGlobalObject();
+ childGlobal.eval(
+ 'for (var k = 0; k < this.N_REGISTRIES; k++) {' +
+ ' var fr = new FinalizationRegistry(function(h){});' +
+ ' (function(){ fr.register({}, 1); })();' +
+ ' globalThis.__registries.push(fr);' +
+ '}');
+ gc();
+ return childGlobal;
+}
+function run() {
+ var childGlobal = setupChildGlobal();
+ globalThis.p1 = Atomics.waitAsync(i32, 0, 0).value;
+ Atomics.notify(i32, 0);
+ childGlobal = null; // child global becomes garbage -> cancelPendingWorkSafe
+ return 0;
+}
+run();
+p1.then(function () { for (var i = 0; i < N_DEFERRED_WORK1; i++) setTimeout(function () { }, 10); });
+gc(); gc(); gc();
+var p2 = Atomics.waitAsync(i32, 0, 0).value;
+Atomics.notify(i32, 0);

DeferredWorkTimer::cancelPendingWorkSafe(JSGlobalObject*)에서 한 줄이 삭제되었습니다. 이 함수는 *globalObject->m_weakTickets를 순회하면서 아직 살아있는 모든 ticket을 cancelPendingWork(ticket.ptr())로 cancel합니다. 패치 이전에는 여기에 더해, 죽어가는 global에 속한 모든 weak ticket에 대해 std::make_tuple(ticket.ptr(), [](DeferredWorkTimer::Ticket) { }) — raw Ticket과 noop Task의 쌍 — 을 m_tasks deque에 조건 없이 append했습니다. 여기에는 이전 scheduleWorkSoon() 호출로 이미 entry를 갖고 있던 ticket도 포함되었습니다. 패치는 이 append를 제거하되, 마지막의 if (!isScheduled() && !m_currentlyRunningTask) setTimeUntilFire(0_s);는 그대로 유지합니다. 따라서 doWork()는 여전히 실행됩니다. 회귀 테스트는 300개의 FinalizationRegistry 인스턴스와 Atomics.waitAsync ticket을 가진 child global을 구동한 뒤 해당 global을 drop시키고, setTimeout 작업으로 heap을 채우면서 gc()를 반복 호출합니다.

Queue entry가 raw address로 대상을 식별하는데, 해당 객체의 유일한 owning reference는 다른 container에 있어서 두 번째 entry가 owner보다 더 오래 살아남고, 이후 새로 들어온 다른 객체를 상대로 재resolve되는 패턴입니다.

DeferredWorkTimer. VM이 소유하는 JSRunLoopTimer로, run loop 위에서 지연된 작업을 실행합니다. 호출 측은 addPendingWork()로 ticket을 얻은 뒤, 작업이 준비되면 scheduleWorkSoon(ticket, task)를 호출합니다. doWork()는 이후 run-loop turn에서 queue를 drain합니다.

TicketDataTicket. TicketDataThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr 객체로, 작업의 GC 의존성(FixedVector<JSCell*> m_dependencies), script-execution owner, cancellation flag를 갖고 있으며 TZone으로 할당됩니다. Ticket은 bare TicketData*에 대한 type alias로, 관리되지 않는 raw pointer입니다.

두 개의 container, 두 가지 소유 강도. UncheckedKeyHashSet<Ref<TicketData>> m_pendingTickets는 strong reference를 보유하며 ticket의 owner 역할을 합니다. m_taskLock으로 보호되는 Deque<std::tuple<Ticket, Task>> m_tasks는 raw pointer와 실행할 callable을 함께 보유합니다.

Weak ticket registry.TicketData는 생성자에서 target()->realm()->addWeakTicket(this)를 통해 자신이 속한 realm에 스스로를 등록합니다. 이로써 JSGlobalObject는 자신에게 속한 ticket 목록을 열거할 수 있습니다. cancelPendingWorkSafe(JSGlobalObject*)JSGlobalObject teardown 과정에서 호출되며, realm에 남아있는 ticket을 cancel 상태로 표시하고 setTimeUntilFire(0_s)로 timer를 깨워 doWork()가 cleanup sweep을 수행하도록 합니다.

Drain loop. doWork()는 queue에 들어있는 각 entry마다 raw ticket을 m_pendingTickets에서 조회합니다. 찾지 못한 entry는 건너뛰고, cancel된 entry는 제거하며, task를 실행하는 동안에는 lock을 풀어둡니다. 마지막으로 m_pendingTickets.removeIf(isCancelled)를 실행해, 한 번도 schedule되지 않은 채 cancel된 ticket을 정리합니다.

Script에서 도달 가능한 ticket 생성 경로. Atomics.waitAsync(Atomics.notify로 resolve됨), WebAssembly의 비동기 compile/instantiate promise, FinalizationRegistry cleanup scheduling이 있습니다.

TZone allocation. WebKit의 type-segregated heap 방식으로, 해제된 객체의 slot은 같은 type의 다른 allocation — 여기서는 또 다른 TicketData — 에 의해 재사용됩니다.

이 버그는 stale raw pointer 문제이면서, ABA 스타일의 identity 혼동으로 드러납니다. 유일한 owner가 drop된 주소를 키로 삼는 queue entry가 있는데, 같은 주소를 가리키는 또 다른 entry가 여전히 queue에 남아있는 상황입니다.

  m_tasks (raw TicketData*)          m_pendingTickets (Ref<TicketData>)
  ─────────────────────────          ─────────────────────────────────
  [T, realTask]  (scheduleWorkSoon)   { T }
  [T, noop]      (teardown append)    <-- the removed line
  doWork() entry 1: T cancelled -> m_pendingTickets.remove(T) -> free(T)
  doWork() entry 2: find(T) keyed on a freed address
      miss -> skipped harmlessly
      hit  -> a NEW ticket T' occupies the slot: take/consume/destroy T'
              and T''s own real queue entry is now the dangling one

이미 scheduleWorkSoon()으로 실제 entry가 queue에 걸려있던 ticket에 대해, teardown 경로는 strong reference도 없고 기존 entry 존재 여부 확인도 없이 같은 주소의 두 번째 entry를 추가했습니다. doWork()는 첫 번째 entry를 처리하면서 ticket->isCancelled()가 참임을 확인합니다. 앞서 루프에서 이 flag를 설정했기 때문입니다. 그 결과 m_pendingTickets.remove(pendingTicket)가 실행되어 마지막 Ref<TicketData>가 사라지고 객체가 파괴됩니다. 이때 중복 entry는 여전히 남아 해제된 주소를 들고 있습니다. Hashing 과정에서는 dereference가 일어나지 않으므로, 대부분의 경우 조회는 miss로 끝나고 무해하게 skip됩니다.

문제가 되는 경우는 주소 재사용입니다. 만약 새로 생성된 TicketData가 방금 해제된 TZone slot에 자리 잡는다면 — 그리고 테스트의 setTimeout flood와 반복적인 gc()는 정확히 이런 slot churn을 만들어내는 장치입니다 — 조회는 무관한 살아있는 ticket과 일치하게 됩니다. ASSERT(ticket == pendingTicket->ptr())는 주소가 같으므로 통과하고, doWork()는 원래 queue되었던 적이 없는 ticket을 dereference하여 소비합니다. m_pendingTickets.take(pendingTicket)가 이를 제거하고, noop task가 실행되며, ticketData = nullptr로 객체가 파괴됩니다. 그 결과 실제 피해자 ticket 자신의 queue entry는 dangling 상태로 남습니다. 같은 append로 인한 부차적이고 race와 무관한 결과로, realm teardown 시 m_tasks가 무한정 늘어나는 문제도 있습니다. 죽어가는 global의 weak ticket 하나당 entry 하나가 추가되어 테스트에서는 300개 이상 쌓이는데, 이것이 주소 재사용 window를 실질적으로 넓히는 요인입니다.

제공된 소스에는 cancelPendingWork(Ticket)의 함수 본문이 잘려 있어 포함되어 있지 않습니다. 이 함수가 단순히 cancelled flag만 설정하고 ticket을 m_pendingTickets에 그대로 남겨둔다는 해석은, doWork() 마지막 부분의 주석 — "cancel a pending ticket and never call scheduleWorkSoon()" 하는 client를 언급하는 부분 — 과 removeIf(isCancelled) sweep을 근거로 한 추정에 해당합니다.

이 취약점은 JavaScript engine의 비동기 작업 스케줄러 내부에서 memory safety를 약화시킵니다. 여기서 걸려 있는 전제는, work queue가 이미 owning Ref<TicketData>가 해제된 raw Ticket을 절대 들고 있지 않아야 한다는 것입니다. 패치 이전의 teardown 경로는 이미 schedule된 ticket을 가진 모든 realm destruction에서 이 invariant를 깨뜨리고 있었습니다. 공격자가 TZone allocator를 조작해 해제된 slot이 stale entry의 drain보다 먼저 재점유되도록 만들 수 있다면, doWork()가 무관한 살아있는 ticket을 소비하고 파괴하도록 유도할 수 있습니다. 이는 engine 내부 객체의 lifetime을 손상시키고, WebContent process 안에서 use-after-free primitive로 이어질 가능성이 있습니다. 이번 패치는 trailing sweep이 이미 처리하는 ticket을 다시 enqueue하지 않도록 함으로써 해당 invariant를 복원합니다.

구조적인 약점은 제거된 한 줄 자체가 아니라, 그 줄을 위험하게 만든 소유권 분리 모델에 있습니다. m_pendingTicketsRef<TicketData>를 보유하는 반면 m_tasks는 bare TicketData*를 보유하므로, m_tasks에 append하는 모든 코드 경로는 자신이 소유하지 않는 lifetime을 암묵적으로 보증하는 셈이 됩니다. doWork()에는 이 취약성을 인지한 듯한 주석이 이미 두 곳에 남아있고, find(ticket) miss-check 역시 정확히 stale entry에 대한 방어 장치입니다. 다만 이 방어는 hash key가 곧 pointer 값이기 때문에 주소 재사용 상황에서는 조용히 실패합니다. 이 클래스의 문제를 근본적으로 해결하려면, queue tuple에 RefThreadSafeWeakPtr<TicketData>를 저장하는 방식이 필요합니다. 해당 타입이 이미 ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr를 상속하고 있는 만큼, 모든 queue entry 작성자가 소유권을 매번 올바르게 추론하도록 맡기는 것보다 이 편이 견고합니다. 아울러 제거된 코드가 순수한 중복이었다는 점도 눈여겨볼 만합니다. 기존 cleanup과 겹치는 방어적으로 보이는 코드가 오히려 새로운 lifetime hazard를 끌어들인 사례이기 때문입니다.