[4] WebRTC incoming sources removed their sink after derived members died
The unregister call ran, correctly, after everything it protected was gone
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도 존재합니다. 예를 들어 어떤RealtimeMediaSourceObserver든preventSourceFromEnding()에서 true를 반환하면requestToEnd()가 차단됩니다. Destruction이 시작될 때 source가 여전히 데이터를 생성 중이면, base class destructor의stop()은RemoveSink()를 호출하기는 합니다(RemoteAudioSource의sink_lock_을 통해 진행 중인OnDatacallback과 정상적으로 동기화됩니다). 다만 그 시점에는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
Source/WebCore/platform/mediastream/cocoa/RealtimeIncomingAudioSourceCocoa.cpp
Patch Details
이번 변경은 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를 새로 받았습니다. RealtimeIncomingAudioSourceCocoa와 RealtimeIncomingVideoSourceCocoa(둘 다 새로 추가되었고 각 header에 선언됨), RealtimeIncomingAudioSourceLibWebRTC(새로 추가), RealtimeIncomingVideoSourceLibWebRTC(이전에는 header에 빈 inline { } body였으나 이제 stop() 호출을 가진 out-of-line 형태로 변경)입니다. startProducingData() / stopProducingData()는 변경되지 않았으며, 여전히 libwebrtc track interface에서 AddSink/AddOrUpdateSink와 RemoveSink를 수행합니다.
Cross-thread callback 등록의 teardown이 base destructor에서 수행되는데, 이미 그 callback이 읽는 derived member들이 파괴된 뒤라는 패턴입니다.
Background
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()에 전달합니다.
RetainPtr과 Lock.
RetainPtr은 destruction 시 자신이 가진 object를 release하는 CF/Objective-C smart pointer이며, WTF::Lock은 lifetime이 감싸는 object에 묶여 있는 일반 mutex object입니다.
Analysis
이 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_audioBufferList의 unique_ptr이 이미 ~WebAudioBufferList()를 실행해 backing allocation을 해제한 뒤에도 이를 dereference하고, 해제된 allocation에 세 필드를 기록한 뒤 *m_audioBufferList를 audioSamplesAvailable()에 전달합니다. 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로 재점유된다면, mData와 mDataByteSize를 그 재점유된 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을 막을 수 있었을 것입니다.
Audit directions
-
Base destructor에 배치된 cross-thread callback deregistration. 이 패턴이 위험한 이유는 C++이 derived member를 base destructor body 실행보다 먼저 파괴하도록 보장하기 때문에, 정확히 callback이 건드리는 그 state에 대해서는 deregistration이 항상 너무 늦게 이루어지기 때문입니다. 좁은 범위:
Source/WebCore/platform/mediastream와Source/WebCore/Modules/mediastream에서stop(),RemoveSink,removeObserver,UnregisterObserver를 호출하는 base-class destructor를 검색하고, 대응하는 callback이 읽는 member를 concrete subclass가 선언하고 있는지 확인하십시오. 신호는 base~Foo()가 deregistration을 수행하는 동시에FooCocoa.h/FooGStreamer.h가 callback body에서 이름이 언급되는std::unique_ptr/RetainPtr/Lockmember를 선언하고 있는 경우입니다. 넓은 범위: 동일한 클래스가 지연된 unregistration 메커니즘 전반을 포괄합니다.NotificationCenter/KVO observer 제거,CFRunLoopSourceinvalidation, cancel handler를 기다리지 않는dispatch_source_cancel, fire handler가 derived state를 읽는Timer에 대한Timer::stop()등이 해당하므로, 등록은 derived constructor에서 이루어지지만 해제는 base destructor에서 이루어지는 클래스를 찾으십시오. 가장 넓은 범위: 동시에 실행 중인 producer로부터의 unregister는 가장 derived된 destructor의 맨 앞에서 수행하고, producer가 진행 중인 callback이 없음을 확인한 뒤에야 object를 destructible로 간주해야 합니다. 이는 Chromium의base::ObserverList/SequenceBoundteardown,Arc<dyn Trait>callback을 사용하는 Rust의Drop순서, superclass finalizer에removeListener가 있는 Java/ObjC listener 전반에도 적용됩니다. 이 단계에서의 매칭 기준은, unregister 호출과 callback이 읽는 state가 동일한 hierarchy 내 서로 다른 클래스에 위치해 있다면, 언어와 무관하게 hit로 간주하는 것입니다. -
Release-build behaviour of debug-only lifecycle contracts.
ASSERT(!isProducingData())는 release 빌드에서 컴파일 과정에서 빠지게 됩니다. 따라서 향후 어떤 subclass가stop()을 호출하는 자신만의 destructor를 빠뜨린다면, 단순히 race 상태에 머무는 게 아니라 sink가 영구적으로 등록된 채 남게 됩니다. 좁게 보면,RealtimeIncomingAudioSource와RealtimeIncomingVideoSource의 모든 subclass를 모든 포트(cocoa/,libwebrtc/gstreamer/)에 걸쳐 나열하고, 각각이 첫 statement로stop()을 호출하는 destructor를 선언하고 있는지 확인해야 합니다.~Foo();선언이 없는finalsubclass 헤더가 바로 그 단서입니다. 넓게 보면,Source/WebCore전체에서 destructor 안에 있는ASSERT(를 검색해, 단순한 local sanity check가 아니라 cross-class contract를 인코딩하는 경우를 찾아야 합니다. 평범한ASSERT옆에 "Subclasses must …" 형태의 주석이 붙어 있는 패턴이 바로 그 모양이며, 이런 경우는 각각RELEASE_ASSERT로 바꾸거나 절대 잊을 수 없는 non-virtual-interface helper로 전환할 후보가 됩니다. 가장 넓게 보면, 빌드 설정에 따라서만 강제되는 safety contract는 실제로 출시되는 설정에서는 강제되지 않는 셈입니다.assert/debug_assert!/DCHECK로 subclass 작성자가 지켜야 할 invariant를 감시하는 다른 코드베이스에도 이 관점을 적용해볼 수 있습니다. release 빌드에 대해서는 "assert가 잡아냈을 것"이라는 답변 자체가 답이 되지 않는다는 점도 함께 새겨야 합니다. -
Investigate whether the veto path that made this reachable creates other "destroyed while still active" states. 어떤 observer든 거부할 수 있는 cooperative-shutdown API라면, graceful path가 항상 보장되는 것은 아닙니다. 따라서 graceful path에서 해제되는 모든 리소스는 destructor 시점의 fallback을 함께 갖추어야 합니다. 좁게 보면,
Source/WebCore/platform/mediastream에서requestToEnd()의 모든 호출자와preventSourceFromEnding()의 모든 override를 추적하고, 각RealtimeMediaSourcesubclass에 대해stop()/stopProducingData()가 해제하는 것 중 destructor가 독립적으로 해제하지 않는 부분이 있는지 확인해야 합니다. 상태가 오직stopProducingData()안에서만 정리되고 대응되는 destructor 처리가 없는 경우가 바로 그 단서입니다. 넓게 보면, 같은 형태가 veto 가능한 teardown protocol 어디에서나 나타날 수 있습니다.beforeunload방식의 취소 가능한 shutdown,MediaStreamTrack의 종료 협상, client가 거부할 수 있는 page-lifecycle freeze/suspend handler 등이 그 예이며, 각각에 대해 veto가 이기고 object가 그럼에도 destroy되는 경우 무슨 일이 일어나는지 점검할 필요가 있습니다. 가장 넓게 보면, 제3자가 veto할 수 있는 shutdown 단계는 리소스가 해제되는 유일한 지점이 되어서는 안 됩니다. 이는 종료를 거부하는 POSIX signal handler부터 Kubernetes preStop hook에 이르기까지 적용되는 원칙입니다. 일치하는 단서는, 다른 누군가가 통제하는 predicate 뒤에만 존재하는 cleanup 로직입니다.