← All reports

Race window lead to reads from deallocated memory

HighWebGPU (GPU process)Race

CVE: CVE-2026-43794 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to memory corruption Apple's description: A memory corruption issue was addressed with improved memory handling. Credit: Dung Do (@_piers2) of Calif.io

d4a150e | Bugzilla 317317

High. 크기 하나가 threshold를 넘는 순간 buffer upload의 의미가 copy에서 alias로 조용히 바뀝니다. 그런데 process 경계 양쪽 어디에서도 빌려온 storage를 함수 밖까지 붙잡아두지 않았습니다. 해당 window를 잡아내는 일 자체는 상당히 까다롭습니다. GPU가 페이지를 wire하기 전에 메모리가 해제되고 다시 점유되어야 하기 때문입니다. 다만 성공했을 때의 대가는 GPU process 메모리를 페이지에서 읽을 수 있는 resource로 되가져오는 것입니다.

대용량 업로드를 GPU로 옮길 때 zero-copy는 표준적인 기법입니다. 수십 MB를 driver 소유의 staging buffer로 memcpy하는 대신, 이미 갖고 있는 페이지를 driver에게 건네고 DMA용으로 wire하게 하는 방식입니다. 이때 치러야 할 비용이 ownership 계약입니다. 페이지의 소유권은 caller에게 남아 있으며, GPU가 실제로 작업을 끝낼 때까지 유효하게 유지해야 합니다. command buffer가 commit되는 시점까지가 아닙니다. WebKit의 Metal WebGPU backend는 32 MiB 이상의 전송에 대해 이 경로를 택하는데, GPU process의 IPC 수신부인 RemoteQueue와 Metal 쪽 WebGPU::Queue 업로드 경로 양쪽 모두 해당됩니다. 문제는 이번 commit 이전까지 어느 계층도 storage를 빌려온 호출이 끝난 뒤까지 owning reference를 유지하지 않았다는 점입니다.

관전 포인트: 페이지가 충분히 큰 WebGPU buffer 또는 texture 업로드를 발생시키면, GPU가 이미 해제되어 재사용된 graphics process 메모리 영역을 읽어들이게 만들 수 있습니다. 그리고 그 내용은 페이지가 다시 읽어낼 수 있는 resource에 담기게 됩니다.

Source/WebKit/GPUProcess/graphics/WebGPU/RemoteQueue.cpp

+#if HAVE(WEBGPU_IMPLEMENTATION)
+#include <WebGPU/WebGPU.h>
+#include <WebGPU/WebGPUExt.h>
+#endif
+
+// WGPU_LARGE_BUFFER_SIZE 이상의 전송에서는 backend가 newBufferWithBytesNoCopy를 사용해 `data`의 mapping을 alias하므로, GPU가 바이트를 다 처리할 때까지 살려두어야 한다. 그보다 작은 전송은 Metal buffer로 동기적으로 복사되므로 `data`는 반환 직후 해제해도 된다.
+static void keepAliveUntilSubmittedWorkDone(WebCore::WebGPU::Queue& backing, RefPtr<WebCore::SharedMemory>&& data)
+{
+#if HAVE(WEBGPU_IMPLEMENTATION)
+ if (!data || data->size() < WGPU_LARGE_BUFFER_SIZE)
+ return;
+ backing.onSubmittedWorkDone([data = WTF::move(data)]() mutable {
+ data = nullptr;
+ });
+#else
+ // caller의 storage를 alias하는 것은 Metal backend뿐이며, 이것이 유일한 WebGPU 구현이다.
+ UNUSED_PARAM(backing);
+ UNUSED_PARAM(data);
+#endif
+}
+
void RemoteQueue::writeBuffer(
...
 
- protect(m_backing)->writeBufferNoCopy(protect(*convertedBuffer), bufferOffset, data ? data->mutableSpan() : std::span<uint8_t> { }, 0, std::nullopt);
+ Ref backing = protect(m_backing);
+ backing->writeBufferNoCopy(protect(*convertedBuffer), bufferOffset, data->mutableSpan(), 0, std::nullopt);
+ keepAliveUntilSubmittedWorkDone(backing, WTF::move(data));
completionHandler(true);
}
 
