c5006bd78735e777c3127591880f223cf77ff9f3 [Site Isolation] Transient activation associated with forced user gesture can outlive its associated frame
Triage note: Tracks m_outstandingForcedUserActivations per frame and revokes them in other processes on didDestroyFrame, didCommitLoadForFrame and dispatchProcessDidTerminate (revokeOutstandingForcedUserActivationsForFrame/ForProcess). Previously forced user-activation tokens propagated to other content processes were never cleaned up, leaving a lingering user-gesture state that content could rely on after the originating frame was gone — a user-activation policy bypass reachable from web content under site isolation.
The bug at a glance
Under site isolation, forced user-activation tokens that WebPageProxy propagated to peer WebContent processes were never revoked when the originating frame was destroyed, navigated, or its process terminated, leaving a stale ‘user is activated’ state those processes could rely on. This is a UIProcess-mediated user-activation policy bypass reachable from web content; severity medium is appropriate. The impact is unauthorized use of gesture-gated behaviors (popups, fullscreen, autoplay, downloads, etc.) without a genuine gesture, not memory corruption or RCE.
Forced (programmatic) user activation is cross-process capability state held in the UIProcess, but it had a lifecycle hole: it was created and point-revoked, yet never garbage-collected when the frame/process that owned it went away, so the activation outlived its frame.
Root cause
The violated invariant is that a forced user-activation token is only live while its originating frame exists and is on its current document; once the frame is destroyed, commits a new load, or its process dies, the token must be revoked in every process that received it. Pre-patch, WebPageProxy broadcast forced activations to other processes (on didNotifyUserActivation) and revoked a token only when the content explicitly called back (didRevokeForcedUserActivation), with no teardown-time cleanup. A frame could force an activation, propagate it to a sibling cross-origin process, and then be removed/navigated; the peer process kept treating itself as activated.
The patch adds UIProcess bookkeeping: HashMap<FrameIdentifier, HashSet> m_outstandingForcedUserActivations. didNotifyUserActivation() records ‘(sourceFrameID -> token)’ when a forcedActivationToken is present; didRevokeForcedUserActivation() removes it and calls the new revokeForcedUserActivationInOtherProcesses(token), which broadcasts Messages::WebPage::RevokeForcedUserActivation to every content process except the token’s owning process, now keyed on token.processIdentifier() rather than on the message sender. Three lifecycle hooks drain the map: didDestroyFrame() and didCommitLoadForFrame() call revokeOutstandingForcedUserActivationsForFrame(frameID) (which, for a main frame, exchanges and flushes the whole map, and for a subframe revokes the frame plus its traverseNext descendants), and dispatchProcessDidTerminate() calls revokeOutstandingForcedUserActivationsForProcess(process), which rebuilds the map keeping only tokens not owned by the terminated process and revokes the rest.
This is entirely identifier/HashMap bookkeeping in the UIProcess (FrameIdentifier, UserGestureTokenIdentifier value types); there is no object lifetime or raw-pointer issue. If the triage had implied memory unsafety it would be wrong — the diff supports only a policy/state-cleanup defect, which matches the PolicyBypass bugclass.
Key code
Recording forced activations and draining them on frame/navigation/process teardown
// didNotifyUserActivation: remember the forced token per source frame
if (forcedActivationToken) {
auto result = m_outstandingForcedUserActivations.add(sourceFrameID, HashSet<UserGestureTokenIdentifier> { });
result.iterator->value.add(*forcedActivationToken);
}
// New teardown drain (subframe path)
Vector<FrameIdentifier> frameIDs { frameID };
if (frame) {
for (RefPtr descendant = frame->traverseNext(frame.get()); descendant; descendant = descendant->traverseNext(frame.get()))
frameIDs.append(descendant->frameID());
}
for (auto frameID : frameIDs) {
for (auto forcedActivationToken : m_outstandingForcedUserActivations.take(frameID))
revokeForcedUserActivationInOtherProcesses(forcedActivationToken);
}
// Broadcast keyed on the token's owning process
if (process.coreProcessIdentifier() != token.processIdentifier())
process.send(Messages::WebPage::RevokeForcedUserActivation(token), pageID);
Fix approach
The patch gives the UIProcess an authoritative ledger of outstanding forced activations keyed by frame and proactively revokes them at every lifecycle boundary where the owning frame ceases to be current: frame destruction (didDestroyFrame), navigation commit (didCommitLoadForFrame), and content-process termination (dispatchProcessDidTerminate). revokeOutstandingForcedUserActivationsForFrame treats a main frame as draining the entire page’s outstanding set (std::exchange of the whole map) and a subframe as draining that frame plus its descendants via traverseNext; revokeOutstandingForcedUserActivationsForProcess filters the map to drop tokens whose UserGestureTokenIdentifier belongs to the dead process. The revoke broadcast is also corrected to target by token.processIdentifier() (the token’s true owner) instead of the current message sender, so revocation reaches the right peers. The net effect is that a forced activation can no longer outlive its frame or process.
Patch walkthrough
Source/WebKit/UIProcess/WebPageProxy.cpp— Records forced tokens in m_outstandingForcedUserActivations on didNotifyUserActivation; adds revokeForcedUserActivationInOtherProcesses/ForFrame/ForProcess; calls the frame drain from didDestroyFrame and didCommitLoadForFrame and the process drain from dispatchProcessDidTerminate; fixes the revoke broadcast to key on the token’s owning process.Source/WebKit/UIProcess/WebPageProxy.h— Declares the three new revoke helpers and adds the HashMap<FrameIdentifier, HashSet> m_outstandingForcedUserActivations member.
Background
User activation — The browser’s record that a user gesture occurred; many APIs (popups, fullscreen, autoplay-with-sound, downloads) are gated on transient or sticky activation.
Forced user activation — A programmatically propagated activation token (UserGestureTokenIdentifier) that, under site isolation, can be pushed from one process to others so a cross-process action is treated as user-initiated.
Site isolation / cross-process activation — Because frames of different sites run in different WebContent processes, the UIProcess (WebPageProxy) must relay and revoke activation state between them.
FrameIdentifier / UserGestureTokenIdentifier — UIProcess-visible value identifiers; the new HashMap maps a source frame to the set of forced tokens it introduced, and the token carries its owning processIdentifier().
Lifecycle hooks — didDestroyFrame, didCommitLoadForFrame, and dispatchProcessDidTerminate are the WebPageProxy points where a frame/process stops being current and outstanding activations must be drained.
traverseNext — Frame-tree walk used to enumerate a subframe’s descendants so their outstanding tokens are revoked together with the frame.
Vulnerability window
- Setup — Attacker page hosts a cross-origin subframe in a separate process under site isolation.
- Force — The subframe triggers a forced user activation; WebPageProxy broadcasts the token to peer processes (didNotifyUserActivation).
- Teardown — The originating frame is removed or navigates, but pre-patch no revocation is sent to the peers.
- Abuse — A peer process still believes it is user-activated and performs a gesture-gated action without a real gesture.
- Fix — didDestroyFrame/didCommitLoadForFrame/dispatchProcessDidTerminate drain m_outstandingForcedUserActivations and broadcast RevokeForcedUserActivation.
Proof of concept
No test ships in the diff. Conceptual trigger (web path inferred): with site isolation enabled, from a cross-origin subframe cause a forced user activation to propagate to a peer process, then remove or navigate the subframe (or crash its process). In the peer, attempt a gesture-gated action (e.g. window.open without a popup block, or fullscreen); pre-patch it succeeds because the stale activation was never revoked, post-patch it is blocked.
Exploitation
- Reach — Reachable from web content able to host a cross-origin, cross-process subframe and induce a forced activation under site isolation.
- Trigger — Propagate a forced activation then tear down the originating frame/process so the token is orphaned in peer processes.
- Primitive — Primary: a durable false ‘activated’ state in a peer process; secondary: gesture-gated capabilities (popups, fullscreen, autoplay, downloads) available without a gesture.
- Escalation (inferred) — Open questions: how long and how reliably the stale activation persists, and whether the specific gated capabilities reachable this way (e.g. download or fullscreen spoofing) can be chained into a user-facing attack; no memory-safety escalation is implied.
Detection & hunting
For defenders and detection engineers:
- Activation without matching gesture — Correlate gesture-gated action grants in a content process with the absence of a live originating frame/activation in the UIProcess ledger.
- Outstanding tokens after teardown — Assert m_outstandingForcedUserActivations is drained for a frame after didDestroyFrame/didCommitLoadForFrame and for a process after termination.
- Revoke targeting — Verify RevokeForcedUserActivation is delivered to all processes except the token’s true owner (token.processIdentifier()).
Remaining risk in the patch
- Owner process is not messaged — revokeForcedUserActivationInOtherProcesses skips the process equal to token.processIdentifier(), so the token’s own process must clean up its local activation independently; if it does not, the stale state persists in the owner.
- Only tracked tokens are drained — The ledger is populated only when didNotifyUserActivation carries a forcedActivationToken; any other path that establishes a forced activation without that notification would not be revoked by these drains.
- Subframe drain scope — revokeOutstandingForcedUserActivationsForFrame on a non-main frame revokes only that frame plus its traverseNext descendants, so a token recorded under a different frame id than the one being torn down could linger until another boundary flushes it.
Audit directions
- Other cross-process capability state — Look for UIProcess state propagated to peers (sticky activation, autoplay allowances, permission grants) that lacks a teardown-time revocation path.
- Frame vs process keying — Audit whether all forced tokens are attributed to a tracked sourceFrameID; untracked introduction paths would escape the drain.
- Navigation boundaries — Check back/forward-cache and process-swap navigations where didCommitLoadForFrame semantics differ, to ensure stale activations are revoked there too.
- Owner-process self-revocation — Confirm the owning process (skipped by revokeForcedUserActivationInOtherProcesses) cleans up its own forced activation on the same boundaries.