← WebKit Silent-Fix Report — 2026-W23

4b574bf8287b6b5783596850d8a17f300b75a7a2  We should wait until we get a safe browsing response before proceeding with downloads

severity medium class Bypass confidence 0.90 WebKit UIProcess Navigation exploitable-grade
Pascoe Fri Jun 5 10:44:33 2026 -0700 full: 4b574bf8287b6b5783596850d8a17f300b75a7a2 bug report ↗ view on GitHub ↗
Primitive: Downloads proceed before Safe Browsing check completes
Triage note: decidePolicyForNavigationAction allowed PolicyAction::Download to proceed while navigation->safeBrowsingCheckOngoing() was still true; fix adds whenSafeBrowsingCheckCompletes/fireSafeBrowsingCheckCompletionCallbacks to gate the download on the Safe Browsing verdict, closing a malware/Safe-Browsing bypass for downloads.
Contents

The bug at a glance

The flaw is remotely reachable by any site that can cause a resource to be handled as a download (via decidePolicyForNavigationAction or decidePolicyForResponse returning Download), including through subframes, and it defeats the Safe Browsing verdict that would otherwise block a known-malicious download. Impact is a security-control bypass rather than direct code execution: the download still lands on disk and the user must open it, so it is an integrity/UX-safety degradation gated on a slow Safe Browsing response, consistent with Medium/6.1.

Safe Browsing on the web has a deliberate escape hatch: if the reputation lookup is too slow, WebKit lets the page load anyway and paints the red warning late. That is tolerable for a page you can still slam shut, but a download has no late-delivery channel: once the bytes start streaming to disk, there is no warning interstitial to retroactively stop it. This patch is the recognition that PolicyAction::Download was inheriting the page’s “proceed on timeout” behavior, so a malicious file flagged by Safe Browsing could begin downloading before (or regardless of) the verdict. The fix stops treating a download like a page and makes it actually wait for the answer.

Root cause

In WebPageProxy, navigation policy decisions run through decidePolicyForNavigationAction and decidePolicyForResponseShared. Each navigation carries an API::Navigation object that tracks the Safe Browsing lookup via m_ongoingSafeBrowsingChecks (exposed as safeBrowsingCheckOngoing()), a possibly-set safeBrowsingWarning(), and a safeBrowsingCheckTimedOut() flag. For ordinary page loads, if the check times out the load proceeds and the warning is shown late; that late path is acceptable because the page can still be interrupted and replaced by a warning UI.

The vulnerable state is a PolicyAction::Download decision reached while navigation->safeBrowsingCheckOngoing() is still true. Before the patch, both policy handlers proceeded straight to the download regardless of the still-pending check (they only consulted navigation->safeBrowsingWarning(), which may not be populated yet because the lookup has not returned). Because a download has no mechanism to be interrupted and replaced by a warning after bytes begin flowing, a URL that Safe Browsing would ultimately flag could become a download before that verdict arrived, silently bypassing the malware/phishing block.

The fix introduces an explicit completion gate on API::Navigation. whenSafeBrowsingCheckCompletes(Function<void()>&&) either invokes the callback immediately if no check is ongoing, or appends it to a new m_safeBrowsingCheckCompletionCallbacks vector; fireSafeBrowsingCheckCompletionCallbacks() drains and runs them. WebPageProxy::beginSafeBrowsingCheck (in WebPageProxyCocoa.mm) now calls fireSafeBrowsingCheckCompletionCallbacks() as soon as !navigation->safeBrowsingCheckOngoing() becomes true, i.e. when the real verdict lands.

Both decidePolicyForNavigationAction and decidePolicyForResponseShared are rewritten so that when policyAction == PolicyAction::Download && navigation->safeBrowsingCheckOngoing(), they defer the entire decision inside whenSafeBrowsingCheckCompletes([...]{ ... }) and return early. When the check completes, the deferred lambda re-examines navigation->safeBrowsingWarning(): if a warning exists it either fails the provisional navigation for a subframe (interruptedForPolicyChangeError + didFailProvisionalNavigationWithError, then PolicyAction::Ignore) or shows the browsing warning for a main frame and only issues PolicyAction::Download if the user chooses ContinueUnsafeLoad::Yes; if no warning exists, it proceeds with PolicyAction::Download. Crucially the verdict is now consulted after the check truly completes, not on a timed-out or not-yet-populated snapshot.

