[6] DeferredWorkTimer queued duplicate raw tickets during realm teardown
A teardown path that added work to the queue it was supposed to drain
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_tasksentry 없이도m_pendingTickets에서 cancel된 ticket을 제거하기 때문입니다. 또한setTimeUntilFire(0_s)가 이미doWork()가 실행되어 해당 cleanup을 수행하도록 보장합니다. 원래305413.677@safari-7624-branch로 반영되었습니다.
Source/JavaScriptCore/runtime/DeferredWorkTimer.cpp
Source/JavaScriptCore/runtime/DeferredWorkTimer.cpp (context, unchanged — doWork())
JSTests/wasm/stress/deferred-work-timer-cancel-duplicate-ticket.js
Patch Details
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되는 패턴입니다.
Background
DeferredWorkTimer.
VM이 소유하는 JSRunLoopTimer로, run loop 위에서 지연된 작업을 실행합니다. 호출 측은 addPendingWork()로 ticket을 얻은 뒤, 작업이 준비되면 scheduleWorkSoon(ticket, task)를 호출합니다. doWork()는 이후 run-loop turn에서 queue를 drain합니다.
TicketData와 Ticket.
TicketData는 ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr 객체로, 작업의 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 — 에 의해 재사용됩니다.
Analysis
이 버그는 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_pendingTickets는 Ref<TicketData>를 보유하는 반면 m_tasks는 bare TicketData*를 보유하므로, m_tasks에 append하는 모든 코드 경로는 자신이 소유하지 않는 lifetime을 암묵적으로 보증하는 셈이 됩니다. doWork()에는 이 취약성을 인지한 듯한 주석이 이미 두 곳에 남아있고, find(ticket) miss-check 역시 정확히 stale entry에 대한 방어 장치입니다. 다만 이 방어는 hash key가 곧 pointer 값이기 때문에 주소 재사용 상황에서는 조용히 실패합니다. 이 클래스의 문제를 근본적으로 해결하려면, queue tuple에 Ref나 ThreadSafeWeakPtr<TicketData>를 저장하는 방식이 필요합니다. 해당 타입이 이미 ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr를 상속하고 있는 만큼, 모든 queue entry 작성자가 소유권을 매번 올바르게 추론하도록 맡기는 것보다 이 편이 견고합니다. 아울러 제거된 코드가 순수한 중복이었다는 점도 눈여겨볼 만합니다. 기존 cleanup과 겹치는 방어적으로 보이는 코드가 오히려 새로운 lifetime hazard를 끌어들인 사례이기 때문입니다.
Audit directions
-
작업 항목을 raw address로 참조하는 queue인데, 유일한 owning reference는 다른 container에 있는 패턴. 좁게 보면,
DeferredWorkTimer::m_tasks에 쓰기를 수행하는 모든 지점 —scheduleWorkSoon,didResumeScriptExecutionOwner,doWork끝부분의suspendedTasks재삽입, 그리고 teardown 경로에 남아있을 수 있는 다른 append들 — 을 점검하여, enqueue된 각Ticket이 entry가 살아있는 동안m_pendingTickets에 반드시 존재함을 보장하는지 확인해야 합니다. 리뷰 시std::make_tuple(x.ptr(), ...)형태나 raw-pointer append이면서 감싸는 scope 끝에서 strongRef가 drop되는 패턴이 있으면 의심 신호입니다. 넓게 보면, JSC/WebCore 어디서든 strong-ref registry와 raw-pointer schedule을 짝지어 쓰는 곳이라면 같은 클래스의 문제가 나타날 수 있습니다 —MicrotaskQueueentry, pointer로 작업을 queue하는JSRunLoopTimer서브클래스, cell address로 조회하는WeakGCMap/WeakGCSet기반 스케줄러 등이 해당합니다. 의심 신호는 element type이T*또는std::tuple<T*, ...>인 container가, 같은 타입의HashSet<Ref<T>>형태 sibling member와 나란히 존재하는 경우입니다. 가장 넓게 보면, 지연 작업 record는 자신이 지칭하는 객체를 소유하거나 최소한 outlive 여부를 확인할 수 있어야 한다는 원칙으로, pointer나 index를 key로 삼고 소유권은 다른 곳에 있는 task queue를 가진 어떤 코드베이스에도 적용됩니다. Chromium의base::TaskRunnerclosure에서 사용되는raw_ptr/WeakPtr, Rust의 slotmap/generational-index arena, rawvoid* udata를 전달하는 epoll/kqueue registration이 그 예입니다. 여기서 상응하는 의심 신호는, bare address나 index로부터 객체를 복원한 뒤 generation이나 epoch 검증 없이 identity를 그대로 신뢰하는 조회 로직입니다. -
불필요한 작업을 오히려 추가하는 teardown 경로. 좁게 보면,
Source/JavaScriptCore/runtime안에서JSGlobalObject/VMteardown 시 호출되면서 queue에 append하거나 timer를 schedule하는 함수들 —cancelPendingWorkSafe,cancelPendingWork(VM&),stopRunningTasks, 그리고setTimeUntilFire(0_s)호출 지점 — 을 검색하여, enqueue된 payload가 realm의 객체가 모두 사라진 뒤에도 drain 가능한지 확인해야 합니다. 의심 신호는 cancel/stop/destroy 함수 본문 안에 있는 append/prepend/schedule 호출입니다. 넓게 보면, WebCore teardown에서도 같은 패턴이 반복됩니다 —ScriptExecutionContext의 stop/suspend 경로, task를 post하는ActiveDOMObject::stop()구현체, purge 대신 dispatch를 수행하는 document detach handler 등이 해당합니다. 의심 신호는 stop/detach handler 본문에 purge 대신 post/dispatch/enqueue가 들어있는 경우입니다. 가장 넓게 보면, shutdown은 monotone drain이어야 한다는 원칙입니다 — teardown은 cancel과 remove만 수행해야 하며 절대 새로 enqueue해서는 안 됩니다. 이는 새 작업을 schedule하는 Node.js의beforeExithandler, goroutine을 spawn하는 Go의contextcancellation 경로, 공유 queue에 push하는 Rust의Drop구현에도 그대로 적용됩니다. 의심 신호는 cancellation 루틴이 queue 길이에 순증가를 일으키는 모든 경우입니다. -
type-segregated allocator에 의해 주소가 재사용될 수 있는 raw pointer를 key로 사용하는 hash lookup. 이로 인해 원래 "찾지 못하면 안전하게 넘어간다"는 guard가 조용한 false match로 바뀝니다. Narrow 범위에서는
doWork()의m_pendingTickets.find(ticket)/ASSERT(ticket == pendingTicket->ptr())guard를 살펴봐야 합니다.T*를HashSet<Ref<T>>의 key로 쓰는 다른 JSC lookup들도 마찬가지로, miss만을 유일한 실패 모드로 가정하고 있는지 확인할 필요가 있습니다. 특징적인 패턴은Ref<T>/RefPtr<T>컨테이너에 대해find(rawPtr)를 호출한 뒤, hit을 identity의 증거로 취급하는 코드입니다. Wider 범위에서는 WebKit의 TZone/IsoHeap 기반 타입 전반(WTF_MAKE_TZONE_ALLOCATED로 선언되어 map key로 쓰이는 클래스들)과, stale identifier가 재발급될 수 있는ObjectIdentifier계열 테이블로 확장해서 봐야 합니다. 특징적인 패턴은 TZone allocation 대상이면서 동시에 주소 기반 lookup key로 쓰이는 타입입니다. Widest 범위에서 보면, allocator가 주소를 재사용할 수 있는 이상 pointer identity는 더 이상 object identity를 보장하지 않습니다. Identity 검증에는 generation counter, UUID, 혹은 strong reference가 필요합니다. 이 패턴은 Windows HANDLE 재사용, close 이후 POSIX fd 재사용, ECS 엔진의 generational index 문제로도 확장됩니다. 공통적으로 나타나는 특징은, 재사용 가능한 주소나 handle 값의 동일성만을 근거로 한 identity 비교입니다.