← All reports

WebGPU importExternalTexture origin-clean bypass in Safari

HighWebCore WebGPU bindingsCrossOrigin

CVE: CVE-2026-43700 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: A cross-origin issue was addressed with improved tracking of security origins. Credit: Vitaly Simonovich, Christian Meurer Xavier

67b563b | Bugzilla 315368

High 등급입니다. Memory-safety 버그가 아니라, 성능 캐시가 우회해버린 origin check를 되살리는 9줄짜리 람다입니다. Primitive는 정확히 하나이며, 이는 same-origin policy가 원래 막으려던 바로 그것입니다. 즉 문서가 볼 권한이 없는 video의 디코딩된 pixel을 script가 읽어내는 문제입니다.

Video frame은 브라우저가 페이지에게 다운로드 없이 넘겨주는 유일한 종류의 pixel 데이터입니다. Media 파이프라인이 디코딩하고 compositor가 화면에 표시하지만, script는 이를 표시할 수 있을 뿐 읽을 수는 없어야 합니다. Media를 다루는 모든 graphics API는 각자의 진입점에서 이 경계를 다시 세워야 합니다. 2D canvas는 drawImage에서 taint 처리로, WebGL은 cross-origin texImage2D 업로드를 거부하는 방식으로, 그리고 WebGPU는 GPUDevice::importExternalTexture에서의 validation으로 이를 구현합니다. 구현 방식은 제각각이지만 invariant는 동일합니다. Video frame에 대한 sampleable handle은, 해당 리소스가 문서와 same-origin이거나 CORS로 승인된 경우가 아니면 절대 script에 반환되어서는 안 됩니다.

관전 포인트: 페이지가 cross-origin video의 디코딩된 frame에 대한 shader-sampleable handle을 얻어 pixel을 읽어낼 수 있으며, 이를 통해 피해자가 로그인된 어떤 사이트에서든 인증이 필요한 video 콘텐츠가 유출될 수 있습니다.

Source/WebCore/Modules/WebGPU/GPUDevice.cpp

