[CoreIPC][GPUProcess] UserMediaCaptureManagerProxy::startProducingData races prepareAudioDescription() against audioSamplesAvailable() lead to various UAF/write-after-unmap
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
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
LayoutTests/ipc/usermedia-capture-start-producing-data-race.html
Patch Details
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_isObservingMedia는 unobserveMedia()에서만 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) 아래에 있기 때문입니다.
Background
GPU-process capture architecture. 최신 WebKit에서는 WebContent process가 카메라나 마이크 접근 권한을 전혀 갖지 않습니다. navigator.mediaDevices.getUserMedia() 호출은 GPU process로의 IPC로 전환되며, 그곳에서 UserMediaCaptureManagerProxy가 RealtimeMediaSourceIdentifier마다 하나씩 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가 할 수 있는 일, 즉 임의의 메시지, 임의의 인자, 임의의 순서를 그대로 재현합니다.
Analysis
이 버그는 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가 수행하는 절차 그대로이며, 특별히 이색적인 조건이 필요하지 않습니다.
getUserMedia({ audio: true })로 실제 audio track을 얻어, mock capture unit이 활성화되어 sample을 계속 전달하도록 만듭니다.- raw IPC를 통해 하드코딩된
RealtimeMediaSourceIdentifier(0xFFFFFFFF, process-global monotonic counter와 절대 충돌하지 않을 만큼 큰 값)로 두 번째 proxy를 생성합니다. StartProducingData를 한 번 보내면, source가 시작되고 observer가 등록되며 capture 스레드의 callback 호출이 시작됩니다.- 이후 10ms 간격으로
StartProducingData를 10회씩, 총 20라운드 반복 전송합니다. - 반복될 때마다 살아 있는 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해 버렸습니다.
Insight
부분적으로만 적용된 idempotence guard는 아예 없는 것보다 더 위험합니다. 코드를 읽을 때 보호되어 있다는 인상을 주기 때문입니다. 어떤 handler가 setup(); publish_to_other_thread(); 형태로 동작하는데 publish 단계만 guard되어 있다면, 반복된 메시지가 이미 다른 스레드와 공유 중인 상태를 재초기화하게 됩니다. guard가 진입 지점에 있는지, 아니면 마지막 호출 내부 깊숙한 곳에 있는지를 반드시 확인해야 합니다.
Audit directions
-
Members written outside the observe/unobserve bracket. 좁게 보면,
Source/WebKit/GPUProcess/webrtc/에 있는UserMediaCaptureManagerProxySourceProxy와 그 형제 클래스들, 즉RemoteCaptureSampleManager,addVideoFrameObserver()/videoFrameAvailable()를 통과하는 video 경로, 그리고m_widthConstraint/m_heightConstraint를 재계산하는applyConstraints/updateVideoConstraints()를 훑어서, observer가 live 상태인 동안 write되는 멤버가 있는지 점검해야 합니다. 단서는 백그라운드 스레드 callback에도 등장하는 멤버가, 락 없이 IPC handler에서 도달 가능한 함수 안에서 대입되는 패턴입니다. 넓게 보면,Source/WebKit/GPUProcess/media/에서 platform observer/callback 인터페이스를 구현하면서 동시에SharedMemory,IPC::Semaphore,*SharedCARingBuffer멤버를 소유하는 클래스들을 grep해 볼 필요가 있습니다. handler 스레드와WorkQueue,MediaTimeclock callback, 또는 CoreAudio/CoreMedia render callback이 필드를 공유하는 곳이라면 어디든 동일한 패턴이 반복될 수 있습니다. 가장 넓게 보면, 이는 WebKit에 국한된 문제가 아니라 reinitialize-after-publish 클래스 전반의 문제입니다. Chromium의 Mojo receiver가 media 스레드가 읽고 있는 shared buffer를 rebind하는 경우도 동일한 invariant를 위반하며, Rust 코드에서Arc<Mutex<>>가 필요한 상황에 단순Arc를 쓰는 경우도 마찬가지입니다. 공통적으로 던져야 할 질문은 하나입니다. setup을 수행하는 제어 메시지를 data plane이 동작 중인 상태에서 두 번 보낼 수 있는가, 그리고 그 두 번째 setup은 무엇을 해제하는가. -
메시지 인자가 아니라 순서(ordering)에 안전성이 좌우되는 handler. 좁게 보면,
UserMediaCaptureManagerProxyMessages.in과RemoteMediaPlayerProxy에 존재하는 모든StartX/StopX/EnableX쌍에 대해, 중간에Stop이 끼지 않은 상태에서 두 번째Start가 호출되면 어떤 일이 벌어지는지 확인해야 합니다. 넓게 보면, 같은 추론이 WebKit IPC 계층에서 신뢰 경계를 넘나드는 모든 two-phase 프로토콜에 적용됩니다.Create/Destroy,BeginX/EndX, buffer-handle 설치 후 사용 등이 해당하며, 이때의 tell은 guard가 handler 자신의 진입점이 아니라 호출하는 helper 내부에만 존재하는 경우입니다. 가장 넓게 보면, 이는 "권한 경계를 넘어 신뢰되는 프로토콜 상태 머신" 클래스에 속하며, 이 invariant는 모든 RPC 표면에 이식 가능합니다. 즉 권한이 있는 쪽이 상태 머신을 직접 소유하고 순서를 벗어난 전환을 무시하거나 거부해야 하며, 권한이 없는 쪽이 프로토콜을 따를 것이라고 가정해서는 안 됩니다. 가장 넓은 관점에서 tell을 매칭하자면, 같은 메소드를 연속으로 두 번 호출하는 것이 명백히 no-op이 아닌 모든 endpoint가 대상입니다. -
새 early return이 건너뛰게 된 side effect. 좁게 보면,
m_shouldReset의 reader들 — ring buffer 재초기화 여부를 결정하는 audio callback 경로 — 와m_isStopped를 추적해서,start()가 더 이상 수행하지 않는 reset을 callback이 여전히 기대하는 정상적인 시퀀스가 남아있지 않은지 확인해야 합니다. 넓게 보면, fix가 "lock을 잡는" 방식이 아니라 "flag 확인 후 early return"하는 방식일 때마다, 건너뛰게 된 side effect의 모든 소비자를 다시 점검할 필요가 있습니다. 이때의 tell은 함수 상단에 삽입된 guard가 세 가지 이상의 서로 다른 mutation을 수행하는 함수를 대상으로 한다는 점입니다. 가장 넓게 보면, early-return 방식의 idempotence guard는 크래시를 유발한 side effect 하나뿐 아니라, 자신이 short-circuit시키는 함수 안의 모든 side effect에 대해 정당화되어야 합니다. 여기서 audio-callback 상호작용을 확인하는 일은 정적 코드 분석만으로는 판단하기 어려우며, 타이밍에 민감한 테스트나 TSan 실행이 필요할 것으로 보입니다. -
같은 클래스의 video branch. 좁게 보면,
start()는 이제 두 branch 모두에 guard를 적용하지만,observeMedia()는 constraint 파라미터 —addVideoFrameObserver(*this, { m_widthConstraint, m_heightConstraint }, m_frameRateConstraint)— 를 사용해 video 경로를 등록하며, 이 파라미터는updateVideoConstraints()가 독립적으로 변경합니다.UserMediaCaptureManagerProxy.cpp안에서updateVideoConstraints()와applyConstraints의 모든 호출자를 대상으로,isObservingMedia()가 true인 동안 실행되는 경로가 있는지 확인해야 합니다. 넓게 보면, lifecycle method 내부에서RealtimeMediaSource::Type이나 이와 유사한 type enum으로 분기하는 다른 WebKit 클래스들도 점검 대상입니다. 한쪽 branch에서 발생한 문제로 촉발된 fix는 대체로 나머지 branch를 점검하지 않은 채로 남겨두며, 이때의 tell은 start/stop/reconfigure 메소드 내부에 존재하는 type switch입니다. 가장 넓게 보면, 이 클래스는 "크래시 리포트를 발생시킨 branch만 강화된 polymorphic lifecycle method"에 해당합니다. 여기서 이어지는 규칙은, reporter의 PoC가 오직 하나의 branch만 실행했을 가능성이 있기 때문에, lifecycle guard는 해당 메소드가 dispatch하는 모든 type branch에 대해 검증되어야 한다는 것입니다.