Popunder bypass via overlappping transient activations
CVE: CVE-2026-43804 · Safari 26.6 · Released July 27, 2026 Impact: Visiting a website may lead to an app denial-of-service Apple's description: This issue was addressed through improved state management. Credit: Heiko Kiesel of SEEMOO, TU Darmstadt
Low — memory corruption도 없고, cross-origin read도 없으며, sandbox 경계를 넘는 동작도 없습니다. 다만 웹 콘텐츠 쪽에 조건 없는 raise-that-window primitive가 그대로 넘어갑니다. 앞을 막고 있던 guard가 "사용자가 방금 조작했는가"가 아니라 "호출한 페이지가 화면에 떠 있는가"만 묻고 있었기 때문입니다.
어떤 브라우저든 페이지가 스스로 할 수 있는 동작과 사용자가 요청했을 때만 허용되는 동작 사이에 선을 긋습니다. 윈도우 관리는 확실히 후자 쪽에 속합니다. top-level window를 이동하거나 크기를 바꾸거나 닫거나 앞으로 끌어올리는 동작은 모두 실제 상호작용 이후에만 허용되어야 하며, HTML user-activation 모델이 그 규칙을 집행합니다. window.open() 호출이 WebContent process에서 도달하는 지점이 바로 WebCore::createWindow()인데, 이 함수에는 성격이 전혀 다른 두 개의 출구가 있습니다. 하나는 새 browsing context를 만드는 경로이고, 다른 하나는 이름으로 기존 context를 찾아 재사용하는 경로입니다. 여기서 지켜져야 할 invariant는 두 출구가 동일한 수준으로 gate되어야 한다는 점입니다. 둘 다 사용자 눈에 똑같이 드러나는 동작이기 때문입니다.
관전 포인트: 페이지와 그 페이지가 연 popup이, 사용자 조작 없이 아무 때나 서로의 앞뒤 순서를 바꿀 수 있었습니다. 사용자가 보고 있는 창 뒤로 다른 창을 숨기거나, 두 창 사이에서 focus를 계속 튕기게 만들기에 충분한 수준입니다.
Source/WebCore/loader/FrameLoader.cpp
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/VerifyUserGestureFromUIProcess.mm
Patch Details
기능적으로 바뀐 부분은 한 줄입니다. WebCore::createWindow()의 named-target branch, 즉 request.frameName()이 비어 있지 않고 _blank도 아니며 openerFrame.loader().findFrameForNavigation()을 통해 이미 존재하는 frame으로 해석되는 경우에 들어가는 분기가 대상입니다. 이 분기에서 page->chrome().focus() 앞을 막는 guard에 조건 두 개가 추가되었습니다. 패치는 RefPtr openerWindow = openerFrame.window();를 if-initializer 밖으로 끌어올리고, 조건을 page && isInVisibleAndActivePage(openerFrame) && openerWindow && openerWindow->hasTransientActivation()으로 확장했습니다. 결과적으로 대상 창을 앞으로 끌어올리려면, window.open()을 호출한 창이 현재 살아 있는 transient activation을 보유하고 있어야 합니다.
여기서 패치가 하지 않은 일에 주목할 필요가 있습니다. activation을 요구하기만 하고 consume하지는 않습니다. commit message는 이것이 release branch(305413.1061@safari-7624.5-branch)에 반영된 버전과 의도적으로 다른 선택임을 명시하고 있으며, 그쪽 버전은 consume을 수행했습니다. trunk에서 이 지점에 consume을 넣으면 imported/w3c/web-platform-tests/html/browsers/windows/consume-user-activation/window-open.html이 깨집니다. 311026@main에서 이미 window.open()의 consumption 범위를 새 browsing context를 실제로 생성하는 경우로 좁혀두었기 때문인데, 이는 spec의 "rules for choosing a navigable"를 따른 결과입니다. 이름으로 재사용하는 경로는 아무것도 생성하지 않으므로, activation을 소모해서는 안 됩니다.
diff의 나머지는 regression test인 TEST(VerifyUserGesture, PopunderPreventedViaDualEventListeners)입니다. 이 테스트는 보고된 우회 형태를 처음부터 끝까지 그대로 재현합니다. domain1.com의 opener 페이지 위에서 실제 mouseDown/mouseUp 쌍이 발생하고, 이 페이지는 스스로를 opener로 명명한 뒤 pointerdown 핸들러에서 domain2.com의 popup을 엽니다. 이어서 click 핸들러에서, 즉 같은 물리적 클릭에서 파생된 다른 이벤트에서 popup 쪽으로 진입해 window.open('', 'opener')를 실행합니다. 검증 조건은 opener의 focusWebView UI-delegate callback이 단 한 번도 호출되지 않는 것입니다.
Background
user-activation 모델. 브라우저는 스스로 실행되는 스크립트와 사용자 입력의 직접적인 결과로 실행되는 스크립트를 구분합니다. 클릭이나 키 입력 같은 상호작용은 창마다 유지되는 짧은 수명의 플래그, 즉 transient activation을 설정합니다. gate가 걸린 API들은 사용자가 인지할 만한 동작을 수행하기 전에 이 플래그를 조회합니다. LocalDOMWindow::hasTransientActivation()은 해당 플래그가 현재 유효한지를 반환하고, consumeTransientActivation()은 이를 소모합니다. 그래서 한 번의 상호작용은 정확히 한 번의 gated action만 승인하게 됩니다.
activation이 전파되는 범위. 문서가 activation을 받으면, HTML 모델은 이를 상위 frame과 같은 frame tree 안의 same-origin 하위 frame으로 전파합니다. 다만 다른 top-level browsing context로는 넘어가지 않습니다. 따라서 새로 열린 popup은 자기 activation 없이 시작하며, 사용자가 popup을 직접 조작할 때에만 activation을 획득합니다.
이름을 지정한 window targeting. window.open(url, name)은 무조건 창을 새로 만들지 않습니다. 먼저 이름이 일치하는 기존 browsing context를 찾는데, 이 조회를 FrameLoader::findFrameForNavigation()이 담당하며 호출자가 접근할 수 있는 frame으로 범위가 제한됩니다. _blank와 _self 키워드는 isBlankTargetFrameName()과 isSelfTargetFrameName()이 따로 인식해 각자의 경로에서 처리합니다.
createWindow(). window.open() 뒤에 있는 WebCore 함수입니다. 반환 타입은 std::pair<RefPtr<Frame>, CreatedNewPage>인데, 이름이 지정된 target이 이미 존재하면 그 frame을 재사용하면서 새 page가 생성되지 않았음을 보고하고, 그렇지 않으면 embedder에게 생성을 요청합니다. 두 결과는 함수 내부에서 구조적으로 다른 경로를 거칩니다.
Chrome::focus()와 그 앞의 predicate. Chrome::focus()는 특정 Page를 담고 있는 창을 앞으로 가져와 달라고 embedder에 요청하는 WebCore 측 호출이며, API 테스트에서는 TestUIDelegate의 focusWebView block으로 드러납니다. isInVisibleAndActivePage(frame)은 page 상태에 대한 predicate로, 해당 frame의 page가 현재 보이는 상태이고 active window 안에 있는지를 판정합니다.
Popunder. 콘텐츠가 두 번째 창을 연 뒤 원래 창이 그 위로 올라오도록 만드는 창 순서 조작 기법입니다. 새 창은 열린 채로 남지만, 사용자가 실제로 보고 있는 창 뒤에 가려집니다.
RefPtr. WebKit의 reference-counted smart pointer입니다. openerFrame.window()는 frame의 LocalDOMWindow를 반환하며, 여기서는 검사가 진행되는 동안 로컬 RefPtr로 보유됩니다.
Analysis
capability 검사가 있어야 할 자리를 ambient-authority 검사가 대신 차지하고 있던 사례입니다.
Before: After:
window.open('', 'opener') window.open('', 'opener')
└─► findFrameForNavigation() └─► findFrameForNavigation()
└─► isInVisibleAndActivePage? └─► isInVisibleAndActivePage?
"am I on screen?" ── yes ─┐ AND hasTransientActivation?
│ "did a user touch ME?"
┌──────────────────────────┘ │
▼ popup: no ──┴──► blocked
chrome().focus() ← no gesture required
다이어그램의 왼쪽 열이 패치 이전 인가 로직의 전부입니다. isInVisibleAndActivePage(openerFrame)이 답하는 질문은 "호출한 페이지가 현재 화면에 있고 active window 안에 있는가"입니다. 이것은 호출자가 우연히 놓인 환경을 기술할 뿐, 호출자가 보유한 권한이 아닙니다. 공격자 페이지가 방금 열어서 맨 앞에 놓여 있는 popup이라면, 이 질문의 답은 간단히 yes이고 그 상태가 계속 유지됩니다. predicate는 frame 하나를 받아 거기서 도달 가능한 상태를 읽을 뿐이며, 특정 사용자 상호작용에 묶인 요소는 아무것도 없습니다. 오른쪽 열은 빠져 있던 질문을 추가하는데, popup은 구조상 이 질문에 no로 답할 수밖에 없습니다.
동작 메커니즘은 여기서 곧바로 따라옵니다. 이름이 기존 frame으로 해석되는 순간 createWindow()는 named-target branch로 단락되어 들어갑니다. 그래서 page를 생성하는 경로를 지키는 popup-blocking 검사나 새 창용 activation 검사는 아예 조회되지 않습니다. 결과적으로 이 호출은 순수한 "저 창을 앞으로 올려라" primitive로 축소되며, findFrameForNavigation()을 통해 target을 이름으로 해석할 수 있는 frame이라면 누구나 사용할 수 있게 됩니다. 실제로는 공격자 자신의 browsing-context group 안에 있는 창들, 즉 스스로 만들어낸 opener/openee 체인이 그 대상입니다. popup에서 window.open('', 'openerName')을 호출하고, URL을 비워 두어 navigation조차 발생하지 않게 하면, 그것으로 exploit은 끝입니다.
제목의 "overlapping transient activations"는 보고자가 이 코드의 이전 상태를 상대로 찾아낸 구체적인 우회를 가리키며, regression test가 이를 정확히 인코딩하고 있습니다. 물리적인 클릭 한 번은 activation을 실어 나르는 단일 이벤트가 아니라 하나의 연속된 시퀀스입니다.
- opener에서
pointerdown이 발생합니다. 핸들러가window.open('https://domain2.com/popup')을 호출하는데, 이는 실제로 새 browsing context를 생성하는 경우이므로 activation을 consume합니다. - popup이 로드되어 스크립트를 실행하고
refocusOpener()를 정의합니다. - opener에서
click이 발생합니다. 물리적으로는 동일한 상호작용이지만 별개의 이벤트이며, opener의 창에 transient activation을 새로 발급합니다. - opener의
click핸들러가 popup 쪽으로 진입해popup.refocusOpener()를 호출하고, 그 안에서window.open('', 'opener')가 실행됩니다. - 이 호출은 기존 opener frame을 이름으로 해석하고, 생성 경로는 완전히 건너뛴 채
page->chrome().focus()를 호출합니다. opener가 popup 위로 올라오면서 popunder가 성립합니다.
popup 생성 시점에 적용되는 consumption 기반 완화책은 1단계에서 이미 충족되고 소모됩니다. 그리고 3단계에서 페이지는 어차피 새 토큰을 다시 받게 됩니다. fix가 상위 지점이 아니라 focus를 호출하는 자리에 놓인 이유가 여기에 있으며, consume이 아니라 require가 올바른 동사인 이유이기도 합니다. 4단계의 호출은 popup의 realm에서 시작되는데, popup의 창은 activation을 한 번도 보유한 적이 없습니다. activation은 frame tree 안에서만 전파되고 top-level window를 절대 넘지 않기 때문입니다. 따라서 요구만 해도 popunder는 consume과 똑같은 강도로 차단되며, spec 기준으로 이 경로가 소모할 이유가 없는 토큰을 쓰지 않아도 됩니다.
영향 범위는 제한적입니다. 문제의 호출은 WebContent process에서 시작되어 Chrome::focus() → ChromeClient::focus()를 거쳐 UI process로 전달되고, 실제 raise는 UI process가 수행합니다. 공격자에게 유리한 방향으로 sandbox 경계가 넘어가지도 않고, WebContent process에 추가 capability가 들어오지도 않습니다. UI process는 그저 정상적인 UI 동작을 수행할 뿐이며, 다만 그것을 요청할 자격이 없던 호출자를 위해 수행하게 됩니다. 공격자가 얻는 것은 자기 창 집합에 대한 임의의 z-order 제어입니다. 광고나 phishing 표면을 popunder로 배치하거나, focus를 가로채거나, 루프로 돌려 focus를 계속 튕기게 만들 수 있습니다. Apple의 advisory가 app denial-of-service로 분류한 것이 마지막 경우입니다. memory-safety, cross-origin 데이터 접근, sandbox 측면의 결과는 없습니다.
권한이 필요한 cross-window raise가 "내 페이지가 보이고 active한가"라는 ambient 상태에 gate되어 있었습니다. popup이 기본적으로 충족하는 조건이며, popup이 절대 보유할 수 없는 사용자 상호작용 토큰이 아니었습니다.
Insight
이 commit은 release branch에서는 올바랐던 fix가 trunk로 forward-port되면서 미묘하게 어긋나는 사례이기도 합니다. branch 버전은 activation을 consume했습니다. 다만 trunk에서는 그 사이에 window.open()의 consumption 의미가 새 browsing context 생성에 한정되도록 좁혀졌고(311026@main), 이름으로 재사용하는 경로에서 consume하면 WPT conformance test가 깨지게 됩니다. trunk 형태를 지탱하는 근거는 정책적 판단이라기보다 threat model 관찰에 가깝습니다. popup의 principal은 애초에 토큰을 획득할 수 없으므로, 여기서는 require-check가 consume-check와 정확히 같은 강도를 갖습니다. 여기서 일반화할 수 있는 원칙이 나옵니다. consume은 spec이 지시하는 자리에서만 수행하고, 공격자의 principal이 해당 권한을 획득할 수 없음을 증명할 수 있다면 단순한 요구 검사를 택하는 편이 낫습니다.
Audit directions
-
호출자가 보유한 토큰이 아니라 ambient 상태에 gate된, 스크립트에서 도달 가능한 UI capability. Narrow:
Chrome::focus()/Chrome::unfocus()/ChromeClient::focus의 스크립트 도달 가능한 호출 지점을 전부 점검하고, 여기에Source/WebCore/page/LocalDOMWindow.cpp의 window 관리 표면(moveBy/moveTo/resizeBy/resizeTo/close)을 더합니다. 각각에 대해 gate가 호출을 시작한 창에서hasTransientActivation()을 조회하는지 확인합니다. Wider: 같은 부류에는 "환경이 적절해 보이는가"를 묻고 "이 호출자가 권한을 보유했는가"는 묻지 않는 모든 capability가 포함됩니다. fullscreen 진입, pointer lock, 다운로드 시작, clipboard 쓰기, autoplay unmute 등이 여기에 해당합니다. Widest: 본질은 ambient authority 대 capability의 문제이며, 개별 요청에 묶인 토큰 대신 전역 상태나 세션 상태(is-foreground, is-active-tab, is-logged-in)를 읽어 판정하는 모든 권한 시스템에 그대로 적용됩니다. Chromium의 대응되는 gesture gate, 모바일 permission broker, 세션 컨텍스트를 키로 쓰는 cloud IAM 검사 등이 그 예입니다. 탐지 단서: (narrow) 부수 효과를 일으키는 권한 호출인데 그것을 감싸는if가 visibility/activity predicate만 나열하고 gesture predicate는 없는 경우, (wider) 호출자를 식별하는 인자를 전혀 받지 않는 capability gate, (widest) 판정 입력이 전부 ambient 전역 상태뿐인 권한 결정. -
일회용 권한 토큰에서 드러나는 consume과 require의 비대칭. 좁은 범위:
Source/WebCore에서consumeTransientActivation호출 지점과UserGestureIndicator가 토큰을 소모하는 지점을 검색합니다. 그다음 각 지점별로 토큰 소모가 스펙상 요구되는 동작인지 판단할 필요가 있습니다. 아니면hasTransientActivation()확인만으로 충분하면서 스펙 준수에도 문제가 없는지를 함께 따져야 합니다.311026@main에서는 이미window.open()의 소모 범위를 새 browsing context 생성 시점으로 좁혔습니다. 넓은 범위: 하나의 사용자 상호작용 안에서 다른 producer가 one-shot 토큰을 다시 발급할 수 있는 곳이라면 같은 형태가 반복됩니다. autoplay unlock으로 이어지는pointerdown/mouseup/click조합, gesture로 게이팅되는requestFullscreen, 일회성 권한 프롬프트, CSRF double-submit 토큰이 여기에 해당합니다. 가장 넓은 범위: 발급 경로를 하나도 빠짐없이 집계해야만 일회용 권한이 실제로 일회용이 됩니다. 이 원칙은 producer가 여럿이고 consumer는 하나인 nonce나 ticket 체계 전반에 그대로 옮겨집니다. 판별 신호: 보안 판단을 내리는 코드가 consume 계열 API를 호출해 그 boolean 결과만 사용하는 경우입니다. 이때 전혀 무관한 다른 경로가 같은 flag를 다시 설정할 수 있고, 그 재설정이 공격자의 다음 호출보다 먼저 일어날 수 있다면 같은 패턴에 해당합니다. -
cross-realm 호출 사슬에서 잘못된 principal로부터 읽히는 권한. 좁은 범위:
openerFrame파라미터가WebCore::createWindow()와 그 주변의FrameLoader/NavigationScheduler진입 지점을 어떻게 통과하는지 추적합니다. 각 보안 판단 지점마다 {호출을 시작한 document의 window, opener frame의 window, 최종적으로 결정된 target frame의 window} 중 어느 쪽을 실제로 읽고 있는지 확인해야 합니다. 그리고 정책이 의도한 대상이 그중 어느 쪽인지와 대조할 필요가 있습니다. 넓은 범위: realm A에 정의된 함수가 realm B의 스크립트로부터 호출되는 지점이라면 동일한 질문이 성립합니다.WindowProxy를 통해 노출된 함수,postMessage핸들러, frame 경계를 넘어 설치된 event handler가 여기에 해당합니다. entry context나 incumbent context를 쓰는 대신frame->document()로 principal을 끌어오는 binding도 마찬가지입니다. 가장 넓은 범위: 호출자의 신원이 아니라 피호출자의 home context에서 권한을 끌어오는 시스템이라면 confused deputy 유형에 들어갑니다. ambient service credential로 동작하는 RPC 서비스, plugin host, capability를 전달하는 proxy가 대표적입니다. 판별 신호: 보안 게이트가 진입 지점 자신의 실행 context에서 상태를 가져오지 않는 경우입니다. 대신frame->/document()->/page()->를 타고 들어가 상태에 도달한다면 같은 패턴입니다. -
공허하게 통과할 수 있는 negative assertion 회귀 테스트. 좁은 범위:
PopunderPreventedViaDualEventListeners가 실제로 차단 경로를 지나가는지 검증할 필요가 있습니다. 이 테스트는EXPECT_FALSE(focusCalled)를 검사하지만, 그 trigger는if (popup && popup.refocusOpener)안쪽에 놓여 있습니다. 게다가 popup은 opener와 cross-origin 관계입니다(domain2.com대domain1.com). 이 경우WindowProxy는 allowlist에 등록된 property만 노출합니다. 여기에 positive control을 추가하는 편이 좋습니다. same-origin 변형을 하나 두거나, 호출이 실제로 시도되었다는 사실을 검사하는 assertion을 넣는 방식입니다. 그래야 fix가 되돌려졌을 때 테스트가 실패하게 됩니다. 넓은 범위: negative assertion을 쓰면서 trigger가 feature detection이나 capability guard 뒤에 놓인 TestWebKitAPI/WPT 케이스라면 같은 약점을 갖습니다. WebKit의 user-gesture, popup blocker, permission 관련 테스트에서 흔히 보이는 형태입니다. 가장 넓은 범위: positive control이 짝으로 붙어 있지 않은 negative assertion 테스트는 언어나 프레임워크를 가리지 않습니다. 무관한 제약이 trigger를 막기 시작하는 순간부터, 어느 테스트 스위트에서든 아무 신호 없이 무력화됩니다. 판별 신호: 어떤 flag에 대해EXPECT_FALSE/assert_false를 검사하는데, 그 flag를 설정하는 유일한 경로가 optional 호출이나 guard 뒤에 있는 경우입니다. guard가 실제로 통과된다는 사실을 증명하는 짝 테스트까지 없다면 같은 패턴에 해당합니다. -
생성 경로의 정책 검사를 건너뛰는 lookup 성공 경로. 좁은 범위:
WebCore::createWindow()의 나머지 early-exit 분기들을 점검합니다. 이미 존재하는 named target을 재사용할 때 popup blocking, sandbox flag,noopener/noreferrer처리, navigation policy 검사가 우회되지 않는지 확인해야 합니다. 이 검사들은 새 page를 생성하는 경로에서 수행되는데, named target 재사용 분기는 그 지점에 닿기 전에 반환합니다. 넓은 범위: "있으면 찾아 쓰고 없으면 만든다" 형태를 가지면서 검증 로직이 생성 경로에만 있는 함수라면 모두 점검 대상입니다. resource cache, worker 및SharedWorker재사용, service worker의 client 매칭이 여기에 해당합니다. 가장 넓은 범위: lookup으로 얻은 객체도 생성 시와 동일한 정책으로 검증되어야 한다는 원칙은 어떤 코드베이스의 memoized 리소스나 interned 리소스에도 적용됩니다. connection pool, object cache, 그리고 occupied 분기가 vacant 분기의 검사를 건너뛰는 Rustentry()계열 API가 대표적입니다. 판별 신호: lookup 결과로 찾아낸 기존 객체를 먼저 반환해 버리는 함수입니다. 생성 경로가 권한 검사를 수행하는 블록에 도달하기 전에 반환한다면 같은 패턴에 해당합니다.