← All reports

[5] SpeechSynthesis teardown dispatches events inside a script-disallowed scope

MediumWebCore Web Speech APIRace

Teardown promised no script would run, then fired an event at a live listener.

5273531

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 리스너가 실행되는 상황을 만들 수 있습니다.

ActiveDOMObject::stop() and suspend() are called from within ScriptExecutionContext::forEachActiveDOMObject, which holds a ScriptDisallowedScope that prohibits JavaScript from running. The previous SpeechSynthesis::stop() and suspend() implementations both called cancel(), which synchronously fires error events on every queued utterance. In a debug build this trips the ScriptDisallowedScope assertion; in a release build it silently executes JS listeners during a scope that is supposed to forbid it.

The fix introduces stopPlatformSpeech(), called by both stop() and suspend(), which clears the utterance queue and nulls m_currentSpeechUtterance without firing any events, then tells the platform/client to cancel. Any subsequent completion callbacks from the platform find m_currentSpeechUtterance null and return early in handleSpeakingCompleted().

A secondary crash was found where PlatformSpeechSynthesizerMock::cancel() calls speakingErrorOccurred() synchronously, re-entering handleSpeakingCompleted() before the caller has returned. Since stopPlatformSpeech() nulls m_currentSpeechUtterance before calling cancel, the re-entrant call arrived with a null current utterance and hit the ASSERT(m_currentSpeechUtterance) in handleSpeakingCompleted(). 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

void SpeechSynthesis::handleSpeakingCompleted(SpeechSynthesisUtterance& utterance, bool errorOccurred)
{
// Ignore callbacks for stale utterances. This can happen when cancel() is called
 
- // and a new utterance is queued before the platform's async cancel callback fires.
+ // and a new utterance is queued before the platform's async cancel callback fires, or when
+ // stopPlatformSpeech() already cleared m_currentSpeechUtterance and the platform cancel()
+ // fired this callback synchronously (e.g. the mock).
if (!m_currentSpeechUtterance || &utterance != currentSpeechUtterance())
return;
...
void SpeechSynthesis::suspend(ReasonForSuspension)
{
 
- if (speaking())
 
- cancel();
+ stopPlatformSpeech();
}
 
void SpeechSynthesis::stop()
{
 
- if (speaking())
 
- cancel();
+ stopPlatformSpeech();
+}
+
+void SpeechSynthesis::stopPlatformSpeech()
+{
+ // ScriptExecutionContext::forEachActiveDOMObject 내부에서 호출되며, 이 함수는
+ // ScriptDisallowedScope를 보유하고 있으므로 cancel()처럼 여기서 error 이벤트를 발생시킬 수 없습니다.
+ // platform으로부터 뒤늦게 도착하는 completion callback은 m_currentSpeechUtterance == nullptr을
+ // 보게 되어 handleSpeakingCompleted()에서 무시됩니다.
+ m_utteranceQueue.clear();
+ m_currentSpeechUtterance = nullptr;
+ m_isPaused = false;
+ if (RefPtr speechSynthesisClient = m_speechSynthesisClient.get())
+ speechSynthesisClient->cancel();
+ else if (RefPtr platformSpeechSynthesizer = m_platformSpeechSynthesizer)
+ platformSpeechSynthesizer->cancel();
}

LayoutTests/fast/speechsynthesis/speech-synthesis-stop-cross-document-utterance-crash.html

+onload = () => {
+ let frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ if (frame.contentWindow.internals)
+ frame.contentWindow.internals.enableMockSpeechSynthesizer();
+ let synth = frame.contentWindow.speechSynthesis;
+ // Utterance는 opener의 document에서 생성되므로, iframe의 SpeechSynthesis가 teardown되는
+ // 시점에도 그 ActiveDOMObject context는 여전히 살아 있습니다.
+ let u = new SpeechSynthesisUtterance("test");
+ u.onerror = () => {};
+ synth.speak(u);
+ frame.remove();
+ ...
+};

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_currentSpeechUtterancestd::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입니다.

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)SpeechSynthesisUtterancem_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을 대신합니다.

이 버그의 본질은 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()iframespeechSynthesis에서 호출됩니다. 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 함수 이전 부분에서 잘려 있기 때문입니다.

이 버그는 자신을 가리는 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는 별도로 점검할 가치가 있습니다.