We should wait until we get a safe browsing response before proceeding with downloads
CVE: CVE-2026-28971 · Safari 26.5 · 2026년 5월 13일 출시 Impact: 악성 iframe이 다른 웹사이트의 다운로드 설정을 악용할 수 있습니다. Apple's description: 이 문제는 개선된 UI 처리를 통해 해결되었습니다. Credit: Khiem Tran
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
Source/WebKit/UIProcess/API/APINavigation.h
Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
Source/WebKit/UIProcess/WebPageProxy.cpp
Patch Details
이번 변경은 세 부분으로 구성됩니다. 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은 interruptedForPolicyChangeError가 didFailProvisionalNavigationWithError를 통해 보고된 뒤 PolicyAction::Ignore를 받습니다. 반면 main frame은 browsing-warning의 title이 PageLoadState에 반영되고 interstitial이 표시되며, ContinueUnsafeLoad::Yes는 다시 PolicyAction::Download로, 나머지는 모두 Ignore로 매핑됩니다. warning이 없다면 원래대로 completionHandlerWrapper(PolicyAction::Download)가 호출되어 download가 시작됩니다.
response 경로에는 기계적인 디테일이 하나 있습니다. frameInfo는 completionHandlerWrapper의 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 경로를 각각 다룹니다.
Background
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입니다.
Analysis
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 자체는 특별할 것이 없다는 점도, 이 수정이 필요했던 이유 중 하나입니다.
- attacker가 통제하는 URL로 navigation이 시작되도록 아무 콘텐츠나 로드시킵니다. top-level이든, 사용자가 신뢰하는 페이지 안의 iframe이든 상관없습니다.
- 해당 URL을
Content-Disposition: attachment나, embedder가WKNavigationResponsePolicyDownload로 넘기는 MIME type으로 응답합니다. (navigation-action 경로라면downloadattribute가 붙은 링크를 사용해도 됩니다.) safeBrowsingCheckOngoing()이 아직 true인 동안 header가decidePolicyForResponseShared()에 도달할 만큼 빠르게 응답합니다. 혹은 단순히 약 250ms의 listener 타임아웃을 넘기기만 해도 됩니다. 캐시가 차가운 상태거나 회선이 혼잡한 경우, reputation 서비스 스스로가 이 조건을 만들어 줍니다.- 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 타임아웃을 적용했습니다.
Insight
흥미로운 지점은 race 자체가 아니라, fail-open policy와 그것이 gate하는 action의 되돌릴 수 있는 정도 사이의 불일치입니다. WebKit의 Safe Browsing 설계는 의도적으로 non-blocking이며, page load가 시작된 이후에도 interstitial로 덮을 수 있다는 사실에 전적으로 기대고 있습니다. safeBrowsingCheckTimedOut() 분기가 바로 그 사후 커버이고, 그 자체는 합리적인 엔지니어링입니다. 다만 이 설계가 감지하지 못하는 지점이 있습니다. 같은 verdict를 소비하는 어떤 지점이 되돌릴 수 없는 효과를 낼 때입니다. 비동기 security oracle이 관대하게 타임아웃되도록 허용될 때마다, 그 verdict를 소비하는 모든 지점을 하나하나 다시 살펴보며 해당 action을 여전히 되돌릴 수 있는지 확인해야 합니다. 되돌릴 수 없는 지점이 바로 버그이며, 이런 버그는 그 자리만 봐서는 버그처럼 보이지 않습니다. 각 call site의 코드는 그저 설계가 허용한 대로 동작하고 있을 뿐이기 때문입니다.
Audit directions
-
되돌릴 수 없는 consumer에게 fail-open oracle이 값을 넘기는 패턴. invariant는, guard되는 action이 verdict가 나올 때까지 걸리는 시간 동안 계속 되돌릴 수 있는 상태로 남아 있을 때만 fail-open이 안전하다는 것입니다. Narrow:
Source/WebKit/UIProcess/WebPageProxy.cpp와WebPageProxyCocoa.mm에서navigation->safeBrowsingWarning()과safeBrowsingCheckOngoing()을 읽는 모든 지점을 나열하고, 각각에 대해 그 분기가 나중에showBrowsingWarning()으로 여전히 덮을 수 있는 provisional load를 남겨두는지 확인해야 합니다.PolicyAction::LoadWillContinueInAnotherProcess와 QuickLook/PreviewConverterresponse 경로가 먼저 점검할 대상입니다. Wider: load가 계속 진행되는 동안 비동기로 결과가 나오는 WebKit UI-process gate라면 같은 형태가 반복해서 나타납니다.NetworkExtensionContentFilterverdict, content-blocker 평가, app-bound-domain 및 enhanced-security check 등이 해당됩니다. 코드에서 찾을 때는, timeout flag를 기록하면서 동시에 뒤늦은 remediation 분기를 타는 completion block을 단서로 삼으면 됩니다. Widest: 이는 soft-fail security-oracle 설계 전반의 문제입니다. Chromium의 SafeBrowsing check delegate 타임아웃, TLS OCSP의 soft-fail, 메일 및 file-sync 파이프라인의 비동기 malware scanning이 그 예입니다. 코드베이스를 넘어 적용할 수 있는 규칙은 이것입니다. oracle의 각 consumer에 대해, verdict가 늦게 도착했을 때 되돌려야 할 action이 무엇인지 명명해 보십시오. 되돌릴 방법을 명명할 수 없다면, 그 oracle은 타임아웃이 아니라 반드시 블로킹해야 합니다. -
보안 UI는 frame 종류에 따라 게이트되지만, 해당 기능 자체는 그렇지 않습니다. 여기서 지켜져야 할 invariant는, subframe이 어떤 결과에 도달했을 때 그 결과에 대한 유일한 user-facing safeguard가 main frame 전용이어서는 안 된다는 점입니다. Narrow:
Source/WebKit/UIProcess/WebPageProxy.cpp와 각PageClient구현체에서 interstitial 표시, 경고 제목,PageLoadState전환을 가드하는isMainFrame()조건을 검색해볼 필요가 있습니다. 수정 전 코드인if (frame->isMainFrame() && safeBrowsingWarning->url().isValid())가 전형적인 패턴이며, 이번 패치의if (!frame->isMainFrame())early-Ignore가 수정된 형태에 해당합니다. Wider: subframe이 유발할 수는 있지만 경고는 main frame에 대해서만 가능한 모든 effect가 대상입니다. 다운로드 개시, 외부 URL 및 app-scheme 실행 (shouldOpenExternalURLsPolicy), 권한·credential 프롬프트 등이 해당됩니다. 판별 기준은 다음과 같습니다. 보안 결정의 effect 경로에는 frame-type 조건이 없는데, notification 경로에는 있는 경우가 그 신호입니다. Widest: "nested context는 parent의 privilege는 상속하지만 parent의 warning surface는 상속하지 않는다"는 일반적인 클래스가 여기 해당됩니다. 브라우저 extension frame, 네이티브 앱 안에 embed된 WebView, Chromium 자체의 frame-scoped 권한 프롬프트, 그리고 prompt가 top-level document에는 anchor되지만 해당 capability는 그렇지 않은 모든 UI가 이 범주에 들어갑니다. Match tell: embedded context에서 도달 가능한 capability인데, consent UI는 embedder에 붙어 있는 경우입니다. -
Success path 하나에서만 drain되는 continuation queue. 여기서 지켜져야 할 invariant는, park된 모든 continuation이 error, cancellation, destruction을 포함해 최소 하나의 확실한 invocation 경로를 가져야 한다는 점입니다. Narrow:
Source/WebKit/UIProcess/API/APINavigation.{h,cpp}의m_safeBrowsingCheckCompletionCallbacks를 추적해볼 필요가 있습니다. 이 멤버의 유일한 drain 지점은beginSafeBrowsingCheck()의 completion block에 추가된fireSafeBrowsingCheckCompletionCallbacks()호출뿐이며,Navigation::~Navigation()은 defaulted 상태입니다. 따라서 navigation이 superseded되거나 lookup 도중 페이지가 닫힐 때 큐에 쌓인 policyCompletionHandler에 어떤 일이 벌어지는지 확인해야 합니다. 이 부분은 static reading만으로는 확인되지 않으며, 절대 반환하지 않는 lookup stub을 이용한 runtime test가 필요합니다.SafeBrowsing.mm의DelayedLookupContextswizzle이 이미 적절한 harness 역할을 할 수 있습니다. Wider: 외부 응답을 기다리며 API object에 park되는 다른 UI-process callback 벡터들도 대상이 됩니다. permission request manager, authentication challenge listener, policy listener proxy 등이 해당하며, 판별 기준은Vector<Function<...>>나Vector<CompletionHandler<...>>멤버의 유일한std::exchange나 clear 지점이 하나의 success-path callback 안에만 존재하는지 여부입니다. Widest: "reject 경로가 도달 불가능한 promise"라는 일반적인 클래스가 여기 해당됩니다. happy path에서만 resolve되는 JS promise, send 없이 drop되는 Rust oneshot sender, 그 밖의 모든 deferred-continuation 설계가 포함됩니다. Match tell: 큐를 drain하는 call site 개수를 세어보고, 정확히 하나뿐이면서 그것이 success path에 있다면, failure 경로와 teardown 경로에서 continuation이 stranded됩니다.