← All issues

[2] Use-after-free in AudioBufferSourceNode via detached ArrayBuffer backing store

The audio buffer that stayed detachable until you pressed play

Severity: High | Component: WebCore Web Audio | a7e4fdb

diff에서 재생 상태를 확인하던 조건문이 제거되어 캐시된 channel pointer가 pin되지 않은 상태로 남습니다. 이로 인해 detach 후 해제된 ArrayBuffer backing store를 audio render thread가 읽게 되므로 High로 평가됩니다. info leak으로의 확장은 해제된 store를 attacker 통제 하에 재사용해야 하는데, 이 부분은 diff 자체에서는 제공되지 않습니다.

페이지가 오디오 데이터를 담은 ArrayBuffernode.buffer에 할당하면, AudioBufferSourceNodem_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=trueplaybackRate=0 상태로 재생을 시작합니다. 노드는 renderFromBuffer()에서 dangling span을 통해 해제된 메모리를 읽게 됩니다. 패치는 메모리를 조건 없이 pin하도록 수정했으며, 이제 structuredCloneTypeError를 발생시킵니다. 아울러 renderFromBuffer에 세 가지 방어적 강화 조치가 추가되었습니다.

Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp

@@ setBufferForBindings
- if (isPlayingOrScheduled())
- acquireBufferContent();
+ acquireBufferContent();
 
@@ renderFromBuffer
double pitchRate = totalPitchRate();
bool reverse = pitchRate < 0;
+
+ if (!bufferLength)
+ return false;
...
- m_virtualReadIndex = std::min(m_virtualReadIndex, static_cast<double>(bufferLength - 1));
+ m_virtualReadIndex = std::min(m_virtualReadIndex, static_cast<double>(bufferLength) - 1);
...
if (readIndex >= maxFrame)
readIndex -= deltaFrames;
+
+ if (readIndex >= bufferLength)
+ return false;
 
for (unsigned i = 0; i < numberOfChannels; ++i)
std::ranges::fill(m_destinationChannels[i].subspan(writeIndex).first(framesToProcess), m_sourceChannels[i][readIndex]);

LayoutTests/webaudio/audiobuffersource-detached-buffer-crash.html

+ const node = ctx.createBufferSource();
+ node.buffer = audioBuffer; // spans cached; pre-fix: no pin (UNSCHEDULED)
+ const channelBuffer = audioBuffer.getChannelData(0).buffer;
+ structuredClone(channelBuffer, { transfer: [channelBuffer] }); // detach + free backing store
+ gc(); gc();
+ node.loop = true;
+ node.playbackRate.value = 0; // hits the unguarded !pitchRate path
+ node.start();
+ await ctx.startRendering(); // renderFromBuffer reads dangling m_sourceChannels

핵심 수정은 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 상태로 남는 패턴.

Web Audio에서 AudioBufferSourceNodeAudioBuffer를 재생합니다. 각 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 분기가 선택됩니다.

이 취약점은 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 - 1SIZE_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는 패치에서 일관되게 지지됩니다.