ExceptionOr<Ref<GPUExternalTexture>> GPUDevice::importExternalTexture(GPUExternalTextureDescriptor&& externalTextureDescriptor)
{
+#if ENABLE(VIDEO)
+ auto checkVideoElementOriginTaint = [this](const HTMLVideoElement& videoElement) -> std::optional<Exception> {
+ RefPtr context = scriptExecutionContext();
+ if (RefPtr securityOrigin = context ? context->securityOrigin() : nullptr; securityOrigin && videoElement.taintsOrigin(*securityOrigin))
+ return Exception { ExceptionCode::SecurityError, "GPUDevice.importExternalTexture: Cross origin external videos are not allowed in WebGPU"_s };
+ return std::nullopt;
+ };
+#endif
+
#if ENABLE(VIDEO) && PLATFORM(COCOA)
if (RefPtr externalTexture = externalTextureForDescriptor(externalTextureDescriptor)) {
externalTexture->undestroy();
...
+ if (auto exception = checkVideoElementOriginTaint(videoElementRef))
+ return WTF::move(*exception);
+
m_videoElementToExternalTextureMap.remove(videoElementRef);
if (auto optionalMediaIdentifier = externalTextureDescriptor.mediaIdentifier()) {
m_backing->updateExternalTexture(externalTexture->backing(), *optionalMediaIdentifier);
...
Ref videoElementRef = externalTextureDescriptor.source;
#endif
+ if (auto exception = checkVideoElementOriginTaint(videoElementRef))
+ return WTF::move(*exception);
+
WeakPtr videoElementPtr = videoElementRef.ptr();
m_videoElementToExternalTextureMap.set(videoElementRef, externalTexture.get());
m_previouslyImportedExternalTexture.first = videoElementRef.ptr();

LayoutTests/fast/webgpu/regression/repro_315368b.html

+ video.requestVideoFrameCallback(() => {
+ try {
+ let et1 = device.importExternalTexture({source: video}); // 새 texture 경로
+ let et2 = device.importExternalTexture({source: video}); // 캐시된 texture 경로 (Cocoa)
+ log('Pass');
+ } catch (e) {
+ log('FAIL: ' + e.message);
+ }
+ ...
+ });

기능적 변경은 한 파일의 함수 하나로 국한되어 있습니다. #if ENABLE(VIDEO)로 감싸진 로컬 람다 checkVideoElementOriginTaintScriptExecutionContext로부터 문서의 SecurityOrigin을 가져와, source element가 그 origin을 taint하는지 확인합니다. Taint한다는 답이 나오면 ExceptionCode::SecurityError"GPUDevice.importExternalTexture: Cross origin external videos are not allowed in WebGPU" 메시지를 담은 std::optional<Exception>을 반환하고, 그렇지 않으면 std::nullopt를 반환합니다. taintsOrigin의 파라미터 타입을 호출 지점에서 완전하게 만들기 위해 #include "SecurityOrigin.h"도 추가되었습니다.

이 람다는 실제로 중요한 두 지점, 즉 함수가 texture를 반환하는 두 경로 모두에서 호출됩니다.

  importExternalTexture(descriptor)
    │
    ├─ [COCOA] externalTextureForDescriptor(descriptor) → hit?
    │     ├─ undestroy()
    │     ├─ ✚ checkVideoElementOriginTaint(videoElementRef)   ← 추가됨
    │     ├─ m_videoElementToExternalTextureMap.remove(...)
    │     ├─ m_backing->updateExternalTexture(backing, *mediaIdentifier)
    │     └─ return cached Ref<GPUExternalTexture>
    │
    └─ miss / non-Cocoa: create new GPUExternalTexture
          ├─ ✚ checkVideoElementOriginTaint(videoElementRef)   ← 추가됨
          ├─ m_videoElementToExternalTextureMap.set(...)
          └─ return new Ref<GPUExternalTexture>

Cache-hit 경로에서의 배치는 의도적입니다. Check는 externalTexture->undestroy() 다음, 그리고 map을 변경하고 m_backing->updateExternalTexture(externalTexture->backing(), *optionalMediaIdentifier)가 backing을 element의 현재 media source로 재연결하기 에 위치합니다. 재연결 전에 거부한다는 것은, import가 거부되면 캐시된 texture가 새로 선택된 리소스가 아니라 기존에 가리키던 곳을 그대로 가리킨 채로 남는다는 의미입니다.

나머지 4개 파일은 LayoutTest입니다. repro_315368.html은 same-origin data: video를 한 번 import하여 생성 경로를 검증하고, repro_315368b.html은 하나의 requestVideoFrameCallback 안에서 동일한 element를 두 번 import하여 두 번째 호출이 Cocoa의 cache-hit 경로를 타도록 합니다. 두 테스트 모두 CONSOLE MESSAGE: Pass를 기대합니다. 이 assertion의 방향성에 주목할 필요가 있습니다. 배포된 테스트 커버리지는 새로 추가된 check가 정당한 same-origin import까지 거부하지 않는다는 점을 확인하는 것으로, 이는 새로 guard가 붙은 fast path가 안고 있는 regression 위험이기 때문입니다.

Origin-clean media와 tainting. WebKit은 media 리소스별로, 디코딩된 pixel을 읽는 행위가 embedding 문서가 볼 권한이 없는 데이터를 노출시키는지를 추적합니다. HTMLMediaElement::taintsOrigin(const SecurityOrigin&)이 바로 이 predicate로, element에 현재 로드된 리소스가 cross-origin이면서 전달된 origin에 대해 CORS로 승인되지 않은 경우 true를 반환합니다. 이는 cross-origin 이미지를 drawImage한 뒤 <canvas>가 "tainted" 상태로 전환될 때 사용되는 것과 동일한 predicate이며, 이후에는 getImageDatatoDataURL이 예외를 던지게 됩니다.

SecurityOrigin과 script context. SecurityOrigin은 문서의 보안 컨텍스트를 식별하는 scheme/host/port 튜플입니다. ScriptExecutionContext::securityOrigin()GPUDevice를 소유한 문서의 origin, 즉 import가 어떤 권한을 기준으로 검사되어야 하는지를 결정하는 origin을 반환합니다.

WebGPU의 external texture. GPUDevice.importExternalTexture({source: videoElement})GPUExternalTexture를 생성합니다. 이는 video frame을 WGSL의 texture_external 타입으로 shader에 노출시키는 WebGPU 객체로, textureSampleBaseClampToEdge로 샘플링됩니다. 일반적인 GPUTexture와 달리 그 내용물은 페이지가 업로드한 것이 아니라 media 파이프라인이 소유하고 있으며, external texture는 그에 대한 view에 해당합니다. requestVideoFrameCallback은 새로 디코딩된 frame이 표시 가능해질 때마다 발생하는 <video> API로, 페이지가 import를 수행하기에 자연스러운 시점입니다.

mediaIdentifier와 GPU-process backing. GPUExternalTextureDescriptor는 WebGPU의 GPU-process 측에 media sample source를 지정하는 opaque mediaIdentifier를 갖고 있습니다. WebGPU::Device::updateExternalTexture(backing, identifier)는 이미 생성된 external-texture backing을 다른 media source로 재연결하는 역할을 하며, 이를 통해 동일한 WebCore 측 객체가 새로운 할당 없이도 다른 frame을 가리키도록 다시 연결될 수 있습니다.

Cocoa import 캐시. PLATFORM(COCOA)에서는 GPUDevicem_videoElementToExternalTextureMapm_previouslyImportedExternalTexture에 import 결과를 기억해둡니다. 이를 통해 동일한 HTMLVideoElement를 반복 import할 때 frame마다 새로 할당하는 대신 하나의 GPUExternalTexture를 재사용하게 됩니다. externalTextureForDescriptor가 이 조회를 수행하며, hit이 발생하면 importExternalTexture는 조기에 반환합니다.

ExceptionOr<T>. 예외를 던질 수 있는 WebCore binding의 반환 타입입니다. ExceptionOr<Ref<GPUExternalTexture>>로 선언된 함수에서 Exception { ExceptionCode::SecurityError, ... }를 반환하면, JavaScript 쪽에는 값 대신 SecurityError DOMException으로 전달됩니다.

근본 원인은 답을 결정짓지 못하는 key에 대해 authorization 결정을 캐싱했다는 데 있습니다. 캐시는 element identity를 key로 사용합니다. m_videoElementToExternalTextureMap.set(videoElementRef, ...), .remove(videoElementRef)가 그렇습니다. 하지만 실제로 검사되어야 할 속성은 element의 현재 리소스가 가진 속성입니다. HTMLVideoElement는 가변적인 컨테이너로서, 생애주기 동안 src가 다시 가리켜질 수 있고, taintsOrigin()은 호출되는 그 순간 로드되어 있는 리소스를 기준으로 결과를 알려줍니다. 즉 삽입 시점에 한 번 수행된 validation이 조회 시점에도 여전히 유효하다는 보장이 없습니다.

  t0  element created, src = same-origin.mp4
      │
      ▼
  t1  importExternalTexture(el)   ── cache MISS ── validate ✓ ── insert ── return tex
      │
  t2  el.src = https://victim.example/private.mp4      ← provenance가 바뀜
      │                                                  cache key는 그대로
      ▼
  t3  importExternalTexture(el)   ── cache HIT  ── (validate 없음) ──┐
                                                                   │
              m_backing->updateExternalTexture(tex, mediaIdentifier@t2)
                                                                   │
                                       return SAME tex, now bound to victim frames

화살표를 따라가 보면, t1에서 t3까지 이어지는 것은 element pointer 하나뿐이며, 이는 바로 변하지 않은 그 값입니다. Import의 적법성을 결정짓는 요소들, 즉 로드된 리소스, 그 origin, CORS 승인 여부는 모두 t2에서 새로 선택되고, t3에서 updateExternalTexture에 의해 새로운 mediaIdentifier와 함께 다시 배선됩니다. Cache-hit 경로는 t1에서 반환했던 것과 동일한 Ref<GPUExternalTexture>를 반환하지만, 그 뒤에 있는 frame은 더 이상 그때 승인받은 frame이 아닙니다. Commit message는 이 누락을 정확히 짚고 있습니다. "Add missing cross-origin check when the cache is reached"라는 문구 그대로이며, 실제로 반영된 패치는 두 반환 경로 모두에 check를 설치했고, 함수 안 다른 어디에도 taintsOrigin 호출은 존재하지 않습니다.

이 문제가 단순한 화면 표시 수준의 유출이 아니라 완전한 읽기 primitive가 되는 이유는, WebGPU에 write-only texture라는 개념이 없기 때문입니다. Script가 일단 texture_external을 손에 넣으면, 일반적인 API만으로도 다음 체인이 완성됩니다.

  1. External texture를 bind group에 연결하고, fragment shader에서 textureSampleBaseClampToEdge로 샘플링합니다.
  2. 공격자가 소유한 GPUTexture, 즉 페이지 스스로 할당한 taint 추적이 없는 일반 color attachment에 렌더링합니다.
  3. copyTextureToBufferMAP_READ로 생성된 GPUBuffer에 복사합니다.
  4. mapAsync를 호출하고 결과 ArrayBuffer에서 pixel을 읽어냅니다.

이 네 단계 어디에도 origin check가 없습니다. 각 단계 모두 페이지가 이미 소유하고 있다고 전제되는 데이터를 다루기 때문입니다. External texture에 대한 보안 모델 전체는 사실상 0단계, 즉 import 시점에 handle을 애초에 넘겨주지 않는 것에 달려 있습니다. 유출은 WebContent process 안에서, 공격자 페이지의 JS heap으로 이루어집니다. Frame 자체는 media와 GPU 파이프라인이 생성하지만, 정상적인 WebGPU readback 경로를 통해 전달되므로 어떤 sandbox 경계도 넘지 않고 별도의 escape도 필요하지 않습니다. 이는 renderer 내부에서 일어나는 same-origin policy bypass이며, sandbox escape를 대체하는 것이 아니라 그것과 결합될 수 있는 성격의 취약점입니다.

결과적으로 나타나는 것은 전형적인 cross-origin media 유출입니다. 피해자의 브라우저가 ambient credential로 fetch할 수 있는 video라면, 즉 세션 쿠키 뒤에 있는 private stream이나 개인 media라면 무엇이든 픽셀 단위로 읽힐 수 있으며, 공격 페이지가 원하는 frame rate로 import를 반복하면 됩니다. 피해자 입장에서 이 읽기 동작은 수동적입니다. 공격자 페이지를 방문하는 것만으로 충분한데, src를 다시 지정하는 element와 결과를 샘플링하는 shader 모두 공격자가 제어하기 때문입니다.

패치는 authorization을 캐시 entry가 아니라 사용 시점의 속성으로 되돌림으로써 invariant를 복원합니다. checkVideoElementOriginTaint는 호출될 때마다 origin taint를 다시 계산합니다. 즉 람다가 실행될 때마다 scriptExecutionContext()->securityOrigin()을 다시 조회하고 videoElement.taintsOrigin()을 다시 호출합니다. 이에 따라 위 그림의 t3 시점 cache hit도 t2에서 로드된 리소스를 기준으로 재평가되며, updateExternalTexture가 backing을 재연결하기 전에 SecurityError를 반환합니다. 캐시는 여전히 객체를 기억하지만, 더 이상 권한까지 기억하지는 않습니다.

DOM element를 key로 삼은 capability 캐시가, 삽입 시점에 로드되어 있던 리소스로부터 authorization을 물려받았습니다. 반면 element는 다음 hit이 발생하기 전에 얼마든지 다른 origin의 리소스를 로드할 수 있는 상태였습니다.

흥미로운 지점은 check가 빠졌다는 사실 자체가 아니라, 어디서 빠졌는가입니다. WebKit에서 origin-clean enforcement는 역사적으로 리소스가 처음 소비되는 순간에 붙어 있었습니다. Canvas는 drawImage에서, WebGL은 texImage2D에서 거부하며, 이는 소비하는 호출과 보안 결정이 동일한 이벤트이기 때문에 성립하는 방식입니다. External-texture 캐싱은 처음으로 이 결합을 깨뜨립니다. 결정은 element가 m_videoElementToExternalTextureMap에 들어가는 시점에 내려지는 반면, texture가 실제로 노출하는 리소스는 이후 updateExternalTexture(..., *optionalMediaIdentifier)에 의해 다시 선택되기 때문입니다. DOM node를 key로 하는 파생 capability를 기억해두는 성능 캐시라면 어떤 것이든 이 위험을 그대로 물려받게 됩니다. DOM node는 서로 다른 provenance를 가진 리소스를 담을 수 있는 가변 컨테이너인 반면, 캐시된 capability는 재검증되지 않기 때문입니다. Identity는 provenance가 아닙니다.