← All reports

[CoreIPC][GPUProcess] UserMediaCaptureManagerProxy::startProducingData races prepareAudioDescription() against audioSamplesAvailable() lead to various UAF/write-after-unmap

HighWebKit GPU process media capture IPCRace

CVE: CVE-2026-43731 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to memory corruption Apple's description: A use-after-free issue was addressed with improved memory management. Credit: dr3dd

866892a | Bugzilla 314115

High. 메시지 인자 자체는 완전히 정상적인 값이며, 위험한 것은 오직 반복 호출입니다. 두 번째 StartProducingData가 살아있는 capture 스레드가 mid-write 중인 ring buffer를 해제해 버립니다. 이 경로에 도달하려면 이미 WebContent에서 code execution을 확보한 상태여야 하므로, 이 자체로 sandbox escape가 되는 건 아니고 GPU sandbox로 이어지는 chain의 한 링크에 해당합니다.

마이크와 카메라의 바이트 스트림은 더 이상 renderer를 거치지 않습니다. 페이지가 getUserMedia()를 호출하면, capture source 전체가 GPU process 안에서 생성되며, 이 프로세스가 디바이스 핸들을 소유하고 shared-memory ring buffer를 통해 오디오 프레임을 되돌려 보냅니다. 이 중계 역할을 하는 GPU 측 객체가 UserMediaCaptureManagerProxySourceProxy이며, capture가 시작되면 두 개의 서로 다른 생명주기를 갖게 됩니다. 하나는 GPU main thread에서 IPC 메시지를 처리하는 생명주기이고, 다른 하나는 백그라운드 work queue에서 platform capture unit의 sample callback을 처리하는 생명주기입니다. 이 둘을 분리해 주는 invariant는 단순하지만 코드에 명시되어 있지는 않습니다. 즉, proxy가 sample observer로 등록되고 나면, capture 스레드에 넘겨준 buffer와 semaphore는 observer가 제거될 때까지 그 스레드의 소유가 됩니다.

관전 포인트: 이미 renderer를 장악한 공격자가 start-capture 메시지를 두 번 보내는 것만으로, 방금 해제되어 unmap된 ring buffer에 audio 스레드가 계속 sample 데이터를 쓰도록 만들 수 있습니다.

Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp

void start()
{
+ // A compromised WebContent process may send StartProducingData repeatedly. Once we are
+ // observing, prepareAudioDescription() would race the capture thread's audioSamplesAvailable().
+ if (m_isObservingMedia)
+ return;
+
m_shouldReset = true;
m_isStopped = false;
m_source->start();
 
if (m_source->type() == RealtimeMediaSource::Type::Audio) {
if (auto* description = m_source->audioStreamDescription())
prepareAudioDescription(*description);
}
 
observeMedia();
}

LayoutTests/ipc/usermedia-capture-start-producing-data-race.html

+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const sourceId = 0xFFFFFFFFn;
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.CreateMediaSourceForCaptureDeviceWithConstraints(0, {
+ id: sourceId, device: { ... type: 2, label: 'Mock audio device 1', isMockDevice: true }, ...
+ }, () => { resolve(); });
+
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.StartProducingData(0, { id: sourceId, pageIdentifier: IPC.pageID });
+
+ let count = 0;
+ const interval = setInterval(() => {
+ for (let i = 0; i < 10; i++)
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.StartProducingData(0, { id: sourceId, pageIdentifier: IPC.pageID });
+ if (++count > 20) { clearInterval(interval); ... done(); }
+ }, 10);

production code 변경 전체는 UserMediaCaptureManagerProxySourceProxy::start() 상단에 추가된 4줄뿐입니다. m_isObservingMedia가 이미 설정되어 있으면 즉시 return하는 early return입니다. 그 아래 코드는 그대로입니다. 함수는 여전히 m_shouldReset을 설정하고, m_isStopped를 clear하고, 내부 RealtimeMediaSource를 시작시키고, audio source의 경우 prepareAudioDescription()을 통해 오디오 배관을 다시 구축하고, observeMedia()를 호출합니다. 다만 observer가 이미 live 상태인 두 번째 진입에서는 이 모든 과정을 건너뛸 뿐입니다.

