Content served as video/mp2t can be loaded as html
CVE: CVE-2026-28871 · Safari 26.4 · Released March 24, 2026 Impact: Visiting a maliciously crafted website may lead to a cross-site scripting attack Apple's description: A logic issue was addressed with improved checks. Credit: @hamayanhamayan
High. 같은 navigation의 연속된 두 단계가 "이것은 media인가"를 서로 다른 조건으로 물었습니다. 넓은 쪽은 표시 허용 판정에서 이겼고, 좁은 쪽은 handler 선택에서 졌습니다. 그 결과 응답이 HTML parser를 기본 분기로 두는 handler chain으로 떨어졌습니다. Memory primitive는 없고, 바이트를 서빙한 origin에서 script가 실행되는 형태입니다.
프레임 렌더링으로 끝나는 모든 navigation은 하나처럼 보이는 두 개의 결정을 거칩니다. 먼저 loader가 응답을 다운로드 대상이 아니라 표시 가능한 것으로 판단합니다. 그 다음 factory가 바이트를 처리할 Document 서브클래스를 고릅니다. 문제는 두 결정이 서로 다른 코드에서, 서로 다른 정보를 보고 내려진다는 점입니다. loader는 엔진이 처리 가능하다고 선언한 MIME type의 평면적인 registry를 참조하고, DOMImplementation::createDocument는 content-type 조건들을 순서대로 검사하는 if-chain을 실행합니다. 이 둘을 묶어주는 불변 조건은 표시가 허용된 type 집합과 특정 handler가 실제로 처리하겠다고 나서는 type 집합이 정확히 일치해야 한다는 것입니다. chain의 마지막 무조건 분기가 HTMLDocument를 생성하기 때문입니다.
관전 포인트: 사용자가 업로드한 바이트를 transport-stream video type으로 되돌려주는 사이트에서, 그 바이트가 markup으로 파싱되었습니다. 결과적으로 inline script가 해당 사이트의 cookie, storage, DOM을 그대로 쥔 채 실행되었습니다.
Source/WebCore/dom/DOMImplementation.cpp
Source/WebCore/platform/MIMETypeRegistry.cpp
Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
LayoutTests/media/iframe-load-html-as-m2ts.html
Patch Details
production 코드 변경 세 개와 regression test 하나로 구성되어 있습니다. 세 변경은 각각 다른 방향에서 같은 틈을 막습니다.
DOMImplementation.cpp에서는 createDocument의 #if ENABLE(VIDEO) 분기가 더 이상 MediaEngineSupportParameters를 만들지 않고, MediaPlayer::supportsType(parameters) 호출도 제거되었습니다. 대신 MIMETypeRegistry::isSupportedMediaMIMEType(contentType)을 조회합니다. registry 수준의 조건이자, loader가 이 응답을 표시할지 판단할 때 참조했던 바로 그 테이블입니다. 이로써 navigation의 두 단계가 하나의 기준을 공유하게 되었습니다.
MIMETypeRegistry.cpp에서는 isSupportedMediaMIMEType에 집합 조회보다 앞서는 top-level type 검사가 추가되었습니다. 입력 type을 소문자로 변환한 뒤 video/, audio/, application/ 중 하나로 시작하지 않으면 false를 반환합니다. 어떤 엔진이 text/*나 image/* top-level type으로 무엇을 등록하든, 그 응답이 MediaDocument::create로 흘러갈 수는 없게 되었습니다.
MediaPlayerPrivateMediaSourceAVFObjC.mm에서는 MediaSource Extensions 엔진의 getSupportedTypes가 AVStreamDataParserMIMETypeCache::singleton().supportedTypes()를 더 이상 내보내지 않고 types.clear()를 호출합니다. MSE 전용 container type은 이제 통합된 supportedMediaMIMETypes() 집합에 나타나지 않으며, 따라서 canShowMIMEType도 통과하지 못합니다. MSE feature detection에는 영향이 없습니다. 해당 경로는 MediaPlayerPrivateMediaSourceAVFObjC::supportsTypeAndCodecs를 거치는데, 이 함수는 parameters.platformType == PlatformMediaDecodingType::MediaSource가 아니면 IsNotSupported로 즉시 반환합니다.
layout test는 iframe을 data:video/mp2t,<h1>Error</h1><script>parent.postMessage("fail","*")</script>로 이동시키고, message가 도착하면 실패로 처리합니다. data: URL 문서는 opaque origin을 받기 때문에, script 실행 여부를 밖에서 관찰할 수단은 postMessage뿐입니다. 그래서 test의 positive assertion은 frame의 문서가 readyState == 'complete'에 도달하는지 하나로 단순합니다. 응답이 더 이상 표시 대상이 아니므로 iframe이 초기 about:blank 상당의 문서에 그대로 머문다는 뜻입니다.
Background
Content-type 표시 허용 판정. 응답이 도착하면 WebKit은 이를 프레임에 렌더링할지, 아니면 다른 쪽(다운로드, 외부 handler)으로 넘길지 결정합니다. 이 결정은 MIMETypeRegistry::canShowMIMEType(type)을 거치며, 엔진이 처리 가능하다고 선언한 type들을 모아둔 WebCore 중앙 테이블에 대한 멤버십 검사입니다.
그 테이블의 media 부분. supportedMediaMIMETypes()는 고정된 목록이 아닙니다. 등록된 모든 MediaPlayerFactory에 getSupportedTypes() 집합을 물어 합집합으로 만든 결과입니다. MIMETypeRegistry::isSupportedMediaMIMEType은 이 합집합에 대한 멤버십 검사입니다. 핵심은 이 합집합의 키가 type 하나뿐이라는 점입니다.
더 세밀한 질의. MediaPlayer::supportsType(MediaEngineSupportParameters)는 훨씬 풍부한 질문을 던집니다. 파라미터에는 codec까지 포함된 content type, URL, 그리고 URL 단독 로드와 MediaSource 로드를 구분하는 platformType이 담깁니다. 각 엔진은 IsNotSupported, MayBeSupported, IsSupported 중 하나로 답합니다. 즉 엔진은 type만 담는 registry가 애초에 실어나를 수 없는 정보를 기준으로 판단할 수 있습니다.
MSE와 Apple 플랫폼 엔진. Media Source Extensions는 페이지가 MediaSource를 만들고 container 바이트 구간을 SourceBuffer에 append한 뒤 그 결과를 media element에 붙이는 JS API입니다. MediaSource.isTypeSupported()는 어떤 container/codec 조합을 append할 수 있는지 알려줍니다. Apple 플랫폼에서는 전용 엔진인 MediaPlayerPrivateMediaSourceAVFObjC가 이를 구현하며, URL을 직접 재생하는 엔진들과 나란히 등록됩니다. 파싱은 AVFoundation의 AVStreamDataParser가 담당하고, AVStreamDataParserMIMETypeCache는 그 parser가 이해하는 container type을 나열하는 싱글턴입니다.
document factory. DOMImplementation::createDocument(contentType, frame, settings, url, ...)는 load policy가 이미 응답을 받아들인 뒤에 실행됩니다. content type을 text/html, application/xhtml+xml, text/plain, PDF, image, media, model, FTP directory listing, plugin 순서의 조건들과 차례로 대조하고, 일치하는 Document 서브클래스를 반환합니다.
handler 선택이 script에 갖는 의미. MediaDocument는 URL을 가리키는 <video> element 하나만 담아 합성한 문서로, 응답 바이트를 markup으로 파싱하는 일은 없습니다. 반면 HTMLDocument는 바이트를 HTML parser에 넘기고, parser는 inline script와 외부 script를 해당 문서의 origin에서 실행합니다.
video/mp2t는 MPEG-2 Transport Stream container이며, HLS 스트림 내부의 세그먼트 파일 형식으로 가장 널리 알려져 있습니다.
Analysis
gatekeeper와 dispatcher의 조건이 어긋난 사례입니다. 표시 허용 검사는 모든 media backend를 OR로 묶고, dispatch 검사는 더 좁은 질문을 다시 던집니다. 그 사이 틈에 놓인 type들이 chain의 기본 분기로 떨어집니다.
Admission (loader) Dispatch (createDocument)
canShowMIMEType("video/mp2t") MediaPlayer::supportsType({type, url})
└─► supportedMediaMIMETypes() └─► MSE engine: platformType
= ∪ getSupportedTypes() != MediaSource → IsNotSupported
├─ AVFoundation URL engine └─► no other engine claims it
└─ MSE engine ──────────┐ │
AVStreamDataParser │ ▼
cache: video/mp2t ─┘ if-chain matches nothing
│ │
ADMITTED ────────────────► HTMLDocument::create()
다이어그램의 왼쪽 열부터 따라가 보겠습니다. MSE 엔진의 static getSupportedTypes는 AVStreamDataParserMIMETypeCache 전체를 한 문장으로 출력 집합에 대입했습니다. 여기 담긴 것은 AVFoundation byte-stream parser가 MediaSource를 통해 공급받았을 때 파싱할 수 있는 container type들입니다. 어떤 엔진도 URL만으로 재생하지 않는 type이라는 뜻입니다. 다만 getSupportedTypes에는 그 조건을 표현할 자리가 없습니다. 이 함수가 채우는 registry의 키가 type 하나뿐이기 때문입니다. 그래서 video/mp2t가 supportedMediaMIMETypes()에 들어갔고, canShowMIMEType이 true를 반환했으며, loader는 응답을 프레임에 표시하기로 확정했습니다.
이번에는 오른쪽 열입니다. createDocument는 갓 기본 생성된 MediaEngineSupportParameters를 들고 MediaPlayer::supportsType(parameters)를 호출했습니다. content type과 URL은 채워졌지만 platformType은 기본값 그대로였고, 그 값은 MediaSource가 아닙니다. MediaPlayerPrivateMediaSourceAVFObjC::supportsTypeAndCodecs는 정확히 이 경우에 IsNotSupported로 즉시 반환합니다. MSE 엔진은 URL 로드를 담당할 수 없기 때문입니다. MSE가 아닌 엔진들도 이 type을 맡겠다고 나서지 않았습니다. 결국 media 분기는 loader가 바로 그 엔진의 이름으로 이미 허용해 둔 type을 거절한 셈입니다.
아무도 맡지 않은 응답이 어디로 가는지가 버그의 나머지 절반입니다. if-chain은 text/html, XHTML, text/plain, PDF, image, media, model, FTP-dir, plugin을 차례로 훑고 어느 것에도 걸리지 않습니다. 그리고 chain의 끝은 열린 방향으로 실패합니다. 일치하지 않은 경우에 HTMLDocument를 생성하기 때문입니다. 서버가 video container로 표시했고, 호스팅 사이트는 무해한 사용자 업로드 데이터로 취급했을 바이트가 HTML parser로 넘어갑니다. 그리고 inline <script>가 그 바이트를 서빙한 URL의 security origin에서 실행됩니다. commit message는 결과를 그대로 적어두었습니다 — "which leads to the DOMImplementation creating a HTMLDocument for a non-playable media type". regression test도 같은 사실을 인코딩합니다. parent.postMessage("fail", "*")는 mp2t 응답이 markup으로 파싱된 경우에만 실행될 수 있습니다.
여기서 깨지는 security model 가정은 파일 업로드를 받는 모든 호스트와 user-content origin이 의존하는 바로 그 전제입니다. media Content-Type으로 바이트를 서빙하면 해당 origin에서의 script 실행 관점에서 그 바이트가 무해해진다는 전제입니다. 공격자가 제어하는 바이트를 MSE 전용 media type으로 서빙하던 origin에서는 그 바이트가 markup으로 파싱되었습니다. 결과적으로 same-origin DOM 접근, document.cookie, 인증 정보가 실린 fetch, storage 유출이 가능해집니다. 호스팅 사이트를 겨냥한 stored-XSS 경로에 해당합니다. 무엇이 아닌지도 분명히 해둘 필요가 있습니다. policy 단계에서는 선언된 type이 존중되었고, 어긋난 것은 handler 선택뿐입니다. sniffing이 일어나지 않았으므로 X-Content-Type-Options: nosniff와 철저한 Content-Type 표기로도 막을 수 없었습니다. 영향 범위는 WebContent process 안에 머뭅니다. sandbox나 process 경계를 넘지 않으며, memory-safety primitive도 관여하지 않습니다.
fix는 양쪽 끝에서 틈을 봉합합니다. createDocument는 이제 표시 허용 단계가 사용한 것과 같은 registry 조건을 호출하므로, media로 허용된 것은 MediaDocument가 되고 markup이 되는 일은 없습니다. MSE 엔진은 MSE 전용 type을 registry에 더 이상 기여하지 않으므로, video/mp2t는 애초에 표시 대상으로 허용되지 않습니다. iframe이 초기 문서에 그대로 머문다는 test의 기대와 맞아떨어집니다. video/·audio/·application/ 접두사 검사는 이중 안전장치로, 앞으로 어떤 엔진이 text/*나 image/* top-level type으로 항목을 등록하더라도 MediaDocument::create까지 도달하지 못하게 막습니다. 교환 조건은 의도된 것이고 diff에도 드러나 있습니다. WebKit은 이제 단독 .ts URL에 media viewer를 띄우는 대신 표시를 거절하며, MSE feature detection은 platformType == MediaSource 조건 아래 supportsTypeAndCodecs를 거치도록 남겨두었습니다.
MSE 전용 container type이 loader의 표시 허용 집합 안에는 있으면서 document factory의 dispatch 집합 밖에 놓였고, factory의 미일치 분기는 HTML parser였습니다.
Insight
이 CVE를 넘어 기억해둘 구조적 사실은, media registry가 전제 조건이 서로 다른 backend들의 합집합이라는 점입니다. MediaPlayerFactory::getSupportedTypes의 키는 type 하나뿐인 반면, 짝을 이루는 supportsTypeAndCodecs는 platformType, EME 상태, playback target까지 조건으로 걸 수 있습니다. 그렇다면 MSE에서만 동작하는 엔진은 단독으로는 아무것도 재생할 수 없는 type들로 type 전용 registry를 부풀릴 수밖에 없습니다. 이 비대칭은 특정 backend의 실수에서 나온 것이 아니라 인터페이스 자체에 내장되어 있습니다. 합집합을 기준으로 허용한 뒤 더 엄격한 질문을 다시 던지는 소비자라면, 구조상 같은 틈을 재생산하게 됩니다. 다만 그 결과가 빈 프레임이 아니라 script 실행이었다는 것은 별개의 설계 결정입니다. DOMImplementation::createDocument의 마지막이 일치하지 않은 입력에 대해 scripting이 가능한 handler를 반환하는 이상, 그 chain 어디에서 발생한 조건 불일치든 곧바로 XSS로 전환됩니다.
Audit directions
-
Gatekeeper와 dispatcher의 판정 조건 불일치. 지켜져야 할 invariant는 이것입니다: 응답을 표시하도록 허용하는 판정 조건과, 그 응답의 handler를 고르는 판정 조건은 같아야 한다. Narrow —
DOMImplementation::createDocument의 모든 분기를 따라가 봅니다 (isSupportedImageMIMEType,isPDFMIMEType,isUSDMIMEType,supportsWebVisibleMimeType, 그리고isTextMIMEType/XML로 이어지는 꼬리 부분). 각 분기를MIMETypeRegistry::canShowMIMEType과, 해당 응답을 실제로 허용할 때 쓰이는 loader policy check와 하나씩 대조합니다. Wider — 같은 형태는 "먼저 허용하고 그다음 dispatch하는" 2단계 구조라면 WebKit 어디에서나 나타납니다.PolicyChecker의 download 대 display 정책, plugin 가시성 판정(dispatch 시점에는PluginData::OnlyApplicationPlugins를 쓰면서 허용 시점에는 더 넓은 집합을 쓰는 경우), archive와 QuickLook 처리가 여기에 해당합니다. Widest — 더 일반화하면 "allowlist와 handler table이 서로 다른 답을 내놓는" 부류이며, WebKit 밖으로도 그대로 옮겨집니다. catch-all로 404를 흘려보내는 HTTP 프레임워크 router, OS의 파일 타입 handler 레지스트리, Android intent filter가 같은 계열입니다. narrow 단계의 match tell: 허용 판정은 N개의 backend를 합집합으로 묶어 답하는데, 바로 옆의 dispatch 호출은 context, platform type, codec 같은 파라미터를 추가로 전달합니다. 허용 판정 쪽은 그 파라미터를 한 번도 본 적이 없습니다. wider 단계에서는, 진행할지 여부를 정하는 판단과 누가 진행할지를 정하는 판단이 서로 다른 이름의 helper에서 나오는 함수라면 모두 대상이 됩니다. widest 단계에서는 이렇게 자문해 보면 됩니다. "문을 통과시켜 주는 집합이, 실제로 존재하는 문의 집합보다 클 수 있는가?" -
dispatch chain 끝에 놓인 fail-open 기본 분기. invariant는 이것입니다: 어디에도 해당하지 않는 입력은 권한이 가장 낮은 handler로 떨어져야 하며, 가장 기능이 풍부한 parser로 가서는 안 된다. Narrow — plugin lookup 이후에 오는
DOMImplementation::createDocument의 꼬리 부분을 살펴봅니다. 특정 판정 조건에 한 번도 걸리지 않은 채HTMLDocument::create까지 도달하는 content type이 무엇인지 열거해 봅니다. 이때supportedMediaMIMETypes(),supportedImageMIMETypes(), plugin MIME 집합의 모든 항목을 chain에 직접 흘려보내면 그 목록을 실측으로 만들 수 있습니다. Wider — 마지막return이 가장 허용적인 선택지를 생성하는 다른 WebCore dispatcher에도 같은 방식으로 접근해 볼 수 있습니다. content sniffer,ParserContentPolicy기본값, attachment 및Content-Dispositionfallback이 후보입니다. Widest — 어떤 코드베이스든 parser를 고르는 코드라면 재사용 가능한 질문은 하나로 압축됩니다. "아무것에도 매칭되지 않은 입력은 어디로 가며, 그 분기는 위쪽 분기들보다 안전한가 아니면 더 강력한가?" Match tell:equalLettersIgnoringASCIICase나 레지스트리 판정이 길게 이어지는 if-chain인데, 마지막의 조건 없는return이 script를 실행하는 객체를 생성하는 형태입니다. -
맥락에 따라 지원 여부가 달라지는 backend가 채우는 capability cache. invariant는 이것입니다: type만으로 키가 구성된 레지스트리를, type 이상의 정보가 필요한 맥락에서 조회해서는 안 된다. Narrow — 다른
MediaPlayerFactory::getSupportedTypesoverride들(GStreamer 및 WebM MSE factory,MediaPlayerPrivateAVFoundationObjC, remote/GPU-process media player proxy)을 점검합니다. 각각에 대해 형제 격인supportsTypeAndCodecs가 type과 codec을 넘어선 조건에서 early return하는지 확인합니다.parameters.platformType,playbackTargetType, EME/CDM 상태 등이 그런 조건입니다. 그러면서도getSupportedTypes는 여전히 backend cache 전체를 공개하고 있는지 함께 봅니다. Wider — 같은 비대칭은 다른 singleton MIME cache 주변에도 존재합니다.AVAssetMIMETypeCache,AVStreamDataParserMIMETypeCache, image decoder type cache, 그리고ContentType을 버리고 맨 type 문자열만 넘기는 지점 전반이 그렇습니다. Widest — 같은 질문에 서로 다른 granularity로 답하는 feature detection API와 capability 레지스트리는 결국 어긋나게 됩니다.isTypeSupported(type, opts)와listSupportedTypes()를 동시에 노출하는 plugin/codec 레지스트리라면 점검할 가치가 있습니다. Match tell:getSupportedTypes가 backend cache 전체를 한 문장으로 대입하고, 같은 클래스 안의supportsType*은 그 cache가 본 적 없는 필드를 guard clause로 검사하는 형태입니다. -
guard는 정규화한 값으로, lookup은 원본으로.
MIMETypeRegistry::isSupportedMediaMIMEType에 새로 추가된 guard는mimeType.convertToASCIILowercase()를 세 개의 prefix와 비교합니다. 다만 뒤따르는supportedMediaMIMETypes().contains(mimeType)는 정규화하지 않은 원본 문자열을 그대로 사용합니다. 두 단계가 일치하려면 해당HashSet의 hash와 동등성 비교가 대소문자를 접어서 처리해야 합니다. 이 비대칭이 무해한지는 확인해 볼 필요가 있습니다. 이런 형태는 과도하게 거부하거나(집합에서는 매칭되었을 대소문자 혼합 type), 순서가 반대인 배치에서는 거부해야 할 입력을 통과시키기 때문입니다. Narrow —MIMETypeRegistry.h/.cpp에서supportedMediaMIMETypes()의 comparator를 확인하고,ASCIICaseInsensitiveHash를 명시적으로 사용하는 인접한additionalSupportedImageMIMETypes()와 비교합니다. Wider —MIMETypeRegistry.cpp와HTTPParsers.cpp를 검색해, 정규화한 지역 변수를 만들어 두고는 원본을 그대로 넘기는 다른 판정 코드가 있는지 찾습니다. Widest — 일반화하면 "정규화는 한 번, 비교는 두 번" 부류에 해당합니다. check와 그 이후의 사용처가 문자열의 어떤 형태를 기준으로 삼는지에 대해 서로 다른 답을 갖는 곳이라면, path traversal이나 header smuggling 버그도 같은 형태에서 출발합니다. Match tell: 정규화된 복사본을 담은 지역 변수가 함수 안의 두 check 중 정확히 하나에서만 읽히는 형태입니다.