Escape key no longer guarantees cancelling fullscreen (with keyboard lock) (311660)
CVE: CVE-2026-64730 · Safari 26.6 · Released July 27, 2026 Impact: Visiting a website that frames malicious content may lead to UI spoofing Apple's description: The issue was addressed with improved UI. Credit: Kagami Rosylight of Mozilla
Medium — no memory is touched, but the feature that shipped enabled hands a page the Escape key and withholds the notice that told the user what to press instead. The escalation ceiling is phishing against a user who believes the exit gesture is broken, not a primitive.
Fullscreen is the one Web API that lets a page ask for the entire display, and the trade it makes is explicit: the browser hides its own URL bar, tab strip, and security indicators, and in exchange guarantees the user a way out that the page cannot intercept. Keyboard lock deliberately erodes half of that guarantee — it exists so that in-browser terminals, remote-desktop clients, and games can receive Escape, Meta combinations, and function keys instead of having the browser eat them. The engine compensates by substituting a press-and-hold Escape (roughly 1.5 seconds) as the unsuppressible exit path, which means the guarantee now rests on the user knowing that gesture exists.
The angle: A page that frames hostile content can take the whole screen, swallow the short Escape press, and hold the user in an attacker-painted surface long enough to impersonate the browser's own interface or another site's login page.
Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml
Patch Details
One file, one preference, six lines of value changes. FullScreenKeyboardLock moves from status: stable back to status: unstable, and its defaultValue flips from true to false for all three consumers — WebKitLegacy, WebKit, and WebCore. The condition: ENABLE(FULLSCREEN_API) build gate is untouched, as are the human-readable name and description strings.
What the diff does not contain is the important part. No C++ changes. No IDL binding changes. Nothing in the fullscreen exit path, the key-event routing, or the hold-timer logic. The commit message names this precisely: a revert of 303093@main, the commit that flipped the flag on in the first place. The keyboard-lock implementation stays in the tree in full; the patch only makes it unreachable in a default build.
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)
That shape — enablement-only in, enablement-only out — is what tells you this was never an implementation defect. The engine did what it was built to do.
Background
Fullscreen API. A page calls requestFullscreen() on an element and, subject to a user-activation requirement, the element occupies the whole display. While it is active the browser withholds its own surfaces: URL bar, tab strip, TLS and permission indicators, window decorations. Everything the user can see is drawn by the page.
Keyboard lock. A separate capability, only meaningful in fullscreen, that routes normally-reserved key combinations to the page's key event handlers rather than letting the browser or the OS consume them. Escape is the interesting member of that set, because Escape is also the browser's default fullscreen-exit gesture. Locking it is the whole point for a web terminal or a streamed remote desktop, where a bare Escape must reach the far end.
Press-and-hold exit gesture. Because a locked Escape would otherwise leave the user with no keyboard exit, engines substitute a held Escape — on the order of a second and a half — as an exit path the page cannot suppress. The gesture is engine-side and unconditional; it is paired, by design, with embedder-owned user-interface text that tells the user to hold the key. Two layers, two owners, one contract.
UnifiedWebPreferences.yaml. WTF's single source of truth for WebKit feature flags. A build script under Source/WTF/Scripts/Preferences/ reads it and generates the per-framework WebPreferences accessors that WebKit, WebKitLegacy, and WebCore consume. Two fields matter here. status is the maturity label: stable means shipped and on by default, unstable means still behind a switch. defaultValue carries the per-consumer initial value; embedders remain free to override it at runtime.
Analysis
This is a mitigation-completeness failure — half of a two-part safety contract shipped enabled while the other half was still unwritten.
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)
Follow the two branches in the diagram. Branch (a) — the engine-side hold gesture — was implemented and works; a user holding Escape for the full interval leaves fullscreen no matter what the page does. Branch (b) is where the invariant breaks. The commit message states plainly that the user-interface work instructing the user to hold Escape was never completed. A mitigation whose entire safety argument is the user knows to do this instead is not shipped until the part that tells them is shipped too.
The consequence is a discoverability failure that reads to the user as a broken key. Concretely:
- A page enters fullscreen and requests keyboard lock.
- The user, wanting out, presses
Escapethe way they always have — a short press. - The press is routed to the page's key handlers. Fullscreen does not end.
- Nothing on screen explains why, because the explanatory UI does not exist.
- The user concludes the key is not working and keeps looking at whatever the page is drawing.
Step 5 is the attacker's window, and its length is bounded by the user's patience rather than by anything the engine enforces. During it the page owns every pixel with the browser's own trust indicators suppressed — which is exactly the condition under which a convincing replica of browser UI, an OS-style dialog, or another origin's login form becomes plausible. Apple's impact line pins the reachable case: visiting a website that frames malicious content. The top-level page the user chose to visit need not be the hostile one; framed third-party content inherits the same fullscreen surface, so the origin the user believes they are looking at and the origin painting the screen can differ.
What an attacker gains from this is credential phishing and false origin attribution, not a memory-corruption primitive — nothing here crosses a process boundary, and none needs to. The whole issue lives inside the WebContent process and its rendering of the fullscreen surface; the target is the human at the keyboard, not process isolation. It is worth being precise about the ceiling too: the user is not genuinely trapped. Holding Escape still works, and OS-level window management is untouched. The exit route is undiscoverable, not absent.
The fix restores the invariant by removing reachability rather than by repairing behavior. With defaultValue: false on all three consumers, a page's keyboard-lock request no longer takes effect in a default build, so the short Escape press returns to being the fullscreen-exit gesture the user already knows. The status: unstable demotion is the matching bookkeeping: the flag stops asserting that every collaborating layer is complete. Per the commit message, the enablement is expected to reland once the instructional UI is available.
Keyboard lock shipped enabled while the UI that would have told users to hold Escape did not, leaving a page free to swallow the exit gesture and own the entire display.
Insight
Note the shape of the fix: the revert is the whole fix. The keyboard-lock implementation remains in the tree, so any embedder or downstream port that flips FullScreenKeyboardLock back on re-enters the identical state — with the instructional UI still missing. A non-default true here should be read as re-introducing the issue, not as a supported configuration. More generally, status: stable is an assertion that engine, embedder UI, and platform HUD are all finished, and nothing mechanical checks that assertion; it is one token in one YAML file standing in for readiness work that lives in other repositories entirely.
Audit directions
-
Capabilities that suppress a reserved user gesture, where the compensating affordance is owned by a different layer than the enforcement. The failure mode is that the enforcing layer can ship and be enabled independently of the informing layer, so the mitigation looks present in code review while being invisible to the user. Narrow: grep
Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yamlfor otherstatus: stableflags in thedomandhtmlcategories that gate input interception or chrome suppression — pointer lock, screen orientation lock (FullscreenRequirementForScreenOrientationLockingEnabledsits directly adjacent in this file),beforeunloadprompting, system-key handling — and for each confirm the corresponding embedder HUD/toast string actually exists underSource/WebKit/UIProcessrather than assuming the engine gesture suffices. Match tell: a preference defaulted true whose description mentions lock/capture/suppress, with no matching localizable string in the embedder. Wider: the same class appears in any consent-or-notice mechanism split across process boundaries — permission prompts rendered by the UI process for capabilities enforced in WebContent, autoplay and audio-capture indicators, screen-capture "you are sharing" banners; the tell is enforcement and notification living in different processes with no shared readiness gate. Widest: the reusable invariant — if a feature removes a user's default escape hatch, the replacement must be discoverable through a channel the feature cannot suppress — holds in Chromium's keyboard-lock exit bubble, in mobile OS immersive and kiosk modes, and in any embedded UI that traps input. The question to ask at that rung: if the user does the obvious thing and nothing happens, what tells them what to do instead? If the answer is "nothing", the mitigation is incomplete. -
The gap between
status: stableand actual cross-layer readiness in the preferences pipeline — a single-token maturity declaration implicitly asserting completeness of work outside the file's own repository slice. Narrow: review the generator scripts underSource/WTF/Scripts/Preferences/and check whether anything validates that astableflag has platform-side support on every listed consumer; the uniform three-waydefaultValueblock in this diff shows the flag can be flipped acrossWebKit,WebKitLegacy, andWebCorewith no per-port readiness check. Wider: the same shape shows up in any feature-flag system where the flag name is the only coupling between subsystems — build-timeENABLE()macros paired with runtime preferences, and experimental-versus-internal-debug flag tiers that can diverge per port. Widest: the invariant is that a flag's maturity label must be derived from the readiness of every layer it activates, not asserted by the layer that happens to own the flag file — which applies equally to LaunchDarkly-style flag services and to Chromium'sbase::Featureplus finch-config split. Match tell: any flag whose enablement commit touches only YAML/config and no UI or platform code, then later gets reverted the same way; grep this file's revert history for that signature to enumerate features that shipped ahead of their UI. -
Ports and embedders that override
FullScreenKeyboardLockto true after this revert. Because the patch changes only the default and leaves the implementation intact, the vulnerable state stays reachable by configuration. ExamineSource/WebKit/UIProcessfullscreen controllers and any port-specific preference bundles or test harnesses that set fullscreen preferences en masse, and verify none enable this flag as a side effect of enabling fullscreen generally. Match tell: any call site that sets fullscreen-related preferences by iterating a category or matching a name prefix rather than naming individual flags — category-wide enablement silently re-enablesunstableflags. This rung is WebKit-specific by construction; the ceiling is the set of downstream ports consuming this YAML, and it does not generalize past them.