이 패치에서 흥미로운 지점은 오히려 하지 않은 부분입니다. observeMedia()는 이미 idempotent했습니다. 새로 추가된 guard가 참조하는 것과 동일한 m_isObservingMedia 플래그를 보고 스스로 early return하고 있었습니다. 즉 start() 하단의 재등록 자체는 애초에 문제가 아니었습니다. 문제는 guard의 위치였습니다. 함수 한 단계 더 안쪽에 있었고, 호출 지점 위쪽 코드는 조건 없이 실행되고 있었습니다.

  Before:                              After:
  start()  (2nd StartProducingData)    start()  (2nd StartProducingData)
    ├─► m_shouldReset = true             └─► if (m_isObservingMedia) return;   ← guard here
    ├─► m_source->start()
    ├─► prepareAudioDescription()  ◄── frees + reallocates shared state
    └─► observeMedia()
          └─► if (m_isObservingMedia)
                  return;               ← guard was only here

m_isObservingMediaunobserveMedia()에서만 clear되며, 이는 stop()과 proxy의 destructor에서 호출됩니다. 정상적인 stop()/start() 쌍은 여전히 전체 경로를 그대로 타게 됩니다. 이번 guard가 억제하는 것은 반복된 start 호출뿐입니다.

나머지 세 개 파일은 테스트용 부속물입니다. 버그를 JavaScript에서 유발하는 새 IPC layout test 하나, 한 줄짜리 기대값(This test passes if it does not crash.), 그리고 glib TestExpectations에 추가된 Skip 항목입니다. 해당 코드가 #if PLATFORM(COCOA) && ENABLE(MEDIA_STREAM) 아래에 있기 때문입니다.

GPU-process capture architecture. 최신 WebKit에서는 WebContent process가 카메라나 마이크 접근 권한을 전혀 갖지 않습니다. navigator.mediaDevices.getUserMedia() 호출은 GPU process로의 IPC로 전환되며, 그곳에서 UserMediaCaptureManagerProxyRealtimeMediaSourceIdentifier마다 하나씩 UserMediaCaptureManagerProxySourceProxy를 생성합니다. 이 proxy는 platform 수준 capture 객체인 WebCore::RealtimeMediaSource를 감싸며, 미디어를 renderer로 되돌려 보내는 역할을 담당합니다. 이 proxy에 대한 제어 메시지(CreateMediaSourceForCaptureDeviceWithConstraints, StartProducingData, StopProducingData, RemoveSource)는 GPU process main thread에서 dispatch됩니다.

The sample-observer callback path. RealtimeMediaSource::AudioSampleObserver는 platform capture unit이 오디오 프레임을 넘겨주는 인터페이스입니다. 이 인터페이스의 audioSamplesAvailable() 메서드는 main thread가 아니라 별도의 백그라운드 capture WorkQueue에서 호출됩니다. 즉 등록된 observer는 실제로 두 스레드에서 동시에 실행됩니다. 등록과 제거는 addAudioSampleObserver() / removeAudioSampleObserver()를 통해 이루어지며, 이 클래스는 이를 각각 observeMedia() / unobserveMedia()로 감싸고 있고, 둘 다 m_isObservingMedia 플래그로 게이팅됩니다.

The shared-memory transport. ProducerSharedCARingBuffer는 CoreAudio ring buffer로, 그 backing store는 GPU process와 WebContent process 양쪽에 매핑된 SharedMemory 영역입니다. GPU 쪽이 producer 역할을 하며 sample frame을 이 매핑에 씁니다. ring buffer 객체를 파괴하면 producer 쪽 매핑도 함께 해제됩니다. 이와 함께 IPC::Semaphore가 새 프레임이 도착했음을 consumer에게 signal합니다. prepareAudioDescription()이 바로 이 구성 요소들, 즉 semaphore, ring buffer, renderer로 전달되는 shared-memory handle, 그리고 캐시된 stream description을 구축하는 함수입니다.