Key code

Downloads now defer until the Safe Browsing verdict lands (decidePolicyForNavigationAction).

if (policyAction == PolicyAction::Download && navigation->safeBrowsingCheckOngoing()) {
    navigation->whenSafeBrowsingCheckCompletes([this, ... completionHandlerWrapper = WTF::move(completionHandlerWrapper), frame, ...] mutable {
        if (RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning()) {
            navigation->setSafeBrowsingWarning(nullptr);
            if (!frame->isMainFrame()) {
                auto error = interruptedForPolicyChangeError(navigation->currentRequest());
                m_navigationClient->didFailProvisionalNavigationWithError(*this, FrameInfoData { frameInfo }, navigation.get(), navigation->currentRequest().url(), error, nullptr);
                completionHandlerWrapper(PolicyAction::Ignore);
                return;
            }
            // ... main-frame: showBrowsingWarning, gate on ContinueUnsafeLoad ...
            return;
        }
        completionHandlerWrapper(PolicyAction::Download);
    });
    return;
}

Patch walkthrough

  • Source/WebKit/UIProcess/API/APINavigation.cpp — Adds Navigation::whenSafeBrowsingCheckCompletes, which runs the callback synchronously if !safeBrowsingCheckOngoing() and otherwise queues it, and Navigation::fireSafeBrowsingCheckCompletionCallbacks, which drains the queue via std::exchange(..., {}) and invokes each callback. This is the deferral primitive that lets a download wait for the verdict.
  • Source/WebKit/UIProcess/API/APINavigation.h — Declares the two new methods and adds the private member Vector<Function<void()>> m_safeBrowsingCheckCompletionCallbacks that backs the deferral queue on each navigation object.
  • Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm — In beginSafeBrowsingCheck, once the lookup resolves and !navigation->safeBrowsingCheckOngoing(), it now calls navigation->fireSafeBrowsingCheckCompletionCallbacks() before the existing timed-out-warning handling. This is what wakes any deferred download decision when the genuine Safe Browsing result arrives.
  • Source/WebKit/UIProcess/WebPageProxy.cpp — Both decidePolicyForNavigationAction and decidePolicyForResponseShared gain a branch: if policyAction == PolicyAction::Download && navigation->safeBrowsingCheckOngoing(), the decision is wrapped in whenSafeBrowsingCheckCompletes([...]{ ... }) and the function returns early. The deferred lambda then honors any safeBrowsingWarning() (failing subframe loads with interruptedForPolicyChangeError, or showing the warning and gating on ContinueUnsafeLoad for main frames) and only calls completionHandlerWrapper(PolicyAction::Download) when the verdict is clean or the user explicitly continues. decidePolicyForResponseShared’s completionHandlerWrapper capture list is also trimmed (frameInfo moved into the new lambda).
  • Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm — Adds five tests using a DelayedLookupContext swizzle to slow the lookup: DownloadDeferredAndBlockedBySafeBrowsing and its PostTimeout variant (delay 500ms, longer than the ~250ms listener timeout) assert EXPECT_FALSE(downloadStarted) once a warning appears; CleanDownloadProceedsAfterSafeBrowsingCheck asserts a clean file still downloads and no warning shows; SubframeDownloadBlockedBySafeBrowsing asserts the subframe load fails and no download starts; NavigationActionDownloadDeferredBySafeBrowsing covers the decidePolicyForNavigationAction path.

Background

Safe Browsing / fraudulentWebsiteWarningEnabled — WebKit’s reputation check that consults a lookup service (SSBLookupContext on Cocoa) for known-malicious URLs and shows a full-page red warning interstitial before the user proceeds. It is enabled via the fraudulentWebsiteWarningEnabled preference.

Proceed-on-timeout design — To avoid stalling navigation on a slow lookup, WebKit lets a page load complete if the verdict is late and shows the warning afterward. The commit message notes this is fine for pages (the red screen still appears) but not for downloads, which have no late-delivery channel.

