← All reports

[6] QueuedVideoOutput tests liveness without holding it

MediumWebCore AVFoundation media backendUAF

f90b4ea

Medium. 해제 이후에 접근되는 대상은 virtual dispatch가 아니라, synchronous observer callback 뒤에 이어지는 단순한 멤버 접근입니다. 또한 이를 발생시키려면 frame notification 내부에서 media player를 정리하는 observer가 필요합니다. 이 전제 조건은 엔진 내부 동작에 해당하며, speech synthesis나 audio listener 사례보다 낮게 평가한 이유이기도 합니다.

weak pointer를 확인한 뒤 그 대상 객체를 사용하는 방식은, 그 사이에 객체를 해제할 수 있는 요소가 전혀 없을 때에만 성립합니다. 그런데 observer 쪽으로 들어가는 synchronous callback은 정확히 그런 해제를 유발할 수 있는 지점입니다. WebCore의 AVFoundation video 경로에서는 QueuedVideoOutput이라는 helper를 사용합니다. 이 클래스는 AVPlayerItemVideoOutput에서 디코딩된 CVPixelBuffer를 꺼내어, presentation time을 key로 하는 frame queue를 main thread media player 쪽에 유지합니다. 위치상으로는 background dispatch queue에서 전달되는 AVFoundation의 decode callback과 WebCore의 MediaPlayerPrivateAVFoundationObjC 사이에 놓여 있으며, 현재 frame이 바뀌면 player에 이를 통지합니다.

관전 포인트: current image changed observer가 media player를 동기적으로 정리하면, QueuedVideoOutput 자신의 메서드가 아직 스택에 남아 있는 상태에서 객체가 해제됩니다. 그 메서드의 나머지 코드는 해제된 TZone 메모리 위에서 계속 실행되는 셈입니다.

패치 이전의 패턴은 파일 안의 모든 main run loop block에서 동일하게 반복되었습니다:

Source/WebCore/platform/graphics/avfoundation/objc/QueuedVideoOutput.mm

// 각 block은 WeakPtr만 캡처한 뒤 operator bool 검사 하나만으로 확인하고,
// 이후 raw pointer로 역참조했습니다:
//   parent->addVideoFrameEntries(...)
//   weakThis->purgeImagesBeforeTime(...)
//   weakThis->nextImageTimeReached()

QueuedVideoOutput.mm의 모든 main run loop block에서 단순한 WeakPtr null 검사가 RefPtr 승격으로 변경되었습니다. RefPtr protectedParent = parent.get() 형태로 참조를 확보하면, 호출된 쪽에서 어떤 재진입이 발생하더라도 객체가 유지됩니다. 변경이 적용된 경로는 addVideoFrameEntries(), purgeImagesBeforeTime(), purgeVideoFrameEntries(), rateChanged(), nextImageTimeReached()입니다.

weak pointer의 생존 확인을 lifetime 보장으로 취급하고, 거기서 얻어 둔 raw pointer가 synchronous callback을 넘어 객체보다 오래 살아남은 패턴.

이 코드가 있는 위치. MediaPlayerPrivateAVFoundationObjC는 AVFoundation을 기반으로 한 WebCore의 media player 구현입니다. QueuedVideoOutput은 여기에 frame을 공급하는 helper로, presentation time을 key로 하는 ImageMap m_videoFrames를 소유하면서 디코딩된 frame을 player 쪽에 계속 전달합니다.

소유 구조. QueuedVideoOutput은 RefCounted<QueuedVideoOutput>이며, strong owner는 하나뿐입니다. QueuedVideoOutput::create()로 객체를 생성한 MediaPlayerPrivateAVFoundationObjC가 그 소유자입니다. 정상적인 상태라면 이 객체를 가리키는 RefPtr은 정확히 하나만 존재합니다.

WeakPtr과 null 검사가 보장하는 범위. WeakPtr에 대한 null 검사는 검사하는 그 순간에 대상 객체가 살아 있다는 사실만 확인해 줍니다. 참조를 획득하지는 않으므로, 이후 호출이 끝날 때까지의 구간에 대해서는 아무것도 보장하지 못합니다. 이 버그는 전적으로 이 차이에서 출발합니다. 다만 null 검사가 겉보기에는 안전성 검사처럼 읽히기 때문에, 리뷰 과정에서 놓치기 쉽습니다.

WeakHashSet observer. QueuedVideoOutput.h에는 WeakHashSet<CurrentImageChangedObserver> m_currentImageChangedObservers가 선언되어 있습니다. weak set에 담긴 observer는 통지하는 쪽의 스택 위에서 동기적으로 호출됩니다. 결과적으로 각 통지 지점은 임의의 WebCore 코드로 들어가는 re-entrancy 지점이 됩니다.

여기서 빠진 invariant는, 객체가 callback에 진입하는 순간뿐 아니라 그 callback이 끝날 때까지 계속 고정되어 있어야 한다는 조건입니다. 버그 유형으로 보면 재진입 teardown에 의한 use-after-free입니다. 객체 본문에 대한 data race가 아니라 lifetime 위반에 해당합니다.

  addVideoFrameEntries()  (main run loop block)
  ───────────────────────────────────────────────
  weakThis  ──► operator bool ✔        (t0: alive)
  raw this  ──► materialized
  notify m_currentImageChangedObservers
      └─► observer tears down media player
            └─► last RefPtr<QueuedVideoOutput> dropped
                  └─► ~QueuedVideoOutput() / invalidate()
                        weakThis cleared, raw this NOT   ◄── failure window opens
  ... remainder of addVideoFrameEntries()
      touches m_videoFrames, re-arms boundary observer
        └─► freed TZone memory

commit message에 따르면 addVideoFrameEntries()는 current image changed observer들을 동기적으로 호출합니다. 이때 observer 중 하나가 media player를 동기적으로 정리할 수 있습니다. 그러면 유일한 RefPtr<QueuedVideoOutput>이 해제되고, invalidate()를 호출하는 ~QueuedVideoOutput()이 실행됩니다. addVideoFrameEntries()는 여전히 스택에 남아 있는 상태입니다. 이 소멸 과정에서 block이 캡처한 WeakPtr은 초기화되지만, 앞서 거기서 얻어 둔 raw this는 그대로 남습니다.

purgeImagesBeforeTime(), purgeVideoFrameEntries(), rateChanged(), nextImageTimeReached()에서도 동일한 구조가 성립합니다. 모두 같은 통지 경로에 도달할 수 있기 때문입니다. crash가 관찰된 한 곳만 고치지 않고 파일 전체에 같은 수정을 적용한 이유이기도 합니다.

exploitability 측면에서 보면, 해제 이후 접근되는 대상은 m_videoFrames나 boundary time observer 재등록처럼 멤버를 다루는 동작입니다. virtual dispatch는 아닙니다. 그래서 곧바로 얻을 수 있는 primitive는 indirect branch를 가로채는 형태가 아니라, 해제된 TZone allocation의 고정된 멤버 offset에 대한 읽기 또는 쓰기입니다. 일반적인 재생만으로도 delegate callback과 주기적 observer tick은 그대로 동작합니다. 다만 전제 조건은 통지 내부에서 player를 정리하는 current image changed observer가 존재해야 한다는 점입니다. 이 부분은 엔진 내부의 실행 순서에 해당하며, 페이지는 직접 스케줄링하지 못하고 영향을 주는 수준에 그칩니다.

이 vulnerability로 인해 media frame 전달 경로에서 WebContent process의 memory safety가 약화됩니다. 더구나 일반적인 video 재생만으로도 지나가는 code path입니다.