[2] AudioBufferSourceNode caches channel pointers over an unpinned ArrayBuffer
A buffer you can free between assigning it and pressing play.
High — free 과정 전체가 스크립트에 의해 제어됩니다. 페이지가 detach 시점, GC 시점, 노드 시작 시점을 모두 결정할 수 있으므로, real-time audio thread의 읽기 동작이 페이지가 원하는 순간에 이미 회수된 메모리를 대상으로 이루어집니다. 같은 경로 위에 존재하는 두 가지 clamp 결함으로 인해, 이 stale read는 원래 allocation의 길이 범위로도 제한되지 않습니다.
Web Audio는 작업을 두 개의 thread로 분리합니다. 페이지 스크립트는 main thread에서 그래프를 구성하고, real-time rendering thread는 고정 크기의 quantum 단위로 해당 그래프를 통해 sample frame을 가져옵니다. 스크립트가 제공한 AudioBuffer를 재생하는 노드인 AudioBufferSourceNode는 이 두 thread를 연결하는 지점입니다. Main thread에서 node.buffer를 할당하면, 채널별로 sample을 담고 있는 Float32Array backing store를 직접 가리키는 span이 캐싱됩니다. 이 backing store는 JSC ArrayBuffer이며, 스크립트는 structured-clone transfer를 통해 언제든 이를 detach할 수 있습니다. 따라서 raw span으로 이를 참조하는 쪽에서는 먼저 non-detachable 상태로 고정(pin)해두는 것이 기본 전제입니다.
관전 포인트: 페이지는 buffer를 할당한 뒤 채널 데이터를 transfer로 빼내고, 원본 allocation을 GC로 수거한 다음 노드를 시작할 수 있습니다. 이 순서를 밟으면 real-time audio thread가 해제된 heap에서 sample frame을 읽게 되는데, 읽기 index에는 별도의 길이 제한도 걸려 있지 않습니다.
Patch Details
AudioBufferSourceNode에 네 곳의 변경이 있었고, JSC의 transfer-error 경로에도 한 곳이 수정되었습니다. 기존 setBufferForBindings는 acquireBufferContent()를 호출하기 전에 isPlayingOrScheduled() 조건으로 pinning 호출을 가드하고 있었는데, 패치에서는 이 조건이 제거되어 할당 시점에 무조건 acquisition이 수행됩니다. renderFromBuffer에는 진입부에 if (!bufferLength) return false; 가드가 새로 추가되었습니다. 또한 loop-wrap clamp 계산이 static_cast<double>(bufferLength - 1)에서 static_cast<double>(bufferLength) - 1로 수정되어, -1 연산이 unsigned 산술 영역에서 double 영역으로 옮겨졌습니다. 그리고 zero-pitch-rate 분기에는 m_sourceChannels[i][readIndex]를 읽기 전에 readIndex < bufferLength 검사가 추가되었습니다. JSC 쪽에서는 errorMessageForTransfer의 assertion이 ASSERT(buffer->isLocked())에서 ASSERT(!buffer->isDetachable())로 완화되었습니다.
Pointer를 가져온 시점보다 나중에 도달하는 lifecycle 상태를 기준으로 pinning을 게이팅하는 패턴이며, 이로 인해 capture와 activation 사이의 전체 구간이 보호되지 않은 상태로 남습니다.
Background
Two-thread model. 그래프 구성, buffer 할당, parameter 변경은 모두 main thread에서 스크립트에 의해 이루어집니다. Rendering은 별도의 real-time thread에서 수행되는데, 이 thread는 quantum마다 각 노드의 process()를 한 번씩 호출하며 절대 block되어서는 안 됩니다. 노드가 필요한 값을 콜백마다 다시 조회하지 않고 미리 캐싱해두는 이유가 여기에 있습니다.
AudioBuffer와 backing store. AudioBuffer는 채널당 하나의 Float32Array를 보유합니다. Sample은 JSC ArrayBuffer에 저장되며, 이는 기본적으로 detach가 가능합니다. structuredClone(view, {transfer:[view]})를 호출하면 내용이 clone으로 이동하고 원본은 detach된 상태로 남으며, 이후 원본 allocation은 GC 대상이 됩니다.
Pinning. Detach 가능한 backing store를 raw reference로 참조하려면 먼저 non-detachable 상태로 만들어야 하며, 이 역할을 acquireBufferContent()가 담당합니다. 이는 regression test에서 이후의 transfer가 TypeError를 던지도록 기대한다는 점, 그리고 transfer-error 경로를 가드하는 ASSERT(!buffer->isDetachable())로부터 추정한 내용입니다.
Read cursor. renderFromBuffer는 m_virtualReadIndex를 buffer 안에서 이동시키며, output frame마다 effective pitch rate만큼 전진시킵니다. Loop 시에는 이 cursor를 buffer 길이 기준으로 clamp합니다. pitchRate가 0인 경우는 별도 분기로 처리되는데, 이때 노드는 단일 sample만 보유한 채 interpolation 없이 std::ranges::fill로 quantum 전체를 채웁니다.
Analysis
Root cause는 invariant가 잘못된 위치에 인코딩되어 있었다는 점입니다. acquireBufferContent()를 감싼 게이트는, 캐싱된 pointer들이 노드가 rendering 중일 때만 backing store가 살아있으면 된다는 전제를 담고 있었습니다. 하지만 실제로 pointer가 capture되는 시점은 할당 시점이지 start() 시점이 아닙니다. 결과적으로 node.buffer = b와 node.start() 사이의 모든 순간에, pin되지 않은 detachable allocation 위에 살아있는 cached span이 존재하게 됩니다.
main thread audio thread
----------- ------------
node.buffer = b
+- cache m_sourceChannels[i]
(no acquireBufferContent:
node not playing yet)
structuredClone(chan, {transfer}) <-- failure window opens
+- ArrayBuffer detached
drop clone; gc()
+- backing store freed
node.loop = true; node.start()
process() -> renderFromBuffer()
m_sourceChannels[i][readIndex]
<- read of freed memory
나머지 hunk들은 이 상태에 도달한 이후 무슨 일이 벌어지는지를 보여주며, reachability를 판단하는 데 중요한 단서가 됩니다. 새로 추가된 if (!bufferLength) return false;는 bufferLength가 진입 시점에 정상적으로 0이 될 수 있음을 전제합니다. Detach된 채널이라면 0이 되는 것이 기대되는 값입니다. 패치 이전에는 loop-wrap clamp가 static_cast<double>(bufferLength - 1)로 계산되었는데, bufferLength가 unsigned 타입이므로 0 - 1이 해당 타입의 최댓값으로 wrap됩니다. 그 결과 m_virtualReadIndex에 걸려 있던 std::min clamp가 사실상 no-op으로 전락합니다. 이 상태에서는 read index가 buffer로부터 아무런 제한도 받지 않게 됩니다.
이와는 별개로, playbackRate.value = 0으로 설정하면 도달하는 !pitchRate 분기는 m_sourceChannels[i][readIndex]를 인덱싱하면서 readIndex < bufferLength 검사를 전혀 수행하지 않았습니다. 패치에서 이 검사가 추가되었다는 사실은, 해당 load 지점이 범위를 벗어난 readIndex로도 도달 가능했음을 시사합니다. loop = true로 설정하면 노드가 종료되지 않으므로, 읽기 경로는 한 번만 실행되고 끝나는 것이 아니라 quantum마다 반복적으로 실행됩니다.
이를 종합하면, 이 primitive는 real-time audio thread에서 해제된 메모리를 반복적으로 읽는 동작에 해당합니다. 읽기 index는 페이지가 playbackRate와 loop parameter를 통해 영향을 줄 수 있으며, 원래 allocation의 범위로 clamp되지도 않습니다. 읽힌 값은 audible output으로 나가기 때문에, 대역폭은 낮지만 지속적으로 회수된 heap을 읽어낼 수 있는 read-back channel이 만들어지는 셈입니다.
이 vulnerability는 JSC의 collector가 관리하는 ArrayBuffer storage와 WebCore의 real-time rendering 경로 사이의 격리를 약화시킵니다. Collector가 이미 회수해 재사용한 allocation이 재검증 없는 thread에서 여전히 addressable하고, 읽기까지 가능한 상태로 남게 됩니다.
Audit directions
- Capture 시점보다 나중에 도달하는 lifecycle 상태로 pinning을 게이팅하는 패턴.
pointer를 지금 캐싱해두고, 사용을 시작할 때 pin한다는 구조 자체가 위험합니다. 그 사이의 간격은 구조적으로 보호받지 못하기 때문입니다. 채널 span을 캐싱하는 노드부터 시작해서,isPlaying/isActive/isScheduled류의 predicate로 가드된acquire*/pin*호출이 다른 Web Audio source/processor 노드에도 있는지 점검할 필요가 있습니다. Code review 시, raw span을 저장하는 함수 안에서 state predicate에 걸려 있는 acquisition 호출을 발견하면 그 span이 언제 capture되었는지 반드시 확인해야 합니다. - Clamp에 들어가는 unsigned length 산술. Unsigned 타입에서
length - 1은 0이 도달 가능한 경우 clamp를 조용히 no-op으로 만들어버립니다. 그리고 "0이 도달 가능하다"는 전제조건은 흔히 무관해 보이는 다른 lifetime 버그에서 만들어집니다. Audio 및 graphics resampler 코드에서std::min/std::max인자 안에 있는- 1패턴을 검색해볼 필요가 있습니다.x가 length나 count일 때static_cast<double>(x - 1)형태가 나타나면 눈여겨봐야 할 지점입니다. - Slow path의 bounds 검사를 건너뛰는 fast path. Zero-pitch-rate 분기는 interpolation 연산을 피하기 위해 만들어졌는데, 그 과정에서 range 검사까지 함께 빠뜨렸습니다.
renderFromBuffer와 유사한 구조의 loop가 WebCore audio 코드 다른 곳에도 있는지 나열하고, 각 specialized branch를 general path의 가드와 비교 대조해볼 필요가 있습니다.