void RemoteQueue::writeTexture(
...
auto convertedDataLayout = objectHeap->convertFromBacking(dataLayout);
 
- ASSERT(convertedDestination);
+ ASSERT(convertedDataLayout);
auto convertedSize = objectHeap->convertFromBacking(size);
ASSERT(convertedSize);
 
- if (!convertedDestination || !convertedDestination || !convertedSize || !data || data->size() <= WebGPU::maxCrossProcessResourceCopySize) {
+ if (!convertedDestination || !convertedDataLayout || !convertedSize || !data || data->size() <= WebGPU::maxCrossProcessResourceCopySize) {
completionHandler(false);
return;
}
 
- protect(m_backing)->writeTexture(*convertedDestination, data ? data->mutableSpan() : std::span<uint8_t> { }, *convertedDataLayout, *convertedSize);
+ Ref backing = protect(m_backing);
+ backing->writeTexture(*convertedDestination, data->mutableSpan(), *convertedDataLayout, *convertedSize);
+ keepAliveUntilSubmittedWorkDone(backing, WTF::move(data));
completionHandler(true);
}

Source/WebGPU/WebGPU/Queue.mm

-constexpr static auto largeBufferSize = 32 * 1024 * 1024;
+constexpr static auto largeBufferSize = WGPU_LARGE_BUFFER_SIZE;
...
 
- if (noCopy)
+ if (noCopy) {
+ if (!newData.isEmpty()) {
+ // 위의 MTLBuffer는 newBufferWithBytesNoCopy로 생성되어 newData의 storage를 alias하므로, GPU가 다 처리할 때까지 해당 storage를 살려둔다.
+ __block Vector<uint8_t> retainedNewData = WTF::move(newData);
+ [m_commandBuffer addCompletedHandler:^(id<MTLCommandBuffer>) {
+ retainedNewData = { };
+ }];
+ }
finalizeBlitCommandEncoder();
+ }
}

Source/WebGPU/WebGPU/WebGPUExt.h

+// 이 값 이상에서 Metal backend는 writeBuffer / writeTexture에서 newBufferWithBytesNoCopy를 사용하며,
+// 복사하는 대신 caller의 storage를 alias한다. 이 크기 이상의 전송을 넘기는 caller는 GPU가 바이트를
+// 다 처리할 때까지 원본 바이트를 반드시 살려두어야 한다 (예: addCompletedHandler 사용).
+// 값은 32 * 1024 * 1024이며, Swift의 clang macro importer가 이를 건너뛰지 않고
+// `WGPU_LARGE_BUFFER_SIZE`로 가져가도록 단일 정수 리터럴로 작성한다.
+#define WGPU_LARGE_BUFFER_SIZE 33554432

Source/WebGPU/WebGPU/RenderPipeline.mm

