← All reports

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

Severity: Medium | Component: WebCore loader | b4390e8 | Bugzilla 304951

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

bool DocumentWriter::begin(const URL& urlReference, bool dispatch, Document* ownerDocument, std::optional<ScriptExecutionContextIdentifier> documentIdentifier, const NavigationAction* triggeringAction)
...
 
- if (currentHistoryItem && currentHistoryItem->policyContainer()) {
+ if (triggeringAction && triggeringAction->type() == NavigationType::BackForward && currentHistoryItem && currentHistoryItem->policyContainer()) {
const auto& policyContainerFromHistory = currentHistoryItem->policyContainer();
ASSERT(policyContainerFromHistory);
document->inheritPolicyContainerFrom(*policyContainerFromHistory);

LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html

+<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';">
...
+document.addEventListener("DOMContentLoaded", function() {
+ const srcdocContent = `
+ <script>
+ (async function() {
+ try {
+ await import('https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py');
+ window.parent.postMessage({type: 'import-result', iframe: '${iframe1.id}', loaded: true}, '*');
+ } catch (e) {
+ window.parent.postMessage({type: 'import-result', iframe: '${iframe1.id}', loaded: false}, '*');
+ }
+ })();
+ <\/script>`;
+ iframe1.srcdoc = srcdocContent; // iframe1에는 초기 srcdoc 속성이 없습니다
+ iframe2.srcdoc = srcdocContent.replaceAll(iframe1.id, iframe2.id);
+});
+<iframe id="iframe1" sandbox="allow-scripts"></iframe>
+<iframe id="iframe2" sandbox="allow-scripts" srcdoc=""></iframe>

LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt

-FAIL Changing contentWindow.location inherits from who changed it. assert_equals: expected "a" but got "p"
+PASS Changing contentWindow.location inherits from who changed it.
-FAIL window.open() inherits from caller. assert_equals: expected "a" but got "p"
+PASS window.open() inherits from caller.
-FAIL Form submission through submit() inherits from owner of form. assert_equals: expected "b" but got "p"
+PASS Form submission through submit() inherits from owner of form.
-FAIL Form submission through button click inherits from owner of form. assert_equals: expected "b" but got "p"
+PASS Form submission through button click inherits from owner of form.

제품 코드의 변경은 한 줄입니다. 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 형태에서 그랬다는 사실이 적혀 있었습니다.

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는 패치가 어떤 명세 동작을 바꿨는지를 정확히 나타냅니다.

이 버그의 본질은 우선순위 판단에 있습니다. 정책을 상속받을 수 있는 정당한 출처가 여러 개 존재하는 상황에서, 코드는 "이 동작에 필요한 출처가 무엇인가" 가 아니라 "마침 값이 채워져 있는 출처가 무엇인가" 를 기준으로 선택했습니다.

  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를 상속했습니다.

inheritance-from-initiator.sub-expected.txt 안에 놓여 있던 FAIL ... expected "a" but got "p" 줄들은, 사실 이 보안 버그를 평문 영어로 기술한 문장이었습니다. 그것도 baseline이 트리에 존재하던 기간 내내 그대로 체크인되어 있었습니다. 누군가는 이미 명세와의 비교를 끝냈고 그 차이를 기록해 두었던 셈입니다. 빠져 있던 것은 그 기록을 "알려진 실패 conformance test" 가 아니라 "우회" 로 읽어내는 사람이었습니다. 보안 관련 WPT 디렉터리에 체크인된 FAIL expectation은 아직 충분히 활용되지 않은 버그 오라클입니다. 엔진 개발자들이 명세 요구 사항과 실제 동작 사이의 간극을 직접 계산해 두었지만, 정작 아무도 검색하지 않는 파일에 넣어 둔 상태입니다.