[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.
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
Source/JavaScriptCore/wasm/WasmStreamingCompiler.cpp
Source/WebCore/bindings/js/JSDOMGlobalObject.cpp
Patch Details
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 재검증 없이 역참조하는 패턴.
Background
WebAssembly.compileStreaming / instantiateStreaming은 fetch 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이 소멸된 이후에도 실행될 수 있습니다.
Analysis
비동기 경계를 넘어 소유자가 소멸된 뒤에도 살아남은 dangling raw pointer로 인한 use-after-free입니다. TicketData와 JSGlobalObject의 수명은 모두 요청한 iframe/realm에 묶여 있습니다. 이 상태에서 컴파일 완료 전에 iframe이 제거되면, iframe의 JSGlobalObject는 GC 대상이 되고 deferred-work ticket은 취소 및 소멸됩니다.
이후 worklist에서 컴파일이 완료되는 시점에, didComplete/fail은 std::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에 의해 완전히 뒷받침됩니다.
Audit directions
- Raw realm pointer를 capture하는 비동기 callback. 원래 iframe/realm이 소멸된 이후에도 실행될 수 있는 callback에서 raw
JSGlobalObject*/realm pointer(또는 raw deferred-work ticket)를 capture하는 네이티브 비동기 작업을 점검합니다.DeferredWorkTimer::addPendingWork의 다른 사용처도 함께 확인하고, JavaScriptCore에서addPendingWork,scheduleWorkSoon,cancelPendingWork호출 지점을 검색합니다. 역참조 전에 ticket을 승격/검증하는지(예:isCancelled확인)도 검토합니다. - bare globalObject/document/frame을 capture하는 binding lambda.
consumeBodyReceivedByChunk와 유사한 Response-streaming 진입점에서 구동되는Source/WebCore/bindings/jscallback을 검토합니다. global object가 raw capture 방식 대신 callback 실행 시점에 다시 조회되고 liveness 검사를 거치는지 확인합니다. - 잔여 Wasm streaming lifecycle 경로.
finalize,addBytes, worklist의didCompileFunction이 realm 소멸 이후에도 실행될 수 있는지 점검하고,takeTicketIfActive()/globalObjectIfActive()를 일관되게 경유하는지 확인합니다.m_ticket을 읽는 경로 중 승격-검증 절차를 거치지 않는 경로가 남아 있지 않은지도 검증합니다.
뒤, 재귀 호출을 통해 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에서 충분히 뒷받침됩니다.
Audit directions
- JS re-entrancy 경계 이후에 적용되는 Structure 스냅샷. 객체의
Structure/transition을 스냅샷으로 저장한 뒤 JS re-entrancy 경계를 넘어, 그 스냅샷을 butterfly 쓰기에 적용하는 native 코드를 점검해야 합니다. 재귀적 값 파싱 이후에 재검증되지 않는 캐시가 있는지, 다른 LiteralParser/JSON fast path와 butterfly 직접 쓰기 헬퍼를 살펴볼 필요가 있습니다.LiteralParser.cpp에서parseRecursively와addPropertyTransitionToExistingStructure를 중심으로 확인하십시오. 파싱 단계 이후에 등장하는 모든nukeStructureAndSetButterfly/allocateMoreOutOfLineStorage호출 지점도 함께 점검해야 합니다. - 생성 도중 re-entrancy를 유발하는
__proto__. Object literal 및 JSON 생성 경로에서underscoreProto/__proto__처리 부분을 검색하십시오. 각각이__proto__값 파싱 이후에 structure, butterfly, prototype이 변경되었을 가능성을 올바르게 처리하는지 확인합니다. 동일한 TOCTOU가 있는지m_visitedUnderscoreProto로직과 bytecode object-literal builder(op_new_object/ put-by-id fast path)도 살펴보십시오. - 이미 transition된 객체에 적용되는 사전 계산된 offset.
(Structure*, PropertyOffset)쌍을 저장한 뒤 나중에 해당 offset에 쓰기를 수행하는 fast path를 점검해야 합니다. 쓰기 직전에 현재 structure가 여전히 일치하는지 확인하십시오. Callback 호출이 가능한 연산 이전에 캐시를 취하는 경우를 찾아보십시오.Source/JavaScriptCore/runtime전체에서trySingleTransition과transitionOffset의 사용 지점을 검색하면 됩니다.