HashMap<String, uint64_t> entryMap;
+ // bump가 wrap되면 array-length entry가 사용자 binding을 alias하게 되어 bounds check가 무력화된다.
+ auto bumpForArrayLength = [&](uint32_t webBinding) -> std::optional<uint32_t> {
+ auto checked = checkedSum<uint32_t>(webBinding, limits().maxBindingsPerBindGroup);
+ if (checked.hasOverflowed())
+ return std::nullopt;
+ return checked.value();
+ };
for (auto& entry : bindGroupLayout.entries) {
...
if (entryName.endsWith("_ArrayLength"_s)) {
 
- webBinding += limits().maxBindingsPerBindGroup;
+ auto bumped = bumpForArrayLength(webBinding);
+ if (!bumped)
+ return @"Binding index overflow in auto-generated layouts";
+ webBinding = *bumped;
isArrayLength = true;
}

이 commit에는 서로 다른 세 가지 변경이 함께 실려 있는데, 제목이 설명하는 내용은 그중 첫 번째뿐입니다.

두 계층에 걸친 lifetime 수정. WebGPUExt.h에 WGPU_LARGE_BUFFER_SIZE(33554432, 즉 32 MiB)가 공용 매크로로 추가되었고, 주석에 ownership 계약이 함께 명시되었습니다. 이 크기 이상에서는 Metal backend가 newBufferWithBytesNoCopy를 사용하며, 호출자의 storage를 복사하지 않고 그대로 alias합니다. 따라서 호출자는 GPU가 해당 바이트를 다 처리할 때까지 원본을 살려 두어야 합니다. 매크로는 정수 리터럴 하나로만 작성되었는데, Swift의 clang macro importer가 인식할 수 있도록 의도한 형태입니다. 덕분에 Queue.swift와 Queue.mm은 각자 들고 있던 32 * 1024 * 1024 리터럴을 버리고 이 매크로를 쓰게 되었습니다. Queue.mm에서는 texture 업로드 경로의 noCopy 분기가 바뀌었습니다. 로컬 Vector<uint8_t> newData를 __block 변수로 옮기고, 이를 [m_commandBuffer addCompletedHandler:] 블록이 캡처합니다. 해당 블록은 GPU가 staging command buffer를 끝낸 뒤에야 retainedNewData = { }로 내용을 비웁니다. 같은 if (noCopy)의 중괄호 안으로 finalizeBlitCommandEncoder() 호출도 이동했습니다. RemoteQueue.cpp에는 static helper keepAliveUntilSubmittedWorkDone()가 새로 추가되었는데, 임계값 이상 크기의 전송에 대해 mapping된 RefPtr<WebCore::SharedMemory>를 backing.onSubmittedWorkDone() 콜백 안에 붙잡아 둡니다. RemoteQueue::writeBuffer()와 RemoteQueue::writeTexture()는 모두 Ref backing = protect(m_backing)를 앞쪽으로 끌어올린 뒤, data->mutableSpan()을 backend에 전달한 직후 이 helper를 호출합니다.

guard 수정. 같은 RemoteQueue.cpp hunk에서 writeTexture()의 copy-paste 결함도 함께 정리되었습니다. ASSERT(convertedDestination)가 ASSERT(convertedDataLayout)로 바뀌었고, guard 역시 if (!convertedDestination || !convertedDestination || ...)에서 if (!convertedDestination || !convertedDataLayout || ...)로 정정되었습니다. 그 결과 *convertedDataLayout으로 역참조되는 std::optional이 사용 전에 실제로 검사됩니다. 이제 같은 guard가 data의 non-null을 보장하므로, 두 호출 지점에 있던 data ? data->mutableSpan() : std::span<uint8_t> { } 삼항 연산은 data->mutableSpan()로 단순화되었습니다.

overflow 수정. RenderPipeline.mm의 Device::addPipelineLayouts()에서는 _ArrayLength 항목에 대한 webBinding += limits().maxBindingsPerBindGroup 증가 두 곳이 모두 checkedSum<uint32_t> 기반 lambda로 감싸졌습니다. 값이 wrap되는 대신 @"Binding index overflow in auto-generated layouts" 에러 문자열을 반환하게 됩니다. 추가된 주석은 무엇이 걸려 있는지 직접 밝히고 있는데, wrap이 발생하면 array-length 항목이 사용자 binding과 겹쳐 bounds check가 무력화된다는 내용입니다.

GPU process 분리. JavaScript에서 호출한 WebGPU API는 WebContent process에서 실행되지만, 실제 Metal 작업은 별도의 GPU process에서 수행됩니다. content 쪽 GPUQueue 하나마다 GPU process에는 대응하는 RemoteQueue 수신자가 존재합니다. 메시지는 IPC stream으로 전달되고, identifier는 다시 backend 객체로 변환되며, 호출은 WebCore::WebGPU::Queue를 대상으로 재현됩니다. 이 클래스는 Source/WebGPU/WebGPU/Queue.mm에서 Metal 위에 구현되어 있습니다.

큰 업로드를 전달하는 방식. WebGPU::maxCrossProcessResourceCopySize를 넘는 업로드는 IPC 메시지에 인라인으로 담기에 너무 큽니다. 그래서 송신 측이 shared memory를 할당하고 WebCore::SharedMemoryHandle을 전송하며, 수신 측은 WebCore::SharedMemory::map()으로 이 handle을 mapping으로 바꿉니다. WebCore::SharedMemory는 reference count로 관리되는데, map()은 RefPtr을 반환하고 마지막 reference가 해제되면 mapping이 해제됩니다.

복사와 alias의 차이. newBufferWithBytesNoCopy는 호출자가 제공한 page-aligned host memory를 복사하지 않고 그대로 alias하는 MTLBuffer를 생성하는 Metal API입니다. 페이지의 ownership은 호출자에게 남아 있고, Metal은 GPU 접근을 위해 이 페이지를 wiring합니다. 32 MiB인 WGPU_LARGE_BUFFER_SIZE는 WebKit backend가 Metal 소유 staging buffer로 그대로 복사하는 대신 이 전략을 택하는 기준점입니다.

Metal command buffer의 비동기 실행. 작업은 encoder에 encoding되고, encoder가 종료되며, command buffer가 commit된 뒤, GPU는 이후 어느 시점에 이를 실행합니다. [MTLCommandBuffer addCompletedHandler:]는 실행이 실제로 끝난 뒤 동작하는 블록을 등록하며, Queue::onSubmittedWorkDone()은 같은 신호를 WebGPU API 수준에서 제공하는 등가물입니다. 한편 __block은 Objective-C의 storage qualifier입니다. 블록이 캡처한 __block 변수는 블록 내부에서 수정할 수 있고, 블록이 heap으로 복사되고 나면 블록의 storage가 그 변수를 소유하게 됩니다. 블록이 객체의 lifetime을 맡겨 둘 만한 장소가 되는 이유입니다.

Object heap 조회. WebGPU::ObjectHeap::convertFromBacking()는 IPC로 전달된 identifier나 descriptor를 backend 값으로 변환해 std::optional을 반환하는데, identifier가 존재하지 않으면 std::nullopt가 됩니다. ASSERT는 release build에서 제거되므로, release 빌드의 정확성은 전적으로 명시적인 if (!converted...) guard에 달려 있습니다.

binding과 array length. WGSL shader는 @group/@binding 인덱스로 리소스를 지정하며, maxBindingsPerBindGroup은 그 인덱스에 대한 device limit입니다. WebKit의 Metal backend는 shader의 runtime-sized array 길이를 담는 _ArrayLength binding을 별도로 생성하는데, 생성된 Metal 코드는 이 값을 bounds check에 사용합니다. 이렇게 만들어진 binding이 사용자 인덱스 공간과 겹치지 않도록, 자동 생성된 pipeline layout은 인덱스에 maxBindingsPerBindGroup만큼 offset을 더합니다.

GPU가 여전히 alias하고 있는 storage가 있습니다. 그런데 그에 대한 마지막 소유 reference는 동기 호출이 끝나는 시점에 해제됩니다. 정작 그 바이트를 읽어 처리하는 쪽은 비동기로 동작합니다. 핵심 버그는 이렇게 ownership이 먼저 만료되는 데 있습니다.

  GPU process (CPU)                          GPU
  ─────────────────────────────────────      ────────────────────────
  map SharedMemory  ──► RefPtr data
  writeBufferNoCopy(data->mutableSpan())
    └─► newBufferWithBytesNoCopy(ptr,len)
    └─► encode blit, commit ───────────────►  (queued)
  return  ──► ~RefPtr ──► unmap pages
                                    ╎
   [range reclaimed / re-mapped]    ╎  ◄── window
                                    ╎
                                              wire pages, read src ──► dest

그림에서 window의 왼쪽 경계는 함수 지역 변수인 RefPtr<WebCore::SharedMemory> data의 소멸이며, 이 시점에 shared memory가 unmap됩니다. 오른쪽 경계는 GPU가 staging blit 도중 실제로 해당 페이지를 wiring하고 읽는 시점입니다. 두 시점을 이어 주는 장치는 없었습니다. RemoteQueue::writeBuffer()와 RemoteQueue::writeTexture()는 전달받은 handle을 mapping하고, data->mutableSpan()을 backend로 전달한 뒤 그대로 반환했습니다. 한 계층 아래에서는 Metal 업로드 경로의 noCopy 분기가 로컬 Vector<uint8_t> newData를 함수 끝에서 scope 밖으로 내보냈고, heap 블록은 allocator로 반환되었습니다. 두 경우 모두, 그 바이트를 읽는 command buffer는 encoding과 commit까지만 진행된 상태였을 뿐 완료되지는 않았습니다. commit 메시지는 그 결과를 정확히 묘사합니다. 해제된 Vector나 SharedMemory 자리에 사용자 메모리가 "swapped in"된다는 표현입니다. 즉 해당 virtual range가 GPU process의 무관한 데이터를 위해 다시 mapping되거나 재할당되는데, MTLBuffer는 여전히 같은 주소를 가리키고 있으므로 DMA는 그 자리에 새로 들어온 내용을 그대로 읽게 됩니다.

이 버그는 공유 변수에 대한 data race라기보다, timing window를 동반한 lifetime 버그에 해당합니다. 그리고 그 window는 실제로 매우 좁습니다. 메모리가 다시 점유되려면 GPU가 페이지를 wiring하기 전에 해제되고 동시에 재사용까지 되어야 하기 때문입니다. commit에 새 테스트가 포함되지 않은 이유이기도 합니다("race window is incredibly small: memory must be freed prior to being wired to the GPU"). 다만 이 race를 이기면 이야기가 달라집니다. alias된 MTLBuffer는 staging blit의 source이므로, 회수된 range는 쓰이는 것이 아니라 읽힙니다. race에 성공했을 때 얻는 primitive는 그 range를 현재 차지하고 있는 내용에 대한 통제되지 않은 정보 노출이며, 그 내용은 web content가 이후 읽어 갈 수 있는 GPUBuffer나 GPUTexture로 전달됩니다. 여기에는 GPU process의 ASLR을 무력화하는 데 쓸 만한 heap pointer가 실릴 수도 있고, 다른 페이지에 속한 픽셀이나 buffer 데이터가 실릴 가능성도 있습니다. GPU process 하나가 여러 content process를 담당하는 구조이므로, 그곳에서 얻어지는 데이터가 공격 페이지와 same-origin이라는 보장이 없기 때문입니다. 반대로 race에 실패하면 관찰되는 결과는 GPU process crash입니다.

패치는 두 계층 모두에서 비동기 window 전체에 걸쳐 ownership을 고정하며, 해제 시점으로는 완료 신호를 사용합니다:

__block Vector<uint8_t> retainedNewData = WTF::move(newData);
[m_commandBuffer addCompletedHandler:^(id<MTLCommandBuffer>) {
    retainedNewData = { };
}];

__block 캡처는 Vector를 블록 storage로 옮깁니다. 그 결과 Vector의 lifetime은 블록의 lifetime과 같아지고, 블록은 구조상 함수보다 오래 살아남습니다. 한 계층 위의 keepAliveUntilSubmittedWorkDone()도 구조적으로 동일한 일을 하는데, RefPtr<WebCore::SharedMemory>를 onSubmittedWorkDone() 콜백 안에 붙잡아 두는 방식입니다. 다만 이 처리는 WGPU_LARGE_BUFFER_SIZE 이상 크기의 전송에만 적용됩니다. 그 아래에서는 backend가 동기적으로 복사하므로 살려 둘 대상 자체가 없기 때문입니다. 이 helper는 RemoteQueue.cpp에서 임계값의 존재를 인지하는 최초의 코드이기도 합니다. 이번 commit 이전까지 해당 상수는 Metal backend 안에 맨몸 리터럴로만 존재했습니다.

도달 조건은 공격자 입장에서 가장 반가운 방향으로 평범합니다. 충분히 큰 payload로 GPUQueue.writeBuffer나 writeTexture를 호출하는 일반 웹 페이지만으로 전체 경로가 실행되며, renderer가 이미 장악되어 있을 필요도 없습니다. 이 버그로 얻는 것 자체가 sandbox escape는 아닙니다. 다만 공격자를 WebContent 밖으로 옮겨, 권한 구성이 다른 별도의 sandbox 내부에서 메모리 정보 노출을 확보하게 해 줍니다. 그 sandbox는 IOKit graphics 접근을 보유하고 있으며, 커널을 겨냥한 후속 작업의 통상적인 발판이기도 합니다.

함께 묶인 나머지 두 수정은 메커니즘상 서로 무관하지만, 같은 process 경계를 공유합니다. writeTexture()의 guard는 convertedDestination을 두 번 검사하면서 convertedDataLayout은 한 번도 검사하지 않았고, 이후 *convertedDataLayout을 역참조했습니다. 그래서 알 수 없는 data-layout backing을 지정한 IPC 메시지가 들어오면, 값이 비어 있는 std::optional에 대한 역참조까지 도달했습니다. 함께 놓여 있던 ASSERT는 release 빌드에서 제거된 상태입니다. 이 경로는 해석 불가능한 backing identifier를 추가로 요구하는데, 일반 페이지보다는 이미 장악된 WebContent process에서 자연스럽게 만들어지는 조건입니다. _ArrayLength overflow는 좀 더 미묘합니다. 생성된 array-length binding을 사용자 인덱스 공간 밖으로 밀어내기 위해 entry.webBinding에 maxBindingsPerBindGroup을 더하는데, 서로 다른 두 루프에서 두 번, 그것도 단순 uint32_t 연산으로 수행됩니다. webBinding이 충분히 크면 값이 wrap되고, array-length 항목이 실제 사용자 binding 위에 겹쳐 놓이게 됩니다. 해당 슬롯은 생성된 shader가 bounds check에 사용하는 runtime array size를 공급합니다. 그러므로 페이지가 제어하는 binding과 겹치면, 그 check가 읽는 길이 값을 페이지가 직접 쥐게 됩니다.

zero-copy 업로드 경로가 함수 반환 시점에 마지막 소유 reference를 해제하는 동안 commit된 MTLBuffer는 여전히 그 페이지를 alias하고 있었고, 결국 GPU가 회수된 range를 페이지에서 볼 수 있는 리소스로 읽어 들이게 된 패턴.

여기서 zero-copy 계약은 전적으로 산문으로만 존재합니다. 패치가 추가한 헤더 주석, 즉 "Callers passing transfers >= this size MUST keep the source bytes alive"라는 문장 자체가 유일한 강제 수단입니다. 이를 뒷받침하는 것은 매직 임계값 하나뿐인데, 이번 commit 전까지 이 값은 Queue.mm과 Queue.swift에 맨몸 리터럴로 중복되어 있었습니다. RemoteQueue.cpp에는 아예 없었고, 임계값이 존재한다는 개념조차 없었습니다. 크기 임계값 하나로 copy와 alias semantics가 조용히 뒤바뀌는 설계는 버그를 양산하기 좋습니다. 모든 호출 지점이 32 MiB를 넘는 순간 메모리 소유자가 바뀐다는 사실을 각자 알고 있어야 하는데, 타입 시스템은 여기에 대해 아무런 정보도 제공하지 않기 때문입니다. 상수를 한곳으로 모은 것은 분명한 개선입니다. 다만 no-copy 진입점이 맨몸 std::span<uint8_t> 대신 소유권을 가진 handle을 받거나 완료 토큰을 반환했다면, API를 잘못 쓰기가 훨씬 어려웠을 것입니다.

피연산자가 중복된 오타는 따로 언급할 가치가 있습니다. 일회성 실수가 아니기 때문입니다. 바로 이 revision 기준으로도, RemoteQueue::writeTextureWithCopy는 convertedDataLayout을 계산하는 자리에 여전히 ASSERT(convertedDestination)을 두고 있습니다. *convertedDataLayout을 역참조하기 전의 guard 역시 if (!convertedDestination || !convertedDestination || !convertedSize) 그대로입니다. RemoteQueue::copyExternalImageToTexture도 convertedSource를 계산하면서 convertedDestination만 두 번 검사합니다. 이 패턴은 손으로 작성된 Remote* IPC 진입점들 사이에서 copy-paste로 번져 나갔고, 기계적인 검색만으로도 찾아낼 수 있습니다.