← All reports

[6] Stale video-frame observer survives capture-proxy destruction

MediumWebKit GPU media captureUAF

Teardown asked a boolean whether it was registered; the map said otherwise

b732463

Medium. 등록과 해제가 서로 대칭을 이루지 못합니다. state-change callback이 observer를 다시 추가하면서 teardown이 참조하는 flag는 건드리지 않기 때문에, 실제로 존재하는 등록에 대해 제거가 생략됩니다. 다만 남겨진 raw pointer가 실제로 dereference되려면, 두 번째 proxy가 같은 source를 살아 있는 상태로 유지하며 계속 데이터를 생산해야 합니다.

카메라와 마이크, 화면 capture는 페이지를 렌더링하는 프로세스에서 동작하지 않습니다. capture source를 소유하는 쪽은 GPU process이며, 여기서 만들어진 frame이 IPC를 통해 WebContent로 전달됩니다. capture session마다 GPU process 안에 proxy 객체가 하나씩 생기는데, 이 proxy는 source에 두 번 등록됩니다. 하나는 설정 변경을 받기 위한 state observer이고, 다른 하나는 실제 미디어 데이터를 받기 위한 frame observer입니다. 이때 두 번째 등록은 session이 데이터를 생산하는 동안에만 유지됩니다. 한편 source는 frame observer들을 lock으로 보호되는 map에 담아 두는데, 이 map의 key는 소유권이 없는 맨 pointer입니다. 그래서 map에 등재되어 있는지 여부가, proxy 자신이 기록하는 관찰 상태와 정확히 일치해야 합니다.

관전 포인트: raw capture IPC를 직접 보낼 수 있고 display-capture 권한까지 확보한 WebContent process라면, StopProducingData, Clone, StartProducingData, 설정 변경, RemoveSource를 특정 순서로 배열할 수 있습니다. 그러면 해제된 pointer가 source의 frame observer map에 그대로 남고, 다음 frame이 도착하는 시점에 capture thread가 그 pointer를 통해 virtual dispatch를 수행하게 됩니다.

UserMediaCaptureManagerProxySourceProxy::sourceConfigurationChanged() 안의 재등록 조건 앞에 m_isObservingMedia &&가 추가되었습니다. 패치 이전에는 device가 Screen 또는 Window이고, m_videoConstraints가 설정되어 있으며, updateVideoConstraints()가 변경을 보고하기만 하면 본문이 실행되었습니다. 데이터 생산 여부는 조건에 포함되지 않았습니다. 본문에서는 source->removeVideoFrameObserver(*this)를 호출한 직후 곧바로 source->addVideoFrameObserver(*this, { m_widthConstraint, m_heightConstraint }, m_frameRateConstraint)를 호출합니다. 패치 이후에는 이 경로가 이미 observing 상태인 proxy로만 한정되며, 해당 상태는 observeMedia()가 설정하고 unobserveMedia()가 해제합니다. 회귀 테스트는 정지된 source를 clone하고, clone 쪽을 시작한 다음, 원본을 제거하는 순서로 구성되어 있습니다.

부가적인 등록 지점이 외부 컨테이너만 갱신하고 teardown 경로가 확인하는 상태 flag는 갱신하지 않아, 실제로 존재하는 등록에 대해 해제가 생략되는 패턴입니다.

  construct ──► addObserver (state, WeakHashSet)
      |
  observeMedia() ──► addVideoFrameObserver, m_isObservingMedia = true
      |
  unobserveMedia() ► removeVideoFrameObserver, m_isObservingMedia = false
      |
  sourceConfigurationChanged()
      └─► addVideoFrameObserver(*this)   flag stays false   <-- window opens
      |
  ~proxy ──► unobserveMedia() early-returns on the flag
      └─► raw key survives in m_videoFrameObservers
      |
  capture thread ──► videoFrameAvailable() through freed memory

이 코드가 있는 위치. UserMediaCaptureManagerProxy와 source마다 하나씩 존재하는 UserMediaCaptureManagerProxySourceProxy는 GPU process 쪽 endpoint에 해당합니다. 카메라, 마이크, 화면 또는 창 capture를 담당하는 WebCore RealtimeMediaSource를 소유하면서 그 상태와 frame을 관찰하고, 미디어를 IPC로 WebContent에 전달합니다.

