← All issues

[6] Race-condition UAF in JSSubscriber GC marking

Severity: Medium | Component: WebCore DOM Observable/Subscriber | db743cc

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

-Vector<VoidCallback*> Subscriber::teardownCallbacksConcurrently()
-{
- Locker locker { m_teardownsLock };
- return m_teardowns.map([](auto& callback) {
- return callback.ptr();
- });
-}
-
-void Subscriber::visitAdditionalChildrenInGCThread(JSC::AbstractSlotVisitor& visitor)
-{
- // GC thread에서 호출될 수 있으므로 여기서 `teardown`을 ref할 수 없습니다.
- SUPPRESS_UNRETAINED_ARG for (auto* teardown : teardownCallbacksConcurrently())
- teardown->visitJSFunctionInGCThread(visitor);
- ...
-}
+template<typename Visitor>
+void Subscriber::visitAdditionalChildrenInGCThread(Visitor& visitor)
+{
+ // 이 함수는 main thread와 동시에 GC thread에서 실행되므로, 어떤 것도 ref해서는 안 됩니다.
+ {
+ Locker locker { m_teardownsLock };
+ SUPPRESS_UNCOUNTED_LOCAL for (auto& teardown : m_teardowns)
+ SUPPRESS_UNCOUNTED_ARG teardown->visitJSFunctionInGCThread(visitor);
+ }
+ SUPPRESS_UNRETAINED_ARG m_observer->visitAdditionalChildrenInGCThread(visitor);
+}

Source/WebCore/dom/Subscriber.h

// Vector<Ref<VoidCallback>> m_teardowns WTF_GUARDED_BY_LOCK(m_teardownsLock);
// void stop() final { Locker locker { m_teardownsLock }; m_teardowns.clear(); }

teardownCallbacksConcurrently()observerConcurrently()를 제거하고, template으로 작성된 단일 Subscriber::visitAdditionalChildrenInGCThreadSubscriber.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.

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이 소멸됩니다.

이 결함은 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에 직접적인 근거가 있습니다.