[3] WebSpeechSynthesisWrapper missing observer deregistration
Severity가 Low로 매겨진 이유는 최신 Foundation이 observer를 weak reference로 저장하고 dealloc 시점에 자동으로 nil 처리하기 때문입니다. Zeroing weak 방식이 도입되기 이전의 Cocoa였다면, 이 정도의 비대칭 구조는 교과서적인 dangling-observer UAF에 해당했을 것입니다. 다만 해당 notification은 웹 콘텐츠에서 임의로 유발할 수 있는 성격이 아니라 시스템에 의해 구동되는 이벤트이므로, 결국 이번 건은 robustness 개선과 패턴 점검 차원의 교훈으로 남습니다.
Observer lifecycle 버그는 어떤 객체가 생성 시점에 notification hub를 구독하고도, 해제되기 전에 구독을 해지하지 않을 때 발생합니다. 이 경우 등록 정보가 subscriber보다 더 오래 남아있게 됩니다. 여기서 subscriber는 WebSpeechSynthesisWrapper로, WebCore의 speech API와 AVFoundation의 speech synthesizer를 연결하는 Objective-C bridge입니다. 이 객체는 Cocoa의 전역 publish/subscribe hub인 NSNotificationCenter에 등록되어 voice 목록 변경을 감지합니다. 이 lifecycle 규약은 대칭 구조를 전제로 합니다. 초기화 시점의 addObserver: 호출은 반드시 해제 시점의 removeObserver: 호출과 짝을 이루어야 하며, 그래야 이미 해제된 observer에게 notification이 전달되는 일이 생기지 않습니다.
관전 포인트: 이번 패치는 defensive value 측면에서만 의미가 있습니다. 최신 Foundation이 이미 해당 observer를 weak reference로 저장하고 자동으로 nil 처리하기 때문에, use-after-free 자체는 도달 불가능합니다. 이 패치는 voice-notification teardown 과정의 robustness gap을 닫는 역할을 하며, -dealloc에서 unregistration이 누락되어 있었다는 패턴 자체가 다른 곳에서도 점검해볼 가치가 있습니다.
이번 커밋은 safari-7624-branch fix의 merge-back에 해당합니다. 원래 fix에서 추가되었던 -availableVoicesDidChange의 main-thread hop과 null-check는 이미 312522@main에서 ensureOnMainThread를 통해 별도로 main에 반영된 상태입니다. 따라서 이번 merge-back은 나머지 -dealloc 변경 사항만을 적용합니다. WebSpeechSynthesisWrapper는 AVSpeechSynthesisAvailableVoicesDidChangeNotification의 observer로 등록되지만, 스스로를 해제하는 코드는 갖고 있지 않았습니다. 이번 fix는 -dealloc을 추가하여 -removeObserver:name:object:를 호출하도록 합니다. Foundation이 이런 observer를 zeroing weak reference로 저장하여 observer가 해제되면 자동으로 무효화되기는 하지만, 등록을 즉시 해제하는 것이 여전히 best practice입니다.
Source/WebCore/platform/cocoa/PlatformSpeechSynthesizerCocoa.mm
Patch Details
이번 패치는 WebSpeechSynthesisWrapper에 -dealloc 메서드를 추가합니다. 새로 추가된 메서드는 [super dealloc]을 호출하기 전에 [[NSNotificationCenter defaultCenter] removeObserver:self name:AVSpeechSynthesisAvailableVoicesDidChangeNotification object:nil]을 먼저 호출합니다. 이는 -initWithSpeechSynthesizer:에 있던 addObserver:selector:@selector(availableVoicesDidChange) name:... 등록에 대응하는 짝으로, 기존에는 이에 상응하는 해제 코드가 없었습니다. 새 메서드는 등록 부분과 동일하게 #if HAVE(AVSPEECHSYNTHESIS_VOICES_CHANGE_NOTIFICATION) 가드로 감싸져 있습니다. 별도로 312522@main에서 반영된 기존의 -availableVoicesDidChange main-thread hop과 null-check는 그대로 유지됩니다.
구독과 구독 해지가 짝을 이루지 못한 패턴 — init 시점에 observer가 등록되지만, deallocation 시점에 이에 대응하는 해지 코드가 없었습니다.
Background
NSNotificationCenter.
NSNotificationCenter는 Foundation의 publish/subscribe hub로, 느슨하게 결합된 observer들을 위한 전역 메시지 버스입니다. 객체는 addObserver:selector:name:object:를 통해 등록되어 지정된 이름의 notification을 수신하며, 해제되기 전에는 removeObserver:를 통해 구독을 해지해야 합니다. 이런 구조의 목적은 notification을 발행하는 쪽과 수신하는 쪽이 서로 직접적인 pointer 참조 없이도 분리될 수 있도록 하는 데 있습니다.
Zeroing weak references.
Selector 기반 API로 등록된 observer의 경우, 최신 Foundation은 이를 zeroing weak reference로 저장합니다. Strong reference였다면 해당 객체를 계속 살려두었을 것이고, unsafe_unretained raw pointer였다면 dangling 상태가 되었을 것입니다. 반면 zeroing weak reference는 observer가 해제되는 시점에 자동으로 nil 처리됩니다. 여기서 핵심은 "zeroing"이라는 속성입니다. 이후에 notification이 post되어도 이미 해제된 pointer로 메시지를 보내는 일이 발생하지 않는다는 뜻입니다.
-dealloc과 wrapper.
-dealloc은 Objective-C에서 observer 해제가 관례적으로 이루어지는 teardown 지점으로, 마지막에는 [super dealloc]으로 이어집니다. WebSpeechSynthesisWrapper는 AVSpeechSynthesizerDelegate를 채택한 NSObject로, WebCore의 PlatformSpeechSynthesizer와 AVFoundation을 연결하는 역할을 합니다. 이 객체는 voice 변경 notification을 관찰하다가, main thread에서 PlatformSpeechSynthesizer::voicesDidChange()를 호출합니다. AVSpeechSynthesisAvailableVoicesDidChangeNotification은 설치된 시스템 voice 목록이 변경될 때 AVFoundation이 post하는 notification으로, background thread에서 post될 수도 있습니다.
Analysis
근본 원인은 observer lifecycle의 비대칭 구조에 있습니다. WebSpeechSynthesisWrapper는 initializer에서 스스로를 등록했지만, 이를 해지하는 -dealloc을 제공하지 않았습니다. -initWithSpeechSynthesizer:에서의 등록에는 대응하는 teardown이 없었고, 그 결과 notification center에 남은 등록 정보가 원래 구독 의도보다 더 오래 유지되는 상황이 만들어졌습니다.
Pre-mitigation Cocoa Modern Foundation (this platform)
(non-zeroing storage) (zeroing-weak storage)
────────────────────── ─────────────────────────────────
init: addObserver (raw ptr) init: addObserver (weak ptr)
... ...
dealloc (no removeObserver) dealloc (no removeObserver)
observer slot -> freed ptr observer slot -> auto-nil'd
notification posts notification posts
dispatch to freed obj UAF dispatch skipped (no-op)
Observer 해제가 누락된 경우의 전형적인 exploitation 방향은 다음과 같습니다. 먼저 wrapper를 할당하고 등록이 이루어지게 한 뒤, 이를 해제하고, notification을 유발하여 center가 이미 해제된 observer에게 availableVoicesDidChange를 dispatch하도록 만드는 방식입니다. 만약 해제된 slot이 attacker가 제어하는 데이터로 재사용된 상태였다면, 이 selector dispatch가 제어된 객체에 도달할 가능성도 있었을 것입니다. 다만 이 경우에는 두 가지 사실이 그 가능성을 무너뜨립니다. 하나는 selector 기반 등록이 zeroing weak reference로 유지되어 deallocation 시점에 pointer가 자동으로 무효화되므로, 해제된 객체로의 dispatch 자체가 일어나지 않는다는 점입니다. 다른 하나는 해당 notification이 웹 콘텐츠에서 임의로 호출할 수 있는 것이 아니라, AVFoundation이 시스템 voice 목록 변경에 따라 구동한다는 점입니다. 따라서 read/write나 control-flow primitive는 도달 불가능하며, 설령 mitigation이 없는 가상의 상황을 가정하더라도 speech 관련 프로세스에서의 별도 sandbox escape가 추가로 필요했을 것입니다.
이번 취약점은 speech-synthesis observer lifecycle의 memory-safety robustness를 약화시키는 성격이지만, 실질적인 trust-boundary 영향은 미미합니다. Observer가 deallocation 전에 구독을 해지해야 한다는 invariant는 WebKit 자체 코드가 아니라 Foundation의 zeroing-weak 동작에 의해서만 지켜지고 있었습니다. 이번 fix는 registration/deregistration의 대칭성을 복원하여, correctness가 더 이상 framework의 세부 동작에 의존하지 않도록 만듭니다.
여기서 얻을 수 있는 교훈은 framework mitigation이 일종의 암묵적 안전망 역할을 한다는 점입니다. Non-zeroing observer storage를 대상으로 했다면 동일한 소스 레벨 버그가 UAF로 이어졌겠지만, 최신 NSNotificationCenter를 대상으로 하면 단순한 정리 미흡 정도에 그칩니다. 다만 이 안전망은 API에 따라 성격이 다르다는 점이 중요합니다. addObserverForName:object:queue:usingBlock:을 통한 등록은 zeroing-weak 방식이 아니며 실제로 dangling 상태가 될 수 있습니다. 따라서 block 기반 등록에서 동일한 비대칭 구조가 발견된다면, 그것은 진짜 버그에 해당합니다.
Audit directions
- Safety가 명시적인 대칭 구조가 아니라 framework mitigation에 의존하는 비대칭 subscribe/unsubscribe 패턴.
Source/WebCore/platform/cocoa와Source/WebCore/platform/audio/cocoa에서addObserver:/addObserverForName:이 사용되면서-dealloc에 대응하는removeObserver:가 없는 곳을 검색해야 합니다. Speech와 audio-session bridge 같은 AVFoundation/media delegate wrapper부터 시작하는 것이 좋습니다. 점검 시 확인할 신호는-init에서addObserver...를 호출하면서도@implementation에-dealloc자체가 없거나,-dealloc이 있어도 deregistration을 건너뛰는 클래스입니다. - Zeroing-weak 보호를 받지 못하는 등록. 각 notification 등록이 zeroing-weak selector API를 쓰는지, 아니면 non-zeroing block API를 쓰는지 확인해야 합니다. KVO(
addObserver:forKeyPath:),CFNotificationCenter,DispatchSource, block 기반addObserverForName:object:queue:usingBlock:에서 동일한 lifecycle 비대칭이 발생하면 Foundation의 zeroing-weak 보호를 받지 못하므로, 동일한 소스 구조라도 실제 dangling-observer/UAF에 해당합니다. WebCore/PAL에서 block 기반 observer 등록을 검색하여, 각 등록이 self를 weak하게 캡처하고 있는지, 그리고 정상적으로 해제되는지 확인해야 합니다. 점검 시 확인할 신호는 block 기반 등록에서 캡처된 self나 token이removeObserver:로 전달되지 않는 경우입니다. - Subscription이 subscriber보다 오래 남는 문제 — memory-safety 결과와 무관하게 성립하는 lifecycle 계약 위반. 이 패턴은 subscribe/unsubscribe 쌍이 init과 teardown으로 나뉘어 있는 곳이라면 어디서든 나타날 수 있습니다. JavaScript DOM의
addEventListener/removeEventListener, Node.js의EventEmitter, Rust의tokio::broadcast, lifecycle scope를 가진 DI 컨테이너 등이 해당됩니다. 재사용 가능한 invariant는 다음과 같습니다. Initializer가 framework 소유의 collection에 등록한다면, destructor는 반드시 구독을 해지해야 합니다. Framework가 이를 자동으로 처리한다고 주장하더라도 마찬가지입니다. 그 보장이 등록 API마다 다르고, edge-case teardown race 상황에서는 대체로 새어나가기 때문입니다. 점검 시 확인할 신호는 constructor에서 구독하지만 destructor에서는 구독을 해지하지 않는 타입입니다.