두 개의 등록, 두 개의 컨테이너. RealtimeMediaSourceObserver 등록은 sourceConfigurationChanged 같은 상태 변경 알림을 받으며, source의 WeakHashSet에 보관됩니다. 반면 VideoFrameObserver(또는 AudioSampleObserver) 등록은 실제 미디어 데이터를 받고, m_videoFrameObservers에 보관됩니다. 이 map은 소유권도 weak 참조도 없는 맨 VideoFrameObserver*를 key로 사용하며, m_videoFrameObserversLock으로 보호됩니다. destructor의 m_source->removeObserver(*this)를 보면, 상태 등록은 생성 시점에 한 번 이루어져 proxy의 lifetime 동안 유지된다고 볼 수 있습니다.

스레드 구성. frame 전달은 백그라운드 capture thread에서 이루어지고, observer 등록과 IPC 처리는 main thread에서 수행됩니다.

source clone 동작. base 클래스의 RealtimeMediaSource::clone()return *this로 구현되어 있습니다. 그래서 clone 결과가 새로운 인스턴스가 아니라 동일한 source 인스턴스일 수 있습니다. 이때 UserMediaCaptureManagerProxy::Clone은 그 인스턴스에 대한 Ref를 쥔 두 번째 proxy를 생성합니다.

root cause는 등록과 해제가 서로 대칭이 아니라는 점에 있습니다. m_videoFrameObservers에 등재되어 있는지 여부는 m_isObservingMedia == true와 정확히 일치해야 합니다. teardown 경로가 destructor에서 호출되는 unobserveMedia() 하나뿐인데, 이 함수가 조건 없이 idempotent하게 제거를 수행하지 않고 flag를 먼저 확인하기 때문입니다. 한편 sourceConfigurationChanged()는 상태 변경 callback이므로 데이터 생산 여부와 무관하게 연결된 모든 proxy에 전달됩니다. 패치 이전에는 이 경로가 flag를 확인하지도, 갱신하지도 않은 채 자기 자신의 raw pointer를 다시 삽입했습니다.

결과적으로 정지되었거나 한 번도 시작된 적 없는 proxy는 m_isObservingMedia == false인 상태에서 raw pointer만 source의 map에 남게 됩니다. device가 Screen 또는 Window이고 m_videoConstraints가 캐시되어 있으며 constraint 변경까지 발생하면, callback이 삽입을 수행합니다. 이 상태에서 앞선 removeVideoFrameObserver(*this)는 아무 일도 하지 않습니다. 이후 객체가 소멸하는 시점에 unobserveMedia()는 곧바로 반환되고, WeakHashSet의 상태 항목만 정리됩니다. raw key는 해제 이후에도 map에 그대로 남습니다.

남겨진 key가 실제로 dereference되는지는, source가 proxy보다 오래 살아남는지에 달려 있습니다. m_sourceRef<RealtimeMediaSource>이므로 proxy가 하나뿐이라면 source도 함께 해제되고, 남은 key는 아무 영향을 주지 않습니다. 핵심 조건은 두 proxy가 같은 source를 공유하는 aliasing 상황입니다. base의 clone()*this를 반환하고, commit 메시지에 따르면 DisplayCaptureSourceCocoa는 이를 재정의하지 않습니다. 그래서 clone을 수행하면 동일한 인스턴스에 대한 Ref를 쥔 두 번째 proxy가 만들어집니다. 회귀 테스트의 구성도 이 해석을 뒷받침합니다. 정지된 source를 clone하고, clone을 시작한 뒤에야 원본을 제거하는 순서는 두 proxy가 하나의 source를 공유할 때에만 crash trigger로서 의미를 갖습니다. 또한 테스트는 그런 메시지 순서를 직접 만들어낼 수 있는 능력을 대신해 IPCTestingAPIEnabled=true를 사용합니다.

이 aliasing이 성립하면 살아남은 clone이 source를 계속 살려 두고 데이터도 계속 생산하게 됩니다. 그 다음 frame이 전달되는 시점에 capture thread가 observer map을 순회하면서, 이미 해제된 메모리를 대상으로 videoFrameAvailable()을 virtual dispatch하게 됩니다. 정리하면 GPU process 내부의 객체 lifetime이 약화되는 문제입니다. 다만 성립 조건으로 display-capture 권한이 필요하고, 정상적인 WebContent 측 UserMediaCaptureManager라면 만들어내지 않을 IPC 순서도 함께 요구됩니다. 게다가 해제와 dispatch가 서로 다른 thread에서 발생합니다.