← All reports

[4] WebRTC incoming sources removed their sink after derived members died

MediumWebCore MediaStream (WebRTC)UAF

The unregister call ran, correctly, after everything it protected was gone

02e76c6

Medium. 진행 중인 media callback과 동기화하는 unregister 호출이 base destructor 안에 있었는데, C++은 이 destructor가 callback이 건드리는 derived member들이 이미 사라진 뒤에야 실행되도록 보장합니다. Crash를 넘어서는 확장에는 좁은 timing race에서 승리하고, 해제된 buffer를 조작된 데이터로 재점유하는 과정이 필요합니다.

Callback 등록에 걸친 object lifetime은 보통 한 가지 규칙으로 안전하게 유지됩니다. Teardown 전에 unregister하고, unregister 호출이 아직 진행 중인 callback이 없음을 확인하도록 만드는 것입니다. WebKit의 WebRTC 수신 경로는 원격 audio 또는 video track에 sink로 스스로를 등록하는 media source object를 생성하고, libwebrtc 라이브러리는 main thread가 아닌 자체 thread에서 디코딩된 media를 이 object에 전달합니다. RemoveSink()는 libwebrtc의 내부 sink lock을 잡는 호출이며, 그래서 이미 실행 중인 callback과 순서를 맞추게 됩니다. 따라서 이 호출이 반환된 이후에야 object를 해체하는 것이 안전합니다.

관전 포인트: 페이지가 observer 하나로 graceful shutdown을 계속 거부하면서 들어오는 track의 마지막 reference를 놓아버리면, decoded-audio callback이 destruction window 안으로 들어올 수 있습니다. 이 경우 pointer와 attacker가 영향을 미칠 수 있는 size field가 해제된 heap allocation에 기록됩니다.

WebRTC audio/video callback이 object destruction 도중 이미 파괴된 member variable에 접근하면서 crash가 발생했습니다. Root cause는 C++ destruction 순서 동작입니다. Derived 클래스에서 컴파일러가 생성한 destructor는 base class destructor를 호출하기 전에 derived member를 먼저 파괴하는데, 정작 audio/video track sink를 제거하는 것은 base class destructor의 stop() 호출입니다. RTCPeerConnection::doClose()는 보통 requestToEnd()를 통해 source를 정지시키지만, source가 데이터를 계속 생성한 채로 destruction에 도달하는 code path도 존재합니다. 예를 들어 어떤 RealtimeMediaSourceObserverpreventSourceFromEnding()에서 true를 반환하면 requestToEnd()가 차단됩니다. Destruction이 시작될 때 source가 여전히 데이터를 생성 중이면, base class destructor의 stop()RemoveSink()를 호출하기는 합니다(RemoteAudioSourcesink_lock_을 통해 진행 중인 OnData callback과 정상적으로 동기화됩니다). 다만 그 시점에는 m_audioBufferList 같은 derived member가 이미 파괴된 상태입니다. 이번 fix는 member destruction이 일어나기 전에 derived destructor가 stop()을 호출해 sink를 제거하도록 보장합니다. 원래 305413.429@rapid/safari-7624.2.5.110-branch로 반영되었습니다.

Source/WebCore/platform/mediastream/RealtimeIncomingAudioSource.cpp

RealtimeIncomingAudioSource::~RealtimeIncomingAudioSource()
{
 
- stop();
+ // Subclasses must call stop() in their destructors to ensure the audio
+ // track sink is removed BEFORE derived members are destroyed. Otherwise,
+ // the OnData callback may access destroyed members on the audio thread.
+ ASSERT(!isProducingData());
m_audioTrack->UnregisterObserver(this);
}

Source/WebCore/platform/mediastream/cocoa/RealtimeIncomingAudioSourceCocoa.cpp

+RealtimeIncomingAudioSourceCocoa::~RealtimeIncomingAudioSourceCocoa()
+{
+ stop();
+}
+
void RealtimeIncomingAudioSourceCocoa::startProducingData()
...
void RealtimeIncomingAudioSourceCocoa::OnData(const void* audioData, int bitsPerSample, int sampleRate, size_t numberOfChannels, size_t numberOfFrames)
{
...
auto& bufferList = *m_audioBufferList->buffer(0);
bufferList.mDataByteSize = numberOfChannels * numberOfFrames * bitsPerSample / 8;
bufferList.mNumberChannels = numberOfChannels;
bufferList.mData = const_cast<void*>(audioData);
audioSamplesAvailable(mediaTime, *m_audioBufferList, m_streamDescription, numberOfFrames);
}

이번 변경은 sink 제거를 두 base-class destructor 밖으로 옮겨 각 concrete subclass로 이동시키고, Cocoa와 GStreamer port에 걸쳐 out-of-line subclass destructor 네 개를 추가했으며, base 쪽에는 debug-build contract check를 남겨두었습니다.

