WebGPU importExternalTexture origin-clean bypass in Safari
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
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
LayoutTests/fast/webgpu/regression/repro_315368b.html
Patch Details
기능적 변경은 한 파일의 함수 하나로 국한되어 있습니다. #if ENABLE(VIDEO)로 감싸진 로컬 람다 checkVideoElementOriginTaint는 ScriptExecutionContext로부터 문서의 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 위험이기 때문입니다.
Background
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이며, 이후에는 getImageData와 toDataURL이 예외를 던지게 됩니다.
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)에서는 GPUDevice가 m_videoElementToExternalTextureMap과 m_previouslyImportedExternalTexture에 import 결과를 기억해둡니다. 이를 통해 동일한 HTMLVideoElement를 반복 import할 때 frame마다 새로 할당하는 대신 하나의 GPUExternalTexture를 재사용하게 됩니다. externalTextureForDescriptor가 이 조회를 수행하며, hit이 발생하면 importExternalTexture는 조기에 반환합니다.
ExceptionOr<T>. 예외를 던질 수 있는 WebCore binding의 반환 타입입니다. ExceptionOr<Ref<GPUExternalTexture>>로 선언된 함수에서 Exception { ExceptionCode::SecurityError, ... }를 반환하면, JavaScript 쪽에는 값 대신 SecurityError DOMException으로 전달됩니다.
Analysis
근본 원인은 답을 결정짓지 못하는 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만으로도 다음 체인이 완성됩니다.
- External texture를 bind group에 연결하고, fragment shader에서
textureSampleBaseClampToEdge로 샘플링합니다. - 공격자가 소유한
GPUTexture, 즉 페이지 스스로 할당한 taint 추적이 없는 일반 color attachment에 렌더링합니다. copyTextureToBuffer로MAP_READ로 생성된GPUBuffer에 복사합니다.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의 리소스를 로드할 수 있는 상태였습니다.
Insight
흥미로운 지점은 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가 아닙니다.
Audit directions
-
Slow path에는 있는 보안 검증이, cache hit에서는 건너뛰어지는 패턴. Capability를 반환하는 모든 경로는 캐시를 채운 경로로부터 authorization을 물려받는 대신 스스로 다시 도출해야 한다는 것이 invariant입니다. Narrow:
Source/WebCore/Modules/WebGPU안에서 validation보다 먼저 캐시나m_previously*fast path를 참조하는 나머지 WebGPU 진입점을 점검하십시오.GPUDevice::externalTextureForDescriptor부터 시작해서, lookup hit에서 조기 반환하는GPUDevice/GPUQueue의 다른 멤버들을 살펴보십시오. 단서는 validation 호출이 담긴 블록보다 텍스트상 위에 있는return문입니다. Wider: 같은 유형의 문제는 리소스가 가변적인 DOM node를 key로 삼는 WebCore의 다른 memoization에서도 나타날 수 있습니다. Canvas의originClean전파, element별 decoder 상태를 캐싱하는 WebGL texture-upload 경로, ImageBitmap 생성 캐시 등이 해당됩니다. 코드 검색 결과에서는 elementRef/WeakPtr를 key로 하고 GPU 또는 decode 측 리소스를 value로 갖는HashMap이나 pair가 단서입니다. Widest: 재사용 가능한 invariant는, 캐시된 authorization 결정이 그 대상이 불변인 동안에만 유효하다는 점입니다. 이를 capability check를 캐싱하는 모든 코드베이스로 확장해 볼 수 있습니다. Authorizing header에 대한Vary없이 key가 정해지는 HTTP 캐시, permission 캐시, DNS 캐시, Chromium의VideoFrameimport 경로 등이 후보입니다. 각 경우에 던져야 할 감사 질문은 다음과 같습니다. 캐시 삽입과 캐시 hit 사이에 대상의 어떤 부분이 변할 수 있으며, hit 시점에 그것이 재검증되는가? -
현재 시점 값을 안정적인 속성처럼 취급하는 패턴.
taintsOrigin()은 지금 이 순간 로드된 리소스에 대한 판단을 내리지만, 이를 사용하는 코드는 이 값을 마치 element의 고정된 속성인 것처럼 취급하는 경우가 흔합니다. Narrow: WebCore 전체에서taintsOrigin(을 검색한 뒤, 각 호출부에서 그 결과값으로부터 파생된 texture, ImageBitmap, decoded surface, cached handle 등이 호출 시점을 넘어 계속 살아남아, 이후 다른 리소스를 가리키도록 재지정될 수 있는지 확인해야 합니다. 우선Modules/WebGPU와platform/graphics하위의 media를 다루는 그래픽 바인딩부터 살펴보는 것이 좋습니다. 여기서는taintsOrigin호출 결과가 게이트 역할을 하고, 그 결과로 통과된 객체가 이후 member container에 저장되는 패턴이 단서가 됩니다. Wider: 이 형태는 mutable한 DOM 대상에 대해 한 번 계산된 policy flag 전반에 적용됩니다. canvas의setOriginTainted가src변경과 상호작용하는 경우, MediaStream track이 교체되는 경우,SourceBufferappend로 스트림 중간에 실제 리소스가 바뀌는 경우 등이 해당합니다. 단서는 attach 시점에 한 번 평가된 boolean이나 gate가, 리소스 변경 시 재평가되는 hook 없이 그대로 유지되는 패턴입니다. Widest: 원칙 자체는 다음과 같습니다 — mutable한 대상에 대한 권한 판단은 사용 시점마다 다시 평가되거나, 아니면 판단 시점에 대상을 고정해야 합니다. 이 원칙은 재오픈 이후 file descriptor와 path를 재검증하는 문제, OS sandbox의 capability handle, 서버 코드베이스의 session 범위 authorization cache에도 동일하게 적용됩니다. -
importExternalTexture뿐 아니라 WebGPU로 유입되는 모든 external-image ingress 경로에 걸친 origin-clean 검증의 일관성. Narrow: WebGPU의 또 다른 external-source 진입점인GPUQueue.copyExternalImageToTexture와,Source/WebCore/Modules/WebGPU에 있는GPUCanvasContext의 configure/present 경로를 추적해서, 각 경로가 context의SecurityOrigin을 기준으로taintsOrigin검사를 통과하지 못하는 source만 받아들이는지 확인해야 합니다. 단서는 video element, canvas,ImageBitmap,OffscreenCanvas등을 함께 처리하는 source-union handler 중SecurityError분기 없이 backing upload에 도달하는 경우가 있는지입니다. Wider: 이 검증 매트릭스를 이미 같은 문제를 해결해 둔 기존 그래픽 API들 — WebGL의 video-upload 거부 로직, 2D canvas의 tainting 처리 — 과 비교해서, 새 API는 허용하지만 기존 API의 검사 테이블에는 없는 source type이 있는지 찾아봐야 합니다. 새 API가 기존 policy를 재사용할 때, 허용되는 source type의 합집합에서 커버리지 공백이 생기는 것은 반복적으로 나타나는 패턴입니다. Widest: 일반화하면, 새로운 API 표면이 기존 policy가 작성될 당시 상정했던 source type의 상위집합을 받아들이게 되는 상황입니다. API v1을 위해 작성된 validation predicate가 더 넓은 input domain을 가진 API v2에서 그대로 재사용되는 코드베이스라면 어디든 점검할 필요가 있습니다. 단서는 validation helper의 파라미터 타입이 실제 호출부가 받아들이는 union보다 좁은 경우입니다.