[1] Media track opaque-root race on the concurrent GC thread
WebCore's media tracks wrote their own GC-thread warning, then raced past it.
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 후보가 됩니다.
Data race in TrackBase::opaqueRoot during GC leading to use-after-free
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
Source/WebCore/html/track/TrackListBase.cpp
Patch Details
이번 변경은 크게 세 부분으로 나뉩니다. 먼저 미리 계산된 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를 순회하는 패턴.
Background
이 코드가 있는 위치. 여기서 다루는 코드는 모두 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이 실행됩니다.
Analysis
이 버그는 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 형태를 띱니다.
TrackBase::opaqueRoot()는SUPPRESS_UNCOUNTED_LOCAL아래에서m_trackList(WeakPtr<TrackListBase>)를 raw pointer로 읽었습니다.- 이 pointer로
TrackListBase::opaqueRoot()를 호출했습니다. - 해당 함수는
m_opaqueRootObserver(Observer를 가리키는WeakPtr)를 raw pointer로 읽었습니다. - 이어서
(*rootObserver)()를 호출해 저장된 lambda를 실행했습니다. 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로 연결됩니다.
Audit directions
- Mutator 소유 객체를 dereference하는 GC thread의 root 조회. Concurrent marker에서 실행되는 코드는 불변 값이나 호출 전체 동안 ref로 유지되는 값만 읽어야 합니다. 그런데 opaque-root callback이나 visit callback이 main thread 소유 객체의
WeakPtr또는 raw pointer를 dereference하면, 이 invariant가 깨지게 됩니다. - Narrow: WebCore에서
opaqueRoot()구현과visitAdditionalChildrenInGCThread본문을 검색할 필요가 있습니다. 그중SUPPRESS_UNCOUNTED_LOCAL,WeakPtr::get(), 또는 "called on a GC thread" 같은 주석이 포함된 지점이 대상입니다. 출발점으로는 owner element에 묶인 다른ActiveDOMObject/EventTarget목록이 적합합니다(e.g.SourceBufferList,TextTrackCueList,MediaController). - Wider: concurrent visitor가 owner back-pointer를 따라가는 곳이라면 같은 형태가 나타납니다.
isReachableFromOpaqueRoots안의WeakPtrback-pointer나, marking 도중 호출되는 observer/callback 객체가 그 예입니다. - Widest: mutator가 실행되는 동안 root 열거 hook이 사용자 정의 C++ 코드를 실행하는 collector라면 concurrent든 incremental이든 모두 해당합니다. V8 embedder tracing(
TraceWrappers/CppHeapvisitor)과 Gecko cycle collector의 traverse hook이 대표적입니다. -
Match tell: GC에서 호출 가능한 함수가 pointer를 로드해 검사한 뒤, 호출 구간 동안 ref를 유지하지 않은 채 그 pointer로 호출하는 패턴입니다.
-
Thread 경계를 넘는 weak reference의 check-then-use. 검사 시점에 non-null인
WeakPtr라도 대상 객체의 lifetime을 보장하지는 않습니다. 그래서 single thread에서는 안전한 idiom이라도, 두 번째 thread가 대상을 해제할 수 있게 되면 UAF로 이어질 가능성이 있습니다. - Narrow: WebCore와 WebKit에서 non-main thread(audio render thread, media decoding queue, GC thread)가 읽는
WeakPtr멤버를 검색해 볼 만합니다. 특히 읽은 직후 plain data를 바로 복사하지 않고 함수 호출로 이어지는 지점이 우선 대상입니다. - Wider:
CheckedPtr, flag로 보호되는 raw back-pointer,WeakPtr뒤에 저장된WTF::Observerwrapper에서도 같은 형태가 나타납니다. - Widest: atomic promotion 없이 weak ref를 승격하는, 전형적인 버그 유형입니다.
std::weak_ptr::lock()과 Rust의Weak::upgrade가 owning handle을 반환하는 이유도 여기에 있습니다. 즉 background thread에서 non-owning weak pointer의 검사와 dereference를 따로 수행하는 코드베이스라면, 어디든 같은 버그가 존재합니다. -
Match tell: owning thread 밖에서 도달 가능한
if (auto* p = weak.get()) p->call()형태입니다. -
Mutable graph에서 유도되어 concurrent하게 사용되는 identity 값. Tree root나 parent chain 같은 key를, mutator가 다시 쓰는 link를 따라가며 재계산하는 경우가 있습니다. 이때 reader는 중간에 깨진(torn) 경로나 이미 해제된 경로를 관찰하게 될 수 있습니다.
- Narrow: wrapper reachability가
root(Node*)/Node::opaqueRoot()에 의존하는 WebCore 객체를 점검할 필요가 있습니다. 특히 Node가 아닌 객체가 element를 가리키는 back-pointer로부터 이 값을 계산하는 경우가 대상입니다. Element가 소유한 다른 collection이나,element->opaqueRoot()로 호출을 전달하는 helper가 그 예입니다. - Wider: background thread에서 DOM이나 render tree pointer를 바탕으로 계산하는 cached key와 hash도 살펴볼 만합니다. Accessibility cache나 off-thread에서 생성되는 style-sharing key가 여기에 해당합니다.
- Widest: concurrent reader는 live 구조를 직접 순회하지 말고, 게시된(published) snapshot을 읽어야 합니다. 이 원칙은 kernel의 RCU reader, lock-free index 구조, 다른 runtime(Go, ART)의 concurrent GC barrier에도 그대로 적용됩니다.
-
Match tell: main thread가 변경할 수 있는 객체의
parent/nextpointer를 따라 loop를 도는 background thread 함수입니다. -
Writer thread와 concurrent reader가 공유하는 non-atomic smart pointer slot.
RefPtr의 copy-construct가 재할당과 경합하는 상황을 생각할 수 있습니다. 이 경우 마지막 reference가 동시에 해제되는 pointer를 읽게 될 수 있습니다. - Narrow: fix 이후의
TrackBase::m_trackOpaqueRoot와TrackListBase::m_trackOpaqueRoot를 확인할 필요가 있습니다. 두 멤버는 GC thread의opaqueRoot()에서 복사되는데, 그 사이setTrackList,clearTrackList,setOpaqueRoot가 이를 재할당합니다. 따라서 owner가 가진Ref<TrackOpaqueRoot>가 경합하는 모든 재할당보다 항상 오래 유지되는지 확인해야 합니다. Owner가 소멸되는 과정도 예외가 아닙니다. - Wider:
ThreadSafeRefCounted타입의RefPtr/Ref멤버 중, lock 없이 GC thread나 worker thread에서 읽히는 것을 검색해 볼 만합니다. - Widest:
std::shared_ptr의 non-atomic load/store race와 같은 계열입니다. C++20의std::atomic<std::shared_ptr>가 바로 이 문제를 다루기 위해 존재합니다. - Match tell: 한 thread에서 재할당되고 다른 thread에서 복사되는데, lock이나 atomic wrapper가 없는 smart pointer 멤버입니다. Review 단계에서는
...InGCThread경로 안에서 읽히는RefPtr멤버마다, 무엇이 대상 객체를 살려 두는지 명시하는 주석이 있어야 합니다.