Fix CSP bypass in sandboxed srcdoc iframes
CVE: CVE-2026-20665 · Safari 26.4 · Released March 24, 2026 Impact: Processing maliciously crafted web content may prevent Content Security Policy from being enforced Apple's description: This issue was addressed through improved state management. Credit: webb
Medium — memory corruption은 없고, 정책이 조용히 적용되지 않는 문제에 그칩니다. 다만 주입된 script가 만들어낸 frame 안에서는 CSP가 XSS 완화 수단으로서 사라집니다. 게다가 유발 조건은 평범한 JavaScript 네 줄이며, heap grooming도 race 승리도 필요하지 않습니다.
응답 헤더와 함께 전달되는 문서는 자기 자신의 보안 정책을 선언할 수 있습니다. 반면 응답 자체가 존재하지 않는 문서, 즉 about:blank, data:, blob:, about:srcdoc 는 정책을 어딘가에서 빌려와야 합니다. HTML 명세는 그 대상을 명확히 지정합니다. navigation을 요청한 initiator이거나, srcdoc 의 경우에는 부모 frame입니다. WebKit은 이렇게 상속되는 정책들을 policy container 하나로 묶어 관리하는데, 여기에는 CSP, referrer policy, COOP/COEP, sandbox flag가 들어갑니다. 이 묶음은 commit 시점에 새 문서로 전달됩니다. 그리고 policy container의 출처는 한 곳이 더 있습니다. 바로 session history entry입니다. 뒤로/앞으로 이동할 때 문서가 처음 로드되던 당시의 상태를 그대로 복원하기 위해 저장해 둔 값입니다. 여기서 지켜져야 할 invariant는 하나입니다. history에 저장된 사본은 실제로 history가 재생되는 상황에서만 권위를 가져야 합니다.
관전 포인트: CSP로 보호되는 페이지에서 injection 발판을 확보한 script는 iframe을 하나 만들고 srcdoc 을 할당하는 것만으로, 어떤 정책도 적용되지 않는 문서를 얻게 됩니다. 페이지의 script-src 가 금지하는 원격 script도 자유롭게 불러올 수 있는 상태입니다.
Source/WebCore/loader/DocumentWriter.cpp
LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
Patch Details
제품 코드의 변경은 한 줄입니다. DocumentWriter::begin() 안에서 frame의 현재 session-history entry로부터 policy container를 복사해 오던 분기에 조건 두 개가 추가되었습니다. triggeringAction && triggeringAction->type() == NavigationType::BackForward 입니다. 나머지는 그대로입니다. 함수 시그니처는 손대지 않았고(triggeringAction 은 이미 파라미터로 존재했지만 이 지점에서는 참조되지 않던 상태였습니다), 분기 본문도 동일하며, 바로 옆의 hasSubstituteData lambda도 변경되지 않았습니다. 결과적으로 history 분기는 아무 navigation이나 흘러 들어올 수 있는 기본 경로가 아니게 되었습니다. 이제는 traversal만 도달할 수 있는 경로입니다.
commit의 나머지는 전부 테스트 쪽 변경인데, 정보량으로 보면 이쪽이 오히려 더 많습니다.
새로 추가된 regression test iframe-srcdoc-import-bypass.html 는 A/B 비교 형태로 구성되어 있습니다. 페이지는 script-src 'self' 'unsafe-inline' 'unsafe-eval' 를 선언하고, sandbox="allow-scripts" 를 가진 iframe 두 개를 생성합니다. 두 iframe의 차이는 단 하나입니다. iframe1 에는 srcdoc 속성이 아예 없고, iframe2 에는 srcdoc="" 가 붙어 있습니다. DOMContentLoaded 시점에 둘 다 script에서 동일한 markup을 할당받습니다. cross-origin URL에 대한 inline module import() 를 수행하고 결과를 부모로 post하는 코드입니다. 대상은 module-pass.py 로, 여덟 줄짜리 Python이 빈 application/javascript 본문을 Access-Control-Allow-Origin: * 와 함께 제공합니다. 그래서 CORS는 로드를 막는 요인이 되지 않습니다. import가 실패한다면 그 이유는 CSP가 거부했기 때문입니다. expectations 파일은 iframe1Loaded is false 와 iframe2Loaded is false 를 단언하고, 그 위에 stylesheet 하나와 script 둘, 총 세 건의 console 거부 로그가 함께 기록되어 있습니다.
import된 WPT 파일의 rebaseline을 보면 이 버그의 실제 영향 범위가 드러납니다. inheritance-from-initiator.sub 의 subtest 네 개가 FAIL에서 PASS로 바뀌는데, 기록되어 있던 실패 문구가 유난히 읽기 쉽습니다.
expected "a" but got "p" # contentWindow.location, window.open()
expected "b" but got "p" # form submission via submit() and button click
각 테스트는 문서를 역할별로 태깅합니다. a 와 b 는 initiator이고, p 는 이전 문서입니다. 트리에 들어 있던 baseline은 결국 하나의 기록이었던 셈입니다. WebKit이 새 문서에게 initiator의 정책 대신 이전 문서의 정책을 건네주고 있었다는 사실, 그것도 srcdoc 과는 무관한 네 가지 navigation 형태에서 그랬다는 사실이 적혀 있었습니다.
Background
Policy container. WebCore는 문서가 상속하는 보안 상태를 객체 하나로 묶습니다. Content Security Policy, referrer policy, cross-origin opener/embedder policy, sandbox flag가 여기에 포함됩니다. 새로 생성된 문서에 이 묶음을 설치하는 것이 Document::inheritPolicyContainerFrom() 입니다.
Content Security Policy. 문서 단위로 적용되는 정책으로, 응답 헤더나 <meta http-equiv> 를 통해 전달됩니다. 문서가 사용할 수 있는 script, style, 연결 엔드포인트를 제한합니다. script-src 'self' 는 ES module import() 를 포함한 script 로드를 문서 자신의 origin으로 한정합니다.
Local schemes. about:blank, about:srcdoc, data:, blob: URL로 만들어지는 문서는 자기 자신의 응답이 없으므로 정책을 실을 헤더도 없습니다. HTML 명세는 이런 문서가 navigation initiator로부터, srcdoc 의 경우에는 부모 frame으로부터 policy container를 상속하도록 규정합니다.
srcdoc iframes. <iframe srcdoc="..."> 는 속성에 담긴 markup을 about:srcdoc 문서로 렌더링합니다. 이 속성은 파싱 시점에 존재할 수도 있고 이후 script에서 할당될 수도 있는데, 어느 쪽이든 할당이 일어나면 frame의 navigation이 시작됩니다.
Initial empty document. 새로 생성된 frame은 실제 로드가 일어나기 전에 about:blank 문서를 동기적으로 commit합니다. 그래서 frame element는 첫 navigation 이전에도 항상 살아 있는 문서를 가지며, frame별 session-history 상태도 함께 갖습니다. WebKit 고유의 동작이 아니라 명세가 요구하는 동작입니다.
HistoryItem and its policy container. HistoryItem 은 frame 하나에 대한 WebCore의 session-history entry입니다. URL과 scroll 상태 외에 policyContainer() 도 저장합니다. 덕분에 back/forward traversal 시 정책을 처음부터 다시 유도하는 대신, 문서가 최초 로드 당시 보유하던 정책을 그대로 복원할 수 있습니다. 원래의 응답은 이미 사라진 뒤이므로 다시 유도하는 방식은 올바른 결과를 낼 수 없습니다.
NavigationAction and NavigationType. 모든 navigation은 자신이 왜 발생했는지를 나타내는 기술자를 함께 갖습니다. link click, form submission, reload, BackForward traversal 등이 여기에 해당합니다. DocumentWriter::begin() 은 이를 optional 파라미터 triggeringAction 으로 전달받습니다.
iframe sandbox. frame 문서에 sandbox flag를 적용하는 속성입니다. sandbox="allow-scripts" 를 지정하면 script는 실행되지만 문서는 opaque origin을 갖습니다. 어떤 것과도 same-origin이 아닌 상태입니다.
WPT expectation baselines. WebKit은 import된 web-platform-tests에 대해 *-expected.txt 파일을 트리에 체크인합니다. 이 baseline에는 현재 실패하는 subtest가 assertion 문구까지 포함해 그대로 기록됩니다. 따라서 rebaseline diff는 패치가 어떤 명세 동작을 바꿨는지를 정확히 나타냅니다.
Analysis
이 버그의 본질은 우선순위 판단에 있습니다. 정책을 상속받을 수 있는 정당한 출처가 여러 개 존재하는 상황에서, 코드는 "이 동작에 필요한 출처가 무엇인가" 가 아니라 "마침 값이 채워져 있는 출처가 무엇인가" 를 기준으로 선택했습니다.
iframe with no src/srcdoc iframe srcdoc=""
───────────────────────── ────────────────────────
1. element inserted 1. element inserted
2. initial about:blank commits 2. parse-time srcdoc load
└─ HistoryItem created, └─ initiator = parent
policyContainer = empty policy = embedder CSP
3. script: iframe.srcdoc = ...
NavigationType != BackForward
└─ begin(): currentHistoryItem
non-null → HISTORY BRANCH ◄── wrong source
└─ document has no CSP
다이어그램 왼쪽 열이 버그 전부입니다. src 도 srcdoc 도 없이 만들어진 frame은 먼저 initial empty document를 commit합니다. 그 commit이 끝나면 frame은 현재 HistoryItem 을 갖게 되는데, 거기에 담긴 policy container가 기술하는 대상은 embedder가 아니라 빈 문서입니다. 이후 script가 srcdoc 을 할당하면 about:srcdoc 으로의 새 navigation이 시작되고, 이때의 NavigationType 은 BackForward 를 제외한 무언가입니다. 문제는 DocumentWriter::begin() 의 조건이 타입을 전혀 확인하지 않았다는 점입니다. non-null인 currentHistoryItem 과 non-null인 policyContainer() 만 확인한 뒤 history 분기를 택했고, 빈 문서의 snapshot을 그대로 inheritPolicyContainerFrom() 에 전달했습니다. initiator의 정책은 한 번도 참조되지 않았습니다. initiator 기반 경로가 실행될 수 있는 시점 이전에, 제어 흐름이 이미 함수의 상속 결정 구간을 벗어난 뒤이기 때문입니다.
오른쪽 열은 대조군입니다. iframe2 는 파싱 시점부터 srcdoc="" 를 갖고 있으므로 첫 실제 로드 자체가 srcdoc 로드입니다. 그래서 상속은 부모를 기준으로 해결되고, embedder의 CSP가 정상적으로 적용됩니다. regression test의 두 <iframe> 은 이 속성 하나만 다르고 나머지는 완전히 동일합니다. initial empty document가 만들어내는 상태 차이를 분리해 내는 깔끔한 방식입니다. history item의 snapshot에 embedder의 CSP가 없다는 사실은 테스트 결과로 확인됩니다. 패치 이전에는 iframe1 의 import가 성공했고, 패치 이후에는 두 frame 모두 loaded === false 를 보고하며 console에도 거부 로그가 남습니다.
srcdoc 문서 입장에서 보면 영향은 부분적이지 않고 전면적입니다. 더 약한 정책이 적용된 것이 아니라 아무 정책도 적용되지 않았습니다. script-src 와 default-src 가 모두 부재했고, 그래서 테스트의 cross-origin import() 가 module-pass.py 를 통과시킬 수 있었습니다. expectations 파일에 차단된 script들과 나란히 stylesheet 차단이 기록된 이유도 같습니다. 누락된 container가 style-src 까지 함께 관장하기 때문입니다. 여기까지 도달하는 데 특별한 기법은 필요하지 않습니다. CSP로 보호되는 페이지에서 HTML 또는 script injection 발판을 가진 공격자, 즉 CSP가 애초에 방어하려던 바로 그 상황을 가정합니다. 이 공격자는 iframe을 추가하고 srcdoc 을 할당하기만 하면, 정책이 없는 실행 컨텍스트에서 원격 script를 로드하고 네트워크로 데이터를 내보낼 수 있게 됩니다. 이 체인 어디에도 memory-safety primitive는 관여하지 않으며, 새로 얻어지지도 않습니다.
한계 두 가지는 정확히 짚어 둘 필요가 있습니다. 먼저 시연된 사례는 sandbox="allow-scripts" 를 사용합니다. 따라서 우회에 성공한 문서는 opaque origin을 가지며, 그 자체로 embedder에 대한 same-origin 접근을 얻지는 않습니다. sandbox flag 역시 policy container를 통해 전달되기는 하지만, 여기서 우회된 대상은 sandbox flag가 아닙니다. 다음으로, embedder와 same-origin인 srcdoc frame(sandbox 속성이 없거나 allow-same-origin 인 경우)에서도 동일하게 history 기반 상속에 도달할 수 있다고 가정해 봅니다. 그 경우 정책 없는 문서가 embedder의 origin으로 동작하게 될 가능성이 있습니다. 다만 이 확장은 예상되는 방향일 뿐입니다. commit 제목과 regression test 모두 시연된 우회 범위를 sandboxed frame으로 한정하고 있기 때문입니다. 그리고 이 모든 일은 WebContent process 내부에서 벌어집니다. 동일 프로세스 안의 정책 집행 실패이므로, renderer를 벗어나려면 별도의 memory-safety 버그가 여전히 필요합니다.
WPT rebaseline은 srcdoc 이 특별한 사례였는지에 대한 의문을 정리해 줍니다. 특별하지 않았습니다. window.open(), contentWindow.location 할당, 그리고 두 가지 form-submission 경로 모두 "p" 를, 즉 이전 문서를 보고했습니다. 명세가 요구한 값은 initiator를 가리키는 "a" 또는 "b" 였습니다. history snapshot이 채워진 frame에서 DocumentWriter::begin() 에 도달한 navigation이라면 종류를 가리지 않고 그 snapshot을 받아갔습니다. 추가된 조건 하나가 다섯 가지 동작을 한꺼번에 바로잡는 이유가 여기에 있습니다. 조건문에 빠져 있던 분류 단계를 되살린 것입니다. 이 navigation이 왜 일어나는지를 먼저 판단하고, 그다음에 정책의 출처를 결정하는 순서입니다.
back/forward 재생을 위해 저장해 둔 policy container가, 값이 존재하기만 하면 모든 navigation에 적용되었습니다. 그 결과 script로 할당된 srcdoc 은 embedder의 CSP 대신 initial empty document의 빈 CSP를 상속했습니다.
Insight
inheritance-from-initiator.sub-expected.txt 안에 놓여 있던 FAIL ... expected "a" but got "p" 줄들은, 사실 이 보안 버그를 평문 영어로 기술한 문장이었습니다. 그것도 baseline이 트리에 존재하던 기간 내내 그대로 체크인되어 있었습니다. 누군가는 이미 명세와의 비교를 끝냈고 그 차이를 기록해 두었던 셈입니다. 빠져 있던 것은 그 기록을 "알려진 실패 conformance test" 가 아니라 "우회" 로 읽어내는 사람이었습니다. 보안 관련 WPT 디렉터리에 체크인된 FAIL expectation은 아직 충분히 활용되지 않은 버그 오라클입니다. 엔진 개발자들이 명세 요구 사항과 실제 동작 사이의 간극을 직접 계산해 두었지만, 정작 아무도 검색하지 않는 파일에 넣어 둔 상태입니다.
Audit directions
-
복원 동작이 아닌 곳에까지 적용되는 snapshot 복원 상태. Invariant는 다음과 같습니다: replay를 위해 캡처한 snapshot은 replay 중에만 유효한 기준이며, 그 외의 모든 동작은 살아 있는 actor에서 상태를 다시 도출해야 합니다. Narrow —
Source/WebCore/loader와Source/WebCore/history에서currentHistoryItem()/HistoryItem::policyContainer()를 읽는 지점, 그리고 복원된 다른 필드를 읽는 지점을 검색합니다. 이때NavigationType::BackForward나 이에 준하는 traversal flag로 조건이 걸려 있지 않은 곳을 찾습니다. 출발점은HistoryController와DocumentLoader, 그리고DocumentWriter::begin()에 남아 있는 나머지 branch입니다. Wider — 캐시된 상태를 담는 carrier를 동작의 종류가 아니라 존재 여부로 참조하는 곳이라면 어디서든 같은 형태가 나타납니다. bfcache 복원 경로,SubstituteData/FrameLoadType::Reload처리, process swap 시의 상태 이전이 여기에 해당합니다. 검색 결과에서 드러나는 단서는if (savedThing && savedThing->field())형태인데, 그 동작이 왜 실행되는지에 대한 확인이 함께 붙어 있지 않습니다. Widest — session resumption이나 state hydration을 갖춘 시스템이라면 같은 원칙이 성립합니다. local-scheme commit에 대한 Chromium의PolicyContainerHost상속, 저장된 auth context를 새 request에 그대로 다시 적용하는 서버 측 session resumption, 새로 계산한 상태보다 serialize된 상태를 신뢰하는 SSR hydration이 그 예입니다. 어디에 옮겨놓아도 통하는 단서는, 복원 경로의 guard가 복원이 요청되었는지가 아니라 저장된 상태가 존재하는지를 검사하는 경우입니다. -
local-scheme document를 생성하면서 상속되는 security state를 결정하는 모든 code path. Invariant는 다음과 같습니다: 자신의 response를 갖지 않는 document는 initiator나 parent에서 policy를 받아와야 하며, 그 외의 어떤 곳에서도 받아서는 안 됩니다. Narrow — WebCore에서
about:blank,about:srcdoc,data:,blob:,javascript:를 생성하는 지점을 모두 열거합니다 (DocumentWriter::begin(),DocumentWriter::replaceDocumentWithResultOfExecutingJavascriptURL(),FrameLoader의 initial empty document 생성,DOMImplementation::createDocument호출 지점). 그리고 각각에 대해inheritPolicyContainerFrom/setContentSecurityPolicy/ origin 상속이 initiator로 귀결되는지 확인합니다. Wider — 이 부류는 CSP에만 국한되지 않고, 생성 시점에 상속되는 모든 security attribute를 포함합니다. sandbox flag, referrer policy, COOP/COEP, 그리고SecurityOriginPolicy가 여기에 들어갑니다. 이때SecurityOriginPolicy는 그것이 감싸고 있는SecurityOrigin과 구분해서 다뤄야 합니다. 눈여겨볼 형태는 document 생성 지점이 상속 상태를 둘 이상의 후보 source에서 읽으면서도, 우선순위를 밝힌 주석이 없는 경우입니다. Widest — 파생된 객체가 자신을 만든 쪽에서 policy를 물려받는 시스템이라면 모두 마찬가지입니다.fork/exec을 거치는 OS process capability 상속, child namespace가 물려받는 container security context, IAM role chaining이 그렇습니다. 질문은 매번 동일합니다 — policy가 실제로 생성을 요청한 주체에서 오는가, 아니면 그 시점에 우연히 current였던 context에서 오는가. -
체크인된 WPT baseline을 bypass oracle로 활용. Narrow —
LayoutTests/imported/w3c/web-platform-tests/content-security-policy/,.../fetch/metadata/,.../html/browsers/origin/,.../permissions-policy/아래에서FAIL이 포함된*-expected.txt파일을 검색한 뒤, assertion 텍스트를 직접 읽습니다. 상속 관련 테스트 이름에expected "<initiator>" but got "<something-else>"형태의 줄이 남아 있다면, 상속 source가 잘못되어 있다는 직접적인 진술에 해당합니다. Wider — 보안 테스트에 대해Failure로 표시된TestExpectations항목과,http/tests/security아래의-expected.txt파일에도 같은 triage를 그대로 적용할 수 있습니다. 단서는 기록된 불일치 가운데 expected 쪽 값이 더 엄격하거나 더 구체적인 경우입니다. Ceiling — 이 단계는 파일 배치가 WebKit baseline 구조에 묶여 있어서, grep 형태로는 다른 프로젝트에 그대로 옮겨가지 않습니다. 기법 자체는 알려진 실패 baseline을 커밋해 두는 프로젝트라면 어디에나 통하며, 점검 질문도 그대로 유지됩니다 — 기록된 실패 중에 단순한 표시상의 차이가 아니라 보안 invariant에 해당하는 것이 있는가. -
이후의 실제 navigation을 위한 상태 carrier로 쓰이는 initial empty document. Invariant는 다음과 같습니다: placeholder document를 위해 기록된 상태가, 뒤이은 실제 load의 committed state로 취급되어서는 안 됩니다. Narrow —
src도srcdoc도 없이 생성된 frame에 대해, 그 frame의 첫HistoryItem이 언제 만들어지고policyContainer()가 무엇을 담고 있는지 추적합니다. 그런 다음 parse 시점에srcdoc=""이 붙은 같은 frame과 비교합니다. 새로 추가된 regression test의 두<iframe>요소에서 관찰되는 차이가 정확히 이 divergence이며, 현재 history item을 기준으로 삼는 다른 frame별 상태에도 동일한 비교를 적용해 볼 만합니다. Wider — 동기적인about:blankcommit이 자기 수명보다 오래 남는 상태를 심어 두는 다른 지점들도 함께 살펴봅니다.canReferToParentFrameEncoding을 거치는 encoding 상속,DocumentWriter::begin()의shouldReuseDefaultView/takeDOMWindowFromwindow 재사용, initial document에 등록된 document lifecycle observer가 그 예입니다. 눈여겨볼 형태는 placeholder commit 중에 채워진 뒤 이후 실제 commit 시점에 읽히는 필드입니다. Widest — "placeholder 객체의 기록이 실제 객체까지 살아남는" 일반적인 부류에 해당합니다. lazy하게 초기화되는 singleton, 일부 필드만 덮어써지는 기본 생성 config 객체, handshake context를 재사용하는 connection pool에서 반복적으로 나타납니다. 기억해 둘 invariant는 이렇습니다 — placeholder 상태는 필드 하나씩 덮어쓰는 정도가 아니라, 폐기되었음이 증명 가능해야 합니다.