IPC Testing API. 테스트별로 IPCTestingAPIEnabled=true를 통해 활성화되는 테스트 전용 기능으로, layout test가 JavaScript에서 직접 다른 프로세스로 raw IPC 메시지를 구성해 전송할 수 있게 해줍니다. WebKit은 이를 이용해 장악된 WebContent process가 할 수 있는 일, 즉 임의의 메시지, 임의의 인자, 임의의 순서를 그대로 재현합니다.

이 버그는 reinitialize-after-publish race에 해당합니다. 이미 다른 스레드에서 동시에 소비 중인 상태를, 락도 없고 사전 detach도 없이 다른 스레드의 메시지 핸들러가 파괴하고 재구축해 버리는 패턴입니다.

  GPU main thread                     capture WorkQueue
  ───────────────                     ─────────────────
  start() #1
    prepareAudioDescription()
      m_ringBuffer   = RB_A
      m_captureSemaphore = SEM_A
    observeMedia()  ──────────────►   audioSamplesAvailable()
                                        reads m_ringBuffer  → RB_A
  start() #2  (attacker)                  ↓
    prepareAudioDescription()             │  (still inside)
      ~RB_A  → SharedMemory unmapped      │
      ~SEM_A → freed                      │
      m_ringBuffer = RB_B                 ▼
                                        store frames into RB_A ← UAF /
                                                                write-after-unmap

왼쪽 열이 이 버그의 전부입니다. 첫 번째 StartProducingData에서 start()가 초기화를 마치고 proxy를 capture unit에 publish하면, 그 순간부터 오른쪽 열이 자신의 스레드에서 m_ringBuffer, m_captureSemaphore, m_audioHandle, m_description을 살아 있는 상태로 계속 참조하게 됩니다. 두 번째 StartProducingData가 오면, 동일한 source identifier와 동일한 정상적인 인자로 호출되더라도, 이 시점에는 m_isObservingMedia가 이미 true이므로 start() 하단의 observeMedia()는 아무 동작도 하지 않습니다. 문제는 그 위에 있는 prepareAudioDescription()에는 이런 검사가 없다는 점입니다. 이 함수는 방금 나열한 멤버 전부를 다시 대입합니다. 이전 ProducerSharedCARingBuffer가 destruct되면서 그 SharedMemory 매핑까지 함께 해제되고, 이전 IPC::Semaphore도 해제되며, 새 객체들이 그 자리를 대신합니다. 이 재할당 과정과 capture 스레드 사이에는 어떤 동기화 장치도 없으며, capture 스레드는 곧 사라질 객체들에 대한 raw reference를 그대로 들고 있는 상태입니다.

여기서 두 가지 서로 다른 memory-safety 결과가 나오며, commit message는 이 둘을 모두 명시하고 있습니다. 첫 번째는 일반적인 heap use-after-free로, capture 스레드가 해제된 ring-buffer 객체를 건드리거나 해제된 semaphore에 signal을 보내는 경우입니다. 두 번째는 조금 더 특이한 write-after-unmap입니다. ring buffer의 backing store가 SharedMemory 영역이고, producer를 파괴하면 이 매핑도 함께 해제되므로, capture 스레드가 다음 번 sample frame을 쓰는 시점에는 GPU process가 더 이상 소유하지 않는 주소 범위에 write가 이루어지게 됩니다. 이 write의 내용에 대한 공격자 영향력은 실재하지만 간접적입니다. 그 바이트는 오디오 sample 데이터이며, 마이크 입력 환경을 제어하는 공격자는 그 값을 어느 정도 제어할 수 있습니다. 다만 diff와 주변 소스만으로는 ring buffer의 필드 레이아웃이 충분히 드러나지 않아, offset 수준의 제어가 어떤 형태인지까지는 확인되지 않습니다.

