← All reports

[1] Media track opaque-root race on the concurrent GC thread

HighWebCore media track bindingsRace

WebCore's media tracks wrote their own GC-thread warning, then raced past it.

49740e9

High 등급이지만 race 조건에 좌우됩니다. 일반 페이지 script만으로도 background marker를 main thread 소유 객체의 check-then-call window 안으로 밀어 넣을 수 있습니다. 다만 그 결과로 생기는 GC thread UAF를 crash 이상으로 끌어올리려면 두 가지가 필요합니다. race에서 반복적으로 이겨야 하고, 해제된 작은 heap 객체도 reclaim해야 합니다.

브라우저 엔진은 C++ 쪽에만 존재하는 참조를 근거로 JavaScript wrapper의 생존 여부를 판단해야 합니다. JSC는 이 reachability를 background marking thread에서 계산합니다. 그동안에도 main thread는 script와 DOM 코드를 계속 실행합니다. WebCore는 opaque root로 이 간극을 메웁니다. Opaque root는 각 wrapper가 보고하는 pointer로, 같은 pointer를 공유하는 wrapper들은 함께 유지되거나 함께 수거됩니다. video.audioTracks 뒤에 있는 track list(TrackListBase, element별 audio·video·text track collection)는 이 root를 소유 media element에서 빌려옵니다. 따라서 marker가 root를 계산하기 위해 읽는 대상은 읽기가 끝날 때까지 살아 있어야 하고, 값도 바뀌지 않아야 합니다.

관전 포인트: 페이지가 media track list를 생성한 뒤, GC pressure 아래에서 소유 element를 해체하거나 re-parent하는 상황을 가정할 수 있습니다. 이 경우 background marker와 race를 일으켜, renderer 내부에서 해제된 메모리를 거쳐 호출이 이루어지도록 만들 가능성이 있습니다. 기본적으로는 crash에 그치며, reclaim을 제어할 수 있을 때에만 control-flow 후보가 됩니다.

To fix the data race, we introduce SourceBuffer and HTMLMediaElement as opaque roots for Track* classes and *TrackList classes instead of using the root node of HTMLMediaElement which can change over time.

We introduce TrackOpaqueRoot, which is a thin ThreadSafeRefCounted wrapper around WebCoreOpaqueRoot, and initialize it with WebCoreOpaqueRoot pointing to SourceBuffer or HTMLMediaElement.

Each Track and TrackList class will have RefPtr and reads the opaque root directly from a GC thread without relying on any pointer indirections.

No new tests since there is no reliable way to test this data race.

Source/WebCore/html/HTMLMediaElement.cpp

 
- , m_opaqueRootProvider(WTF::Observer<WebCoreOpaqueRoot()>::create([weakThis = WeakPtr { *this }] {
 
- // GC thread에서 호출되므로 `this`를 ref할 수 없습니다.
 
- return weakThis->opaqueRoot();
 
- }))
+ , m_trackOpaqueRoot(TrackOpaqueRoot::create(WebCoreOpaqueRoot { this }))
...
+ m_trackOpaqueRoot->clear();
...
 
- m_audioTracks->setOpaqueRootObserver(m_opaqueRootProvider);
+ m_audioTracks->setOpaqueRoot(m_trackOpaqueRoot);

Source/WebCore/html/track/TrackListBase.cpp

WebCoreOpaqueRoot TrackListBase::opaqueRoot() const
{
 
- // GC thread에서 호출되므로 observer를 ref할 수 없습니다.
 
- SUPPRESS_UNCOUNTED_LOCAL if (auto* rootObserver = m_opaqueRootObserver.get())
 
- return (*rootObserver)();
+ SUPPRESS_UNCOUNTED_LOCAL if (RefPtr trackOpaqueRoot = m_trackOpaqueRoot)
+ return trackOpaqueRoot->opaqueRoot();
SUPPRESS_MEMORY_UNSAFE_CAST return WebCoreOpaqueRoot { const_cast<TrackListBase*>(this) };
}

이번 변경은 크게 세 부분으로 나뉩니다. 먼저 미리 계산된 root를 담는 thread-safe holder가 추가되었고, 두 owner가 이 holder를 초기화합니다. 이어서 기존 lookup chain 대신 이 holder가 모든 track list와 track에 전달되었습니다. 마지막으로 media element의 wrapper가 새 root를 보고하도록 custom mark function이 추가되었습니다.

