[1] Dangling client back-reference in PlatformSpeechSynthesizer
The callback kept the speech engine alive and let its listener die.
High. 소유권을 갖지 않는 back-reference가 main thread로의 비동기 전환 이후까지 살아남고, 이후 pure virtual 호출에서 그대로 사용됩니다. 그 결과 해제된 메모리에서 vtable pointer를 읽게 됩니다. 확장 가능성은 deferred callback이 실행되기 전에 해제된 client allocation을 재확보할 수 있는지에 달려 있습니다.
WebKit의 speech synthesis 스택에서 back-reference의 lifetime은 엄격한 소유 방향으로 지켜집니다. DOM 객체가 아래쪽의 platform backend를 소유하고, backend는 살아 있다고 보장된 소유자만 위쪽으로 가리킵니다. window.speechSynthesis 뒤에 있는 DOM 객체인 SpeechSynthesis는 RefPtr<PlatformSpeechSynthesizer>를 보유합니다. 이 platform bridge가 시스템 text-to-speech 엔진을 구동하는 역할을 담당합니다(Cocoa에서는 AVSpeechSynthesizer, GStreamer에서는 Flite, Linux에서는 Spiel, 그리고 테스트용 mock). 다만 backend가 위쪽을 가리키는 pointer는 평범한 C++ reference였습니다. 이런 방식은 해당 reference를 거치는 모든 호출이, 소유자를 이미 살려두고 있는 stack frame에 대해 동기적으로 이루어질 때에만 안전합니다.
관전 포인트: 임의의 페이지에서 speechSynthesis.cancel()을 호출하거나 voice 목록을 조회한 뒤, speech 객체의 마지막 reference를 해제할 수 있습니다. 그러면 deferred main-thread callback이 WebContent process 안의 해제된 메모리를 대상으로 virtual 호출을 수행하게 됩니다.
패치 이전에 back-reference가 저장되던 형태입니다:
Source/WebCore/platform/PlatformSpeechSynthesizer.h
PlatformSpeechSynthesizerClient& m_speechSynthesizerClient;
그리고 commit이 직접 지목한 mock backend의 deferred 호출 지점입니다:
Source/WebCore/platform/mock/PlatformSpeechSynthesizerMock.cpp
callOnMainThread([protectedThis = Ref { *this }, utterance]() {
protectedThis->client().speakingErrorOccurred(*utterance);
});
Patch Details
raw PlatformSpeechSynthesizerClient& 멤버가, 각 dispatch 지점에서 RefPtr로 승격해 사용할 수 있는 저장 방식으로 변경되었습니다. 이제 client로 들어가는 호출 전에 먼저 승격이 이루어집니다. 덕분에 client가 살아 있다고 가정하는 대신, 모든 callback이 진행되는 동안 실제로 고정됩니다.
변환된 deferred 호출 지점은 세 곳입니다. 먼저 위에서 본 mock의 cancel() 경로입니다. 다음은 Cocoa의 비동기 voice 목록 완료 처리로, protectedThis->appendVoices(voices.get()); protectedThis->m_speechSynthesizerClient.voicesDidChange();를 실행하던 부분입니다. 이 경로가 실제로 해제된 client까지 도달할 수 있는지는 protectedThis의 성격에 달려 있습니다. strong capture인지, 아니면 weak handle에서 null 검사를 거친 승격인지에 따라 결과가 달라집니다. PlatformSpeechSynthesizerCocoa.mm은 해당 블록 앞에서 잘려 있어, 둘 중 어느 쪽인지는 확인되지 않습니다. 마지막은 Spiel의 initializeVoiceList lambda입니다. commit message는 뒤의 두 경우를 mock 사례와 함께 "동일한 패턴의 latent async bug"로 묶어 설명합니다.
callee에는 strong reference를 잡지만, 되돌아가 호출할 caller에는 아무 reference도 잡지 않는 비동기 전환 이후 소유권 없는 back-reference를 역참조하는 패턴.
Background
이 코드가 있는 위치. Web Speech API 중 synthesis 쪽은 window.speechSynthesis로 script에 노출되며, 전부 WebContent process에서 실행됩니다. 이때 DOM 쪽에 드러나는 객체가 SpeechSynthesis입니다. 그 아래에 엔진에 의존하지 않는 bridge인 PlatformSpeechSynthesizer가 있고, 다시 그 뒤에 플랫폼별 backend가 붙습니다.
소유 방향. SpeechSynthesis.h를 보면 선언이 class SpeechSynthesis : public PlatformSpeechSynthesizerClient, ... public RefCounted<SpeechSynthesis>, public ActiveDOMObject, public EventTarget 형태입니다. 그리고 RefPtr<PlatformSpeechSynthesizer> m_platformSpeechSynthesizer를 통해 아래쪽의 synthesizer를 소유합니다. 반대로 synthesizer는 결과를 위쪽으로 알려야 합니다. 발화가 끝났다, error가 발생했다, voice 목록이 바뀌었다 같은 내용입니다. 그래서 자신의 client를 가리키는 pointer를 보유합니다.
PlatformSpeechSynthesizerClient. DOM 객체가 구현하는 callback interface입니다. speakingErrorOccurred, voicesDidChange, didFinishSpeaking이 모두 pure virtual로 선언되어 있습니다. 따라서 위쪽으로 올라가는 모든 알림은 vtable dispatch를 거치게 됩니다. 호출은 어떤 필드를 읽기 전에 먼저 해당 객체의 PlatformSpeechSynthesizerClient subobject의 vtable pointer를 읽습니다. 이 subobject가 offset 0에 놓이는 이유는, SpeechSynthesis가 가진 여러 base 중에서 이 base가 가장 먼저 나열되어 있기 때문입니다.
Ref / RefPtr와 lambda capture. lambda capture list 안의 Ref { *this }는 strong reference를 잡습니다. 덕분에 capture된 객체는 lambda가 파괴될 때까지 살아 있습니다. 다만 lambda가 건드릴 다른 객체에 대해서는 아무것도 보장하지 않습니다. capture는 자신이 capture한 대상만 보호하기 때문입니다.
Analysis
deferred work item은 synthesizer에 대해서만 strong reference를 잡습니다. 정작 호출 대상인 client에는 아무 reference도 잡지 않습니다. 한편 synthesizer가 그 client를 가리키는 연결은 raw reference이므로, lifetime 관리에 전혀 관여하지 않습니다. 결과적으로 비동기 경계를 넘는 과정에서 capture가 비대칭해지는 것이 root cause입니다.
main thread (script) deferred main-thread work item
──────────────────── ──────────────────────────────
cancel() ──────────────► callOnMainThread(Ref{*this}, utterance)
drop last RefPtr
~SpeechSynthesis() ◄── client freed here
... protectedThis alive (strong)
client().speakingErrorOccurred()
└─► vtable read on freed block
▲ failure window
버그에 도달할 수 있게 만드는 것은 오히려 protectedThis의 strong capture입니다. 이 capture가 synthesizer를 살려두는 덕분에, 살아남지 못한 client를 역참조하는 시점까지 실행이 이어집니다. commit message는 이 비동기 처리가 도입된 시점을 309349@main으로 지목합니다.
같은 fix가 막아주는 두 번째 경로는 동기 쪽에 있습니다. SpeechSynthesis::voicesDidChange()는 dispatchEvent(Event::create(eventNames().voiceschangedEvent, ...))를 호출하므로, client의 handler에서 author script가 실행됩니다. 이 script가 호출 도중에 SpeechSynthesis 객체의 마지막 reference를 해제하는 경우, raw reference는 아무 보호도 제공하지 않습니다. 반면 RefPtr 승격을 거치면 event handler가 무엇을 하든 각 dispatch가 진행되는 동안 client가 살아 있게 됩니다.
exploitability 측면을 보면, 해제된 메모리에 대한 접근이 pure virtual 호출에서 발생합니다. 따라서 재확보된 블록의 PlatformSpeechSynthesizerClient subobject에 있는 vtable slot이 일반 데이터가 아니라 code pointer로 사용됩니다. 이런 종류의 버그가 취할 수 있는 가장 강한 형태입니다. 해당 slot을 제어한다는 것은 indirect branch target을 제어한다는 뜻이기 때문입니다. 다만 이를 script에서 구성하려면 ~SpeechSynthesis()와 queue에 올라간 main-thread work item 사이의 allocation race에서 이겨야 합니다. 이 순서는 동일 thread 안에서 결정되므로, attacker가 영향을 줄 수는 있어도 직접 스케줄하지는 못합니다. 재확보 단계 역시 이번 변경이 직접 입증하는 사항이 아니라 예상되는 방향입니다.
사용자 제스처 없이 일반 페이지 script에서 도달할 수 있는 표면입니다. 그래서 WebContent process의 memory safety를 약화시키는 문제에 해당합니다.
Audit directions
- Strong self capture와 raw client dispatch 조합.
Ref { *this }나protectedThis를 capture한 뒤, raw reference 또는 raw pointer인 멤버를 거쳐 호출하는 lambda는 정확히 엉뚱한 객체를 보호합니다. 이번 commit이 함께 수정한 나머지 두 지점도 같은 형태입니다. Cocoa의 비동기 voice 목록 완료 처리와 Spiel의initializeVoiceListlambda가 그렇습니다. 즉 같은 abstraction을 공유하는 플랫폼별 backend 전반에서 이 패턴이 반복된다는 점이 확인된 셈입니다. 코드 리뷰에서는 capture list에protectedThis가 있고 함수 본문이 나중에Foo&멤버를 역참조하는 경우, "그쪽은 누가 살려두고 있는가"를 물어볼 만합니다. - event를 dispatch하는 client handler.
voicesDidChange()는dispatchEvent를 거쳐 script를 실행합니다.EventTarget으로 흘러 들어가는 platform 계층의 알림은 모두 re-entrancy 지점에 해당합니다. 이 지점에서는 알림을 보낸 쪽의 소유자가 그 아래에서 해제될 수 있습니다. 점검 대상은 구현체가EventTarget서브클래스인 platform callback interface입니다. 호출 직전에만 reference를 잡는지, 아니면 호출이 끝날 때까지 유지하는지를 확인할 필요가 있습니다.