← All issues

[3] Use-after-free of StreamingCompiler::m_ticket across iframe teardown

compileStreaming hands a raw deferred-work ticket and a raw globalObject to a callback that fires later — remove the iframe first, and both are already gone.

Severity: High | Component: JavaScriptCore Wasm streaming compiler | acee67e

diff는 raw deferred-work ticket과 raw JSGlobalObject capture를 각각 liveness 검사가 포함된 weak reference로 전환했습니다. 이를 통해 iframe teardown 이후 해제된 realm 범위 상태에 대한 안정적인 역참조 취약점이 수정된 점에서 High로 평가됩니다. 더 강력한 memory-corruption primitive로의 확장은 공격자가 해제된 allocation을 안정적으로 재사용할 수 있어야 하는데, diff에서는 이를 확인할 수 없습니다.

이 패치는 Wasm streaming compiler가 컴파일 완료 전에 종료되는 iframe 내에서 호출될 때 발생하는 UAF를 수정합니다. streaming 컴파일의 완료 결과는 컴파일 시작 시 생성된 lambda에서 처리됩니다. 이 lambda는 globalObject에 대한 raw pointer를 직접 capture하며, streaming compiler를 통해 TicketData에 대한 raw pointer도 간접적으로 보유합니다. lambda가 이 두 객체보다 오래 살아남는 상황이 가능합니다. 컴파일 완료 전에 iframe이 제거되면, iframe의 globalObject가 수집되고 ticket은 취소 및 소멸됩니다. 컴파일이 완료되는 시점에 lambda는 dangling pointer를 역참조하게 됩니다.

Source/JavaScriptCore/wasm/WasmStreamingCompiler.h

- DeferredWorkTimer::Ticket m_ticket;
+ ThreadSafeWeakPtr<DeferredWorkTimer::TicketData> m_ticket;

Source/JavaScriptCore/wasm/WasmStreamingCompiler.cpp

