← All reports

[5] Subscriber: teardown callbacks freed while a GC thread walks the snapshot

MediumWebCore DOM Observables — SubscriberRace

db743cc

Medium 등급입니다. Lock의 scope가 snapshot을 만드는 구간에만 걸려 있었고, 정작 그 snapshot을 사용하는 구간에는 걸려 있지 않았습니다. 그 결과 반환된 raw pointer들은 lock이 원래 보장하려던 lifetime guarantee를 전혀 갖지 못했습니다. 안정적으로 재현 가능한 방법이 fix에 함께 제시되지 않아, 실질적인 exploitability는 main thread와 GC thread 사이의 동기화되지 않은 race를 이겨야 하는 수준에 머무릅니다.

JavaScriptCore는 main thread와 병행 동작하는 전용 GC thread에서 heap을 마킹합니다. 따라서 이 thread들이 marking 도중 역참조하는 모든 C++ 객체는 그 역참조 구간 내내 살아 있어야 합니다. 이는 보통 GC 자체의 객체 lifetime으로 보장되거나, destruction까지 함께 직렬화하는 lock으로 보장됩니다. WebKit의 DOM binding들은 custom mark function을 통해 이 marking 과정에 참여합니다. C++ 쪽에서 generated binding이 볼 수 없는 JS 값을 들고 있는 wrapper의 경우, visitAdditionalChildrenInGCThread 구현이 그 값들을 순회하며 보고하는 역할을 맡습니다. GC thread에서 도달하는 코드는 main thread가 소유한 객체의 refcount를 건드려서는 안 되므로, 이런 함수들은 의도적으로 refcount 대신 raw pointer를 빌려 씁니다. 즉 refcounting이 아닌 다른 무언가가 그 역참조 대상들의 생존을 보장해야 하는 구조입니다.

관전 포인트: 어떤 페이지가 subscription의 teardown callback들을 정리하는 시점이 하필 GC thread가 해당 Subscriber를 marking하는 순간과 겹치면, collector가 해제된 메모리를 읽고 그 자리에 남아 있던 값을 그대로 slot visitor에 넘기는 상황이 발생할 수 있습니다.

이번 PR은 JSSubscriber::visitAdditionalChildren의 race condition을 수정합니다. 이 race condition은 VoidCallback 객체의 use-after-free로 이어집니다. Subscriber::teardownCallbacksConcurrently가 lock을 잡고 VoidCallback*Vector를 만드는 동안에도, main thread는 그와 별개로 진행을 계속할 수 있었습니다. GC thread가 그 VoidCallback에 대해 visitJSFunction을 호출하는 도중에 main thread가 해당 객체를 destroy할 수 있었던 것입니다. 안정적인 재현 방법이 없어 새로운 테스트는 추가되지 않았습니다.

Source/WebCore/dom/Subscriber.cpp

-Vector<VoidCallback*> Subscriber::teardownCallbacksConcurrently()
+template<typename Visitor>
+void Subscriber::visitAdditionalChildrenInGCThread(Visitor& visitor)
{
 
- Locker locker { m_teardownsLock };
 
- return m_teardowns.map([](auto& callback) {
 
- return callback.ptr();
 
- });
-}
+ // Do not ref anything in this function, which runs in a GC thread concurrently to the main thread.
+ {
+ Locker locker { m_teardownsLock };
+ SUPPRESS_UNCOUNTED_LOCAL for (auto& teardown : m_teardowns)
+ SUPPRESS_UNCOUNTED_ARG teardown->visitJSFunctionInGCThread(visitor);
+ }
 
-InternalObserver* Subscriber::observerConcurrently()
-{
 
- return &m_observer.get();
+ SUPPRESS_UNRETAINED_ARG m_observer->visitAdditionalChildrenInGCThread(visitor);
}
 
