Cross-Origin Iframe Can Read Clipboard via Top-Level User Interaction in Safari
CVE: CVE-2026-43713 · Safari 26.5.2 · Released June 29, 2026 Impact: Visiting a website may leak sensitive data Apple's description: A permissions issue was addressed with additional restrictions. Credit: Jody Ritonga
Severity가 High인 이유는, 이 문제가 check가 누락된 경우라기보다는 잘못된 check를 사용한 경우에 가깝기 때문입니다. Clipboard access가 thread 전역의 gesture state를 기준으로 승인되었는데, 이 state는 WebKit이 의도적으로 frame 경계를 넘어 전파하는 값입니다. Attacker 자신의 페이지에서 클릭 한 번이면 충분하고, victim origin에서는 아무런 user prompt도 뜨지 않습니다. Memory corruption조차 필요 없이, 시스템 pasteboard가 그것을 얻을 자격이 없던 frame에게 그대로 넘어갑니다.
시스템 pasteboard는 브라우저가 머신 위의 다른 모든 애플리케이션과 공유하는 유일한 state입니다. 그렇기 때문에 Clipboard API spec은 read와 write가 반드시 relevant global object 위에서의 transient activation으로 승인되어야 한다고 못박고 있습니다. 여기서 relevant global object란 navigator.clipboard가 실제로 호출된 window를 의미하며, 단순히 tree 어딘가에 있는 window를 뜻하지 않습니다. 그런데 WebKit은 "사용자가 방금 뭔가를 했는가"라는 질문에 대해 두 가지 서로 다른 답을 함께 가지고 있었습니다. 하나는 spec에 맞춘 window별 transient activation flag였고, 다른 하나는 훨씬 오래된 ambient 방식인 UserGestureIndicator로, 현재 thread에 gesture token이 살아 있는지를 추적하는 방식이었습니다. 후자는 애초에 gesture가 promise chain이나 timer를 넘어, 그리고 결정적으로 postMessage를 통해 다른 frame으로 넘어가서도 살아남도록 설계된 것이었습니다. 문제는 바로 이 "살아남는" 성질이 origin 단위의 authorization check로 쓰기에는 부적합하게 만든다는 점입니다.
관전 포인트: Attacker 자신의 페이지 어디서든 클릭 한 번만 있으면, 그 페이지가 embed한 cross-origin iframe이 사용자가 마지막으로 복사한 내용을 읽어갈 수 있었습니다. 비밀번호, 일회용 코드, 지갑 주소 등이 그 대상이 될 수 있었고, 반대로 나중에 붙여넣기 할 clipboard 내용을 조용히 덮어쓰는 것도 가능했습니다.
Source/WebCore/Modules/async-clipboard/Clipboard.cpp
Write path: Read path: Clipboard::write/writeText() Clipboard::read/readText() └─► shouldProceedWithClipboardWrite ├─ before: if (!document) only RequiresUserGesture: └─ after: + !documentHasTransientActivation before: processingUserGesture() │ (thread-global, forwarded) └─► frame->requestDOMPasteAccess() after: frameHasTransientActivation (downstream, gesture-driven) (this window only)
나머지 변경 내용은 테스트 관련 코드이며, 그 자체로 시사하는 바가 큽니다. `http/tests/security/clipboard/` 아래에 새로 추가된 두 파일이 정확히 이 공격을 재현합니다. 하나는 최상위 페이지로, 버튼의 click handler가 cross-origin iframe(`http://localhost:8080/...`, 테스트 자신의 origin과는 다른 origin)에 `postMessage`로 method 이름을 전달합니다. 다른 하나는 iframe helper로, `message` listener가 `readText`, `read`, `writeText`, `write` 중 하나를 호출하고 그 결과가 resolve되었는지 reject되었는지를 부모에게 알려줍니다. 네 가지 경우 모두 기대값은 `rejected` / `NotAllowedError`입니다. 별도로 `async-clipboard-helpers.js`에는 `readClipboardWithUserActivation()`가 추가되었습니다. 이 함수는 버튼을 추가하고, iOS에서 탭이 stale한 layer tree를 hit-test하지 않도록 `UIHelper.ensurePresentationUpdate()`를 await한 뒤 버튼을 활성화하고, 그 이후에야 `navigator.clipboard.read()`를 호출합니다. 기존에 `read()`를 사전 activation 없이 호출하던 두 개의 editing 테스트가 이 helper를 사용하도록 변경되었습니다. `async-navigator-clipboard-basics.https.html`에 대한 WPT resync도 같은 작업을 더 큰 규모로 수행합니다. 파일 내 *모든* clipboard 호출 앞에 `getPermissions()` helper(permission grant와 `waitForUserActivation()`)가 삽입되었고, `user-activation.js`에는 `trySetPermission`, `tryGrantReadPermission`, `tryGrantWritePermission`, `sendPasteShortcutKey`가 추가되었습니다.
**Background**
**The async Clipboard API surface.** `Source/WebCore/Modules/async-clipboard/Clipboard.cpp`는 `navigator.clipboard`를 구현합니다. Promise 기반의 `read()`, `readText()`, `write()`, `writeText()`가 여기에 해당합니다. 이 코드는 WebContent process에서 실행되며, JS binding과 WebCore의 platform pasteboard abstraction 사이에 위치합니다. 페이지 스크립트가 시스템 pasteboard에 접근할 수 있는지 여부를 결정하는 컴포넌트이기도 합니다. Downstream으로는 `Pasteboard`와 `PagePasteboardContext`를 거쳐 platform에 도달합니다. `Pasteboard::createForCopyAndPaste()`가 반환하는 handle의 `changeCount()`와 `allPasteboardItemInfo()`가 시스템 clipboard의 현재 상태를 나타냅니다.
**Transient activation.** HTML 명세에 따르면 transient activation은 `Window` 단위의 타임스탬프 플래그입니다. 사용자가 어떤 window에서 클릭, 탭, 또는 키 입력을 하면 그 window는 이후 짧은 시간 동안 "transiently activated" 상태가 됩니다. WebCore에서는 이를 `LocalDOMWindow::hasTransientActivation()`로 조회합니다. 전파 규칙 역시 명세에 정의되어 있는데, 상호작용 시점에 상호작용이 일어난 window의 ancestor frame들과 same-origin descendant frame들에게만 부여됩니다.
**`UserGestureIndicator` and gesture tokens.** WebKit의 더 오래된, 명세 이전 방식의 메커니즘은 다른 방식으로 동작합니다. `UserGestureIndicator`는 사용자가 시작한 이벤트가 dispatch되는 동안 스택에 push되는 RAII scope object입니다. `UserGestureIndicator::processingUserGesture()`는 인자를 받지 않는 static 함수로, "현재 이 thread에서 gesture token이 활성 상태인가"만을 의미합니다. `Document`도 `LocalDOMWindow`도 인자로 받지 않으며, 어느 쪽과도 연관되어 있지 않습니다. 이 token은 나중에 다시 capture되고 재확립될 수도 있는데, 이는 비동기 continuation도 여전히 사용자 시작 동작으로 간주되도록 하기 위한 설계입니다. 팝업 차단, 자동재생, 다운로드, 전체화면 기능이 모두 이 지속성에 의존합니다.
**Relevant global object.** Web IDL 관점에서 이는 메서드가 호출된 object가 속한 realm의 `Window`를 의미합니다. iframe 안에서 호출된 `navigator.clipboard.readText()`의 경우, relevant global object는 그 iframe의 `Window`이지 embedder의 window가 아닙니다.
**`ClipboardAccessPolicy` and the read hook.** `shouldProceedWithClipboardWrite()`는 `javaScriptCanAccessClipboard()`와 `Editor::isCopyingFromMenuOrKeyBinding()`을 먼저 확인한 뒤, `Settings` 레벨의 세 가지 값(`Allow` / `RequiresUserGesture` / `Deny`)을 갖는 enum을 참조합니다. 새로 추가된 layout test는 `JavaScriptCanAccessClipboard=false`, 즉 `RequiresUserGesture` 설정으로 실행됩니다. read 쪽에서는 `LocalFrame::requestDOMPasteAccess()`가 페이지가 pasteboard 내용을 소비해도 되는지를 UI process에 묻는 hook 역할을 하며, 경우에 따라 platform paste UI를 노출하기도 합니다.
**`postMessage` and gesture forwarding.** `LocalDOMWindow::processPostMessage`는 수신 window에 `message` 이벤트를 예약합니다. 이때 발신 측의 활성 `UserGestureToken`을 수신 frame의 handler로도 함께 전달하는데, 이는 origin과는 무관하게 의도된 동작입니다. gesture로 시작된 cross-frame protocol이 gesture-gated 동작을 계속 수행할 수 있도록 하기 위한 설계입니다.
<!-- GATE:analysis -->
**Analysis**
이 버그의 본질은 인가 검사가 호출 realm이 아니라 ambient thread state를 기준으로 평가된다는 점입니다. 브라우저의 옷을 입은 전형적인 ambient-authority 실패 사례에 해당합니다.
Origin A (top-level, attacker) │ Origin B (cross-origin iframe) ────────────────────────────────── │ ───────────────────────────────── user clicks button │ └─ UserGestureIndicator pushed │ (thread-global token live) │ └─ postMessage(frame, {...}) ─────┼──► message handler runs │ ↑ A's gesture token forwarded A.hasTransientActivation() == true │ B.hasTransientActivation() == FALSE │ processingUserGesture() == TRUE ◄── bug │ └─ navigator.clipboard.read/write() → allowed
화살표를 따라가 보면 흐름이 드러납니다. 사용자의 클릭은 origin A에서 일어나므로, A의 window만 transient activation을 받습니다. B는 cross-origin이기 때문에 아무것도 받지 못합니다. 그런데 이 click handler는 살아있는 `UserGestureIndicator` scope 안에서 실행되고 있고, `processPostMessage`가 그 token을 frame 경계 너머 B의 `message` handler로 함께 전달합니다. B가 `navigator.clipboard.writeText()`를 호출하는 시점에, 두 primitive는 서로 다른 답을 내놓습니다. `B.hasTransientActivation()`은 명세대로 false이지만, `UserGestureIndicator::processingUserGesture()`는 true입니다. Token이 thread의 속성이고, 현재 thread가 A의 클릭에서 시작된 호출 체인을 풀어내는 중이기 때문입니다. write 경로는 두 번째 질문을 던졌습니다. 그 결과 `true`를 받았고, 그대로 write가 진행되었습니다.
read 경로는 같은 문제의 다른 형태를 갖고 있었습니다. 진입점에 activation 검사 자체가 없었습니다. `readText()`와 `read()`는 `if (!document)`만으로 방어하고, activation 판단은 전적으로 downstream의 `LocalFrame::requestDOMPasteAccess()`에 맡기고 있었습니다. 그런데 이 함수 역시 gesture 기반으로 동작하기 때문에 같은 방식으로 laundering된 token을 물려받습니다. 하나의 API가 두 개의 서로 다른 레이어에서, 서로 다른 두 메커니즘으로 게이트되고 있었고, 어느 쪽도 어떤 origin이 요청하고 있는지는 알지 못했습니다. 새 테스트에서 네 개 메서드 모두에 걸쳐 `NotAllowedError`를 기대하도록 만든 것은, 이 drift가 얼마나 광범위했는지를 보여주는 지표입니다. cross-origin iframe에서의 write는 별다른 장애물 없이 도달 가능했고, read는 `requestDOMPasteAccess()`에 의해 추가로 게이트되어 있었지만 그 prompting 동작은 설정에 따라 달라집니다. 그럼에도 테스트가 네 개 메서드 전부를 검증 대상으로 삼고 있다는 사실 자체가, 수정 전에는 네 메서드 모두 resolve 가능한 상태에 도달했음을 의미합니다.
이 ambient 검사가 안전하지 않은 이유는 부주의 때문이 아니라 설계 의도 때문입니다. `UserGestureIndicator`는 애초에 *지속되도록* 설계되었습니다. 실제 페이지들이 promise chain, `setTimeout` continuation, cross-frame protocol을 거쳐 비동기적으로 gesture-gated 작업을 수행하는 경우가 많기 때문에, gesture가 이런 경로들을 넘어 살아남아야 할 필요가 있습니다. 그런데 이 전파 경로 하나하나가 곧, token이 그것을 획득한 origin보다 더 오래 살아남는 경로이기도 합니다. 반면 transient activation은 전파 규칙이 명문화되어 있고 origin을 인식합니다. 상호작용 시점의 ancestor와 same-origin descendant, 그 이상은 없습니다. cross-origin descendant에 도달할 규칙 자체가 존재하지 않으며, `postMessage`는 그 전파 경로에 포함되지 않습니다.
따라서 이번 fix는 검사를 새로 추가했다기보다는, 질문의 대상을 올바른 object로 바꾼 것에 가깝습니다. 두 helper는 결국 한 줄로 요약됩니다.
```cpp
static bool frameHasTransientActivation(const LocalFrame& frame)
{
RefPtr window = frame.window();
return window && window->hasTransientActivation();
}
여기서 중요한 부분은 parameter입니다. LocalFrame이나 Document를 인자로 받는 predicate는 origin-scoped로 만들 수 있지만, 아무것도 받지 않는 static 함수는 호출 방식과 무관하게 origin-scoped가 될 수 없습니다. shouldProceedWithClipboardWrite()가 frameHasTransientActivation()을 거치도록 바뀌고 두 read 진입점 모두 documentHasTransientActivation()을 갖게 되면서, 이제 네 메서드 모두 pasteboard 관련 로직에 닿기 전에 같은 레이어에서 동일한 per-window 속성을 확인하게 됩니다. B 자신의 window가 transient activation을 갖고 있어야 하며, A에서의 클릭은 B에게 아무런 권한도 부여하지 않습니다. Blink와 Firefox는 이미 이런 방식으로 동작하고 있었습니다.
Exploit 비용은 attacker 자신의 최상위 페이지에서 발생하는 클릭 한 번입니다. Process 경계를 넘는 부분은 없습니다. Pasteboard는 여전히 PagePasteboardContext를 통해 UI process로 broker되며, sandbox를 우회하지도 않습니다. 획득 대상은 사용자 데이터입니다. Cross-origin frame이 사용자가 마지막으로 복사한 내용을 읽을 수 있게 되는데, pasteboard가 시스템 전역이기 때문에 다른 애플리케이션에서 복사한 내용까지 포함됩니다. 실무적으로는 비밀번호 관리자의 출력값, 일회용 코드, 토큰, 주소 등이 이에 해당할 수 있습니다. write 쪽은 그 반대 방향의 시나리오입니다. 제3자 frame이 clipboard 내용을 몰래 바꿔치기하면, 이후 사용자가 터미널이나 지갑 주소 입력란, 다른 사이트에 paste했을 때 attacker가 선택한 데이터가 전달됩니다.
postMessage를 통해 전달된 gesture token 때문에, cross-origin iframe이 자신의 window와는 무관한 clipboard 검사에 "yes"라고 답할 수 있는 상태가 되었습니다.
Insight
흥미로운 지점은 같은 영어 질문에 답하는 두 primitive의 상속 semantics입니다. WebKit이 둘 다 유지하는 이유는 둘 다 필요하기 때문입니다. Ambient indicator는 팝업 차단, 자동재생, 다운로드, 전체화면이 promise chain과 timer를 넘어 gesture를 유지할 수 있도록 하기 위해 존재하며, 이 지속성은 인가라는 맥락을 제외한 거의 모든 곳에서 유용한 기능입니다. processingUserGesture()에 게이트된 capability는 무엇이든, 그 게이트 자체의 결함이 아니라 primitive가 원래 하도록 설계된 동작의 직접적인 결과로서, origin 경계를 넘는 gesture-laundering을 공짜로 얻게 됩니다. 이 때문에 남은 call site들은 범위가 한정되고 열거 가능한 audit 대상이 되며, message 이벤트 handler 내부에서 도달 가능한 부분집합이 특히 수확이 큰 지점입니다.
Audit directions
-
Ambient state used as an authorization oracle. Narrow:
Source/WebCore전체에서UserGestureIndicator::processingUserGesture()와UserGestureIndicator::currentUserGesture()가 capability gate에 쓰이는 지점들을 검색합니다. 전체화면 요청, 팝업/window.open, 다운로드, 자동재생 및 media playback, Web Share, permission prompt, Payment Request가 대상입니다. 각각에 대해, 호출한 window의hasTransientActivation()을 사용했다면 결과가 달라지는지 확인해야 합니다. Match tell은 다음과 같습니다. 인자를 받지 않는 static "gesture가 활성 상태인가" 질의가, 특정 origin에게 결과가 노출되는 동작을 인가하고 있는 경우입니다. 검사가Document나LocalDOMWindow를 인자로 받지 않는다면 origin-scoped일 수 없습니다. Wider: 이 범주는 subject 대신 thread-local이나 scope stack을 읽는 모든 인가 로직으로 확장됩니다. WebCore의 script-execution scope flag,ScriptDisallowedScope계열의 ambient guard 등, "누가 호출했는가"가 아니라 "지금 스택에 누가 올라와 있는가"를 기준으로 삼는 것이라면 모두 해당됩니다. Widest: 이는 전형적인 ambient authority vs. capability 문제이며, 인가 상태는 thread가 아니라 principal에 귀속되어야 한다는 불변식은 다른 환경에서도 반복해서 등장합니다. JVM과 .NET의Thread.currentThread()에 스코프된 permission context, Node의 AsyncLocalStorage/CLS가await경계를 넘어 auth context를 전파하는 경우, 그리고 권한이 다른 handler로 그대로 전달되는 request-scoped context object 등이 그 예입니다. 이식 가능한 match tell은 다음과 같습니다. 인가를 위해 읽는 값이 인자로 전달된 identity가 아니라 call-stack의 조상 관계에 의존하는 경우입니다. -
UserGestureToken이 넘나드는 모든 경계. Narrow: 이 버그가 촉발하는 forwarding 지점인LocalDOMWindow::processPostMessage에서 출발해, token을 capture하고 재수립하는 다른 지점들을 나열합니다. Timer로의 forwarding, promise reaction,requestAnimationFramecallback,BroadcastChannel과MessagePortdelivery, worker→document 메시지 라우팅이 그 대상입니다. 각 수신자에 대해 이후 무엇이 허용되는지를 확인해야 합니다. Match tell은 다음과 같습니다. Live input event가 아니라 저장된 또는 수신된 token으로부터UserGestureIndicator가 구성되고, 이후 그 gesture를 사용하는 코드가 원래 생성한Document와 다른Document에 속해 있는 패턴입니다. Wider: 동일한 형태는 realm이 issuer와 다른 callback으로 capability token이 복사되는 모든 경우에 적용됩니다. 페이지를 대신해 동작하는 service worker의fetchhandler, notification이나 permission delegation, extension 방식의 메시지 라우팅이 여기에 해당합니다. Widest: delegated authorization token은 delegatee의 identity를 함께 실어야 하며, 이후 그 identity에 대해 재검증되어야 합니다. OAuth의audaudience validation, object-capability 시스템에서의 capability 전달, cross-realm postMessage 프로토콜 전반이 이 원칙을 따릅니다. 감사 질문은 언제나 하나입니다. "consumer가 이 token이 자신을 위해 발급되었는지 검증하는가?" -
서로 다른 predicate로 gating되는 paired API. Narrow: 이번 commit에서 write 쪽은
shouldProceedWithClipboardWrite()를 거치는 반면, read 쪽에는 entry-point check가 없이 downstream의requestDOMPasteAccess()에만 의존하고 있었습니다. 이런 비대칭 구조 때문에 API surface의 절반이 spec에서 벗어나게 됩니다.Source/WebCore/Modules에서 get/set, read/write, subscribe/emit 같은 상호 보완적 쌍을 노출하는 클래스를 살펴보고, 각각의 guard prologue를 diff해볼 필요가 있습니다. 우선 legacyEditor/DataTransferpaste surface와 clipboard entry point부터 점검하는 것이 좋습니다. Match tell은 다음과 같습니다. 한 interface 위의 형제 method 두 개가 있는데, 첫ifblock의 형태가 서로 다른 경우입니다. 하나는 permission helper를 호출하고, 다른 하나는 null check만 수행합니다. Wider: 한쪽 방향의 check는 entry point에 있고 다른 쪽은 shared downstream helper 안으로 밀려나 있는 API라면 모두 이 클래스에 해당합니다. 이런 구조에서는 이후 그 helper를 리팩터링할 때 한쪽만 조용히 바뀌어버릴 수 있습니다. Widest: 하나의 capability family에 속한 모든 operation은 같은 layer에서 authorize되어야 합니다. Filesystem의 read handle과 write handle, 하나의 validator를 공유하는 IPC 메시지 family,SELECT에만 적용되고UPDATE에는 적용되지 않는 database policy가 같은 문제를 겪습니다. 이식 가능한 match tell은 M개의 형제 operation 중 N개에서만 호출되는 permission helper입니다. -
누락된 gate를 오히려 기대 동작으로 굳혀버린 test. Narrow: 이번 fix는 두 개의 editing test에
readClipboardWithUserActivation()을 통해 user activation을 retrofit해야 했고,async-navigator-clipboard-basics.https.html의 사실상 모든 케이스에도 마찬가지였습니다. 다시 말해 fix 이전의 테스트 suite는 activation 없이도 clipboard method를 실행하고 그대로 통과하고 있었다는 뜻입니다. Permission과 activation으로 gating되는 다른 LayoutTest들도 같은 속성을 갖는지 훑어볼 필요가 있습니다. Async test body에서UIHelper.activateElement나test_driver.click없이 API를 곧바로 호출하지는 않는지 확인해야 합니다. Match tell은 spec상 transient activation이 필요한 operation에 대해 성공을 기대값으로 기록해 둔 test입니다. Wider: spec precondition을 충족하지 않고도 통과하는 suite는 그 precondition에 대한 regression을 애초에 감지할 수 없습니다. 그리고 WPT resync에서 갑자기 여기저기에 precondition을 추가해야 한다면, guard가 애초부터 없었다는 강한 신호로 볼 수 있습니다. Widest: gating된 operation에 대한 test는 gate가 제거되었을 때 실제로 실패할 수 있어야 합니다. 이는 어떤 authorization layer에도 적용 가능한 일반적인 mutation-testing 속성이며, 구체적인 감사 방법은 guard를 로컬에서 지워보고 무언가 빨갛게 변하는지 확인하는 것입니다. -
Suspension point 이전에 확인되는 precondition, 그 이후에 전달되는 effect. Narrow:
Clipboard::read()는m_activeSession을 수립하고,readText()는 이후 재비교를 위해pasteboard->changeCount()의 snapshot을 남깁니다. 다만 새로 추가된 activation check는 synchronous prologue에서만 실행됩니다. 이 entry guard와 pasteboard data가 실제로 script에 도달하는 시점 사이에서 transient activation이 만료되거나 document가 navigate될 수 있는지를 추적해볼 필요가 있습니다. Data는 queue된TaskSource::Clipboardtask를 통해서, 혹은 유지되고 있는 session 위의ClipboardItem::getType()을 통해서 도달합니다. Match tell은 synchronous prologue에서 단 한 번 평가되는 guard가 있는데, 그 guard로 보호되는 effect는 이후의 task나 저장된 session object로부터 전달되는 패턴입니다. Wider: 동일한 TOCTOU 형태는 precondition을 validate한 뒤 event loop를 거쳐 완료되는 모든 WebCore API에 적용됩니다.queueTask이전의 permission check, promise resolution 이전의 origin check, IPC reply handler가 실행되기 전에 읽힌 settings check가 모두 여기에 해당합니다. Widest: yield 이전에 확인된 precondition은, 보호 대상 effect가 resume 이후에 발생한다면 resume 이후에 다시 수립되어야 합니다. 이는 authorization과 effect가 suspension point로 분리되는 모든 async runtime에 적용되는 원칙입니다. Stale permission을 들고 있는 Rustasync fn부터, request context를 capture하는 Go handler,await이전에 authorize하는 JS middleware까지 모두 해당됩니다.