holder는 새 파일 TrackOpaqueRoot.h에 정의된 TrackOpaqueRoot입니다. WebCoreOpaqueRoot 값 하나를 감싼 ThreadSafeRefCounted wrapper로, opaqueRoot()와 clear() accessor를 제공합니다. HTMLMediaElement에서는 m_opaqueRootProvider가 제거되었습니다. 이 멤버는 WTF::Observer<WebCoreOpaqueRoot()>였고, 내부 lambda는 WeakPtr { *this }를 capture한 뒤 weakThis->opaqueRoot()를 호출했습니다. 그 자리에는 m_trackOpaqueRoot = TrackOpaqueRoot::create(WebCoreOpaqueRoot { this })가 생성됩니다. ~HTMLMediaElement는 m_trackOpaqueRoot->clear()를 호출합니다. 또한 ensureAudioTracks/ensureTextTracks/ensureVideoTracks는 setOpaqueRootObserver(m_opaqueRootProvider) 대신 setOpaqueRoot(m_trackOpaqueRoot)를 호출하도록 변경되었습니다. SourceBuffer에도 같은 처리가 적용되었습니다. m_trackOpaqueRoot(TrackOpaqueRoot::create(opaqueRoot()))로 초기화하고, destructor에서 clear()를 호출하며, videoTracks(), audioTracks(), textTracks()에서 setOpaqueRoot를 호출합니다.

읽는 쪽에서는 TrackListBase의 WeakPtr<OpaqueRootObserver> m_opaqueRootObserver가 RefPtr<TrackOpaqueRoot> m_trackOpaqueRoot로 변경되었습니다. 새로 추가된 virtual setOpaqueRoot()는 m_inbandTracks에 있는 모든 track에 holder를 전파합니다. TextTrackList::setOpaqueRoot는 이를 override하여 m_addTrackTracks와 m_elementTracks까지 처리합니다. 한편 TrackBase에도 별도의 RefPtr<TrackOpaqueRoot>가 추가되었습니다. 이 값은 setTrackList()에서 설정되고 clearTrackList()에서 해제됩니다. 이제 TrackBase::opaqueRoot()는 m_trackList를 역참조하지 않고 이 값을 직접 읽습니다.

마지막으로 HTMLMediaElement.idl에 JSCustomMarkFunction이 추가되었습니다. 새 파일 JSHTMLMediaElementCustom.cpp는 visitAdditionalChildrenInGCThread를 구현합니다. element의 wrapper가 visit될 때마다 wrapped().trackOpaqueRoot().opaqueRoot()를 opaque root로 추가합니다.

  Before (GC thread):                        After (GC thread):
  TrackBase::opaqueRoot()                    TrackBase::opaqueRoot()
    └─► m_trackList.get()   (WeakPtr)          └─► copy RefPtr m_trackOpaqueRoot
          └─► TrackListBase::opaqueRoot()            └─► ->opaqueRoot()  (stored value)
                └─► m_opaqueRootObserver.get()
                      └─► (*observer)()  ─► lambda
                            └─► weakThis->opaqueRoot()
                                  └─► DOM tree-root walk

Concurrent collector의 root lookup이 고정된 안정적인 값을 읽지 않고, 어떤 reference도 잡지 않은 채 mutator 소유 weak reference와 변경 가능한 graph link를 순회하는 패턴.

이 코드가 있는 위치. 여기서 다루는 코드는 모두 WebContent process에서 실행됩니다. 페이지 script를 실행하는 sandbox 처리된 renderer입니다. DOM 객체는 C++ 객체이고, script가 다루는 JavaScript 객체는 JSC garbage collector가 관리하는 얇은 wrapper입니다.

Concurrent marking. JSC의 marker는 helper thread에서 실행됩니다. 그동안 main thread, 즉 mutator는 JavaScript와 DOM 작업을 계속 수행합니다. 따라서 ...InGCThread라는 이름의 함수와, 이 함수들이 호출하는 모든 opaqueRoot() 구현은 main thread 코드와 동시에 실행됩니다.

Opaque root. GC는 DOM 객체 사이의 C++ 참조를 볼 수 없습니다. 그래서 WebCore는 각 wrapper의 C++ 객체가 opaque root pointer를 보고하도록 합니다. visit된 wrapper 중 하나라도 addOpaqueRoot로 그 pointer를 추가하면, isReachableFromOpaqueRoots가 같은 pointer를 반환하는 모든 wrapper가 유지됩니다. 결과적으로 GC가 C++ ownership을 이해하지 않아도, reachable한 멤버 하나만으로 관련 wrapper 묶음 전체를 살려 둘 수 있습니다.

Node의 opaque root. DOM node의 경우 관례적으로 node가 속한 tree의 root가 opaque root가 됩니다. 따라서 분리된 subtree나 연결된 document는 각자의 wrapper들을 하나의 단위로 유지합니다.

Track 소유 관계. HTMLMediaElement(<video>/<audio> 뒤의 C++ 객체)와 MSE SourceBuffer(script가 제공한 media segment를 공급하는 Media Source Extensions 객체)는 각각 AudioTrackList, VideoTrackList, TextTrackList를 lazy하게 소유합니다. 각 list는 자신의 track(TrackBase subclass)을 보관합니다. 반대로 각 track은 WeakPtr<TrackListBase> m_trackList로 자신의 list를 가리킵니다. list와 track의 wrapper는 owner의 opaque root를 사용합니다. 그래야 script가 video.audioTracks[0]을 잡고 있을 때 element와 일관된 상태가 유지됩니다.

