← All reports

[4] JSC DeferredWorkTimer hands raw tickets to Wasm background threads

HighJSC runtimeUAF

A dead Wasm ticket comes back as someone else's live job.

15c117a

High. 이 primitive는 단순한 dangling read가 아닙니다. 해제된 ticket의 allocator slot이 재사용되기 때문에, stale pointer가 살아있는 무관한 ticket과 우연히 일치하여 그 dispatch를 가로챌 수 있습니다. 그 결과 피해자 ticket이 살려두고 있던 cell들의 rooting이 풀려버립니다. 이 경로에 도달하려면, global object를 회수하는 collection이 진행되는 동안 Wasm compile이 여전히 진행 중이어야 합니다.

백그라운드 스레드에서 완료되는 비동기 JS API는 VM 자신의 run loop가 처리할 때까지 promise와 그 operand들을 어딘가에 보관해 둘 곳이 필요하며, 그 사이 동안 이 garbage-collected 객체들을 계속 reachable 상태로 유지해줄 무언가도 필요합니다. DeferredWorkTimer가 바로 그 보관소 역할을 합니다. VM 레벨 scheduler로서, Wasm compile/instantiate/validate, Atomics.waitAsync, FinalizationRegistry cleanup 등 대기 중인 작업마다 ticket을 하나씩 들고 있습니다. 각 ticket은 collector가 방문하는 dependency vector를 갖고 있어서, 거기에 이름이 올라간 cell들이 계속 살아있게 됩니다. ticket에 대한 유일한 strong reference는 VM의 main thread에 있는 timer의 set 안에 존재하며, 작업을 마친 백그라운드 스레드는 dispatch를 요청할 때 자신의 ticket을 이름으로 지목합니다.

관전 포인트: worklist thread에서 아직 실행 중인 WebAssembly.compile이 있는 상태에서 그 global object가 collect되면, 해당 스레드는 이미 해제된 ticket 주소를 손에 쥔 채로 남게 됩니다. 그 주소에 새로운 allocation이 재사용되면, stale task가 살아있는 job을 밀어내고 아무도 rooting하지 않는 cell들을 대상으로 실행될 수 있습니다.

이번 변경은 안전하지 않은 사용처를 patch하는 대신, 안전하지 않은 표현 자체를 제거합니다. addPendingWork()는 이제 bare TicketData* 대신 WeakTicket을 반환합니다. scheduleWorkSoonIfActive()scheduleWorkSoon(Ticket, Task&&)를 대체하여 유일한 enqueue 경로가 되었고, queue에 넣기 전에 weak reference를 promote하면서 동시에 검사합니다. task queue는 bare pointer를 담던 Deque<std::tuple<Ticket, Task>>에서 Ref<Ticket>을 담는 형태로 바뀌었고, 그 결과 queue에 들어간 ticket은 enqueue와 dispatch 사이에서 해제될 수 없게 되었습니다. 마지막으로 JSWebAssembly.cpp의 세 비동기 진입점 — webAssemblyModuleValidateAsync, instantiate, compileAndInstantiate — 은 createSharedTask lambda에서 promise, globalObject, instance, importObject를 캡처하는 방식을 중단하고, 이제 확실히 살아있음이 보장된 ticket으로부터 task body 내부에서 해당 operand들을 다시 얻어옵니다.

소유자만이 strong reference를 들고 있는 상태에서, 동일 객체의 raw address가 다른 스레드로 새어나가고, allocator slot 재사용이 stale address를 거짓된 identity match로 둔갑시키는 패턴입니다.

Ticket과 rooting. ticket은 대기 중인 비동기 job 하나를 가리키는 handle입니다. 그 dependency vector는 JSGlobalObject::visitChildrenImpl에서 방문되며, 이 과정을 통해 promise, global object, 그 외 operand들이 비동기 구간 동안 살아있게 됩니다. 취소된 ticket은 이 방문에서 건너뛰어집니다.

Fix 이전의 ownership 구조. timer는 VM의 main thread에서 UncheckedKeyHashSet<Ref<TicketData>> m_pendingTickets를 들고 있었고, 이 set이 유일한 strong reference였습니다. addPendingWork()는 호출자에게 TicketData*를 넘겨주었습니다.

Thread-safe refcounting과 weak promotion. TicketDataThreadSafeRefCounted이므로 refcount 갱신이 스레드 간에 atomic하게 이루어집니다. ThreadSafeWeakPtr<T>는 객체를 살아있게 유지하지는 않지만, 객체가 여전히 살아있을 때에 한해 atomic하게 RefPtr로 변환하는 promote가 가능합니다. WasmStreamingCompiler는 이미 ThreadSafeWeakPtr<TicketData> m_ticket을 갖고 있었고, takeTicketIfActive()를 통해 이를 promote해 왔습니다.

TZone allocation. TicketWTF_MAKE_TZONE_ALLOCATED로 선언되어 있어, type별로 분리된 allocator zone에서 제공됩니다. 해제된 slot은 같은 타입의 이후 allocation을 위해 재사용되므로, 새로 생성된 TicketData가 파괴된 객체가 있던 것과 정확히 같은 주소에 놓일 수 있습니다.

Wasm worklist thread. Streaming 및 non-streaming Wasm compilation은 백그라운드 worklist thread에서 실행됩니다. 이들의 완료 callback은 VM의 run loop가 아니라 바로 그 스레드에서 발생합니다.