trigger 시퀀스는 새로 추가된 layout test가 수행하는 절차 그대로이며, 특별히 이색적인 조건이 필요하지 않습니다.

  1. getUserMedia({ audio: true })로 실제 audio track을 얻어, mock capture unit이 활성화되어 sample을 계속 전달하도록 만듭니다.
  2. raw IPC를 통해 하드코딩된 RealtimeMediaSourceIdentifier(0xFFFFFFFF, process-global monotonic counter와 절대 충돌하지 않을 만큼 큰 값)로 두 번째 proxy를 생성합니다.
  3. StartProducingData를 한 번 보내면, source가 시작되고 observer가 등록되며 capture 스레드의 callback 호출이 시작됩니다.
  4. 이후 10ms 간격으로 StartProducingData를 10회씩, 총 20라운드 반복 전송합니다.
  5. 반복될 때마다 살아 있는 capture 스레드를 상대로 prepareAudioDescription()이 다시 실행됩니다. crash가 발생하지 않으면 테스트를 통과한 것입니다.

패치 이전 코드가 얼핏 안전해 보였던 이유는 m_isObservingMedia라는 idempotence 플래그가 실제로 존재했기 때문입니다. observeMedia()unobserveMedia() 모두 이 플래그를 참조하고 있었습니다. 다만 이 플래그가 지켜주는 범위는 등록 단계뿐이었고, 그 등록이 드러내는 상태를 만들어내는 setup 작업까지는 보호하지 못했습니다. guard가 호출 프레임 한 단계 더 깊은 곳에 위치하면서, 비용이 낮은 연산만 보호하고 실제로 파괴적인 연산은 열어둔 셈입니다.

fix는 다소 무딘 방식을 택했지만, 이 경우에는 그것이 옳은 선택입니다. capture 스레드도 함께 잡아야 하는 락을 새로 도입하는 대신, race를 일으키는 재대입 자체를 아예 없애 버렸습니다. guard가 start() 최상단으로 옮겨지면서, 공유 멤버는 observer가 등록되기 전과 stop()이 observer를 제거한 이후의 구간에서만 write되도록 바뀌었습니다. 코드가 원래부터 전제하고 있던 invariant가 이제는 관례가 아니라 구조적으로 강제됩니다. 정상적인 stop()/start() 사이클은 영향을 받지 않습니다. stop()이 다음 start()가 실행되기 전에 unobserveMedia()를 호출해 플래그를 clear하기 때문입니다. 이 취약점은 무작위로 마주치는 종류가 아니라 이미 장악된 상태 이후의 pivot에 해당합니다. renderer는 이미 장악되지 않고서는 raw IPC를 보낼 수 없지만, GPU process는 카메라와 마이크 디바이스 접근 권한을 WebContent와는 별개의, 더 관대한 sandbox profile 아래에서 보유합니다. 따라서 이 pivot이 성공하면 확보 가능한 권한 범위가 WebContent sandbox를 크게 넘어서게 되며, 이는 chain을 완성하는 단계라기보다 한 단계 더 진전시키는 성격에 가깝습니다.

두 번째 StartProducingData 메시지가 GPU main thread에서 오디오 setup을 다시 실행시켜, capture 스레드가 동시에 sample frame을 쓰고 있던 ring buffer를 해제하고 unmap해 버렸습니다.

부분적으로만 적용된 idempotence guard는 아예 없는 것보다 더 위험합니다. 코드를 읽을 때 보호되어 있다는 인상을 주기 때문입니다. 어떤 handler가 setup(); publish_to_other_thread(); 형태로 동작하는데 publish 단계만 guard되어 있다면, 반복된 메시지가 이미 다른 스레드와 공유 중인 상태를 재초기화하게 됩니다. guard가 진입 지점에 있는지, 아니면 마지막 호출 내부 깊숙한 곳에 있는지를 반드시 확인해야 합니다.