PolicyAction::Download — The navigation-policy outcome that converts a navigation or response into a file download instead of a rendered page. It is chosen either by decidePolicyForNavigationAction (WKNavigationActionPolicyDownload) or decidePolicyForResponse (WKNavigationResponsePolicyDownload).

safeBrowsingCheckOngoing / m_ongoingSafeBrowsingChecks — State on API::Navigation tracking whether reputation lookups are still outstanding. The patch adds a parallel completion-callback queue so consumers can react precisely when the last check finishes rather than polling or racing the timeout.

Vulnerability window

  1. Navigation begins — A page triggers a navigation/response destined to become a download; WebPageProxy starts the Safe Browsing lookup and marks safeBrowsingCheckOngoing().
  2. Policy decision races the lookup — The embedder (or WebKit) resolves the policy to PolicyAction::Download while the lookup has not yet returned.
  3. Pre-patch bypass — The download proceeds immediately, consulting only a not-yet-populated safeBrowsingWarning(); a URL that Safe Browsing would flag begins downloading with no interstitial and no way to retract it.
  4. Verdict arrives late — beginSafeBrowsingCheck resolves the lookup; but for the old code the download decision was already committed, so the late warning cannot stop the file.
  5. Fix: defer — The download decision is queued via whenSafeBrowsingCheckCompletes and returns early; nothing is downloaded yet.
  6. Fix: gate — fireSafeBrowsingCheckCompletionCallbacks runs the deferred decision, which blocks/fails the download if a warning exists (or gates a main-frame download on the user’s ContinueUnsafeLoad choice) and only downloads when the verdict is clean.

Proof of concept

The regression tests are a faithful PoC: a swizzled DelayedLookupContext makes the reputation lookup respond slowly, the delegate forces the navigation/response to a download, and the test spins until a Safe Browsing warning appears. On a vulnerable build the download begins before the warning (the EXPECT_FALSE fails); on the fixed build the download is deferred and then blocked. The PostTimeout variant uses a 500ms delay to prove the deferral waits for the actual result, not merely the ~250ms listener timeout. A real-world malicious payload is not needed to demonstrate the ordering bug.

// Reconstructed from SafeBrowsing.mm (DownloadDeferredAndBlockedBySafeBrowsing).
DelayedLookupContext.delayDuration = 50_ms; // slow the reputation lookup
ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(),
    @selector(sharedLookupContext),
    [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);

webView.configuration.preferences.fraudulentWebsiteWarningEnabled = YES;

__block bool downloadStarted = false;
delegate.decidePolicyForNavigationResponse = ^(WKNavigationResponse *, void (^h)(WKNavigationResponsePolicy)) {
    h(WKNavigationResponsePolicyDownload);
};
delegate.navigationResponseDidBecomeDownload = ^(WKNavigationResponse *, WKDownload *) {
    downloadStarted = true;
};

[webView evaluateJavaScript:@"window.location = 'https://example2.com/malicious'" completionHandler:nil];
while (![webView _safeBrowsingWarning])
    TestWebKitAPI::Util::spinRunLoop();
EXPECT_FALSE(downloadStarted); // pre-patch this fails: the download started before the verdict

Exploitation

  1. Positioning — An attacker hosts a file that Safe Browsing will classify as malicious and arranges for it to be fetched as a download (Content-Disposition, or an embedder that returns Download policy), ideally where the reputation lookup is slow enough to lose the race.
  2. Trigger — Navigate the top frame or a subframe to the malicious URL so PolicyAction::Download is chosen while safeBrowsingCheckOngoing() is still true; pre-patch the bytes begin streaming to disk immediately.
  3. Outcome — The flagged file lands on the user’s disk with no warning interstitial. Because downloads cannot be retracted after they begin, the Safe Browsing block is effectively bypassed; the residual step is convincing the user to open the file.

Detection & hunting

For defenders and SOC / detection engineers:

  • Download-vs-warning ordering
  • Release logging
  • Regression tests

Audit directions

  • Other PolicyAction outcomes vs ongoing checks
  • Completion-callback lifetime
  • Subframe download semantics
  • Timeout interplay

Before / after

Loading diff…