← All reports

[2] Replayed CreateAudioSourceProvider drops the provider mid-callback

HighWebKit GPU process media pipelineAuthBypass

Overwriting a callback was a free, and a real-time thread was inside it.

0181991

High. 멱등성 검증이 없는 create-once GPU-process 메시지로 인해, 실시간 오디오 스레드가 실행 중일 수도 있는 객체의 마지막 소유자들이 두 번째 전송만으로 파괴될 수 있습니다. Controlled primitive로의 확장 여부는 이 timing window를 이겨내고, 오디오 스레드의 다음 전송 전에 해제된 allocation을 재확보할 수 있는지에 달려 있습니다.

WebKit은 미디어 디코딩과 오디오 tapping을 별도의 GPU process에서 처리하며, renderer는 이를 전적으로 IPC로 구동합니다. 즉 이 process에 도달하는 모든 메시지는 신뢰할 수 없는 입력으로 취급됩니다. RemoteMediaPlayerProxy는 하나의 <video>/<audio> element를 대신해 실제 WebCore::MediaPlayer를 소유하는 GPU-side receiver이고, RemoteAudioSourceProviderProxy는 platform tap에서 디코딩된 오디오를 renderer의 Web Audio graph로 되돌려주는 bridge 역할을 합니다. 프로토콜은 정상적인 renderer라면 player당 정확히 한 번만 이 bridge를 요청할 것이라 가정하며, 객체의 lifetime 역시 그 가정을 전제로 설계되어 있습니다.

관전 포인트: 미디어가 재생되는 동안 compromised renderer가 기존 GPU-process 메시지 하나를 두 번 전송하면, 실시간 스레드가 호출 중인 live audio-bridge 객체에 대한 모든 strong reference가 소실될 수 있습니다.

Commit message는 그 메커니즘을 정확하게 서술합니다.

A well-behaved WebContent process sends CreateAudioSourceProvider at most once per RemoteMediaPlayerProxy. A second message reaches AudioSourceProviderAVFObjC's setConfigureAudioStorageCallback / setAudioCallback, which overwrite the callbacks without taking tapStorage->lock while a MediaToolbox thread may be invoking them under that lock, freeing the captured RemoteAudioSourceProviderProxy mid-use. Reject the duplicate message with a MESSAGE_CHECK.

Source/WebKit/GPUProcess/media/RemoteMediaPlayerProxy.cpp

