← All reports

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

MediumWebKit UIProcess navigation policyLogicError

CVE: CVE-2026-28971 · Safari 26.5 · 2026년 5월 13일 출시 Impact: 악성 iframe이 다른 웹사이트의 다운로드 설정을 악용할 수 있습니다. Apple's description: 이 문제는 개선된 UI 처리를 통해 해결되었습니다. Credit: Khiem Tran

4b574bf | Bugzilla 311288

Medium — memory corruption은 없습니다. 문제는 reputation 점검이 "아직 응답이 없음"을 "경고 없음"으로 읽어버린다는 데 있습니다. escalation 조건은 전적으로 서버 쪽에서 통제할 수 있습니다. 약 250ms 타임아웃만 버티면, interstitial이 한 번도 뜨지 않은 채로 flagged된 파일이 디스크에 저장됩니다.

내비게이션을 진행하기 전에 원격 reputation 서비스에 조회를 거치는 브라우저는, 그 서비스가 느릴 때 어떻게 동작할지를 결정해야 합니다. WebKit의 답은 일단 진행하고 나중에 사과하는 방식입니다. load는 계속 진행되고, verdict가 결국 "malicious"로 나오면 이미 로드된 페이지 위에 전체 화면 빨간 interstitial이 그려집니다. 이 사후 커버가 바로 타임아웃을 안전하게 만드는 장치입니다. 이 방식이 동작하는 이유는 덮어씌울 페이지 load가 여전히 그 자리에 남아 있기 때문입니다. 반면 PolicyAction::Download는 내비게이션을 종료시키고 응답을 download 기계로 넘긴 뒤 아무것도 남기지 않는, policy 결과 중 유일한 outcome입니다.

관전 포인트: 페이지, 혹은 다른 사람의 페이지 안에 있는 iframe의 서버 응답이 reputation lookup보다 먼저 도착하면, fraudulent-website 경고가 한 번도 뜨지 않은 채 flagged된 파일이 사용자의 다운로드 폴더에 기록될 수 있습니다.

Source/WebKit/UIProcess/API/APINavigation.cpp

+void Navigation::whenSafeBrowsingCheckCompletes(Function<void()>&& callback)
+{
+ if (!safeBrowsingCheckOngoing()) {
+ callback();
+ return;
+ }
+ m_safeBrowsingCheckCompletionCallbacks.append(WTF::move(callback));
+}
+
+void Navigation::fireSafeBrowsingCheckCompletionCallbacks()
+{
+ for (auto& callback : std::exchange(m_safeBrowsingCheckCompletionCallbacks, { }))
+ callback();
+}

Source/WebKit/UIProcess/API/APINavigation.h

bool NODELETE safeBrowsingCheckOngoing();
void setSafeBrowsingWarning(RefPtr<WebKit::BrowsingWarning>&&);
RefPtr<WebKit::BrowsingWarning> NODELETE safeBrowsingWarning();
+ void whenSafeBrowsingCheckCompletes(Function<void()>&&);
+ void fireSafeBrowsingCheckCompletionCallbacks();
void setSafeBrowsingCheckTimedOut() { m_safeBrowsingCheckTimedOut = true; }
@@
ListHashSet<size_t> m_ongoingSafeBrowsingChecks;
+ Vector<Function<void()>> m_safeBrowsingCheckCompletionCallbacks;

Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm

