[6] Race-condition UAF in JSSubscriber GC marking
GC thread가 stale raw pointer를 통해 해제된 VoidCallback을 역참조할 수 있는 실제 race condition이 수정된 사안입니다. 다만 실용적인 UAF primitive로 확장되려면 main thread와 GC thread 간의 좁은 타이밍 window를 안정적으로 제어하고, 해제된 allocation을 재사용해야 합니다. 이 두 조건 모두 diff만으로는 확인되지 않으므로 Medium으로 평가됩니다.
이 PR은 JSSubscriber::visitAdditionalChildren의 race condition을 수정합니다. 해당 결함은 VoidCallback 객체의 use-after-free로 이어집니다. Subscriber::teardownCallbacksConcurrently가 lock을 획득해 Vector<VoidCallback*>을 생성하는 동안, main thread는 VoidCallback을 해제할 수 있습니다. 이 시점에 GC thread가 동일 객체에 대해 visitJSFunction을 호출하면 문제가 됩니다. 안정적인 재현 방법이 없어 새로운 테스트는 추가되지 않았습니다.
Source/WebCore/dom/Subscriber.cpp
Source/WebCore/dom/Subscriber.h
Patch Details
teardownCallbacksConcurrently()와 observerConcurrently()를 제거하고, template으로 작성된 단일 Subscriber::visitAdditionalChildrenInGCThread를 Subscriber.cpp에 통합했습니다. 새로운 메서드는 m_teardowns(Vector<Ref<VoidCallback>>) 전체를 순회하는 동안 Locker locker { m_teardownsLock }을 유지합니다. lock이 유지된 상태에서 teardown->visitJSFunctionInGCThread(visitor)를 호출하고, locked block 이후에 m_observer를 방문합니다. JSSubscriber::visitAdditionalChildrenInGCThread는 이제 단순히 wrapped().visitAdditionalChildrenInGCThread(visitor)로 전달합니다.
Lock 획득 중 refcount 객체를 raw pointer로 snapshot한 뒤, lock 해제 후 역참조하는 과정에서 발생하는 use-after-free.
Background
Observable API는 JS에 Subscriber를 노출합니다. subscriber.addTeardown(callback)으로 등록된 VoidCallback JS 함수는 subscription이 종료될 때 실행되며, m_teardownsLock으로 보호되는 Vector<Ref<VoidCallback>> m_teardowns에 저장됩니다.
JSC는 concurrent garbage collector를 사용합니다. marking이 별도의 GC thread에서 실행될 수 있으며, 이때 main thread는 JS를 동시에 실행할 수 있습니다. 따라서 custom mark hook(visitAdditionalChildrenInGCThread 계열)은 thread-safe해야 하며, ref()/deref()를 호출해서는 안 됩니다. collector thread에서의 refcount 변경은 안전하지 않기 때문입니다. 이러한 이유로 코드는 lock으로만 보호된 raw access를 사용합니다.
ActiveDOMObject::stop()은 런타임이 호출하는 lifecycle teardown으로, context 종료 시 동일한 lock 하에 m_teardowns를 초기화합니다. Ref<VoidCallback>은 sole-ownership smart pointer입니다. vector를 초기화하면 마지막 reference가 dropped되어 callback이 소멸됩니다.
Analysis
이 결함은 snapshot-then-use-after-unlock(TOCTOU) 형태의 전형적인 race-condition use-after-free입니다.
패치 이전에는 m_teardowns를 보호하는 lock이 callback을 unowned raw pointer 형태의 Vector<VoidCallback*>으로 복사하는 동안만 유지되었습니다. GC thread가 visitJSFunctionInGCThread를 통해 해당 pointer를 역참조하기 전에 lock은 이미 해제된 상태였습니다. GC thread가 방문하는 모든 VoidCallback*이 여전히 살아있어야 한다는 invariant는 snapshot 시점에만 보장되었고, 실제 사용 시점에는 보장되지 않았습니다. GC thread가 의도적으로 callback을 ref()하지 않기 때문에, 두 시점 사이의 간격 동안 객체를 살아있게 유지할 수단이 없었습니다.
lock 해제 이후 GC thread가 순회를 시작하기 전의 window에서, main thread는 해당 VoidCallback 객체를 소멸시킬 수 있습니다. Subscriber::stop()이 m_teardownsLock을 획득하고 m_teardowns.clear()를 호출하면, 각 항목의 마지막 Ref<VoidCallback>이 dropped되어 destructor가 실행됩니다. 이후 GC thread는 stale raw pointer만 보유한 채 이미 해제된 메모리에 대해 visitJSFunctionInGCThread를 호출합니다.
다만 stop()이 실제로 GC marking과 동시에 실행되는지, 즉 이 race가 이론적 수준을 넘어 실제로 발생하는지는 diff만으로 직접 증명할 수 없습니다. commit의 "안정적인 재현 방법 없음" 메모가 이를 반영합니다.
race를 안정적으로 제어할 수 있는 공격자라면, 방문 경로를 통해 재사용된 heap 메모리에 접근하거나 그 내용을 읽어낼 가능성이 있습니다. heap 조건을 제어할 수 있는 환경에서는 이를 renderer 내의 use-after-free primitive로 발전시킬 가능성도 고려할 수 있습니다. 다만 안정적인 타이밍 제어와 allocation 재사용은 모두 추론에 기반한 부분입니다.
이 vulnerability는 main thread의 객체 소멸이 concurrent GC marking과 경쟁하도록 허용함으로써, WebContent process 내부의 memory safety를 약화시킵니다. custom GC mark hook에서 도달 가능한 객체는 해당 hook이 실행되는 동안 살아있어야 한다는 가정이 있습니다. 패치 이전에는 raw pointer snapshot이 생성되는 시점에만 이 가정이 성립했습니다.
패치 후에는 전체 방문 과정에서 m_teardownsLock을 유지하므로, stop()의 clear()가 방문 중 동시에 실행될 수 없습니다. 결과적으로 Ref<VoidCallback> 항목들은 방문이 완료되는 동안 살아있는 상태를 유지합니다.
"lock 하에 snapshot을 생성하고, lock 해제 후 사용하는" 패턴의 위험성이 바로 여기에 있습니다. snapshot이 의도적으로 ownership을 제거하는 경우, concurrent GC marking의 non-ref 요구사항이 일반적인 안전장치를 제거합니다. 따라서 lock은 역참조가 완료되는 시점까지 전체 window를 포괄해야 합니다.
Note: 해제 경로가 실제로 GC marking과 경쟁하는지, 그리고 race를 안정적으로 제어해 실용적인 primitive를 얻을 수 있는지는 추론에 기반합니다. lock 범위 결함과 해당 수정은 diff에 직접적인 근거가 있습니다.
Audit directions
- Lock으로 보호된 refcount 컨테이너를 raw pointer로 snapshot하는 패턴. lock으로 보호된 refcount 객체 컨테이너를 lock 하에 raw pointer로 snapshot한 뒤 lock을 해제하고, 이후 해당 raw pointer를 역참조하는 경우입니다.
visitAdditionalChildrenInGCThread와visitJSFunctionInGCThread구현을 검색해, 보호용Locker가.map(...)/.ptr()snapshot에만 적용되는 게 아니라 전체 방문 루프를 포괄하는지 확인해야 합니다. - 의도적으로
ref()를 회피하는 concurrent GC 코드.SUPPRESS_UNRETAINED_ARG,SUPPRESS_UNCOUNTED_ARG, 또는NODELETE로 표시된 코드는 liveness를 전적으로 lock에 의존합니다. 각 지점을 살펴보고, 대응하는 mutator 측 변경(clear, 제거, destructor)이 동일한 lock을 사용하는지 확인해야 합니다.WTF_GUARDED_BY_LOCK멤버를 해당 멤버를 변경하는ActiveDOMObject::stop()및 destructor 경로와 교차 검증해야 합니다. - 유사한 callback 레지스트리. 다른 Observable/EventTarget 스타일의 callback 레지스트리(teardown 목록, observer 목록)가 GC 방문 전 과정에서 lock을 유지하는지 확인해야 합니다.
Subscriber.cpp,InternalObserver, 그리고 lock으로 보호된Vector<Ref<...Callback>>멤버를 가진 유사한 DOM 객체를 살펴보고, 동일한 release-before-use 패턴이 존재하는지 점검해야 합니다.