Escape 키로 keyboard lock이 걸린 fullscreen을 취소한다는 보장이 사라진 문제 (311660)
CVE: CVE-2026-64730 · Safari 26.6 · 2026년 7월 27일 릴리스 Impact: 악성 콘텐츠를 프레임으로 삽입한 웹사이트를 방문하면 UI spoofing으로 이어질 수 있음 Apple 설명: 개선된 UI로 문제를 해결함 Credit: Kagami Rosylight of Mozilla
Medium — 메모리를 건드리는 문제는 아니지만, 출시된 기능이 페이지에 Escape 키를 넘겨주면서도 사용자에게 "대신 무엇을 눌러야 하는지" 알려주는 안내는 빠져 있었습니다. 이 문제가 확장될 수 있는 한계는 primitive가 아니라, exit 제스처가 고장 났다고 믿는 사용자를 노리는 phishing입니다.
Fullscreen은 페이지가 화면 전체를 요청할 수 있는 유일한 Web API이며, 그 대가로 명확한 계약을 맺습니다. 브라우저는 자신의 URL 바, 탭 스트립, 보안 표시기를 모두 숨기는 대신, 페이지가 가로챌 수 없는 탈출 수단을 사용자에게 보장합니다. Keyboard lock은 이 보장 중 절반을 의도적으로 깎아내리는 기능입니다. 브라우저 내장 터미널, 원격 데스크톱 클라이언트, 게임 등이 Escape, Meta 조합, function 키를 브라우저가 가로채지 않고 그대로 전달받을 수 있도록 하기 위해 존재합니다. 대신 엔진은 약 1.5초간 눌러야 하는 press-and-hold Escape를 페이지가 억제할 수 없는 탈출 경로로 제공하는데, 결국 이 보장은 사용자가 그 제스처의 존재를 알고 있는지에 의존하게 됩니다.
관전 포인트: 악성 콘텐츠를 프레임으로 삽입한 페이지가 화면 전체를 장악한 뒤 짧은 Escape 입력을 흡수해버리면, 사용자는 attacker가 그려낸 화면 안에 계속 머무르게 됩니다. 이 시간 동안 브라우저 자체의 UI나 다른 사이트의 로그인 페이지를 흉내 낼 여지가 생깁니다.
Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml
Patch Details
파일 하나, preference 하나, 값 변경 6줄로 이루어진 patch입니다. FullScreenKeyboardLock은 status: stable에서 status: unstable로 되돌아가고, WebKitLegacy, WebKit, WebCore 세 consumer 모두에서 defaultValue가 true에서 false로 바뀝니다. condition: ENABLE(FULLSCREEN_API) build gate는 그대로 유지되며, human-readable name과 description 문자열도 손대지 않습니다.
이 diff에 담기지 않은 부분이 오히려 중요합니다. C++ 코드 변경이 없습니다. IDL binding 변경도 없습니다. fullscreen exit 경로, key-event routing, hold-timer 로직 어디에도 손을 대지 않았습니다. commit message는 이 점을 정확히 명시합니다. 이 patch는 플래그를 처음 켰던 303093@main 커밋의 revert입니다. keyboard-lock 구현 자체는 트리에 온전히 남아 있고, 이 patch는 단지 기본 빌드에서 도달 불가능하게 만들 뿐입니다.
Before (303093@main): After (this revert):
status: stable status: unstable
└─► default: true └─► default: false
└─► page may lock └─► lock request inert
└─► short Esc → page └─► short Esc → exit fullscreen
(hold-to-exit UI
never shipped)
enablement만 켰다가 enablement만 다시 끈, 이 단순한 형태 자체가 이 문제가 구현상의 결함이 아니었음을 말해줍니다. 엔진은 원래 설계된 대로 동작했을 뿐입니다.
Background
Fullscreen API. 페이지가 요소에 대해 requestFullscreen()을 호출하면, user-activation 요구 조건을 만족하는 한 그 요소가 화면 전체를 차지합니다. 이 상태에서는 브라우저 자신의 화면 요소, 즉 URL 바, 탭 스트립, TLS 및 permission 표시기, 윈도우 장식이 모두 가려집니다. 사용자 눈에 보이는 모든 것은 페이지가 직접 그린 화면입니다.
Keyboard lock. fullscreen 상태에서만 의미를 갖는 별도의 기능으로, 평소 브라우저나 OS가 가로채던 키 조합을 브라우저가 소비하지 않고 페이지의 key event handler로 그대로 라우팅합니다. 이 조합 중에서도 Escape가 특히 중요한데, Escape는 동시에 브라우저의 기본 fullscreen 탈출 제스처이기도 하기 때문입니다. 웹 터미널이나 스트리밍되는 원격 데스크톱 입장에서는 순수한 Escape 입력이 반대편 끝까지 전달되어야 하므로, 이를 잠그는 것이 바로 이 기능의 목적입니다.
Press-and-hold exit gesture. Escape가 잠기면 사용자에게 키보드로 빠져나갈 방법이 아예 없어지는 상황을 막기 위해, 엔진들은 약 1.5초가량 Escape를 누르고 있는 동작을 페이지가 억제할 수 없는 탈출 경로로 제공합니다. 이 제스처는 엔진 쪽에서 무조건적으로 동작하도록 설계되어 있으며, 설계상 사용자에게 "키를 누르고 있어야 한다"고 알려주는 embedder 소유의 UI 텍스트와 짝을 이루게 되어 있습니다. 계층은 둘, 소유 주체도 둘이지만, 계약은 하나입니다.
UnifiedWebPreferences.yaml. WebKit feature flag에 대한 WTF의 single source of truth입니다. Source/WTF/Scripts/Preferences/ 아래의 build script가 이 파일을 읽어, WebKit·WebKitLegacy·WebCore가 사용하는 framework별 WebPreferences accessor를 생성합니다. 여기서 중요한 필드는 두 개입니다. status는 성숙도 표시로, stable은 출시되어 기본으로 켜져 있음을, unstable은 여전히 스위치 뒤에 숨어 있음을 의미합니다. defaultValue는 consumer별 초기값을 담고 있으며, embedder는 런타임에 이 값을 자유롭게 override할 수 있습니다.
Analysis
이 문제는 mitigation-completeness 실패에 해당합니다. 안전을 담보하는 두 부분짜리 계약 중 절반만 켜진 채 출시되었고, 나머지 절반은 아직 작성되지 않은 상태였습니다.
page enters fullscreen ──► requests keyboard lock
│ │
│ ├─► (a) engine: hold-Esc 1.5s exit [SHIPPED]
│ └─► (b) embedder: "hold Esc to exit" [MISSING]
│
▼
user presses Esc briefly ──► delivered to page handlers
│ │
└─ nothing visibly happens └─ attacker keeps full display
(no URL bar, no indicators)
다이어그램의 두 분기를 따라가 보겠습니다. 분기 (a), 즉 엔진 쪽 hold 제스처는 구현되어 정상 동작합니다. 사용자가 Escape를 전체 구간만큼 누르고 있으면 페이지가 무엇을 하든 fullscreen을 벗어날 수 있습니다. 문제는 분기 (b)에서 invariant가 깨진다는 점입니다. commit message는 사용자에게 Escape를 누르고 있으라고 안내하는 UI 작업이 완료되지 않았다는 사실을 명확히 밝히고 있습니다. mitigation 전체의 안전성 논리가 사용자가 대신 이 동작을 알고 있다는 전제에 의존하는 이상, 그 사실을 알려주는 부분까지 함께 출시되기 전까지는 완성되었다고 볼 수 없습니다.
그 결과 사용자 입장에서는 키가 고장 난 것처럼 보이는 discoverability 실패로 이어집니다. 구체적인 흐름은 다음과 같습니다.
- 페이지가 fullscreen에 진입하고 keyboard lock을 요청합니다.
- 빠져나가고 싶은 사용자가 평소처럼
Escape를 짧게 누릅니다. - 이 입력은 페이지의 key handler로 전달됩니다. fullscreen은 종료되지 않습니다.
- 설명 UI 자체가 존재하지 않기 때문에, 화면상에는 아무 이유도 나타나지 않습니다.
- 사용자는 키가 작동하지 않는다고 결론짓고, 페이지가 그리고 있는 화면을 계속 바라보게 됩니다.
5번 단계가 바로 attacker에게 열리는 시간대이며, 그 길이는 엔진이 강제하는 무언가가 아니라 사용자의 인내심에 달려 있습니다. 이 시간 동안 페이지는 브라우저 고유의 신뢰 표시기가 모두 억제된 채로 화면 전체를 소유하게 되는데, 이는 정교하게 만든 브라우저 UI 복제본이나 OS 스타일의 dialog, 다른 origin의 로그인 폼이 그럴듯하게 먹혀드는 조건과 정확히 일치합니다. Apple의 impact 항목도 도달 가능한 케이스를 명시합니다. 악성 콘텐츠를 프레임으로 삽입한 웹사이트를 방문하는 경우가 그것입니다. 사용자가 직접 방문한 최상위 페이지가 악성일 필요는 없습니다. 프레임으로 삽입된 third-party 콘텐츠도 동일한 fullscreen 화면을 상속받기 때문에, 사용자가 보고 있다고 믿는 origin과 실제로 화면을 그리고 있는 origin이 서로 다를 수 있습니다.
attacker가 이를 통해 얻는 것은 credential phishing과 false origin attribution이며, memory-corruption primitive는 아닙니다. 여기서는 process 경계를 넘는 동작이 전혀 없고, 애초에 그럴 필요도 없습니다. 이 문제는 전적으로 WebContent process 내부, fullscreen 화면을 렌더링하는 부분에 머물러 있으며, 공격 대상은 process isolation이 아니라 키보드 앞의 사람입니다. 한계도 분명히 짚어둘 필요가 있습니다. 사용자가 진짜로 갇히는 것은 아닙니다. Escape를 누르고 있는 방법은 여전히 유효하고, OS 레벨의 window management도 손대지 않은 상태입니다. 탈출 경로가 사라진 것이 아니라, 발견하기 어려워졌을 뿐입니다.
이 fix는 동작을 고치는 방식이 아니라 도달 가능성 자체를 제거하는 방식으로 invariant를 복원합니다. 세 consumer 모두 defaultValue: false로 바뀌면서, 기본 빌드에서는 페이지의 keyboard-lock 요청이 더 이상 효력을 갖지 않게 됩니다. 결과적으로 짧은 Escape 입력이 사용자가 이미 알고 있던 fullscreen 탈출 제스처로 다시 돌아갑니다. status: unstable로의 격하는 이에 맞춘 bookkeeping에 해당하며, 이 플래그가 더 이상 관련 계층 전체가 완성되었다는 주장을 하지 않도록 만듭니다. commit message에 따르면, 안내 UI가 준비되는 대로 이 enablement는 다시 reland될 예정입니다.
keyboard lock이 켜진 채로 출시되었지만, 사용자에게 Escape를 누르고 있으라고 알려줄 UI는 없었습니다. 그 결과 페이지가 exit 제스처를 흡수하고 화면 전체를 계속 소유할 수 있는 상태가 되었습니다.
Insight
이 fix의 형태를 눈여겨볼 필요가 있습니다. revert 자체가 fix의 전부입니다. keyboard-lock 구현은 트리에 그대로 남아 있으므로, 어떤 embedder나 downstream port가 FullScreenKeyboardLock을 다시 true로 돌리면 안내 UI가 여전히 빠진 채로 동일한 상태로 되돌아가게 됩니다. 여기서 true로의 non-default 설정은 지원되는 구성이 아니라, 이 문제를 다시 끌어들이는 행위로 읽어야 합니다. 좀 더 일반화하면, status: stable은 엔진·embedder UI·platform HUD가 모두 완성되었다는 주장이지만, 이를 기계적으로 검증하는 장치는 존재하지 않습니다. YAML 파일 하나의 토큰 하나가 전혀 다른 repository에 걸쳐 있는 준비 작업 전체를 대변하고 있는 셈입니다.
Audit directions
-
예약된 사용자 제스처를 억제하는 capability에서, 이를 보완하는 안내 수단이 enforcement 계층과 다른 계층에 속해 있는 경우. 이런 구조의 실패 양상은, enforcement 계층은 안내 계층과 독립적으로 출시되고 켜질 수 있어서 code review에서는 mitigation이 존재하는 것처럼 보이지만 실제 사용자에게는 보이지 않는다는 점입니다. Narrow:
Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml에서dom,html카테고리 안에 있으면서 input interception이나 chrome 억제를 gate하는 다른status: stable플래그들을 검색해 보십시오 — pointer lock, screen orientation lock (이 파일에서FullscreenRequirementForScreenOrientationLockingEnabled가 바로 옆에 있습니다),beforeunloadprompting, 시스템 키 처리 등이 대상입니다. 각 항목에 대해, 엔진 제스처만으로 충분하다고 가정하지 말고Source/WebKit/UIProcess아래에 실제로 대응되는 embedder HUD·toast 문자열이 존재하는지 확인하십시오. 일치 신호는 description에 lock·capture·suppress가 언급되어 있으면서 기본값이 true인 preference인데 embedder 쪽에 대응되는 localizable string이 없는 경우입니다. Wider: process 경계에 걸쳐 나뉘어 있는 consent-or-notice 메커니즘 전반에서 같은 유형이 나타납니다. WebContent에서 강제되는 capability에 대해 UI process가 렌더링하는 permission prompt, autoplay와 audio-capture 표시기, screen-capture "you are sharing" 배너 등이 그 예이며, 일치 신호는 enforcement와 notification이 서로 다른 process에 존재하면서 공유되는 readiness gate가 없는 경우입니다. Widest: 재사용 가능한 invariant는 다음과 같습니다 — 어떤 기능이 사용자의 기본 탈출 수단을 제거한다면, 그 대체 수단은 해당 기능이 억제할 수 없는 channel을 통해 발견 가능해야 한다. 이 invariant는 Chromium의 keyboard-lock exit bubble, 모바일 OS의 immersive·kiosk 모드, input을 가두는 모든 embedded UI에도 동일하게 적용됩니다. 이 단계에서 던져야 할 질문은 다음과 같습니다. 사용자가 당연한 행동을 했는데 아무 일도 일어나지 않는다면, 대신 무엇을 해야 하는지 알려주는 수단이 있는가? 답이 "없다"라면, 그 mitigation은 미완성입니다. -
preferences pipeline에서
status: stable과 실제 cross-layer readiness 사이의 간극 — 토큰 하나짜리 성숙도 선언이 해당 파일이 속한 repository 범위 밖의 작업까지 완료되었다고 암묵적으로 주장하는 구조입니다. Narrow:Source/WTF/Scripts/Preferences/아래의 generator script들을 살펴보고,stable플래그가 나열된 모든 consumer에서 platform 쪽 지원을 실제로 갖추고 있는지 검증하는 로직이 있는지 확인하십시오. 이번 diff에 나타난 균일한 3중defaultValue블록은, port별 readiness check 없이도WebKit,WebKitLegacy,WebCore전체에 걸쳐 플래그를 뒤집을 수 있음을 보여줍니다. Wider: 플래그 이름 자체가 subsystem 간 유일한 연결고리 역할을 하는 모든 feature-flag 시스템에서 같은 형태가 나타납니다. build-timeENABLE()매크로와 runtime preference가 짝을 이루는 경우, port마다 서로 어긋날 수 있는 experimental 대 internal-debug 플래그 tier가 그 예입니다. Widest: invariant는 플래그의 성숙도 라벨이 그 플래그가 활성화하는 모든 계층의 readiness로부터 도출되어야 하며, 해당 플래그 파일을 소유한 계층이 일방적으로 주장해서는 안 된다는 것입니다. 이는 LaunchDarkly류의 flag service와 Chromium의base::Feature+ finch-config 조합 모두에 동일하게 적용됩니다. 일치 신호는 enablement commit이 YAML·config만 건드리고 UI나 platform 코드는 건드리지 않은 뒤, 나중에 동일한 방식으로 revert되는 패턴입니다. 이 파일의 revert 이력에서 같은 signature를 검색하면 UI보다 앞서 출시된 기능들을 찾아낼 수 있습니다. -
이번 revert 이후에도
FullScreenKeyboardLock을 true로 override하는 port와 embedder. 이 patch는 기본값만 바꿀 뿐 구현 자체는 그대로 남겨두므로, 설정을 통해 취약한 상태에 여전히 도달할 수 있습니다.Source/WebKit/UIProcess의 fullscreen controller들과, fullscreen preference를 일괄적으로 설정하는 port별 preference bundle이나 test harness를 검토하고, fullscreen을 전반적으로 켜는 과정에서 이 플래그가 부수적으로 함께 켜지는 곳이 없는지 확인하십시오. 일치 신호는 개별 플래그를 명시적으로 지정하는 대신 카테고리를 순회하거나 이름 prefix를 매칭해서 fullscreen 관련 preference를 설정하는 호출 지점입니다 — 카테고리 단위 enablement는unstable플래그를 조용히 다시 켤 수 있습니다. 이 단계는 구조상 WebKit에 특화되어 있으며, 한계는 이 YAML을 소비하는 downstream port들의 범위이고 그 너머로는 일반화되지 않습니다.