+ if (!navigation->safeBrowsingCheckOngoing())
+ navigation->fireSafeBrowsingCheckCompletionCallbacks();
+
if (!navigation->safeBrowsingCheckOngoing() && navigation->safeBrowsingWarning() && navigation->safeBrowsingCheckTimedOut()) {
protectedThis->setHasShownSafeBrowsingWarningAfterLastLoadCommit();
protectedThis->showBrowsingWarning(navigation->safeBrowsingWarning());

Source/WebKit/UIProcess/WebPageProxy.cpp

protectedPageClient->clearBrowsingWarning();
 
+ if (policyAction == PolicyAction::Download && navigation->safeBrowsingCheckOngoing()) {
+ navigation->whenSafeBrowsingCheckCompletes([
+ this, protectedThis = WTF::move(protectedThis), navigation, completionHandlerWrapper = WTF::move(completionHandlerWrapper),
+ frame, frameInfo = WTF::move(frameInfo), protectedPageClient = WTF::move(protectedPageClient)
+ ] 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);
+ WEBPAGEPROXY_RELEASE_LOG(Loading, "decidePolicyForNavigationAction: Ignoring download because Safe Browsing found a match.");
+ completionHandlerWrapper(PolicyAction::Ignore);
+ return;
+ }
+
+ Ref protectedPageLoadState = pageLoadState();
+ auto transaction = protectedPageLoadState->transaction();
+ protectedPageLoadState->setTitleFromBrowsingWarning(transaction, safeBrowsingWarning->title());
+
+ protectedPageClient->showBrowsingWarning(*safeBrowsingWarning, [protectedThis = WTF::move(protectedThis), completionHandlerWrapper = WTF::move(completionHandlerWrapper), protectedPageClient](auto&& result) mutable {
+ Ref protectedPageLoadState = protectedThis->pageLoadState();
+ auto transaction = protectedPageLoadState->transaction();
+ protectedPageLoadState->setTitleFromBrowsingWarning(transaction, { });
+
+ switchOn(result, [&](const URL& url) {
+ completionHandlerWrapper(PolicyAction::Ignore);
+ protectedThis->loadRequest({ URL { url } });
+ }, [&protectedThis, &completionHandlerWrapper](ContinueUnsafeLoad continueUnsafeLoad) {
+ switch (continueUnsafeLoad) {
+ case ContinueUnsafeLoad::No:
+ if (!protectedThis->hasCommittedAnyProvisionalLoads())
+ protectedThis->m_uiClient->close(protectedThis.ptr());
+ completionHandlerWrapper(PolicyAction::Ignore);
+ break;
+ case ContinueUnsafeLoad::Yes:
+ completionHandlerWrapper(PolicyAction::Download);
+ break;
+ }
+ });
+ });
+ m_uiClient->didShowSafeBrowsingWarning();
+ return;
+ }
+ completionHandlerWrapper(PolicyAction::Download);
+ });
+ return;
+ }
+
if (RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning()) {
navigation->setSafeBrowsingWarning(nullptr);
if (frame->isMainFrame() && safeBrowsingWarning->url().isValid()) {

이번 변경은 세 부분으로 구성됩니다. API::Navigation에 추가된 작은 continuation 메커니즘, Cocoa lookup completion block 안의 fire 지점 하나, 그리고 두 policy 결정 함수에 구조적으로 동일하게 들어간 두 개의 deferral 분기입니다.

API::Navigation에는 Vector<Function<void()>> m_safeBrowsingCheckCompletionCallbacks와 이를 관리하는 메서드 두 개가 추가되었습니다. whenSafeBrowsingCheckCompletes()는 언제 호출해도 안전하도록 작성되어 있습니다. safeBrowsingCheckOngoing()이 이미 false라면 콜백이 그 자리에서 동기적으로 실행되고, 그렇지 않다면 vector에 대기시킵니다. fireSafeBrowsingCheckCompletionCallbacks()std::exchange(m_safeBrowsingCheckCompletionCallbacks, { })를 통해 비웁니다. 이 방식은 순회를 시작하기 전에 빈 vector를 먼저 교체해 넣기 때문에, drain 도중 또 다른 콜백을 추가하는 콜백이 있어도 같은 drain에서 그 콜백이 실행되지 않고, iteration도 무효화되지 않습니다.

fire 지점은 WebPageProxy::beginSafeBrowsingCheck() 안의 lookup completion block에 위치합니다. 기존에 있던 timed-out-warning 분기 바로 위에 배치되어 있고, 그 분기와 동일한 !navigation->safeBrowsingCheckOngoing() guard를 공유합니다. 이 guard가 중요한 이유는, 하나의 navigation에 여러 check가 동시에 진행 중일 수 있기 때문입니다. redirect chain의 각 hop마다 check가 하나씩 추가됩니다. 마지막 check가 끝날 때만 drain이 일어나므로, 지연된 download 결정은 첫 hop의 verdict가 아니라 전체를 종합한 verdict를 보게 됩니다.

  beginSafeBrowsingCheck() completion block
    ├─ (check별 bookkeeping, m_ongoingSafeBrowsingChecks에서 제거)
    ├─ if (!safeBrowsingCheckOngoing())
    │     fireSafeBrowsingCheckCompletionCallbacks()   ← 추가됨: 대기 중이던 download 처리
    └─ if (!safeBrowsingCheckOngoing() && safeBrowsingWarning()
           && safeBrowsingCheckTimedOut())
          showBrowsingWarning(...)                     ← 기존 코드: 사후 커버

decidePolicyForNavigationAction()decidePolicyForResponseShared() 둘 다 동일한 조기 분기를 얻습니다. policyAction == PolicyAction::Download이고 check가 아직 진행 중이면, 이후의 모든 처리가 whenSafeBrowsingCheckCompletes() 람다 안으로 감싸지고 함수는 그대로 반환됩니다. 이 람다는 현재 stack frame보다 오래 살아남아야 하는 것들을 캡처합니다. completionHandlerWrapper, frame, frameInfo, protectedThis/protectedPageClient ref, 그리고 response 경로에서는 request까지 포함됩니다. 람다 내부에서, 그 사이에 BrowsingWarning이 도착해 있었다면 subframe은 interruptedForPolicyChangeErrordidFailProvisionalNavigationWithError를 통해 보고된 뒤 PolicyAction::Ignore를 받습니다. 반면 main frame은 browsing-warning의 title이 PageLoadState에 반영되고 interstitial이 표시되며, ContinueUnsafeLoad::Yes는 다시 PolicyAction::Download로, 나머지는 모두 Ignore로 매핑됩니다. warning이 없다면 원래대로 completionHandlerWrapper(PolicyAction::Download)가 호출되어 download가 시작됩니다.

response 경로에는 기계적인 디테일이 하나 있습니다. frameInfocompletionHandlerWrapper의 capture list에서 빠지고, 대신 deferral 람다로 옮겨집니다. 지연된 error-reporting 경로에서만 이 값이 필요하기 때문입니다.

SafeBrowsing.mm에는 API 테스트 5개가 추가되었고, 모두 인위적인 lookup 지연을 주입하는 DelayedLookupContext class swizzle 위에서 구성됩니다. 그중 핵심은 DownloadDeferredAndBlockedBySafeBrowsingPostTimeout입니다. 이 테스트는 500ms 지연을 사용하며, "the ~250ms listener timeout"을 의도적으로 초과한다는 주석을 달아, deferral이 단순히 타임아웃 만료가 아니라 실제 verdict를 기다린다는 점을 증명합니다. 나머지 테스트는 clean download가 여전히 정상 진행되는 경우, subframe에서 시작된 download가 차단되는 경우, 그리고 navigation-response가 아닌 navigation-action 쪽 download 경로를 각각 다룹니다.

Safe Browsing in WebKit. navigation이 시작되면 UI process는 목적지 URL이 known-bad인지 플랫폼 reputation 서비스에 조회합니다. Apple 플랫폼에서는 WebPageProxyCocoa.mm에 soft-link된 SSBLookupContext를 통해 이루어집니다. 이 lookup은 비동기로 처리됩니다. match가 발견되면 그 결과는 setSafeBrowsingWarning()을 통해 navigation 객체에 BrowsingWarning으로 저장됩니다.

Where the state lives. API::Navigation은 하나의 navigation을 시작부터 끝까지 대표하는 UI-process 객체로, 개별 IPC round-trip보다 오래 살아남기 때문에 navigation별 상태를 걸어두기에 자연스러운 위치입니다. 진행 중인 lookup은 ListHashSet<size_t> m_ongoingSafeBrowsingChecks로 추적되며, 인자 없는 safeBrowsingCheckOngoing()은 해당 navigation의 check가 하나라도 진행 중이면 true를 반환합니다.

The fail-open timeout. WebKit은 reputation 서비스 응답을 무한정 기다리며 load를 막지 않습니다. lookup이 짧은 listener 타임아웃을 넘기면 setSafeBrowsingCheckTimedOut()이 기록되고 load는 그대로 진행됩니다. 이후 verdict가 도착하면 completion block의 safeBrowsingCheckTimedOut() 분기가 이미 로드된 내용 위에 interstitial을 표시합니다. 이 설계 의도는 commit 메시지에 명시되어 있습니다. "safe browsing 응답이 너무 오래 걸리면 우리는 load를 그대로 진행합니다. 일반적인 웹페이지에서는 이 방식이 괜찮은데, red screen이 뜨긴 뜨지만 페이지 load가 시작된 직후 약간 늦게 뜨기 때문입니다."

BrowsingWarning and the interstitial. BrowsingWarning은 reputation match를 나타내는 객체입니다. PageClient::showBrowsingWarning()이 이를 표시하고, 응답으로 URL(사용자가 클릭한 learn-more 링크) 또는 ContinueUnsafeLoad enum 중 하나를 반환합니다. 이 enum은 사용자가 뒤로 물러날지 그대로 진행할지를 담고 있습니다.

PolicyAction. UI process가 navigation policy 질문에 답할 때 쓰는 enum입니다. load하려면 Use, 포기하려면 Ignore, 응답을 DownloadProxy에 넘겨 디스크에 기록하려면 Download를 사용합니다. Download는 navigation을 종료시킵니다. provisional load는 끝나고, download는 별도 트랙에서 진행됩니다.

The two decision points. decidePolicyForNavigationAction()은 request가 나가기 전에 실행되며, 이 시점에는 embedding app의 decidePolicyForNavigationAction: delegate나 링크의 download attribute가 Download를 선택할 수 있습니다. decidePolicyForResponseShared()는 response header가 도착한 뒤 실행되며, 여기서는 decidePolicyForNavigationResponse: delegate가 판단을 내립니다. 서버는 Content-Disposition: attachment나 처리되지 않는 MIME type으로 이 결정을 유도할 수 있습니다. 둘 다 UI process에 위치하며, sandbox된 WebContent에 비해 권한이 높은 쪽입니다.

Function<void()> queues. WTF의 Function은 type-erased callable이므로, Vector<Function<void()>>는 대기 중인 continuation의 목록에 해당합니다. std::exchange(vec, { })는 새 빈 vector를 밀어넣고 기존 vector를 반환하는데, 이는 re-entrancy 위험 없이 이런 목록을 정확히 한 번만 drain하는 표준적인 idiom입니다.

root cause는 null check처럼 보이지만 실제로는 순서 오류입니다. lookup이 진행 중인 동안 navigation->safeBrowsingWarning()은 null을 반환합니다. URL이 깨끗해서가 아니라, 아직 아무도 물어보지 않아서, 정확히는 아직 아무도 응답하지 않아서입니다.

  패치 이전, lookup이 진행 중인 상태에서의 PolicyAction::Download:

  t=0    navigation 시작 ──► beginSafeBrowsingCheck() ──┐  (비동기, SSBLookupContext)
  t=~30  response header 도착                            │
         decidePolicyForResponseShared() → Download       │
           safeBrowsingWarning()  == nullptr ─────────────┼─► "clean"으로 해석됨
           completionHandlerWrapper(Download)             │
  t=~35  DownloadProxy가 소유권을 가짐; provisional load 소멸 │
  t=250  listener 타임아웃: setSafeBrowsingCheckTimedOut() │
  t=~600 ◄─────────── verdict: MATCH ─────────────────────┘
         safeBrowsingCheckTimedOut() branch → showBrowsingWarning()
           …무엇 위에? 파일은 이미 디스크에 있음.

두 policy 함수 모두 이 null을 그대로 읽고 completionHandlerWrapper(PolicyAction::Download)로 곧장 흘러갔습니다. 결과적으로 download는 아직 계산되지도 않은 verdict를 근거로 시작된 셈입니다. 위 타임라인이 버그의 전부를 보여줍니다. t=250 왼쪽에 있는 모든 과정은 fail-open이 개입하기도 전에 실행되고, 맨 아래의 사후 interstitial은 이제 덮어씌울 대상이 남아 있지 않습니다. 그 시점에는 이미 DownloadProxy가 transfer를 소유하고 있고, red screen이 대체했어야 할 provisional load는 사라진 뒤이기 때문입니다.

fail-open이 평소에 타당한 이유는, verdict가 나올 때까지 걸리는 시간 동안 page load가 계속 되돌릴 수 있는 상태로 남아 있기 때문입니다. 그 위에 interstitial을 그리면 사용자는 malicious 페이지의 글자 한 줄도 아직 읽지 않은 상태입니다. 다만 이 논리는 되돌릴 수 있는 outcome에만 암묵적으로 한정되어 있고, enum 중에서 PolicyAction::Download만은 되돌릴 수 없는 outcome입니다. 패치 이전 코드에는 이 구분을 반영하는 부분이 전혀 없었습니다. Download case도 다른 모든 case와 똑같은 fall-through 안에 놓여 있었습니다.

subframe 쪽 문제는 여기에 더해집니다. 패치 이전 decidePolicyForNavigationAction()의 interstitial 경로는 frame type으로 gate되어 있습니다.

if (RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning()) {
    navigation->setSafeBrowsingWarning(nullptr);
    if (frame->isMainFrame() && safeBrowsingWarning->url().isValid()) {
        // …present the interstitial

subframe에서 시작된 download에 제때 도착한 warning이라도 표시할 경로가 없습니다. interstitial이 top-level document에 고정되어 있기 때문입니다. download를 시작하는 capability는 nested context에서도 도달 가능하지만, 이를 지키는 consent UI는 그렇지 않습니다. 이것이 바로 Apple의 impact 설명이 말하는 "악성 iframe이 다른 웹사이트의 다운로드 설정을 이용한다"는 형태입니다. download 결정이 embedding page의 context 아래에서 내려지기 때문에, 사용자는 자신이 있다고 믿는 사이트로부터 파일이 도착한 것처럼 보게 됩니다.

attacker의 trigger 자체는 특별할 것이 없다는 점도, 이 수정이 필요했던 이유 중 하나입니다.

  1. attacker가 통제하는 URL로 navigation이 시작되도록 아무 콘텐츠나 로드시킵니다. top-level이든, 사용자가 신뢰하는 페이지 안의 iframe이든 상관없습니다.
  2. 해당 URL을 Content-Disposition: attachment나, embedder가 WKNavigationResponsePolicyDownload로 넘기는 MIME type으로 응답합니다. (navigation-action 경로라면 download attribute가 붙은 링크를 사용해도 됩니다.)
  3. safeBrowsingCheckOngoing()이 아직 true인 동안 header가 decidePolicyForResponseShared()에 도달할 만큼 빠르게 응답합니다. 혹은 단순히 약 250ms의 listener 타임아웃을 넘기기만 해도 됩니다. 캐시가 차가운 상태거나 회선이 혼잡한 경우, reputation 서비스 스스로가 이 조건을 만들어 줍니다.
  4. null인 safeBrowsingWarning()이 clean으로 해석되고, 파일이 기록됩니다.

이 window의 경계를 결정하는 것은 3번 단계이며, 500ms 지연을 사용하는 DownloadDeferredAndBlockedBySafeBrowsingPostTimeout 테스트는 그 경계가 attacker가 접근할 수 없는 어떤 조건이 아니라 타임아웃 자체라는 점을 확인시켜 줍니다. attacker가 흔히 말하는 의미의 race를 이겨야 하는 것도 아닙니다. reputation lookup은 network round trip을 거치는데, local에서 제공되거나 CDN을 앞세운 response는 이를 손쉽게 앞지르는 경우가 많습니다.

fix는 Download case에 한해서만 순서를 뒤집습니다. policy continuation은 m_safeBrowsingCheckCompletionCallbacks에 대기되고, fireSafeBrowsingCheckCompletionCallbacks()!safeBrowsingCheckOngoing()을 확인한 뒤에야 실행됩니다. 이 시점에서는 safeBrowsingWarning()이 진짜 답을 담고 있으므로, null이 이제야 진짜로 clean을 의미하게 됩니다. main-frame match는 원래부터 있었어야 할 interstitial을 받게 되고, ContinueUnsafeLoad::Yes는 여전히 PolicyAction::Download로 되돌아갈 수 있습니다. 사용자의 override 권한은 유지되되, 정보에 근거한 override가 됩니다. subframe match는 패치 이전 코드에 전혀 없던 분기를 얻습니다. didFailProvisionalNavigationWithError를 통해 interruptedForPolicyChangeError가 보고되고 PolicyAction::Ignore가 뒤따르므로, 렌더링될 수 없는 warning을 보여주는 대신 download 자체가 거부됩니다.

이 방향의 비용도 정확히 짚어둘 필요가 있습니다. deferral queue는 drain 지점이 하나뿐이고, 그마저도 success path 위에 놓여 있습니다. Navigation::~Navigation()은 defaulted 상태입니다. lookup이 끝내 완료되지 않거나, 경쟁하는 navigation에 의해 check 도중 navigation이 파괴되는 경우, vector는 실행되지 않은 항목들을 그대로 안은 채 파괴됩니다. 시작도 실패도 하지 않는 policy 결정이 남고, CompletionHandler는 호출 없이 파괴되는데 WebKit은 debug build에서 이를 assert합니다. fail-open bypass를 fail-closed hang으로 맞바꾼 셈이며, 방향 자체는 옳지만 완전히 무해한 대가는 아닙니다.

패치 이전 코드는 "verdict가 아직 도착하지 않음"을 "verdict가 clean함"으로 읽었고, 이후 어떤 interstitial로도 되돌릴 수 없는 유일한 policy outcome인 PolicyAction::Download에까지 fail-open 타임아웃을 적용했습니다.

흥미로운 지점은 race 자체가 아니라, fail-open policy와 그것이 gate하는 action의 되돌릴 수 있는 정도 사이의 불일치입니다. WebKit의 Safe Browsing 설계는 의도적으로 non-blocking이며, page load가 시작된 이후에도 interstitial로 덮을 수 있다는 사실에 전적으로 기대고 있습니다. safeBrowsingCheckTimedOut() 분기가 바로 그 사후 커버이고, 그 자체는 합리적인 엔지니어링입니다. 다만 이 설계가 감지하지 못하는 지점이 있습니다. 같은 verdict를 소비하는 어떤 지점이 되돌릴 수 없는 효과를 낼 때입니다. 비동기 security oracle이 관대하게 타임아웃되도록 허용될 때마다, 그 verdict를 소비하는 모든 지점을 하나하나 다시 살펴보며 해당 action을 여전히 되돌릴 수 있는지 확인해야 합니다. 되돌릴 수 없는 지점이 바로 버그이며, 이런 버그는 그 자리만 봐서는 버그처럼 보이지 않습니다. 각 call site의 코드는 그저 설계가 허용한 대로 동작하고 있을 뿐이기 때문입니다.