← WebKit Silent-Fix Report — 2026-W22

cfbce92003  postMessage can indefinitely extend the lifetime of a user gesture token

severity medium class Bypass confidence 0.75 WebCore LocalDOMWindow postMessage exploitable-grade
Ryosuke Niwa Fri May 29 01:02:56 2026 -0700 full: cfbce92003b43e255d602b612edd120c86b83ca3 bug report ↗ view on GitHub ↗
Primitive: indefinitely extended user-gesture token enables popups
Triage note: Expires the forwarded user-gesture token so a postMessage loop can no longer keep a stale gesture alive to bypass popup blocking.
Contents

The bug at a glance

OBSERVED: in LocalDOMWindow::processPostMessage, before constructing the UserGestureIndicator for the forwarded gesture, the patch nulls userGestureToForward if it has already expired past UserGestureToken::maximumIntervalForUserGestureForwarding. INFERRED: previously a chain of postMessage relays kept re-installing the same UserGestureToken, indefinitely extending an otherwise-expired gesture and letting a page open popups (or perform other gesture-gated actions) long after the real user activation should have lapsed. Medium: a user-activation / popup-blocker bypass, an abuse/annoyance and phishing-adjacent control, not memory corruption.

User activation is supposed to be short-lived: a gesture token expires after a bounded interval so that gesture-gated capabilities (window.open, etc.) cannot be hoarded. postMessage forwards the active gesture token to the message dispatch so a message handler can act with the originating gesture. The bug: the forwarding path re-created a live UserGestureIndicator from the token without checking whether the token had already expired, so a self-driving postMessage loop refreshed the indicator each hop and kept a dead gesture perpetually alive.

Root cause

When script calls postMessage, LocalDOMWindow captures the currently active user gesture token (userGestureToForward) so that the asynchronously-dispatched message event can run with the same user-activation state as the caller – this is deliberate, so that e.g. a click that triggers a postMessage can still open a window in the message handler. The dispatch is guarded by a scoped UserGestureIndicator constructed from userGestureToForward.

UserGestureToken carries an expiry: hasExpired(interval) reports whether more than interval has elapsed since the activation, and UserGestureToken::maximumIntervalForUserGestureForwarding is the ceiling on how long a gesture may legitimately be forwarded. The intent is that after that window the token is stale and must no longer confer gesture-gated privileges.

The pre-patch code constructed UserGestureIndicator userGestureIndicator(userGestureToForward) unconditionally. Because each postMessage in a relay chain re-installs the token as the active gesture for the duration of the handler, and the handler in turn issues another postMessage that re-captures that same (now-active) token, the effective activation was continually refreshed. A page could therefore bounce a message to itself in a loop, and the gesture token would remain ’live’ indefinitely – long past maximumIntervalForUserGestureForwarding – so a window.open() issued seconds later still counted as user-initiated and evaded the popup blocker.

The fix inserts, immediately before building the indicator: if (userGestureToForward && userGestureToForward->hasExpired(UserGestureToken::maximumIntervalForUserGestureForwarding)) userGestureToForward = nullptr;. Once the token is older than the forwarding ceiling it is dropped, so the UserGestureIndicator is constructed with a null token and the dispatched handler runs without user activation. The relay loop can still spin, but each hop now checks real elapsed time against the original activation, so after the interval the gesture is gone and popup blocking re-engages. OBSERVED: the added test postMessages in a loop for 2000 ms and asserts that a window.open() after ~1 s is blocked.

Key code

Expire the forwarded user-gesture token in processPostMessage

        auto& vm = globalObject->vm();
        auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);

+        if (userGestureToForward && userGestureToForward->hasExpired(UserGestureToken::maximumIntervalForUserGestureForwarding))
+            userGestureToForward = nullptr;
+
        UserGestureIndicator userGestureIndicator(userGestureToForward);
        InspectorInstrumentation::willDispatchPostMessage(frame, postMessageIdentifier);

