[5] SpeechSynthesis teardown dispatches events inside a script-disallowed scope
Teardown promised no script would run, then fired an event at a live listener.
Medium입니다. 페이지 콘텐츠는 엔진이 active-object registry를 순회하며 mutate하는 바로 그 순간에 자신의 리스너가 실행되도록 예약할 수 있습니다. 이를 통해 안정적으로 도달 가능한 debug assertion과, release 빌드에서의 승인되지 않은 script entry가 만들어집니다. 다만 이 walk가 concurrent mutation에 의해 실제로 corrupt될 수 있는지는 제공된 context만으로는 확인되지 않으며, 그래서 severity는 Medium에 머무릅니다.
WebCore는 내부 상태가 mutate되는 도중임을 나타내는 영역을 author JavaScript 실행이 금지된 scope로 표시합니다. Document teardown도 그런 영역 중 하나로, 엔진은 context와 함께 활동이 중단되어야 하는 객체들의 registry를 순회하면서 각 객체의 lifecycle hook을 호출합니다. ActiveDOMObject는 이런 객체들의 base class로, context가 호출하는 stop()과 suspend() override를 제공합니다. Web Speech API의 SpeechSynthesis도 이런 객체 중 하나로, utterance queue를 소유하면서 플랫폼의 text-to-speech back end를 구동합니다. 이 hook들이 따라야 하는 계약은 단순합니다. Script를 실행하지 않고 완료되어야 합니다.
관전 포인트: 페이지는 같은 document의 utterance를 subframe의 speechSynthesis에 넘긴 뒤 해당 subframe을 제거할 수 있습니다. 이렇게 하면 subframe의 context가 active DOM object set을 순회하는 도중, 자신의 onerror 리스너가 실행되는 상황을 만들 수 있습니다.
Commit message
ActiveDOMObject::stop()andsuspend()are called from withinScriptExecutionContext::forEachActiveDOMObject, which holds aScriptDisallowedScopethat prohibits JavaScript from running. The previousSpeechSynthesis::stop()andsuspend()implementations both calledcancel(), which synchronously fires error events on every queued utterance. In a debug build this trips theScriptDisallowedScopeassertion; in a release build it silently executes JS listeners during a scope that is supposed to forbid it.The fix introduces
stopPlatformSpeech(), called by bothstop()andsuspend(), which clears the utterance queue and nullsm_currentSpeechUtterancewithout firing any events, then tells the platform/client to cancel. Any subsequent completion callbacks from the platform findm_currentSpeechUtterancenull and return early inhandleSpeakingCompleted().A secondary crash was found where
PlatformSpeechSynthesizerMock::cancel()callsspeakingErrorOccurred()synchronously, re-enteringhandleSpeakingCompleted()before the caller has returned. SincestopPlatformSpeech()nullsm_currentSpeechUtterancebefore calling cancel, the re-entrant call arrived with a null current utterance and hit theASSERT(m_currentSpeechUtterance)inhandleSpeakingCompleted(). The assert is replaced with an early return for the null case.An existing fuzzer-crash test is also fixed: it was previously passing only because a teardown crash happened to occur after
notifyDone()was already called, masking the crash.
Source/WebCore/Modules/speech/SpeechSynthesis.cpp
LayoutTests/fast/speechsynthesis/speech-synthesis-stop-cross-document-utterance-crash.html
Patch Details
ActiveDOMObject의 teardown hook 두 곳이 모두 재작성되었습니다. 기존에는 suspend(ReasonForSuspension)와 stop() 각각이 if (speaking()) cancel(); 형태로 동작했습니다. cancel()은 제공된 source context에 그대로 남아 있으며 이번 commit에서 수정되지 않았습니다. 이 함수는 platform 또는 client의 cancel()을 호출한 뒤 speakingErrorOccurred()를 호출하고, 비워진 m_utteranceQueue를 순회하며 대기 중이던 모든 utterance에 대해 error/cancel 이벤트를 발생시킵니다. 즉 DOM 이벤트를 dispatch하기 때문에 author JS가 실행될 수 있습니다.
패치는 두 함수의 본문을 새로 추가된 private 함수 SpeechSynthesis::stopPlatformSpeech() 호출로 교체합니다. 이 함수는 SpeechSynthesis.h에서 handleSpeakingCompleted 옆에 선언되어 있습니다. 이벤트를 발생시키지 않는 teardown을 정해진 순서로 수행하는데, 먼저 m_utteranceQueue.clear()를 실행하고, 이어서 m_currentSpeechUtterance = nullptr을 실행합니다. m_currentSpeechUtterance는 std::unique_ptr<SpeechSynthesisUtteranceActivity> 타입이므로, 이 대입으로 activity가 파괴되고 내부의 Ref<SpeechSynthesisUtterance>가 해제됩니다. 다음으로 m_isPaused = false를 설정하고, 그 이후에야 speechSynthesisClient->cancel() 또는 platformSpeechSynthesizer->cancel()을 호출합니다. handleSpeakingCompleted()에서 바뀐 부분은 주석이 확장된 것뿐입니다. if (!m_currentSpeechUtterance || &utterance != currentSpeechUtterance()) return; guard는 변경되지 않은 context로 나타나므로, 이 early return은 이번 revision 이전부터 이미 존재하던 코드입니다.
테스트 쪽에서도 함께 변경되었습니다. 부모 document에서 생성한 utterance를 iframe의 speechSynthesis를 통해 speak시킨 뒤 iframe을 제거하는 새 regression test와 그 expectation 파일이 추가되었습니다. 또한 speech-synthesis-speak-fuzzer-crash.html이 mock synthesizer를 활성화하고, 조기에 종료하는 대신 notifyDone() 이전에 end/error를 기다리도록 재작성되었습니다. 이 재작성된 테스트를 위한 새로운 mac PNG baseline도 함께 추가되었습니다.
Re-entrancy를 금지하는 계약을 가진 scope 안에서, 소유 컨테이너가 순회 중인 상태로 user-extensible code를 실행하는 teardown callback입니다.
Background
ActiveDOMObject.
context와 함께 activity가 suspend되거나 중단되어야 하는 DOM 객체들의 base class입니다. suspend(ReasonForSuspension)와 stop() override를 제공하며, context가 suspension과 teardown 시점에 이를 호출합니다.
ScriptExecutionContext::forEachActiveDOMObject.
context에 등록된 모든 active object에 대해 위 hook들을 호출하는 registry 순회 함수입니다.
ScriptDisallowedScope.
Source/WebCore/dom/ScriptDisallowedScope.h에 정의된 RAII scope로, global counter를 증가시킵니다. 이 counter가 0이 아닌 동안에는 isScriptAllowed()가 false를 반환하며, script가 진입하면 debug build에서 assert가 발생합니다. WebCore 내부 상태가 mid-mutation 상태인 영역을 표시하는 용도입니다.
Re-entrancy. native code가 JavaScript를 호출하는 지점을 가리키며, 이때 JavaScript가 동기적으로 실행되어 반환 전에 C++ 상태를 변경할 수 있습니다.
Web Speech API model.
speechSynthesis.speak(utterance)는 SpeechSynthesisUtterance를 m_utteranceQueue에 추가합니다. queue의 head는 m_currentSpeechUtterance가 되는데, 이는 utterance에 대한 Ref를 보유한 std::unique_ptr<SpeechSynthesisUtteranceActivity>이며, platform layer로 전달됩니다. platform은 완료나 실패를 didFinishSpeaking/speakingErrorOccurred를 통해 되돌려주고, 이 호출들은 handleSpeakingCompleted()로 모여 utterance에 end/error 이벤트를 발생시킵니다.
Utterance realm binding.
SpeechSynthesisUtterance 자체는 자신을 생성한 document에 바인딩된 EventTarget입니다. 이 document는 실제로 그것을 speak시키는 speechSynthesis 객체가 속한 document와 같을 필요가 없습니다.
PlatformSpeechSynthesizerMock.
Source/WebCore/platform/mock/에 있는 test back end로, internals.enableMockSpeechSynthesizer()를 통해 활성화되며 layout test에서 실제 TTS engine을 대신합니다.
Analysis
이 버그의 본질은 script-disallowed scope 내부에서 발생하는 script re-entrancy이며, lifecycle invariant를 위반하는 문제입니다. 여기에 더해 platform completion callback에서 발생하는 null-state re-entrancy가 부차적인 결함으로 존재합니다.
iframe context teardown parent document (still live)
─────────────────────── ────────────────────────────
forEachActiveDOMObject
{ ScriptDisallowedScope }
SpeechSynthesis::stop()
cancel()
speakingErrorOccurred() ─────────► u.onerror listener runs
(author JS, mid-iteration)
drain m_utteranceQueue ◄───────── listener may mutate state here
} scope exits
두 hook 모두 cancel()에 위임하는 구조였고, cancel()은 queue에 있는 항목마다 이벤트를 발생시킵니다. 이벤트 dispatch는 author가 등록한 listener까지 도달하므로, teardown 경로에서 JavaScript가 동기적으로 실행될 가능성이 있었습니다. 문제는 이 지점이 애초에 그런 실행을 금지하기 위해 존재하는 scope 내부라는 점입니다. 다만 제공된 ScriptExecutionContext.cpp 발췌본은 forEachActiveDOMObject 이전 부분이 잘려 있습니다. 따라서 caller의 정체와 그것이 보유한 scope는 패치 자체가 추가한 주석과 일반적인 WebCore 지식에 근거해 판단한 내용입니다.
일반적인 경우에는 이 위반이 겉으로 드러나지 않습니다. utterance 자신의 ScriptExecutionContext는 소유 document가 teardown될 시점에는 이미 죽어 있는 경우가 많아서, dispatchEvent가 아무 효과를 내지 못하기 때문입니다. 반면 이번 regression test는 실패를 유발하는 구조를 의도적으로 구성합니다. utterance는 부모 document에서 생성되고, speak()는 iframe의 speechSynthesis에서 호출됩니다. iframe이 제거되면 iframe context의 teardown 순회가 stop()을 호출하고, cancel()이 아직 살아있는 context를 가진 utterance에 error 이벤트를 발생시키며, 결과적으로 부모의 u.onerror listener가 실행됩니다. 테스트 자체의 주석에도 이 근거가 명시되어 있습니다 ("Utterance is created in the opener's document so its ActiveDOMObject context is still alive"). 다만 이벤트가 실제로 부모의 listener에 도달한다는 부분은 테스트가 명시한 의도로부터 유추한 것입니다. handleSpeakingCompleted/speakingErrorOccurred의 dispatch 본문이 제공된 source에서는 잘려 있기 때문입니다. debug build에서는 이 상황이 assertion을 유발하고, release build에서는 JS가 그대로 실행됩니다.
두 번째로, 순서와 관련된 결함이 존재합니다. cancel()이 speakingErrorOccurred()를 동기적으로 호출하는 platform back end라면, stopPlatformSpeech()/cancel()이 반환되기 전에 handleSpeakingCompleted()로 re-entrant하게 진입하게 됩니다. 패치는 platform 호출 이전에 m_currentSpeechUtterance를 null로 만들어 이 문제를 해소합니다. 그 결과 re-entrant callback은 원래 그 자리에 있던 assertion 대신 if (!m_currentSpeechUtterance ...) return; guard에 걸리게 됩니다. 다만 이번 revision에서 제공된 mock은 error를 callOnMainThread로 예약합니다. 즉 비동기적으로 동작한다는 뜻입니다. 새 주석이 설명하는 동기적 callback 구조는 여기 제공된 mock source에서는 나타나지 않습니다.
발견 경위는 debug assertion triage가 variant analysis로 이어진 흐름으로 보입니다. 기존 speech-synthesis-speak-fuzzer-crash.html이라는 이름 자체가, 원래 fuzzer가 이 모듈에서 crash를 발견했음을 시사합니다. 이 테스트는 이번 commit에서 mock synthesizer를 활성화하고 notifyDone() 이전에 end/error를 기다리도록 재작성되었는데, 이는 기존 테스트가 completion 경로를 전혀 exercise하지 않은 채로 통과해왔음을 짐작하게 합니다. stop()/suspend() 도중 ScriptDisallowedScope assertion이 발생한다는 사실은 cancel()의 이벤트 dispatch를 직접 가리키며, cross-document utterance 케이스를 구성한 것은 event target의 context가 언제까지 살아있는지를 따져본 뒤 엔지니어가 의도적으로 만들어낸 형태로 보입니다.
이 vulnerability는 document teardown과 context suspension 과정에서 WebCore가 의존하는 script-execution-forbidden invariant를 약화시킵니다. 여기서 걸려 있는 security model의 전제는, engine이 lifecycle-critical한 구조체를 순회하며 변경하는 동안에는 author code가 전혀 실행되지 않는다는 것을 ScriptDisallowedScope가 보장한다는 점입니다. 이 경우 그 구조체는 teardown hook이 호출되고 있는 active-DOM-object registry 자체입니다. 패치 이전에는, 동일 document의 utterance를 subframe의 speechSynthesis에 넘긴 뒤 그 subframe을 제거함으로써 페이지 콘텐츠가 바로 그 지점에서 실행될 자신의 listener를 예약할 수 있었습니다. 즉 "내 밑에서는 아무것도 바뀌지 않는다"는 engine의 전제는 event target이 대체로 이미 죽어 있었기 때문에 우연히 성립했을 뿐입니다. 공격자가 얻을 수 있는 이론적인 이득은 teardown 도중 공격자가 원하는 시점에 re-entrancy window를 여는 것입니다. 최소한으로는 debug assertion을 안정적으로 유발할 수 있고, release build에서는 허가되지 않은 script 진입이 발생합니다. 만약 re-entrant listener가 진행 중인 teardown 순회가 여전히 참조하는 객체를 등록, 파괴, navigate할 수 있다면, 단순한 abort를 넘어 lifecycle bookkeeping의 state corruption으로 이어질 가능성도 있습니다. release build의 순회가 실제로 concurrent mutation에 취약한지는 확인되지 않습니다. 제공된 ScriptExecutionContext.cpp 발췌본이, registry가 어떻게 snapshot되는지 보여줄 iteration 함수 이전 부분에서 잘려 있기 때문입니다.
Insight
이 버그는 자신을 가리는 mitigation 뒤에 숨어 있던 전형적인 사례입니다. teardown 도중 cancel()이 이벤트를 발생시키는 동작 자체는 거의 항상 무해했는데, utterance의 context가 document와 함께 죽는 경우가 대부분이었기 때문입니다. 이 위반은 누군가 cross-document 케이스를 의도적으로 구성했을 때에야 비로소 드러났습니다. WebCore 안에 "teardown 중에 이벤트를 발생시키지만 어차피 target은 죽어 있다"는 식의 논리가 남아 있다면, scriptExecutionContext()가 teardown을 수행하는 module과 다른 EventTarget이라는 동일한 escape hatch가 없는지 재검토할 필요가 있습니다. 재작성된 fuzzer test는 더 일반적인 교훈도 남깁니다. 기존 테스트는 completion callback을 기다리지 않는 경로에서 notifyDone()을 호출했고, 그 결과 harness가 이미 pass를 기록한 뒤에 teardown crash가 발생할 수 있었습니다. 테스트 대상 코드보다 먼저 끝나버리는 regression test는 소리 없는 coverage 공백입니다. 앞으로를 생각하면, stopPlatformSpeech()는 cancel()이 쓰던 순서를 의도적으로 뒤집습니다. 호출 동안 스택에 RefPtr을 들고 있는 대신, platform을 호출하기 전에 current-utterance의 ownership을 먼저 해제하는 방식입니다. 이 순서 덕분에 re-entrant callback이 안전해지지만, 동시에 WebCore의 utterance가 마지막 reference를 이미 잃은 시점에 platform layer가 호출될 수 있다는 뜻이기도 합니다. 이 trade-off는 별도로 점검할 가치가 있습니다.
Audit directions
-
Teardown/suspension callback이 그런 코드는 실행되면 안 된다고 선언한 scope 안에서 user-extensible code를 실행하는 패턴. 컨테이너가 순회 중일 때 호출되는 lifecycle hook은 반드시 이벤트를 발생시키지 않아야 하지만, 이 invariant는 지키기 어렵습니다. 이벤트 dispatch는 대개 여러 frame 깊이 아래에 있고, 일반적인 경우에는 아무 효과도 없는 것처럼 보이기 때문입니다. Narrow:
Source/WebCore/Modules와Source/WebCore/html에서ActiveDOMObject의void stop()/suspend(ReasonForSuspension)override를 grep하고, 이들이dispatchEvent,queueTaskToDispatchEvent,DeferredPromiseresolution, 또는 JS callback에 transitively 도달하는지 확인해야 합니다. match tell은stop()/suspend()본문이 public JS-facing API가 호출하는 것과 같은 메서드(여기서는cancel())에 위임하는 경우입니다. public 메서드는 애초에 이벤트를 발생시키도록 작성되어 있기 때문입니다. Wider: engine이 no-mutation guard 아래에서 registry를 순회하며 참가자들을 호출하는 곳이라면 어디든 같은 class의 문제가 나타납니다. style/layout recalculation이ResizeObserver/IntersectionObserverdelivery를 호출하는 경우, IDB transaction abort 경로, media element teardown 등이 해당됩니다. 코드 검색 결과에서의 match tell은, scope guard가 스택에 걸려 있는 동안 member container에 대해 element별로 callback을 호출하는 함수입니다. Widest: 자신이 소유한 컨테이너의 iterator를 들고 있는 동안에는 신뢰할 수 없거나 extensible한 코드에 절대 re-entrant하게 진입해서는 안 됩니다. 이 패턴은 Java collection의 ConcurrentModificationException, Rust observer list의RefCell이중 borrow panic, Node EventEmitter의 dispatch 중 listener-list mutation 등으로 반복해서 나타납니다. 코드베이스를 넘나들며 챙겨야 할 질문은 "이 loop에 도달하는 callback을 누가 등록할 수 있으며, 그 callback이 element를 추가하거나 제거할 수 있는가?"입니다. -
동기적으로 다시 콜백할 수 있는 하위 layer를 호출할 때, clear-then-notify와 notify-then-clear 중 어떤 순서를 쓰는가. re-entrant callback이 들여다볼 모든 상태는 outbound call 이전에 최종 값에 도달해 있어야 합니다. 이 원칙은 back end의 sync/async delivery 계약이 저자가 우연히 테스트했던 구현체 하나로만 암묵적으로 정의되어 있을 때 깨지기 쉽습니다. Narrow:
m_speechSynthesisClient/m_platformSpeechSynthesizer로 호출하는 다른SpeechSynthesis경로들 —cancel(),pause(),resumeSynthesis(),startSpeakingImmediately()— 을 점검하고, 각각에서 outbound call 이후에 변경되는 상태가 있는지 확인해야 합니다. match tell은 같은 함수 안에서->cancel()/->speak()/->pause()호출문 아래쪽에 텍스트상으로 위치한 member assignment입니다. Wider: mock back end와 real back end가 쌍을 이루는 모든 WebCore module이 같은 class에 해당합니다.PlatformSpeechSynthesizerMock(여기서는callOnMainThread를 사용하므로 비동기)을 production Cocoa synthesizer 및 WebKit2의SpeechSynthesisClient구현체들과 비교하고, media, WebRTC, geolocation mock에 대해서도 동일하게 점검해야 합니다. match tell은 real back end가 post하는 동안 mock이client().someDidHappen(...)을 mocking 대상 메서드와 같은 스택에서 호출하는 경우입니다. Widest: sync-versus-async delivery가 타입 시스템으로 강제되지 않는 callback interface는 결국 양쪽 방식 모두로 구현되기 마련입니다. completion handler를 쓰는 모든 코드베이스에 해당하는 이야기입니다 — Chromium의base::OnceCallback이 posted되는 경우와 run-inline되는 경우, poll 시점에 완료되는 Rust future, 반환 전에 콜백이 호출되기도 하는 Node API 등이 그 예입니다. 계속 챙겨야 할 감사 질문은 "만약 이 callback이 내 호출이 반환되기 전에 실행됐다면, 어떤 상태를 관찰하게 될까?"입니다. -
Cross-document object graphs where module A's teardown fires events into document B's still-live context. Context를 파괴하면 그 context가 유발할 수 있는 모든 script 실행이 함께 멈춰야 합니다. 하지만 cross-document 방식으로 객체를 공유하면 이 원칙이 깨집니다. Event target의 context가 module 자신의 context와 다르기 때문입니다. 좁게 보면, DOM 객체 인자를 받으면서 자신의 것과
scriptExecutionContext()를 비교하지 않는 WebCore API들을 점검해야 합니다. 이번 사례는SpeechSynthesis::speak(SpeechSynthesisUtterance&)입니다. 일치 여부를 판별하는 기준은,EventTarget을 상속한 파라미터가 진입 시점에 동일 document 여부 확인 없이 멤버 큐에 저장되는지입니다. 조금 더 넓게 보면, 한 realm에서 생성된 객체를 다른 realm의 controller가 계속 붙들고 있는 모든 경우에 동일한 패턴이 적용됩니다. Web Animations의 effect, frame 간에 전달되는MediaStreamTrack, cross-frame API에 전달되는AbortSignal등이 해당합니다. 코드 검색 시 확인할 기준은,ActiveDOMObject의 멤버로Ref<SomeEventTarget>을 담는Deque/Vector가 있고, 그 객체 자신의 teardown이 이 컬렉션을 순회하며 dispatch를 수행하는지입니다. 가장 넓게 보면, lifetime scope는 reachability 기준으로 닫혀야 합니다. Tenant별 또는 session별로 teardown을 수행하는 모든 시스템 — DI container의 scope, actor supervision tree, 더 오래 사는 scope가 소유한 객체를 담고 있는 request-scoped cache 등 — 이 동일한 failure mode를 갖습니다. 이 패턴을 식별하는 기준은, scope A가 lifetime을 관리하는 객체가 scope B의 shutdown이 비우는 컬렉션 안에 놓여 있는지입니다. -
m_currentSpeechUtterance를 platform 쪽cancel()호출보다 먼저 해제하면,PlatformSpeechSynthesisUtterance가 여전히 client back-pointer를 들고 있는 상태에서SpeechSynthesisUtterance의 마지막 reference가 사라질 수 있는지 확인해야 합니다.Source/WebCore/Modules/speech/SpeechSynthesisUtterance.cpp와Source/WebCore/platform/PlatformSpeechSynthesisUtterance.h부터 살펴봐야 합니다. 먼저 platform utterance가 들고 있는 client reference가WeakPtr인지, raw pointer인지, 아니면 refcounted 객체인지 확인합니다. 그다음PlatformSpeechSynthesizerMock::cancel()에서 지연 호출되는client().speakingErrorOccurred(*utterance)와 Cocoa 쪽 대응 코드를 추적해야 합니다. 일치 여부를 판별하는 기준은, platform 객체에서 WebCore 객체로 향하는 back-pointer가 raw이거나 null로 초기화되지 않는 형태이면서, WebCore 쪽을 먼저 해제하는 teardown 경로가 존재하는지입니다. Platform 쪽이 독립적으로 refcount되어 WebCore owner보다 오래 살아남는 모든 WebCore/platform 객체 쌍에서 동일한 비대칭을 점검할 가치가 있습니다.