[2] Use-after-free in AudioBufferSourceNode via detached ArrayBuffer backing store
The audio buffer that stayed detachable until you pressed play
diff에서 재생 상태를 확인하던 조건문이 제거되어 캐시된 channel pointer가 pin되지 않은 상태로 남습니다. 이로 인해 detach 후 해제된 ArrayBuffer backing store를 audio render thread가 읽게 되므로 High로 평가됩니다. info leak으로의 확장은 해제된 store를 attacker 통제 하에 재사용해야 하는데, 이 부분은 diff 자체에서는 제공되지 않습니다.
페이지가 오디오 데이터를 담은 ArrayBuffer를 node.buffer에 할당하면, AudioBufferSourceNode는 m_sourceChannels[i]에 AudioBuffer의 channel 데이터를 가리키는 raw pointer를 캐시합니다. 이 pointer는 JSC가 관리하는 ArrayBuffer backing store를 직접 가리킵니다. 이전 두 commit(bug 270007, bug 272607)에서도 같은 버그를 수정한 적이 있습니다. 메모리를 pin(non-detachable로 설정)하는 방식이었지만, 노드가 이미 재생 중일 때만 적용되었습니다. start() 호출 전(UNSCHEDULED_STATE)에 buffer를 설정하면, isPlayingOrScheduled()가 false이기 때문에 pin이 건너뛰어집니다. attacker는 먼저 buffer를 설정해 span은 캐시되지만 pin은 적용되지 않은 상태를 만듭니다. 이후 structuredClone을 통해 channel ArrayBuffer를 transfer(detach)하고, 복사본을 해제한 뒤 GC를 유발해 backing store를 해제합니다. 그런 다음 loop=true와 playbackRate=0 상태로 재생을 시작합니다. 노드는 renderFromBuffer()에서 dangling span을 통해 해제된 메모리를 읽게 됩니다. 패치는 메모리를 조건 없이 pin하도록 수정했으며, 이제 structuredClone은 TypeError를 발생시킵니다. 아울러 renderFromBuffer에 세 가지 방어적 강화 조치가 추가되었습니다.
Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp
LayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html
Patch Details
핵심 수정은 setBufferForBindings에 있던 조건부 if (isPlayingOrScheduled()) acquireBufferContent();를 무조건 실행되는 acquireBufferContent();로 변경한 것입니다. 이로써 channel ArrayBuffer가 non-detachable로 pin됩니다. renderFromBuffer에는 세 가지 강화 조치가 추가되었습니다. 먼저 캐시된 m_sourceChannels pointer를 읽기 전에 if (!bufferLength) return false;로 조기 탈출하는 코드가 추가되었습니다. 다음으로 loop-wrap clamp의 표현식이 수정되었습니다. 기존의 static_cast<double>(bufferLength - 1) 대신 static_cast<double>(bufferLength) - 1을 사용하도록 변경되어, bufferLength == 0일 때 size_t underflow로 SIZE_MAX가 되는 문제를 방지합니다. 마지막으로 bounds check가 없었던 유일한 경로인 !pitchRate 분기에 if (readIndex >= bufferLength) return false;가 추가되었습니다. ArrayBuffer.cpp에서는 errorMessageForTransfer의 assertion도 변경되었습니다. ASSERT(buffer->isLocked())에서 ASSERT(!buffer->isDetachable())로 수정되어 non-detachable lifetime 모델에 맞춰졌습니다.
외부 소유 backing memory의 lifetime pinning이 객체 재생 상태를 조건으로 하도록 구현되어, 스케줄링 전에 buffer를 설정하면 캐시된 raw pointer가 dangling 상태로 남는 패턴.
Background
Web Audio에서 AudioBufferSourceNode는 AudioBuffer를 재생합니다. 각 channel의 샘플 데이터는 JSC ArrayBuffer로 backed된 Float32Array에 저장됩니다. node.buffer에 값을 할당하면 setBufferForBindings가 호출되고, render thread가 lock-free로 접근할 수 있도록 channel span의 raw pointer를 m_sourceChannels에 캐시합니다. 노드에는 스케줄링 상태가 있으며, start()가 호출되기 전까지는 UNSCHEDULED_STATE입니다. 이 상태에서는 isPlayingOrScheduled()가 false를 반환합니다. acquireBufferContent()를 통한 pinning은 channel ArrayBuffer를 non-detachable로 표시합니다. 이 상태에서 transfer 목록을 포함한 structuredClone을 호출하면 detach 대신 TypeError가 발생합니다. 이 상태는 isDetachable()로 확인할 수 있습니다. ArrayBuffer를 transfer하면 source buffer가 detach되고, 참조가 모두 사라지면 GC가 backing store를 회수합니다. audio render thread는 process() → renderFromBuffer()를 실행하며, 캐시된 pointer에서 m_sourceChannels[i][readIndex]를 직접 읽습니다. playbackRate.value = 0으로 설정하면 pitchRate == 0이 되어 !pitchRate render 분기가 선택됩니다.
Analysis
이 취약점은 use-after-free에 해당합니다. setBufferForBindings는 AudioBuffer의 channel 데이터를 가리키는 raw span pointer를 캐시합니다. 해당 메모리를 유지하려면 acquireBufferContent()를 통해 기저 ArrayBuffer를 pin해야 합니다. 패치 이전에는 이 pinning이 isPlayingOrScheduled()를 조건으로 했습니다. 따라서 페이지가 UNSCHEDULED_STATE에서 node.buffer를 할당하면, span은 캐시되지만 pin은 건너뛰어졌습니다. 이후 페이지는 structuredClone(channelBuffer, {transfer:[channelBuffer]})를 호출해 ArrayBuffer를 detach하고, transfer된 복사본을 해제한 뒤 GC를 강제 실행해 backing store를 해제할 수 있습니다. 이 시점에 m_sourceChannels는 dangling pointer를 보유하게 됩니다.
재생을 시작하면 audio render thread에서 renderFromBuffer가 실행되고, 해제된 메모리에서 m_sourceChannels[i][readIndex]를 읽게 됩니다. underflow 버그가 이를 악화시킵니다. bufferLength == 0(detach/빈 상태)이면 size_t 산술 연산에서 bufferLength - 1이 SIZE_MAX로 wrap됩니다. bufferLength의 선언 타입은 diff에 드러나지 않지만, double 형변환으로 미루어 unsigned 타입으로 볼 수 있습니다. 결과적으로 std::min(m_virtualReadIndex, (double)SIZE_MAX)는 read index를 사실상 제한하지 못하게 됩니다. 한편 !pitchRate(playbackRate == 0) 분기에는 readIndex >= bufferLength 검사가 없었습니다. PoC가 선택하는 경로가 바로 이 분기입니다.
이 취약점은 web content에서 직접 도달 가능한 audio render thread의 use-after-free read로, exploit이 가능합니다. 안정적인 read/leak을 위해서는 render 시점 전에 해제된 backing store를 heap grooming으로 재사용해야 합니다. 재사용에 성공하면, 읽힌 데이터가 audio output bus로 흘러들어 렌더링된 PCM을 통해 다시 읽어낼 수 있는 read primitive가 됩니다. 또한 interpolation 연산이 해제된 메모리의 내용을 더 멀리 전파시킬 가능성도 있습니다. info leak을 넘어선 확장은 해제된 슬롯을 어떤 객체가 재사용하느냐에 달려 있습니다.
이 취약점은 WebContent process 내부의 memory safety를 약화시킵니다. 보안 모델은 살아있는 audio 노드의 캐시된 channel pointer가 가리키는 메모리가 노드 생존 기간 동안 유효하다고 가정합니다. 이 불변 조건은 ArrayBuffer를 non-detachable로 pin함으로써 보장되어야 합니다. 패치 이전에는 이 조건이 buffer를 스케줄링 이후에 설정했을 때만 성립했습니다. 스케줄링 전에 먼저 설정하면 store가 script에서 detach 및 해제 가능한 상태로 남았습니다. 획득 가능한 primitive는 renderer 내부에 한정됩니다. 시스템에 영향을 미치려면 별도의 sandbox escape가 필요합니다.
이번이 동일한 근본 문제에 대한 세 번째 수정입니다. 이전 두 패치는 channel ArrayBuffer를 pin해야 한다는 점은 올바르게 파악했지만, 두 경우 모두 pin 조건을 isPlayingOrScheduled()에 걸었습니다. 바로 이 조건이 실제 결함이었습니다. pointer는 조건 없이 캐시되는데, 보안상 중요한 lifetime 보장을 상태에 의존적으로 구현한 것이 문제였습니다. 일반적인 교훈은 다음과 같습니다. raw pointer가 할당 시점에 캐시된다면, 이를 보호하는 keep-alive 역시 할당 시점에 설정되어야 합니다. 이후 상태 전환 시점으로 미뤄서는 안 됩니다. 추가된 underflow 수정과 !pitchRate bounds check는 defense-in-depth 패턴을 보여줍니다. detach primitive를 제거한 이후에도, 길이가 0인 buffer가 out-of-bounds로 읽히지 않도록 render loop를 강화했습니다.
Note: 이전 수정 사항에 대한 언급, 새로 보호된 읽기 경로의 분기 연관, bufferLength의 선언 타입, acquireBufferContent()의 정확한 동작은 diff에서 직접 확인되는 내용이 아니라 commit 메시지와 테스트 코드를 바탕으로 추론한 것입니다. 핵심적인 detach-해제-read 흐름과 복합 underflow는 패치에서 일관되게 지지됩니다.
Audit directions
- Cache-now, pin-later mismatch. 속성 설정 시점에 객체에 캐시되는 raw pointer 또는 span이 있는데, 해당 backing memory를 살려두는 keep-alive가 조건부(상태, 재생, attachment)로만 설정되는 패턴입니다. WebCore 전반에서 이러한 불일치를 점검해야 합니다. webaudio 모듈에서
acquireBufferContent유형의 pin이 있는 다른 위치를 검색하고, span이 캐시되는 시점을 기준으로 pin이 무조건 적용되는지 확인해야 합니다.AudioBufferSourceNode::setBufferForBindings와m_sourceChannels/channel pointer를 보유한 sibling 노드부터 시작하는 것이 좋습니다. - Unsigned
length - 1as an upper bound.std::min/clamp에서 상한으로 사용되는 size_tlength - 1은 length가 0일 때SIZE_MAX로 underflow되어 bound가 무력화됩니다. WebCore/WTF에서std::min(.*, .*- 1)및 부동소수점으로 형변환되거나 bounds 연산에 사용되는size() - 1을 검색하고, 각각에 길이 0에 대한 조기 탈출이 있는지 확인해야 합니다. audio, canvas, media sample-buffer render loop가 주요 대상입니다. - Real-time/worker threads reading JS-owned backing stores via cached pointers. 이처럼 캐시된 pointer가 lock(
isLocked())만이 아니라 pinning(isDetachable()인식)으로 보호되는지 점검해야 합니다.ArrayBuffer.cpp에서 assertion이isLocked()에서!isDetachable()로 변경된 것은 이 두 lifetime 모델이 혼동되기 쉽다는 신호입니다.ArrayBufferbacking store를 사용하는 다른 cross-thread 소비자에서도 동일한 혼동이 있는지 살펴봐야 합니다. - Bounds-check parity across render branches. 이번 수정에서
!pitchRate분기가 여러 경로 중 유일하게 보호되지 않은 경로였음이 드러났습니다.renderFromBuffer의 모든 read 분기에서readIndex가bufferLength에 대해 bounds check되는지 확인해야 합니다. 또한 sibling 분기에는 bounds check가 있지만 특정 산술 분기에서 이를 건너뛰는 다른 다중 분기 render/interpolation loop가 있는지도 살펴봐야 합니다.