← All reports

Cross-Origin Iframe Can Read Clipboard via Top-Level User Interaction in Safari

HighWebCore async clipboardCrossOrigin

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

73645ab | Bugzilla 314806

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

+#include "LocalDOMWindow.h"
-#include "UserGestureIndicator.h"
...
+// https://w3c.github.io/clipboard-apis/ requires the relevant global object to have transient
+// activation. Transient activation is not propagated to cross-origin iframes, so a user
+// interaction on a top-level page cannot be used by a cross-origin iframe to access the
+// clipboard via postMessage.
+static bool frameHasTransientActivation(const LocalFrame& frame)
+{
+ RefPtr window = frame.window();
+ return window && window->hasTransientActivation();
+}
+
+static bool documentHasTransientActivation(const Document& document)
+{
+ RefPtr window = document.window();
+ return window && window->hasTransientActivation();
+}
+
static bool shouldProceedWithClipboardWrite(const LocalFrame& frame)
{
...
case ClipboardAccessPolicy::RequiresUserGesture:
 
- return UserGestureIndicator::processingUserGesture();
+ return frameHasTransientActivation(frame);
...
void Clipboard::readText(Ref<DeferredPromise>&& promise)
{
RefPtr frame = this->frame();
RefPtr document = frame ? frame->document() : nullptr;
 
- if (!document) {
+ if (!document || !documentHasTransientActivation(*document)) {
promise->reject(ExceptionCode::NotAllowedError);
return;
}
...
void Clipboard::read(Ref<DeferredPromise>&& promise)
{
RefPtr frame = this->frame();
RefPtr document = frame ? frame->document() : nullptr;
 
Patch Details
 
프로덕션 변경은 단일 파일에서 10줄 규모입니다. `Clipboard.cpp`는 `UserGestureIndicator.h` include를 완전히 제거하고 `LocalDOMWindow.h`를 새로 가져온 뒤, 서로 다른 핸들을 통해 동일한 동작을 수행하는 두 개의 static helper를 추가합니다. `frameHasTransientActivation(const LocalFrame&)`는 `frame.window()`를 거쳐 확인하고, `documentHasTransientActivation(const Document&)`는 `document.window()`를 거쳐 확인합니다. 두 함수 모두 `window && window->hasTransientActivation()`를 반환합니다. 둘 다 null-safe하게 구현되어 있어서, window로부터 detach된 frame은 실패 쪽으로 처리됩니다.
 
이 helper들은 API의 두 축에 각각 연결됩니다. write 쪽에서는 `Clipboard::writeText()`와 `Clipboard::write()`의 관문 역할을 하는 `shouldProceedWithClipboardWrite()`가 구조는 그대로 유지한 채, `ClipboardAccessPolicy::RequiresUserGesture` 분기의 본문만 `UserGestureIndicator::processingUserGesture()`에서 `frameHasTransientActivation(frame)`으로 교체됩니다. read 쪽에서는 `Clipboard::readText()`와 `Clipboard::read()`의 진입점에 activation 검사가 아예 없었습니다. 기존의 `if (!document)` 조기 반환이 `if (!document || !documentHasTransientActivation(*document))`로 확장되어, 조건을 만족하지 못하면 `NotAllowedError`로 promise를 reject합니다. `read()`는 이 경로에서 `m_activeSession`도 함께 초기화하는데, 이는 이미 document가 null인 경우에 하던 처리와 동일합니다. 이렇게 해서 reject된 read가 이후의 `getType()` 호출에 stale session을 남기지 않도록 합니다.
 

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"라고 답할 수 있는 상태가 되었습니다.

흥미로운 지점은 같은 영어 질문에 답하는 두 primitive의 상속 semantics입니다. WebKit이 둘 다 유지하는 이유는 둘 다 필요하기 때문입니다. Ambient indicator는 팝업 차단, 자동재생, 다운로드, 전체화면이 promise chain과 timer를 넘어 gesture를 유지할 수 있도록 하기 위해 존재하며, 이 지속성은 인가라는 맥락을 제외한 거의 모든 곳에서 유용한 기능입니다. processingUserGesture()에 게이트된 capability는 무엇이든, 그 게이트 자체의 결함이 아니라 primitive가 원래 하도록 설계된 동작의 직접적인 결과로서, origin 경계를 넘는 gesture-laundering을 공짜로 얻게 됩니다. 이 때문에 남은 call site들은 범위가 한정되고 열거 가능한 audit 대상이 되며, message 이벤트 handler 내부에서 도달 가능한 부분집합이 특히 수확이 큰 지점입니다.