RealtimeIncomingAudioSource::~RealtimeIncomingAudioSource()RealtimeIncomingVideoSource::~RealtimeIncomingVideoSource()는 더 이상 stop()을 호출하지 않습니다. 대신 새 contract를 설명하는 주석과 함께 ASSERT(!isProducingData())를 갖게 되었고, 이어서 기존의 m_audioTrack->UnregisterObserver(this) / m_videoTrack->UnregisterObserver(this)를 그대로 유지합니다.

Subclass destructor 네 개가 추가되었거나, 첫 번째이자 유일한 statement로 stop()을 갖는 body를 새로 받았습니다. RealtimeIncomingAudioSourceCocoaRealtimeIncomingVideoSourceCocoa(둘 다 새로 추가되었고 각 header에 선언됨), RealtimeIncomingAudioSourceLibWebRTC(새로 추가), RealtimeIncomingVideoSourceLibWebRTC(이전에는 header에 빈 inline { } body였으나 이제 stop() 호출을 가진 out-of-line 형태로 변경)입니다. startProducingData() / stopProducingData()는 변경되지 않았으며, 여전히 libwebrtc track interface에서 AddSink/AddOrUpdateSinkRemoveSink를 수행합니다.

Cross-thread callback 등록의 teardown이 base destructor에서 수행되는데, 이미 그 callback이 읽는 derived member들이 파괴된 뒤라는 패턴입니다.

C++ destruction order. Object가 파괴될 때는 가장 derived된 destructor body가 먼저 실행되고, 그 클래스의 non-static data member들이 선언 역순으로 파괴된 뒤에야 base class destructor body가 실행됩니다. 따라서 base destructor는 derived class member를 관찰하거나 보호할 수 없습니다.

libwebrtc sinks. webrtc::AudioTrackInterface::AddSink()/RemoveSink()webrtc::VideoTrackInterface::AddOrUpdateSink()/RemoveSink()는 디코딩된 원격 media를 수신할 object를 등록합니다. Callback인 AudioTrackSinkInterface::OnData, VideoSinkInterface::OnFrame은 main thread가 아니라 libwebrtc 자체의 audio, video thread에서 전달됩니다. RemoveSink()는 libwebrtc의 내부 sink lock을 잡는 연산이며, 그래서 이미 진행 중인 callback과 순서를 맞추게 됩니다.

RealtimeMediaSource lifecycle. start()/stop()은 producing 상태를 전환합니다. stop()은 virtual stopProducingData()를 호출하며, 이 클래스들에서는 RemoveSink(this)를 수행합니다. isProducingData()는 source가 현재 활성 상태인지를 알려줍니다. requestToEnd()RTCPeerConnection::doClose()가 사용하는 협조적 shutdown 경로이며, observer는 RealtimeMediaSourceObserver::preventSourceFromEnding()에서 true를 반환해 이를 거부할 수 있습니다.

WebAudioBufferList. CoreAudio AudioBufferList allocation을 소유하는 WebCore wrapper입니다. buffer(0)은 이 allocation 안 첫 번째 AudioBuffer struct에 대한 reference를 반환하며, audio callback이 이 struct의 mData/mDataByteSize/mNumberChannels 필드를 채운 뒤 audioSamplesAvailable()에 전달합니다.

RetainPtrLock. RetainPtr은 destruction 시 자신이 가진 object를 release하는 CF/Objective-C smart pointer이며, WTF::Lock은 lifetime이 감싸는 object에 묶여 있는 일반 mutex object입니다.

이 vulnerability는 destruction order와 cross-thread callback이 충돌하며 발생하는 use-after-free입니다.

  ~RealtimeIncomingAudioSourceCocoa (pre-fix)   WebRTC audio thread
  ──────────────────────────────────────────    ───────────────────
  derived dtor body: (empty)
  destroy m_logTimer
  destroy m_audioBufferList -> free()
  destroy m_streamDescription
                                    ────────►   OnData(): m_audioBufferList->buffer(0)
                                                writes mDataByteSize / mNumberChannels
                                                / mData into the freed allocation
  ~RealtimeIncomingAudioSource:
    stop() -> RemoveSink()  <-- synchronization arrives too late

RemoveSink()가 반환하기 전까지는 WebRTC delivery thread가 이 object 위에서 OnData()/OnFrame()을 실행 중일 수 있습니다. 이 호출이 base destructor에서만 이루어졌기 때문에, derived member destruction이 시작되는 시점부터 base destructor body가 실행되기 전까지 window가 존재했습니다. 이 window 동안 동시에 실행되는 OnData()m_audioBufferListunique_ptr이 이미 ~WebAudioBufferList()를 실행해 backing allocation을 해제한 뒤에도 이를 dereference하고, 해제된 allocation에 세 필드를 기록한 뒤 *m_audioBufferListaudioSamplesAvailable()에 전달합니다. Video 쪽에서는 동시에 실행되는 OnFrame()m_pixelBufferPoolLock을 잡으려 시도하는데, 이때 이미 그 Lock object 자체가 파괴된 상태이며, CFRelease가 이미 실행된 m_pixelBufferPool / m_blackFrame RetainPtr을 읽게 됩니다.

