Twitch.tv may stall at ad transition during live streaming
Component: WebCore Media Source Extensions | 93e7a14
MSE Coded Frame Processing은 새로 들어온 encoded frame이 이미 버퍼링된 콘텐츠와 어떻게 상호작용할지 규정하는 표준 알고리즘입니다. 이 중 step 1.14/1.15는 overlap-removal 단계로, 새 frame의 presentation range가 겹치는 기존 버퍼 콘텐츠를 삭제합니다. B-frame(bidirectional predictive)은 디코딩 후 표시되는 특성상 PTS가 DTS보다 큽니다. 그리고 fMP4 세그먼트 끝부분에서는, decode order상 마지막 샘플의 trun.sample_duration이 실제 presentation duration이 아니라 decode-grid상의 placeholder 값에 불과합니다.
LayoutTests/media/media-source/media-source-append-b-frame-tail-overshoot.html
해당 tail duration이 placeholder 값이기 때문에, frameEndTime = pts + duration 계산 결과가 다음 버퍼 샘플의 PTS를 약간 초과할 수 있습니다. 다만 이는 실제 editorial overlap을 의미하지 않습니다. 그런데 스펙을 문자 그대로 따르는 삭제 로직은 이 경우에도 정상 콘텐츠를 지워버립니다. 그 결과 buffered-range에 gap이 생기고, Twitch의 ad splice 지점에서 재생이 멈추는 문제로 이어집니다. 이번 fix는 트랙별로 "B-frame tail이면서 timeFudgeFactor 이내"인지 판단하는 heuristic을 추가해, 이 경우 삭제 대신 forward-shift 경로로 우회하도록 합니다. 이를 위해 MediaSample::createCopyWithAdjustedStartTime, TrackBuffer::adjustSampleStartTime, SampleMap::replaceSample이 새로 도입되었으며, overshoot만큼 겹친 샘플을 앞으로 밀어내는 방식으로 동작합니다. 이 과정에서 SampleMap의 presentation-order submap, decode-order submap, 그리고 TrackBuffer::m_decodeQueue까지 세 구조체가 함께 변경됩니다.
fMP4 tail B-frame: dts=50 pts=80 dur=30 => frameEnd=110
Next buffered content sync: pts=100
BEFORE (spec-literal 1.14):
Erase range [50, 110) from buffered
content sync @ pts=100 ∈ [50,110) → REMOVED
step 1.15 cascades dependents
buffered: [0, 0.110) ← gap, playback stalls
AFTER (new heuristic path):
overshoot = frameEnd(110) − nextSample.pts(100) = 10 ms
10ms < timeFudgeFactor() AND B-frame tail?
YES → adjustSampleStartTime: shift sync pts 100→110
SampleMap::replaceSample (erase+hint-insert, both submaps)
m_decodeQueue entry updated to match
1.14 erase range [50,110) no longer contains the sync
buffered: [0, 0.200) ← continuous, no stall
Significance
이번 fix는 서로 맞물려 있는 세 개의 MSE bookkeeping 구조체 — SampleMap의 두 submap과 decode queue — 를 원자적으로 rewrite하는 timing-mutation 코드 경로를 추가하며, 이 경로는 공격자가 제공하는 fMP4 바이트에 의해 직접 구동됩니다. 이를 통해 라이브 스트림이 ad splice 지점에서 멈추는 문제는 해결되지만, 그 대가로 긴밀하게 결합된 sample bookkeeping 안에 새로운 상태와 lifetime 관련 공격 표면이 생겨납니다.
Audit directions
Narrow: SampleMap::replaceSample은 presentation-order submap과 decode-order submap 양쪽에서 erase+hint-insert를 수행하는데, 조정된 샘플의 key가 이웃 key들과 비교해 엄격하게 정렬되어 있는지는 검증하지 않습니다. duration이 0인 샘플이 offset을 0으로 clamp하면 "새" key가 기존 key와 동일해지고, 이 경우 erase+insert는 사실상 no-op이 되지만 m_buffered는 여전히 변경됩니다. 그 결과 두 구조체가 서로 어긋나는 상태(desync)에 빠집니다. 리뷰 시 눈여겨봐야 할 신호는, 새 key가 기존 key로부터 산술적으로 도출되면서도 ordering assertion이 없는 erase-then-insert 쌍입니다.
Wider: range bookkeeping의 대칭성 문제입니다. TrackBuffer::adjustSampleStartTime은 원본 샘플의 [pts, presentationEndTime) 구간을 m_buffered에서 빼고, 조정된 range를 다시 더합니다. 만약 호출 시점에 원본 range가 이미 없는 상태라면 — 예를 들어 샘플이 아직 완전히 commit되지 않았거나, 동시에 진행 중인 step-1.15 cascade가 이미 해당 range를 제거한 경우 — 뺄셈 연산은 no-op이 되고, 대응하는 제거 없이 조정된 range만 추가됩니다. 이는 media element의 seekable range를 왜곡시키는 spurious buffered range를 만들어냅니다. TrackBuffer 내 다른 모든 m_buffered mutation 지점을 대상으로 동일한 subtract-then-add 패턴이 있는지, 그리고 각각이 뺄셈이 실제로 무언가를 제거했는지 검증하는지 점검할 필요가 있습니다. 인접 이슈로는, 트랙별 isPresentationTail flag가 append loop 이전 processPendingMediaSamples에서 계산된다는 점이 있습니다. 오디오/비디오 샘플이 인터리빙된 multi-track 세그먼트에서는 이 flag가 엉뚱한 샘플에 설정될 수 있고, 그 결과 스펙상 반드시 제거되어야 할 non-tail video 샘플에서도 shift 경로가 실행될 가능성이 있습니다.
Widest: platform/engine 간 split-brain 문제입니다. MediaSampleAVFObjC::createCopyWithAdjustedStartTime은 CMSampleBufferCreateCopyWithNewTiming을 사용해, multi-sample CMSampleBuffer의 각 sub-sample에 offset을 적용합니다. 만약 조정된 start time이 음수가 되거나 sub-sample duration이 underflow하는 경우, AVFoundation은 해당 buffer를 그대로 받아들일 수 있는 반면 WebCore의 TimeRanges bookkeeping은 다른 range를 계산할 수 있습니다. 이 경우 platform decoder와 MSE의 buffered attribute가 서로 다른 값을 갖게 됩니다. 이 문제는 WebCore가 platform-media timing 상태를 미러링하는 모든 지점에 일반화될 수 있습니다. 즉, 양쪽이 동일한 mutation으로부터 각자 range를 독립적으로 계산하는 모든 곳에서 divergence 케이스에 대한 테스트가 필요합니다. 가장 생산적인 접근 방식은 changeType, appendWindowStart, timestampOffset 경계를 넘나들며 B-frame tail 경로를 건드리는 조작된 fMP4 세그먼트를 fuzzing하는 것입니다.