근본 원인은 addPendingWork()가 다른 곳에 유일한 strong reference가 있는 객체에 대해 raw pointer를 넘겨주었다는 데 있습니다. 이 표현 방식 하나에서 두 가지 노출 경로가 파생되었습니다. 먼저, JSWebAssembly.cpp의 세 비동기 경로는 worklist thread에서 실행되는 createSharedTask lambda 안으로 bare TicketData*를 그대로 복사해 넣었는데, 이때 함께 캡처된 raw GC pointer들의 유일한 marking root는 바로 그 ticket의 dependency vector였습니다. 두 번째로 — 이 부분은 weak promotion을 제대로 수행하던 호출자마저 걸려들게 만드는데 — 기존의 scheduleWorkSoon(Ticket, Task&&)는 task deque에 append하는 순간 promote된 RefPtr을 즉시 bare pointer로 다시 격하시켰습니다. 그 결과 enqueue부터 dispatch까지의 구간 동안 소유되지 않은 주소가 queue에 그대로 남아있게 되었습니다.

  VM main thread                       Wasm worklist thread
  --------------                       --------------------
  addPendingWork() -> TicketData* -------> raw ptr captured in lambda
  GC: globalObject unreachable
  cancelPendingWorkSafe(): mark cancelled
  doWork() trailing sweep:
    last Ref dropped -> TZone slot freed
  unrelated addPendingWork()
    reuses the same slot
                                       scheduleWorkSoon(stalePtr, task)
  doWork(): m_pendingTickets.find(stalePtr)
    matches the LIVE ticket at that address
    -> victim evicted, stale task dispatched
    -> victim's dependencies stop being rooted

재현 과정에서는 해제 경로가 중요한 역할을 합니다. 연결된 JSGlobalObject가 unreachable 상태가 되었는데도 백그라운드 compilation이 여전히 실행 중이면, cancelPendingWorkSafe()globalObject->m_weakTickets를 순회하면서 각 ticket을 cancelled로 표시하고 0초짜리 timer를 세팅합니다. 추가된 테스트의 주석에 따르면 이 시점은 GC 종료 시점에 해당합니다. 이 시점에는 아직 해당 ticket에 queue된 task가 없으므로, 해제는 loop 내부의 ticket->isCancelled() 분기에서 일어나지 않습니다. 대신 doWork() 끝부분의 trailing sweep에서 cancelled entry들을 m_pendingTickets에서 제거하면서 마지막 Ref<TicketData>가 해제되고, 그 객체의 TZone slot이 반환되는 과정에서 발생합니다.

여전히 raw pointer를 들고 있던 백그라운드 스레드는 이후 fix 이전 버전의 scheduleWorkSoon(stalePtr, ...)를 호출합니다. 이 호출 자체는 ticket을 dereference하지 않습니다. m_taskLock을 잡고 주소를 append한 뒤 isScheduled() / m_currentlyRunningTask를 확인할 뿐입니다. dangling dereference는 이후 doWork()에서 실제로 발생합니다. pointer를 key로 하는 m_pendingTickets.find(ticket) 조회, 그리고 뒤이은 ticket->isCancelled() / ticket->target() 호출 지점입니다. 그리고 이는 그 조회가 일치할 때만 일어납니다. 조회가 miss라면 해당 entry는 건너뛰어지고 아무것도 dereference되지 않으므로, 이 취약점에 도달하려면 slot 재사용이 반드시 필요한 조건이 됩니다.

조회가 일치하는 경우 두 가지 일이 이어집니다. doWork()는 그 무관하지만 살아있는 ticket을 m_pendingTickets에서 제거하고 그 자리에 stale task를 실행하며, 그 결과 원래의 죄 없는 작업 항목은 유일한 strong reference를 잃고 그 dependency vector가 더 이상 자신이 살려두던 cell들을 rooting하지 못하게 됩니다. 이때 ASSERT(ticket == pendingTicket->ptr())는 단순 address 일치만으로 통과합니다. 이어서 fix 이전 버전에서는 ticket parameter를 완전히 무시하고 자체적으로 캡처해둔 raw promise / globalObject / instance / importObject를 사용하던 stale JSWebAssembly.cpp lambda body가, ticket 취소·파괴와 함께 marking root를 잃어버린 cell들을 대상으로 실행됩니다.

결과적으로 이는 ThreadSafeRefCounted 객체에 대한 use-after-free이며, slot 재사용을 거쳐 pointer identity confusion으로 확장되고, 여기서 다시 파괴된 ticket이 rooting하고 있던 JS cell들에 대한 use-after-free로 이어집니다. 이를 성공시키려면 compile이 아직 진행 중인 상태에서 collection이 global object를 회수하도록 유도해야 하고, dispatch 이전에 해제된 slot에 새로운 ticket이 할당되도록 만들어야 합니다. 두 조건 모두 allocation pressure나 여러 비동기 Wasm job을 동시에 진행시키는 방식으로 스크립트가 어느 정도 영향을 줄 수 있는 요소입니다.

이 취약점은 collector의 reachability graph가 진행 중인 비동기 job이 다루는 모든 것을 포괄한다는 보장을 약화시킵니다. 구조적으로, operand가 더 이상 아무것에도 rooting되지 않은 채로 job이 dispatch될 수 있는 상황이 만들어지는 셈입니다.