Source가 데이터를 여전히 생성 중인 상태에서 destruction이 시작될 수 있는 이유는 commit message에서 확인됩니다. RTCPeerConnection::doClose()는 보통 requestToEnd()를 통해 source를 정지시키지만, preventSourceFromEnding()에서 true를 반환하는 observer가 하나라도 있으면 이 경로가 차단됩니다. 그래서 여전히 활성 상태인 source에서 마지막 reference가 drop될 수 있습니다. doClose(), requestToEnd(), preventSourceFromEnding() 어느 것도 제공된 파일에는 나타나지 않으므로, 이 reachability precondition은 commit message에서 그대로 인용한 내용입니다.

Reachability는 web content로부터 시작됩니다. 페이지가 RTCPeerConnection을 생성하고 inbound audio 및/또는 video track을 negotiate하면, 그 결과로 RealtimeIncomingAudioSourceCocoa/RealtimeIncomingVideoSourceCocoa가 생성되고 시작됩니다. RealtimeIncomingAudioSource::create()는 곧바로 source->start()를 호출합니다. Race의 양쪽 모두 script의 영향을 받습니다. Destruction 시점은 source에 대한 마지막 Ref가 drop되는 시점(peer connection을 닫거나, MediaStreamTrack reference를 놓거나, transceiver를 제거하는 시점)을 따르고, callback의 빈도는 원격 peer를 따르는데 attacker가 연결 반대편을 소유하고 있다면 packet rate와 audio format 역시 attacker가 통제하게 됩니다.

즉각적으로 관찰되는 영향은 WebRTC audio 또는 video thread에서의 memory corruption이며, 가장 흔하게는 crash입니다. 이는 commit message에서도 보고하는 내용입니다. 만약 해제된 WebAudioBufferList allocation이 OnData()가 field store를 실행하기 전에 attacker가 조작한 object로 재점유된다면, mDatamDataByteSize를 그 재점유된 slot에 기록하는 동작이 제한적인 heap-write primitive로 이어질 가능성이 있습니다. 하나는 attacker가 직접 선택하지 않는 pointer-sized 값(libwebrtc buffer 주소로, pointer plant 용도로 유용)이고, 다른 하나는 channel count, frame count, sample width를 통해 attacker가 영향을 미칠 수 있는 32-bit size field입니다. 반대로 buffer(0)이 dereference되기 전에 해제된 allocation이 attacker-controlled 데이터로 재점유된다면, 이어지는 audioSamplesAvailable(..., *m_audioBufferList, m_streamDescription, numberOfFrames) 처리 과정에서 조작된 AudioBuffer가 audio pipeline 안에서 out-of-bounds read 또는 write로 이어질 가능성이 있습니다. 다만 audioSamplesAvailable()의 구현이 제공된 context 밖에 있으므로 이는 조건부입니다. Video 쪽에서는 이미 파괴된 WTF::Lock 위에서 동작하거나 이미 release된 RetainPtr을 다루는 상황이 발생하므로, 선형적인 heap write보다는 over-release나 재점유된 CF object 상태로 이어질 가능성이 있습니다.

이 vulnerability는 WebRTC 수신 경로를 호스팅하는 process의 memory safety를 약화시킵니다. 여기서 걸려 있는 전제는, 다른 thread에서 callback sink로 등록된 object는 그 state가 해체되기 전에 완전히 unregister되어야 하고 등록자의 동기화가 완료되어야 한다는 것입니다. Fix 이전에는 이 전제가 base-class member에만 성립했을 뿐, derived member에는 성립하지 않았습니다. Apple 외 port에서도 GStreamer variant가 같은 형태의 문제를 갖고 있습니다.

이번 fix는 구조적으로 깔끔한 패턴(base destructor 한 곳에서 deregister)을 의도적으로 포기하고, 중복되지만 정확한 패턴(모든 leaf destructor에서 deregister)을 택했습니다. Base destructor가 구조적으로 너무 늦은 시점이기 때문입니다. 이것이 cross-thread 등록에 대한 RAII teardown의 일반 원칙입니다. 가장 derived된 destructor의 맨 앞, 즉 object의 모든 state가 아직 온전한 가장 이른 시점에서 unregister해야 합니다. 다만 앞으로도 주의할 부분이 있습니다. 새 contract의 안전망은 ASSERT(!isProducingData())인데, 이는 release build에서는 컴파일에서 빠집니다. 앞으로 자신만의 stop() 호출 destructor 없이 추가되는 subclass가 있다면, release build에서는 sink를 전혀 제거하지 못한 채로 남게 됩니다. 이는 libwebrtc track에 완전히 dangling 상태인 sink pointer가 등록되어 있는 것으로, 지금 고치는 문제보다 명백히 더 나쁜 failure mode입니다. RELEASE_ASSERT를 쓰거나, base-class stop()을 belt-and-braces 방식의 두 번째 호출로 남겨두었다면 이 gap을 막을 수 있었을 것입니다.