+#define MESSAGE_CHECK(assertion, message) MESSAGE_CHECK_WITH_MESSAGE_BASE(assertion, m_webProcessConnection.get(), message)
+
namespace WebKit {
...
void RemoteMediaPlayerProxy::createAudioSourceProvider()
{
#if ENABLE(WEB_AUDIO) && PLATFORM(COCOA)
+ MESSAGE_CHECK(!m_remoteAudioSourceProvider, "RemoteAudioSourceProvider already created.");
+
RefPtr player = m_player;
if (!player)
return;
...
+#undef MESSAGE_CHECK

LayoutTests/ipc/create-audio-source-provider-twice.html

+<!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+ const tap = new IPCWireTap('GPU', 'Outgoing');
+ tap.tapNext(CoreIPC.messages['RemoteMediaPlayerManagerProxy_CreateMediaPlayer'].name,
+ (proc, connId, msgName, typed, parsed) => { playerId = parsed.identifier; resolve(); });
...
+ for (let i = 0; i < 5; i++) {
+ CoreIPC.GPU.RemoteMediaPlayerProxy.SetShouldEnableAudioSourceProvider(playerId, { shouldEnable: true });
+ CoreIPC.GPU.RemoteMediaPlayerProxy.CreateAudioSourceProvider(playerId, {});
+ await new Promise(resolve => setTimeout(resolve, 20));
+ }

RemoteMediaPlayerProxy::createAudioSourceProvider() 상단에 한 줄짜리 MESSAGE_CHECK가 추가되었습니다. 이 check는 "RemoteAudioSourceProvider already created."라는 메시지와 함께 !m_remoteAudioSourceProvider를 검증합니다. 파일에는 namespace WebKit 위쪽에 표준적인 local #define MESSAGE_CHECK(assertion, message) MESSAGE_CHECK_WITH_MESSAGE_BASE(assertion, m_webProcessConnection.get(), message)가 추가되었고, 하단에는 짝을 이루는 #undef가 추가되었습니다. 이를 통해 check가 WebContent와 맞닿은 IPC::Connection에 연결되며, 위반 메시지가 들어오면 handler를 계속 진행하는 대신 해당 connection을 종료시킵니다. 나머지 handler 로직(RefPtr player = m_player; if (!player) return; 및 이어지는 provider 생성 코드)은 변경되지 않았습니다.

부가 변경: IPC testing API로 구동되는 새로운 regression test가 추가되었습니다. 이 test는 outgoing RemoteMediaPlayerManagerProxy_CreateMediaPlayer 메시지를 tap해서 MediaPlayerIdentifier를 확보하고, 실제 .mp4<video>에 로드한 다음, SetShouldEnableAudioSourceProvider(true)CreateAudioSourceProvider를 20ms 간격으로 다섯 번 연속 전송합니다. Expectation 파일에는 "This test passes if WebKit does not crash."라는 내용만 담겨 있습니다.

Create-once 프로토콜 메시지에 멱등성 검증이 누락되어, 재초기화가 다른 스레드가 여전히 실행 중일 수 있는 객체의 마지막 소유자들을 해제시켜버리는 패턴.

이 코드가 있는 위치. WebKit은 미디어 디코딩, GPU rendering, 오디오 tapping을 별도의 com.apple.WebKit.GPU process에서 처리하며, renderer는 Remote*Proxy message receiver를 통해 IPC로 이를 구동합니다.

RemoteMediaPlayerProxy. GPU-side 객체(IPC::MessageReceiver, RefCounted)로, renderer 내 하나의 미디어 element를 대신해 실제 WebCore::MediaPlayer를 소유합니다.

MESSAGE_CHECK / MESSAGE_CHECK_WITH_MESSAGE_BASE. WebKit의 IPC validation macro입니다. Local #define이 이를 특정 IPC::Connection에 바인딩하며, assertion이 false이면 해당 connection이 종료되고 문제를 일으킨 process가 kill되므로, handler는 이후 로직으로 진행되지 않습니다.

오디오 tap 경로. AudioSourceProviderAVFObjC는 AVFoundation playback item에 MediaToolbox audio tap을 설치하는 Cocoa 오디오 source provider입니다. Tap은 전용 실시간 MediaToolbox 스레드에서 PCM 오디오를 전달하며, 이때 client가 넘긴 callback을 호출합니다. setAudioCallbacksetConfigureAudioStorageCallback은 이 callable들을 저장하는 setter로, 하나는 shared audio storage를 할당하고 하나는 새 sample 도착을 알립니다.

RemoteAudioSourceProviderProxy. MediaPlayerIdentifierconst Ref<IPC::Connection>을 갖는 GPU-side ThreadSafeRefCounted 객체입니다. configureAudioStorage()ProducerSharedCARingBuffer를 할당하고 RemoteAudioSourceProviderManager::AudioStorageChanged를 전송하며, newAudioSamples()SetNeedsFlush를 전송합니다.

Ref / ThreadSafeRefCounted. ThreadSafeRefCounted 객체에 대한 마지막 Ref가 파괴되면 그 객체도 함께 파괴됩니다. Lambda 안에서 값으로 캡처된 Ref는 그 lambda 객체가 존재하는 동안만 대상 객체를 살려두므로, 이를 보관하던 callable을 다른 것으로 교체하면 해당 reference도 함께 해제됩니다.

IPC testing API (coreipc.js, IPCWireTap). IPCTestingAPIEnabled=true로 test별로 활성화되는 test 전용 기능으로, layout test가 outgoing IPC를 관찰하고 GPU process로 임의의 메시지를 합성해 전송할 수 있게 해줍니다. Compromised renderer가 보낼 수 있는 것을 그대로 모델링한 도구입니다.

이 문제는 IPC state-machine check 누락에서 비롯된 cross-thread lifetime violation입니다.

  WebContent (attacker)      GPU main thread            MediaToolbox tap thread
  ────────────────────       ───────────────            ───────────────────────
  CreateAudioSourceProvider ► create(): m_remote… = Ref
                               setAudioCallback([Ref])
                                                        ─► invoking audioCallback
  CreateAudioSourceProvider ► reassign m_remote…  (deref)      │  reads m_connection
                               overwrite slots     (deref)     │  reads m_identifier
                               ── refcount 0 ── free ──────────┤
                                                               ▼
                                                        UAF read / call

createAudioSourceProvider()에는 state guard가 존재하지 않았습니다. "renderer는 proxy당 CreateAudioSourceProvider를 최대 한 번만 전송한다"는 프로토콜 invariant는 구현 단계에서 전제로만 삼았을 뿐, 오작동하는 sender에 대해서는 강제되지 않았습니다. 추가된 assertion을 통해, RemoteMediaPlayerProxy가 provider에 대한 member owner를 유지한다는 사실을 알 수 있습니다. 즉 두 번째 메시지는 factory에 재진입해 그 member를 재할당하게 됩니다. (Member 선언 부분은 header의 잘려나간 영역에 있어 직접 확인되지는 않지만, m_remoteAudioSourceProvider가 strong-owning이라는 점은 MESSAGE_CHECK가 함의하는 바입니다.)

제공된 RemoteAudioSourceProviderProxy.cpp 소스를 보면, RemoteAudioSourceProviderProxy::create(identifier, connection, localProvider)는 단순히 객체를 생성하는 데 그치지 않습니다. 반환 전에 AudioSourceProviderAVFObjC 인스턴스에 두 개의 lambda를 설치합니다.

localProvider.setConfigureAudioStorageCallback([remoteProvider](auto&&... args) { ... });
localProvider.setAudioCallback([remoteProvider](auto startFrame, auto numberOfFrames, bool needsFlush) { ... });

두 lambda 모두 remoteProvider(Ref<RemoteAudioSourceProviderProxy>)를 값으로 캡처합니다. 따라서 하나의 provider에 대해 최소 두 곳의 독립적인 strong-reference 보유자가 존재하게 됩니다. Proxy의 m_remoteAudioSourceProvider member와, platform provider가 들고 있는 두 개의 callback capture입니다. 중복 CreateAudioSourceProvider는 같은 handler 호출 안에서 이 둘을 동시에 해제시킵니다. Member를 재할당하면 reference 하나가 해제되고, 두 callback slot을 덮어쓰면 이전 callable들과 그 안에 캡처된 Ref들이 함께 파괴됩니다. 이것들이 유일한 strong reference라면, 최초의 RemoteAudioSourceProviderProxy는 refcount가 0이 되는 순간 IPC handler 내부에서 즉시 파괴됩니다.

문제는 파괴 자체가 아니라 타이밍입니다. 덮어써지는 callable들은 바로 MediaToolbox audio tap 스레드가 호출하는 대상이며, 그 객체가 소유한 newAudioSamples() / configureAudioStorage() 내부에서 m_connection(const Ref<IPC::Connection>)과 m_identifier를 역참조합니다. Setter가 slot 교체를 tap 스레드의 호출과 직렬화하는지 여부가 memory-safety 해석의 핵심 전제인데, AudioSourceProviderAVFObjC의 구현은 제공된 source context에 포함되어 있지 않습니다. 만약 tap 스레드가 어떤 slot을 실행하는 도중에 그 slot이 교체되어(그리고 캡처된 Ref가 파괴되어) 버릴 수 있다면 — commit message가 tapStorage->lock 언급을 통해 단정하는 바와 같이 — 오디오 스레드는 해제된 메모리를 읽고 그곳을 통해 호출을 수행하게 됩니다.

추가된 test는 이 trigger 과정을 다음과 같이 밟습니다.

  1. Outgoing RemoteMediaPlayerManagerProxy_CreateMediaPlayer 메시지를 tap하여, GPU-side proxy를 가리키는 MediaPlayerIdentifier를 확보합니다.
  2. 숨겨진 <video src="../media/content/test.mp4">를 추가하고 loadedmetadata를 기다립니다. 이로써 GPU process 안에 live audio track을 가진 실제 AVFoundation item이 존재하게 됩니다.
  3. SetShouldEnableAudioSourceProvider(playerId, true)를 전송해 proxy가 AudioSourceProviderAVFObjC 경로를 설치하도록 하고, 이어서 CreateAudioSourceProvider(playerId)를 전송합니다.
  4. 첫 번째 호출은 RemoteAudioSourceProviderProxy::create(...)를 실행해, Ref를 캡처하는 두 lambda를 platform provider에 저장하고, member에는 나머지 strong reference를 남깁니다.
  5. 20ms의 sleep은 MediaToolbox tap 스레드가 해당 callback 호출을 시작할 수 있는 window를 제공합니다. 이는 test가 세운 liveness 가정이며, 제공된 코드 자체가 보장하는 사항은 아닙니다.
  6. 다음 loop iteration에서 CreateAudioSourceProvider가 다시 전송됩니다. 패치 이전에는 handler가 factory에 재진입해 member를 재할당하고 두 callback slot을 덮어써, 제공된 context 안에서 확인되는 모든 strong reference를 해제하며 최초 proxy의 refcount를 0으로 만듭니다.

Exploitability 측면에서 보면, CreateAudioSourceProvider는 GPU-process IPC endpoint이므로 공격자는 이미 임의의 IPC를 전송할 수 있는 위치, 즉 compromised되거나 스크립트로 제어되는 WebContent process를 확보하고 있어야 합니다. 이는 test가 시뮬레이션하는 상황과 정확히 일치합니다. 가장 즉각적으로 관찰되는 영향은, 오디오 스레드가 파괴된 이후의 proxy에서 m_connection이나 m_identifier를 읽거나 callable 객체 자체가 invoker 아래에서 해체되는 경우 발생하는 GPU-process crash입니다. 만약 공격자가 두 번째 메시지를 tap-thread window 안에 정확히 맞춰 전송하고, m_connection->send(...)가 실행되기 전에 해제된 allocation을 controlled data로 재확보할 수 있다면, IPC::Connection slot이 공격자 영향 하에 놓이게 되어 공격자가 선택한 pointer를 통한 호출과 공격자가 선택한 내용의 message-send로 이어질 가능성이 있습니다. 이를 실현하려면 해당 ThreadSafeRefCounted 객체가 속한 GPU-process heap bucket을 grooming해야 하고, window가 결정적이지 않으므로 여러 번의 시도가 필요하며, renderer가 IPC로 구동할 수 있는 GPU process 내 reclaim primitive도 필요합니다. ProducerSharedCARingBuffer::allocate와 같은 shared-buffer/ring-buffer 할당 경로가 유력한 후보인데, 같은 경로를 통해 renderer가 이미 GPU allocation에 영향을 줄 수 있기 때문입니다. 이런 조건이 갖춰지지 않는다면, 남는 것은 안정적으로 유발 가능한 GPU-process fault입니다.

이 vulnerability는 WebContent-to-GPU process 격리 경계를 약화시킵니다. Security model은 GPU process가 renderer의 모든 메시지를 신뢰할 수 없는 것으로 취급하고 동작 전에 프로토콜 상태를 검증한다는 전제 위에 서 있습니다. 그런데 이 사례에서는 create-once 메시지가 재생될 수 있었고, 그 결과로 발생하는 lifetime violation은 sender보다 더 높은 권한을 가진 process에서 일어납니다. 이미 sandbox 안의 WebContent process에서 code execution을 확보하고, MediaToolbox 오디오 스레드와의 race에서 승리한 공격자라면 GPU-process 메모리를 손상시킬 수 있습니다. GPU process는 hardware 미디어 codec과 capture device에 대해 renderer보다 넓은 접근 권한을 가지므로, sandbox-escape chain에서 의미 있는 한 단계에 해당합니다.

구조적으로 흥미로운 지점은 ownership이 얼마나 얇게 분산되어 있는가입니다. create()는 호출자가 member에 보관하는 Ref를 반환하지만, 반환 전에 세 번째 객체에 대한 lambda capture 형태로 두 개의 strong reference를 추가로 넘겨줍니다. 두 보유자 모두 동일한 duplicate-message 경로에 의해 해제되므로, 객체의 lifetime은 결국 callback-slot 할당의 side effect가 됩니다. Callback을 덮어쓰는 행위 자체가 암묵적으로 deref()인 셈입니다. setFooCallback()이 이전 callback이 캡처하고 있던 owner를 해제해버릴 수 있는 API는, invoker가 다른 스레드에서 동작하는 순간 항상 lifetime hazard가 됩니다. 그리고 slot 교체가 invocation과 직렬화되어 있지 않다면, 양쪽 모두 refcounting을 올바르게 해도 이 문제는 해결되지 않습니다. 특기할 점은 이번 fix가 synchronization 계층이 아니라 protocol 계층에서 이루어졌다는 사실입니다. 이는 renderer가 구동하는 경로는 막아주지만, 이 경로에 도달할 수 있는 다른 caller가 있다면 AudioSourceProviderAVFObjC 내부의 근본적인 구조는 그대로 남아 있게 됩니다.

위험한 상황 하나는 다른 스레드가 실행 중일 수 있는 callback slot이 교체되는 경우입니다. Setter가 invoker 측이 보유한 lock을 획득하지 않은 채 저장된 callable을 덮어쓰면, 기존 callable을 파괴하는 작업이 그 callable 자신의 실행과 race하게 됩니다. 여기서 성립해야 할 invariant는 callable의 storage lifetime이 그 실행과 serialize되어야 한다는 것입니다. 좁게 보면, Source/WebCore/platform/graphics/avfoundation/objc/AudioSourceProviderAVFObjC를 점검하여 모든 set*Callback setter를 나열하고, 각각이 MediaToolbox tap process callback이 보유한 것과 동일한 lock을 잡는지 확인해야 합니다. 이후 다른 WebCore audio-tap 및 render-callback client에도 동일한 점검을 반복할 필요가 있습니다. 조금 더 넓게 보면, 같은 패턴이 다른 형태로도 나타납니다. Completion 경로가 보유한 lock 바깥에서 재할당되는 CompletionHandler/Function 멤버, 그리고 소유 멤버가 다른 스레드에서 재할당되는 동안 Ref를 캡처하는 RunLoop-dispatch block이 그 예입니다. 여기서 찾아야 할 형태는, main thread에서 기록되고 real-time 또는 work-queue thread에서 읽히는 callable 타입 멤버가 비대칭적인 locking을 갖는 경우입니다. 가장 넓게 보면 이는 "dispatch 도중 listener slot이 mutate되는" 일반적인 클래스에 해당하며, 이 invariant는 handler가 실행 중에 교체될 수 있는 어떤 runtime에도 그대로 옮겨갑니다. 예를 들어 caller가 보유하지 않은 Mutex 뒤에서 Box<dyn Fn>을 swap하는 Rust, callback 도중 재할당되는 Java listener 필드, stream을 멈추지 않은 채 callback pointer를 재기록하는 PortAudio/ALSA 같은 C audio API가 모두 여기 해당합니다. 판별 기준은 invoker가 보유한 lock을 먼저 찾은 뒤, invoked state의 모든 writer가 그 lock을 잡는지 확인하는 것입니다.

또 다른 위험한 상황은 callback 안에 캡처된 상태로 제3자에게 넘어가면서 object lifetime이 흩어지는 경우입니다. 선언 지점에는 owner가 하나로 보이지만, 실제 reference set에는 코드를 읽는 사람이 볼 수 없는 캡처들이 포함되어 있고, 단 한 번의 API 호출로 그 여러 개가 한꺼번에 사라질 수 있습니다. 여기서 성립해야 할 invariant는 ownership이 선언 지점에서 명확히 드러나야 한다는 것입니다. 좁게 보면, Ref<T>를 반환하기 전에 그 동일한 Ref를 캡처하는 lambda를 collaborator에 설치하는 WebKit factory function을 검색해야 합니다. RemoteAudioSourceProviderProxy::create가 그 template에 해당하며, GPUProcess/media의 다른 proxy와 WebCore/platform/mediastream에서도 같은 install-then-return 형태를 점검하고, 각각에 대해 어떤 단일 code path가 모든 holder를 동시에 drop하는지 확인해야 합니다. 조금 더 넓게 보면, 같은 클래스는 strong ref 대부분이 CoreAudio, AVFoundation delegate, CoreMedia listener 같은 platform framework 객체가 보유한 Function/block 캡처 안에 살아 있는 모든 ThreadSafeRefCounted 객체를 포괄합니다. 판별 기준은 tree 내 다른 곳에 Ref<T> m_owner 선언이 거의 또는 전혀 없는 장수명 클래스인지 여부입니다. 가장 넓게 보면 원칙은 다음과 같습니다. callback slot을 비우는 것이 마지막 deref()가 될 수 있다면, callback을 할당하는 행위는 곧 free()에 해당한다. 이는 handler 등록이 ownership을 넘기는 어떤 framework에도 적용해볼 가치가 있는 원칙이며, 리소스를 캡처하는 Node.js EventEmitter closure, boxed closure에 담겨 C API로 넘어가는 Rust Arc, 등록된 hook을 통해서만 인스턴스를 살려두는 DI container가 모두 해당 사례입니다. 판별 기준은 "누가 마지막 ref를 쥐고 있는가"를 묻는 것이며, 솔직한 답이 "다른 누군가가 덮어쓸 수 있는 callback"이라면 그 지점을 표시해야 합니다.