void StreamingCompiler::didComplete()
{
auto result = makeValidationResult(*m_plan);
- auto ticket = std::exchange(m_ticket, nullptr);
+ auto ticket = takeTicketIfActive();
+ if (!ticket)
+ return;
...
+RefPtr<DeferredWorkTimer::TicketData> StreamingCompiler::takeTicketIfActive()
+{
+ auto ticket = m_ticket.get();
+ m_ticket = nullptr;
+ if (!ticket || ticket->isCancelled())
+ return nullptr;
+ return ticket;
+}
+
+JSGlobalObject* StreamingCompiler::globalObjectIfActive()
+{
+ auto ticket = m_ticket.get();
+ if (!ticket || ticket->isCancelled())
+ return nullptr;
+ return uncheckedDowncast<JSGlobalObject>(ticket->dependencies()[0]);
+}

Source/WebCore/bindings/js/JSDOMGlobalObject.cpp

- inputResponse->consumeBodyReceivedByChunk([globalObject, compiler = WTF::move(compiler)](auto&& result) mutable {
- VM& vm = globalObject->vm();
+ inputResponse->consumeBodyReceivedByChunk([vmPtr = &vm, compiler = WTF::move(compiler)](auto&& result) mutable {
+ auto& vm = *vmPtr;
JSLockHolder lock(vm);
+ auto* globalObject = compiler->globalObjectIfActive();
+ if (!globalObject)
+ return;

m_ticket은 raw DeferredWorkTimer::Ticket에서 ThreadSafeWeakPtr<TicketData>로 변경되었습니다. 새로 추가된 takeTicketIfActive()는 weak pointer를 RefPtr로 승격하며, ticket이 소멸되거나 취소된 경우 null을 반환하고 m_ticket을 비웁니다. didComplete(), fail(), cancel()의 호출 지점은 이제 std::exchange(m_ticket, nullptr)를 무조건 실행하는 대신, null 결과를 받으면 조기 반환합니다. 추가된 helper인 globalObjectIfActive()는 ticket의 dependency 목록에서 살아있는 JSGlobalObject*를 조회하도록 설계되었습니다. JSDOMGlobalObject.cpp에서는 consumeBodyReceivedByChunk lambda가 raw globalObject를 직접 capture하는 방식을 중단합니다. 대신 VM*과 compiler만 capture하고, compiler->globalObjectIfActive()를 통해 global object를 다시 조회하도록 변경되었습니다.

iframe teardown 경계를 넘어 소유자보다 오래 살아남은 비동기 소유 객체의 raw pointer를 liveness 재검증 없이 역참조하는 패턴.

WebAssembly.compileStreaming / instantiateStreamingfetch Response를 받아 body가 스트리밍되는 동안 모듈을 점진적으로 컴파일합니다. DeferredWorkTimer는 지연된 결과를 JS 스레드로 다시 전달하는 JSC의 메커니즘입니다. addPendingWork는 대상 promise와 그 dependency 객체(요청한 JSGlobalObject 포함)를 살아있게 유지하는 TicketData(typedef명 Ticket)를 반환합니다. ThreadSafeWeakPtr<T>::get()은 객체가 아직 살아있으면 원자적으로 RefPtr<T>로 승격하고, 그렇지 않으면 null을 반환합니다. iframe은 자체 JSGlobalObject를 가진 독립 realm을 구성하며, DOM에서 iframe을 제거하면 해당 global object가 수집 대상이 됩니다. streaming 컴파일은 background worklist에서 실행되므로, 완료 callback은 원래 realm이 소멸된 이후에도 실행될 수 있습니다.

비동기 경계를 넘어 소유자가 소멸된 뒤에도 살아남은 dangling raw pointer로 인한 use-after-free입니다. TicketDataJSGlobalObject의 수명은 모두 요청한 iframe/realm에 묶여 있습니다. 이 상태에서 컴파일 완료 전에 iframe이 제거되면, iframe의 JSGlobalObject는 GC 대상이 되고 deferred-work ticket은 취소 및 소멸됩니다.

이후 worklist에서 컴파일이 완료되는 시점에, didComplete/failstd::exchange(m_ticket, nullptr)를 실행하며 이미 해제된 TicketData*를 기반으로 작업을 예약합니다. 마찬가지로 body-chunk lambda도 이미 수집된 global object의 raw pointer(globalObject->vm())를 역참조하게 됩니다. iframe teardown이 구체적으로 trigger되는 시점과 teardown 이후 타이밍은 주변 StreamingPlan/Worklist 코드와 일치하지만, diff에 직접 드러나지는 않습니다. 안정적인 역참조를 넘어서는 exploit 가능성은 조건부입니다. 공격자가 해제된 allocation을 안정적으로 재사용할 수 있다면, dangling pointer 접근을 renderer 내 더 강력한 memory-corruption primitive로 발전시킬 가능성이 있습니다. 다만 그 재사용 가능성은 이번 diff에서 확인되지 않으며, WebContent를 벗어나려면 별도의 sandbox escape도 필요합니다.

이 vulnerability는 WebContent process 내의 memory safety를 약화시킵니다. WebKit의 보안 모델은 realm/iframe에 수명이 묶인 객체가 해당 realm 소멸 이후 역참조되지 않는다고 가정합니다. 패치 이전에는 iframe보다 오래 살아남은 비동기 streaming-Wasm 완료 콜백이 해제된 TicketData와 수집된 JSGlobalObject 모두에 대해 이 가정을 위반했습니다. 제거된 주석에는 m_ticket이 GC가 스캔할 수 없는 PackedPtr이라는 내용이 있었는데, 이 자체가 단서입니다. 이 필드는 의도적으로 GC에 보이지 않도록 설계되어 있었기 때문에, 참조된 상태를 살아있게 유지하는 것이 아무것도 없었습니다.

Note: iframe teardown trigger와 teardown 이후 worklist 타이밍은 commit에 직접 드러나지 않고 주변 코드에서 추론한 것입니다. 핵심 lifetime 위험과 수정 방법 — ticket을 weak reference로 유지하고 global object를 다시 조회하는 방식 — 은 diff에 의해 완전히 뒷받침됩니다.

뒤, 재귀 호출을 통해 property 값을 파싱하는 구조였습니다. 이 재귀 파싱 중에 중첩된 literal에 __proto__가 포함되어 있고 Object.prototype에 setter가 설치된 경우, 임의의 JavaScript가 실행될 수 있습니다. 이 setter는 진행 중인 객체의 shape를 변경할 수 있으므로, object->structure()는 더 이상 스냅샷된 structure와 일치하지 않게 됩니다. 그러나 캐시된 newStructure/offset은 여전히 이전 structure를 기준으로 합니다.

패치 이전 코드는 이 stale transition을 이미 shape가 변경된 객체에 그대로 적용했습니다. 이전의 outOfLineCapacity()를 기반으로 butterfly를 크기 조정하고, stale한 값으로 structure id를 설정하며, stale한 offset 위치에 파싱된 값을 기록했습니다. offset과 capacity delta가 더 이상 해당 객체를 기술하지 않는 structure를 기준으로 계산되었기 때문에, 이 쓰기 연산은 객체의 실제 layout과 일치하지 않는 위치에 도달하게 됩니다. Regression test는 이 유발 방식을 잘 보여줍니다. __proto__ setter는 최초 발동 시 인덱스 0에 대한 setter를 설치하며, 이후의 property 추가에 영향을 미칩니다. 워밍업 단계에서 eval("({...,a:1})")를 두 번 실행하면 outer 객체의 structure에 property a에 대한 단일 transition이 캐시됩니다. 이후 세 번째 eval에서 a:{__proto__:0}이 등장하면 파싱 도중 setter가 발동됩니다. 인덱스 0 setter 설치가 originalStructure를 diverge시키는 정확한 메커니즘은 추가된 guard를 통해 추론되며, diff에서 직접 드러나지는 않습니다.

공격자가 property name/index set을 조작하여 stale offset이 객체의 실제 out-of-line storage 범위를 벗어나도록 유도할 수 있다고 가정합니다. 이 경우 JSFinalObject butterfly에 대한 제어된 잘못된 offset/out-of-bounds write가 발생할 가능성이 있습니다. 이와 동시에 객체의 실제 layout과 일치하지 않는 shape로 structure id가 설정되어, 인접한 heap 데이터에 대한 상대적 read/write로 발전 가능한 structure-confusion primitive로 이어질 수 있습니다. 다만 강력한 primitive를 실현하려면 손상된 butterfly 인근에 유용한 victim 객체를 배치하는 heap grooming이 필요하며, exploit의 영향 범위는 WebContent renderer 내에 한정됩니다.

이 취약점은 renderer 내부의 메모리 타입 안전성을 약화시킵니다. 보안 모델은 LiteralParser가 property offset을 계산할 때 사용한 Structure가 butterfly에 값을 기록하는 시점에도 여전히 유효하다고 가정합니다. 그러나 패치 이전에는 중첩된 literal의 __proto__ setter가 파싱 도중 JS로 재진입할 때마다 이 불변 조건이 위반되었습니다. __proto__는 literal parser 안에서 특히 위험한 토큰입니다. 객체 생성 도중에 user setter를 동기적으로 호출할 수 있는 유일한 literal 토큰이기 때문입니다. 따라서 __proto__를 포함하는 값을 파싱하기 전에 객체 상태를 캐시하는 모든 fast path는, 이후에 임의의 engine 변경이 발생했을 가능성을 반드시 고려해야 합니다.

Note: 정확한 structure divergence 메커니즘과 OOB write의 heap grooming 가능성은 추가된 guard와 테스트를 통해 추론된 것이며, diff에서 직접 드러나지는 않습니다. 다만 핵심 TOCTOU와 그 수정 방식(object->structure()를 재조회하여 불일치 시 slow path로 전환)은 diff에서 충분히 뒷받침됩니다.