-void Subscriber::visitAdditionalChildrenInGCThread(JSC::AbstractSlotVisitor& visitor)
-{
 
- // We cannot ref `teardown` here as this may get called from a GC thread.
 
- SUPPRESS_UNRETAINED_ARG for (auto* teardown : teardownCallbacksConcurrently())
 
- teardown->visitJSFunctionInGCThread(visitor);
-...
+DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(Subscriber);

Source/WebCore/bindings/js/JSSubscriberCustom.cpp

 
- for (auto* teardown : wrapped().teardownCallbacksConcurrently())
 
- teardown->visitJSFunctionInGCThread(visitor);
-
 
- wrapped().observerConcurrently()->visitAdditionalChildrenInGCThread(visitor);
+ wrapped().visitAdditionalChildrenInGCThread(visitor);

Source/WebCore/dom/Subscriber.h

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

이 패치는 JSSubscriber의 custom mark function이 Subscriber의 script-visible 자식들에 접근하는 방식 자체를 다시 작성합니다. 기존에는 JSSubscriber::visitAdditionalChildrenInGCThreadwrapped().teardownCallbacksConcurrently()를 호출했습니다. 이 함수는 m_teardownsLock을 잡고, m_teardowns.map([](auto& callback) { return callback.ptr(); })를 통해 m_teardownsraw pointer로 이루어진 Vector<VoidCallback*>로 snapshot한 뒤, 함수 반환 시점에 lock을 해제했습니다. 그러고 나서야 그 snapshot을 순회하며 teardown->visitJSFunctionInGCThread(visitor)를 호출했습니다.

패치는 teardownCallbacksConcurrently()observerConcurrently()를 완전히 삭제하고, non-template이던 Subscriber::visitAdditionalChildrenInGCThread(JSC::AbstractSlotVisitor&)template<typename Visitor> 버전으로 교체합니다. 새 버전은 순회 전체 구간에 걸쳐 Locker locker { m_teardownsLock }를 유지하며, pointer를 복사해 꺼내는 대신 m_teardowns를 제자리에서 순회합니다. observer 방문은 lock이 걸린 scope 바깥으로 옮겨져, m_observer->visitAdditionalChildrenInGCThread(visitor)를 통해 직접 이루어집니다. DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(Subscriber)가 두 visitor 타입 모두에 대해 이 template을 인스턴스화하고, Subscriber.h는 public 인터페이스를 이 단일 template method로 좁힙니다. SUPPRESS_UNCOUNTED_LOCAL / SUPPRESS_UNCOUNTED_ARG / SUPPRESS_UNRETAINED_ARG annotation과 "Do not ref anything in this function, which runs in a GC thread concurrently to the main thread"라는 주석은, GC thread에서 의도적으로 ref를 걸지 않는다는 사실에 대해 WebKit의 static ref-checker가 경고를 내지 않도록 하는 역할을 합니다.

Collection의 snapshot은 lock으로 보호하면서도, 그로부터 얻어낸 non-owning pointer의 역참조는 lock scope 밖에 두는 패턴입니다.

Observables and Subscriber. DOM Observable API는 subscribe callback에 Subscriber를 넘겨줍니다. Script는 subscriber.addTeardown(fn)을 호출해 정리 callback을 등록하고, WebCore는 이를 Vector<Ref<VoidCallback>> m_teardowns에 저장합니다. Subscription은 완료되거나 error가 발생하거나 AbortSignal이 fire될 때 정리되며, 문서가 종료되거나 navigation이 일어나 ActiveDOMObject가 stop될 때도 마찬가지로 정리됩니다.

VoidCallback. generated WebIDL callback wrapper로, RefCounted C++ 객체이며 하위 JS 함수 객체에 대한 reference를 소유합니다.

Custom mark functions. C++ 쪽에서 generated binding이 볼 수 없는 JS 값을 들고 있는 wrapper에 대해, WebKit은 *Custom.cpp 파일 안에 visitAdditionalChildren / visitAdditionalChildrenInGCThread를 정의합니다. DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(X)는 두 visitor 타입 모두에 대해 template을 인스턴스화합니다.

Concurrent marking. JSC는 main thread와 병행 동작하는 전용 GC thread에서 heap을 마킹합니다. AbstractSlotVisitor는 이 thread들에서 쓰이는 visitor 타입이고, SlotVisitor는 main thread용 변형입니다. GC thread에서 도달하는 코드는 main thread가 소유한 객체의 refcount를 건드려서는 안 되며, 이 때문에 WebKit은 이런 경로에 SUPPRESS_UNCOUNTED_ARG / SUPPRESS_UNRETAINED_ARG annotation을 붙여 static ref-checker를 잠재웁니다.

Ref<T> versus T*. Ref<T>는 reference를 소유하며 객체를 살아 있게 유지하지만, T::ptr()은 ownership이 없는 맨 pointer를 반환합니다. Vector<Ref<T>>를 clear하면 그것이 들고 있던 모든 reference가 해제됩니다.

Lock, Locker, WTF_GUARDED_BY_LOCK. 각각 WTF의 mutex, 그 RAII 방식 scoped 획득, 그리고 어떤 lock이 특정 필드를 보호하는지 선언하는 static annotation입니다.

근본 원인은 lock scope 오류이며, 이것이 use-after-free를 만들어냅니다. teardownCallbacksConcurrently()는 lock을 snapshot을 만드는 데만 사용했을 뿐, 그 snapshot을 사용하는 구간은 보호하지 않았습니다. 이 함수는 Ref<VoidCallback> 원소들을 맨 VoidCallback*로 변환해 값으로 반환했고, lock은 함수 종료 시점에 해제되었습니다. Refcount는 의도적으로 한 번도 증가시키지 않았습니다. GC thread에서 ref를 거는 것 자체가 WebKit 모델상 안전하지 않기 때문입니다. 결과적으로 반환된 vector는 lifetime guarantee를 전혀 갖지 못했습니다. 이후 호출자는 lock이 없는 상태에서 각 pointer를 역참조했습니다.

  GC thread                          Main thread
  ─────────                          ───────────
  lock m_teardownsLock
  copy N raw VoidCallback*
  unlock  ────────────────────┐
                              │      lock m_teardownsLock
                              │      stop(): m_teardowns.clear()
                              │        └─ last Ref dropped
                              │             └─ ~VoidCallback()
                              │      unlock
  ┌───────────────────────────┘
  ▼
  teardown->visitJSFunctionInGCThread(visitor)   ← UAF read of freed object

m_teardownsVector<Ref<VoidCallback>>이므로, Subscriber는 모든 teardown callback에 대한 owning reference를 쥐고 있습니다. Main thread에서 이 ref를 해제하는 경로는 두 곳이며, 둘 다 m_teardownsLock 아래에서 이루어집니다. Subscriber::stop()m_teardowns.clear()를 수행하고, Subscriber::close()는 teardown들을 순회하며 호출한 직후 stop()을 이어서 호출합니다. Concurrent GC marking은 GC thread에서 custom mark function을 실행하는데, marking phase 전체 동안 main thread가 계속 정지되어 있는 것은 아닙니다. 바로 이 지점에서 앞서 본 interleaving이 열립니다. 이 호출은 destroy된 heap 객체로부터 callback이 저장하고 있던 JS 함수 slot을 읽어, 그 결과 값을 slot visitor에 그대로 넘깁니다. visitJSFunctionInGCThread의 정확한 본문은 제공된 context에 VoidCallback.h/.cpp가 포함되어 있지 않아, 그 이름과 일반적인 WebIDL-callback idiom으로부터 추정한 것입니다.

Loop 전체 구간 동안 lock을 유지하면, m_teardowns의 모든 destroy 경로 역시 m_teardownsLock으로 직렬화되기 때문에, GC thread가 관찰하는 m_teardowns의 모든 원소가 방문 구간 내내 살아 있다는 invariant가 복원됩니다. m_observer 쪽은 애초에 lifetime 문제가 아니었습니다. m_observerconst Ref<InternalObserver>로 선언되어 있어 Subscriber의 생존 기간 동안 재할당도 해제도 불가능합니다. 따라서 observerConcurrently()를 제거한 것은 fix가 아니라 정리 작업에 해당합니다.

이 vulnerability는 WebContent process 내부에서, main thread와 JSC의 concurrent GC thread 사이 경계에 있는 memory safety를 약화시킵니다. 여기서 걸려 있는 security model의 전제는, GC thread가 marking 도중 역참조하는 모든 객체가 그 역참조 구간 내내 살아 있어야 한다는 것입니다. Fix 이전에는 teardown callback에 대해 이 보장이 존재하지 않았습니다. 그래서 race를 이긴 attacker는 GC thread가 해제된 VoidCallback을 읽게 만들고, 그 메모리 자리에 남아 있는 값을 그대로 slot visitor에 JS cell reference로 넘기게 할 수 있었습니다. 단순한 crash를 넘어, 해제된 slot을 미리 grooming해둔 attacker가 collector가 무엇을 살아 있는 object-graph node로 취급할지에 영향을 줄 가능성도 이론적으로 존재합니다. 이는 가용성 문제가 아니라 corruption 계열의 결과에 가깝습니다. 다만 이 부분은 free 이후 heap 상태에 대한 추정이며, diff 자체가 확립해주는 사실은 아닙니다.

이번에 삭제된 helper는, call site만 보면 정상적으로 보이지만 실제로는 무용지물인 lock의 전형적인 사례입니다. teardownCallbacksConcurrently()m_teardownsLock을 획득하므로, 코드를 훑어보는 reviewer 입장에서는 동기화가 이루어지고 있다고 판단하기 쉽습니다. 하지만 이 함수가 반환하는 값은 non-owning pointer들의 vector이고, 그 pointer들의 유효성이야말로 원래 lock이 보호하려던 대상입니다. Lock의 scope가 accessor 안에 갇히고 사용처까지 이어지지 않는 순간, 반환된 데이터는 구조적으로 이미 stale한 상태가 됩니다. WebKit 자체의 ref-checker annotation도 여기에 한몫했다고 볼 수 있습니다. 작성자는 GC thread에서 ref를 걸면 안 된다는 사실을 정확히 알고 analyzer를 잠재웠지만, 그 과정에서 "그러면 이 객체는 무엇이 살아 있게 유지해주는가"라는 질문을 유발했을 신호까지 함께 지워버린 셈입니다. Raw pointer를 빌려야만 하는 이런 패턴에서는 lock이든 다른 어떤 keep-alive 수단이든 마지막 역참조 시점까지 유효 범위가 이어져야 하며, 이번 fix가 취한 형태가 정확히 그것입니다.

관전 포인트: GC가 순회하는 컬렉션을 보호한다고 명시된 lock이라면, 소유 참조를 해제하는 모든 경로가 파괴 시점에서도 대칭적으로 그 lock을 획득해야 합니다. Lock이 lifetime을 보장하려면, 컨테이너에 담긴 소유 참조를 해제하는 모든 경로가 해당 lock을 취득해야 하기 때문입니다.

좁게 보면, Subscriber의 경우 stop()close() 양쪽 모두 올바르게 직렬화됩니다. 다른 ActiveDOMObject 서브클래스에도 같은 방식의 점검을 적용할 필요가 있습니다. 즉 stop()이 custom mark function이 읽는 컨테이너를 비우는 경우인데, Source/WebCore에서 void stop() final 본문 중 멤버를 비우거나 재할당하는 곳을 grep으로 찾아 하나씩 확인하는 방식입니다.

넓게 보면, 같은 점검은 destructor, contextDestroyed(), suspend()/resume(), 혹은 abort-algorithm callback에서 변경되는 모든 컨테이너에도 적용됩니다. 이때는 눈에 띄는 setter만이 아니라 guard된 멤버에 대한 모든 writer를 나열하고, 각각이 guard를 실제로 취득하는지 확인해야 합니다.

가장 넓게 보면, lock이 소유하는 writer 집합이 완전할 때만 lifetime을 보장한다는 원칙 자체는 reader가 mutex의 존재만으로 liveness를 가정하는 모든 시스템에 적용할 수 있습니다. 예를 들어 다른 곳에서 해제된 pointer를 담고 있는 Go의 RWMutex 보호 map이나, 하나의 eviction 경로가 mutex를 우회하는 C++ shared_ptr cache가 여기 해당합니다.

매치 지점은, WTF_GUARDED_BY_LOCK 멤버에 대한 write인데 그 write를 감싸는 scope가 해당 lock에 대응하는 Locker를 생성하지 않는 경우입니다. Static annotation이 대부분은 잡아내지만, helper method 뒤에 숨겨진 assignment나 move-out 패턴은 이 검사를 빠져나갈 수 있습니다.