← 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()하지 않기 때문에, 두 시점 사이의 간격 동안 객체를 살아있게 유지할 수단이 없었습니다.

🔒

The concurrency and lifetime implications of this GC-thread race are examined in depth, including how far the resulting memory-safety issue could realistically be pushed.

더 확인하려면 구독해 주세요

🔒

Several reusable audit patterns for concurrent-GC mark hooks are identified, with concrete starting points across WebCore DOM callback registries.

더 확인하려면 구독해 주세요