Patch walkthrough

  • Source/WebCore/page/LocalDOMWindow.cpp — In processPostMessage, just before UserGestureIndicator userGestureIndicator(userGestureToForward), a check was added: if userGestureToForward exists and has expired past UserGestureToken::maximumIntervalForUserGestureForwarding, set it to nullptr. This ensures a token older than the forwarding window no longer confers user activation to the dispatched message handler, breaking the indefinite-extension loop.
  • LayoutTests/fast/events/popup-blocked-after-user-gesture-is-expired-after-post-message.html — New test: after a real activation, it runs a postMessage loop for 2000 ms, then attempts window.open. It asserts PASS only if the popup is blocked (didOpenWindow stays false), confirming the forwarded gesture expired despite the continuous relay.

Background

UserGestureToken — WebCore object representing a user activation. It records when the activation occurred and exposes hasExpired(interval); gesture-gated APIs consult the active token to decide whether an action is user-initiated.

maximumIntervalForUserGestureForwarding — A UserGestureToken constant bounding how long a gesture may be forwarded across async boundaries (like postMessage). Beyond it, a forwarded token should be treated as stale.

UserGestureIndicator — A scoped RAII object that installs a given token as the active user gesture for the duration of its lifetime – here, for the postMessage event dispatch.

Gesture forwarding via postMessage — processPostMessage captures the caller’s active gesture (userGestureToForward) so the async message handler runs with the same activation, intentionally enabling e.g. a handler to open a window on behalf of a genuine click.

Vulnerability window

  1. Feature — postMessage forwards the active user gesture so message handlers can perform gesture-gated actions consistent with the originating activation.
  2. Flaw — The forwarding path re-installed the token unconditionally, so a self-relaying postMessage loop kept refreshing an already-expired gesture.
  3. Abuse — A page could keep a dead gesture alive indefinitely and open popups long after real user activation lapsed, bypassing the popup blocker.
  4. Discovery — bug 310863 / rdar://173355201 identified the indefinite lifetime extension.
  5. Fix — Token is nulled once it exceeds maximumIntervalForUserGestureForwarding, so forwarded activation truly expires; test confirms popup blocked after 1 s.

Proof of concept

VERBATIM from the added test. A genuine activation (UIHelper.activateElement) starts runTest, which spins a self-postMessage loop for 2000 ms and only then calls window.open. Pre-patch the forwarded gesture stayed alive across the loop and the popup opened (FAIL); post-patch the token expires after maximumIntervalForUserGestureForwarding and the popup is blocked (PASS).

function runTest(target)
{
    postMessageLoop(() => {
        const newWindow = window.open('about:blank', '_blank');
        if (newWindow && newWindow.document) {
            newWindow.document.open();
            newWindow.document.write('<!DOCTYPE html><body><script>opener.didOpenWindow = true;</' + 'script>');
        }
        result.textContent = didOpenWindow ? 'FAIL - window did open' : 'PASS';
        if (window.testRunner)
            testRunner.notifyDone();
    });
}

function postMessageLoop(callback)
{
    const start = Date.now();
    window.onmessage = function () {
        if (Date.now() > start + 2000)
            return callback();
        postMessage({}, '*');
    };
    postMessage({}, '*');
}

Exploitation

  1. Seed a real gesture — Obtain one genuine user activation (a click) that starts a postMessage relay.
  2. Keep it alive — Bounce empty postMessage messages to self in a loop; each hop pre-patch re-installed the same token, refreshing activation indefinitely.
  3. Deferred popup — Long after the click, issue window.open (or other gesture-gated action); pre-patch it counts as user-initiated, defeating the popup blocker for delayed/ad-driven popups and phishing windows.

Detection & hunting

For defenders and SOC / detection engineers:

  • window.open long after activation
  • Non-expiring forwarded token

Audit directions

  • Other gesture-forwarding sites
  • hasExpired usage
  • Popup / gesture-gated APIs

Before / after

Loading diff…