WeakPtr와 ThreadSafeRefCounted. WeakPtr는 소유권을 갖지 않는 pointer로, 대상이 파괴되면 null이 됩니다. 다만 검사 시점에 non-null이라고 해서 그 이후의 파괴까지 막아 주지는 않습니다. ThreadSafeRefCounted는 atomic refcount를 사용하므로, 어느 thread에서든 reference를 잡고 해제할 수 있습니다.

WTF::Observer<Sig>. WTF::Function을 감싼 작은 heap 객체입니다. 이를 호출하면 function wrapper의 virtual call operator를 거쳐 저장된 callable이 실행됩니다.

이 버그는 concurrent GC marker와 main thread mutator 사이의 data race입니다. 그 결과 GC thread에서 use-after-free가 발생합니다.

  GC marker thread                          Main thread
  ───────────────────────────────           ─────────────────────────────────
  TrackListBase::opaqueRoot()
    obs = m_opaqueRootObserver.get()
    obs != null  (ok)
                                            last ref to media element drops
                                            ~HTMLMediaElement()
                                              frees Observer (m_opaqueRootProvider)
    (*obs)()  ──► dispatch through freed Observer   <-- UAF on GC thread

다이어그램에서 왼쪽 열의 get()은 검사하는 순간 Observer가 살아 있었다는 사실만 보장합니다. 그 간격 동안 GC thread 쪽에서는 아무도 reference를 잡고 있지 않습니다. 제거된 코드도 이를 명시적으로 밝히고 있습니다("Cannot ref the observer as this gets called on a GC thread"). 이 상태에서 main thread가 소유 HTMLMediaElement나 SourceBuffer를 파괴하면, owner의 const Ref 멤버를 통해 Observer가 해제됩니다. 이어지는 marker의 호출은 결국 해제된 메모리를 거쳐 dispatch됩니다.

이 window는 더 긴 chain 중 한 단계에 불과합니다. 그리고 모든 단계가 같은 check-then-use 형태를 띱니다.

  1. TrackBase::opaqueRoot()는 SUPPRESS_UNCOUNTED_LOCAL 아래에서 m_trackList(WeakPtr<TrackListBase>)를 raw pointer로 읽었습니다.
  2. 이 pointer로 TrackListBase::opaqueRoot()를 호출했습니다.
  3. 해당 함수는 m_opaqueRootObserver(Observer를 가리키는 WeakPtr)를 raw pointer로 읽었습니다.
  4. 이어서 (*rootObserver)()를 호출해 저장된 lambda를 실행했습니다.
  5. HTMLMediaElement의 경우 lambda가 weakThis를 역참조해 HTMLMediaElement::opaqueRoot()를 호출했습니다. commit message는 이 값을 element의 DOM root node로 설명합니다. script가 element를 삽입하거나 제거하면 바뀌는 값이라서 "which can change over time"이라고 적혀 있습니다.

같은 시점에 main thread는 track의 list를 해제하거나, 소유 element 또는 SourceBuffer를 파괴할 수 있습니다. element를 re-parent해서 root lookup이 순회하는 parent chain을 다시 쓸 수도 있습니다. list가 파괴되는 경우에는 1단계와 2단계 사이에서도 같은 형태의 race가 성립합니다. 한편 메모리 해제와 무관한 문제도 있습니다. 5단계의 tree-root walk가 재작성 도중의 parent pointer를 읽으면, 일관되지 않은 root를 반환하게 될 수 있습니다. 이 경우 reachable한 track이나 list wrapper가 unreachable로 판정될 가능성이 있습니다.

fix는 이 chain을 RefPtr load 한 번과 pointer 크기 값 읽기 한 번으로 줄였습니다. media element의 경우 이 값은 element 자신의 주소입니다. element의 lifetime 동안 고정되어 있고, GC thread에서는 역참조되지 않습니다. owner의 destructor에서는 clear()가 이 값을 null로 만듭니다. commit message는 SourceBuffer 자체가 새 root라고 설명합니다. 다만 생성자는 SourceBuffer::opaqueRoot()로 holder를 초기화하며, 이 함수의 본문은 제공된 context에 포함되어 있지 않습니다.

제거된 코드에는 이미 SUPPRESS_UNCOUNTED_LOCAL annotation과 GC thread 실행을 인정하는 주석이 붙어 있었습니다. WebKit의 smart pointer static analyzer가 드러내는 지점이 정확히 이런 곳입니다. 따라서 발견 경로로는 code review나 static analysis를 우선 짐작해 볼 수 있습니다. GC pressure 아래에서 DOM mutation과 media track 접근을 함께 fuzzing하다가 concurrent GC crash를 얻었다는 시나리오도 형태상 들어맞습니다. 해당 window를 안정적으로 유발할 방법이 없기 때문에, commit에는 test가 포함되지 않았습니다.

이 버그는 web content에서 도달 가능합니다. media element와 MSE track list를 사용하고, 여기에 concurrent marker와 race하는 element teardown이나 DOM re-parenting을 결합하는 방식입니다. 즉각적인 영향은 해제되었거나 일관되지 않은 상태를 읽은 GC thread의 crash일 것으로 보입니다. 주된 primitive는 작은 고정 크기 객체에 대한 GC thread use-after-free입니다. 대상은 HTMLMediaElement 또는 SourceBuffer에서 m_opaqueRootProvider가 소유하는 WTF::Observer입니다. 호출 전에 해제된 slot이 reclaim되는 경우를 가정할 수 있습니다. 이때 observer 호출은 저장된 function wrapper를 거쳐 dispatch되므로, control-flow 후보로 이어질 가능성이 있습니다.

다만 열려 있는 질문이 두 가지 남아 있습니다. 먼저 m_opaqueRootObserver.get()과 (*rootObserver)() 사이의 window를 넓히고, Observer storage를 제때 reclaim할 수 있을 만큼 안정적으로 반복할 수 있는지입니다. 다음으로 reclaim된 byte가 WTF::Function dispatch를 조종할 수 있는지입니다. 두 조건이 모두 성립한다면 crash 수준에서 renderer control-flow 후보로 격상될 가능성이 있습니다. 같은 형태의 secondary primitive도 존재합니다. TrackBase::m_trackList를 통해 stale 또는 해제된 TrackListBase를 읽는 경우입니다. tertiary effect인 일관되지 않은 tree-root 결과는 메모리를 직접 손상시키지는 않습니다. 대신 wrapper reachability를 잘못 판단하게 만들어, C++ 객체가 살아 있는 동안 wrapper가 수거될 수 있습니다. 버그 전체가 WebContent process의 marker thread 안에 있습니다. 따라서 renderer 밖으로 영향을 넓히려면 별도의 sandbox escape가 필요합니다.

수정 이후의 코드도 한 번 더 살펴볼 필요가 있습니다. TrackBase::m_trackOpaqueRoot와 TrackListBase::m_trackOpaqueRoot는 일반 RefPtr입니다. GC thread는 이 값을 복사하고, 그 사이 main thread에서는 setTrackList, clearTrackList, setOpaqueRoot가 이를 재할당합니다. 이런 재할당 중에도 보통은 owner가 holder에 대해 가진 reference가 holder를 살려 둡니다. 다만 이는 남은 window를 좁힐 뿐, 구조적으로 닫지는 않습니다. TrackBase.h에 추가된 #include <atomic>은 diff 안에서 사용되지 않습니다. atomic publication을 검토했다는 단서로 볼 수 있습니다.

약화된 경계. 이 vulnerability는 WebContent process 내부의 memory safety를 약화시킵니다. concurrent marker에서 실행되는 코드는 marking 기간 동안 안정적이거나 ref로 잡힌 데이터만 다뤄야 한다는 계약이 깨지기 때문입니다. 위반된 가정은 "검사 시점에 non-null인 WeakPtr는 이어지는 호출 동안에도 유효하다"는 것입니다. 이 가정은 단일 thread에서만 성립합니다. race에서 이긴 attacker는 marker가 해제된 메모리를 거쳐 실행되도록 만들 수 있게 됩니다. 결과는 renderer crash에서부터, heap reclaim을 제어할 수 있는 경우 renderer control-flow 후보에 이르기까지 다양합니다. 여기에 liveness 오판으로 track 객체의 JS-visible state를 잃는 secondary risk도 더해집니다.

시사점. 제거된 코드에는 스스로 경고 신호가 남아 있었습니다. "This gets called on a GC thread so we cannot ref this", "Cannot ref the observer as this gets called on a GC thread"라는 주석과, 각 단계에 붙은 SUPPRESS_UNCOUNTED_LOCAL이 그것입니다. 주석은 GC thread에서 refcounting을 쓸 수 없다는 점을 정확히 인식하고 있었습니다. 문제는 그 자리를 대신할 lifetime 보장을 전혀 두지 않았다는 점입니다. 이번 fix 패턴은 다른 곳에도 재사용할 수 있습니다. GC에 필요한 답을 main thread에서 미리 계산하고, thread-safe holder를 통해 공개하는 방식입니다. 동시에 semantics도 바뀝니다. root가 element의 tree root가 아니라 media element 자체가 되며, 새 custom mark function을 통해 element wrapper가 이 root로 연결됩니다.