isQuarantinedAndNotUserApproved() should use the parsed file URL instead of re-parsing the path
CVE: CVE-2026-86898 · Safari 27 · Released September 14, 2026 Impact: Opening a maliciously crafted webarchive file may lead to universal cross-site scripting Apple's description: A logic issue was addressed with improved state management. Credit: Tomi Garcia (archyxsec)
High. 파일명에 # 하나만 들어가도 quarantine gate가 존재하지 않는 경로를 stat하게 됩니다. 그리고 이 predicate의 모든 error branch는 "allow"를 의미합니다. Memory corruption은 없지만, webarchive가 스스로 고른 origin 아래에서 렌더링됩니다. 전제 조건은 사용자가 파일을 다운로드하고 열도록 유도하는 것뿐입니다.
macOS는 네트워크를 통해 들어온 파일에 quarantine 기록을 남깁니다. WebKit은 .webarchive를 열기 전에 browser 측 프로세스에서 이 기록을 확인합니다. .webarchive는 자기 내용물이 어느 origin에 속하는지 스스로 선언할 수 있는 유일한 로컬 파일 형식이기 때문입니다. 이 gate는 load path의 UIProcess 쪽, 즉 WebPageProxy에 위치하며 하나의 boolean만 답합니다. 이 archive가 quarantine 상태이면서 아직 사용자 승인을 받지 않았는지 여부입니다. 다만 이 답이 의미를 가지려면, predicate가 검사한 파일과 이후 load가 실제로 여는 파일이 같아야 합니다.
관전 포인트: 파일명에 #이 포함된 다운로드된 webarchive는 quarantine gate를 완전히 우회하고, 공격자가 선택한 origin 아래에서 로드됩니다.
Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
Source/WebKit/UIProcess/WebPageProxy.cpp
Source/WebKit/UIProcess/WebPageProxy.h
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
Patch Details
헤더 변경 한 줄이 이번 fix의 전부입니다. isQuarantinedAndNotUserApproved가 const String& 대신 const URL&을 받도록 바뀌었습니다. 나머지 변경은 모두 이 signature에서 따라 나옵니다.
WebPageProxyCocoa.mm에서는 함수 본문이 더 이상 자체적으로 NSURL을 만들지 않습니다. 이전에는 들어온 문자열에 -[NSURL initWithString:]을 적용하고 그 결과에서 pathExtension을 읽었습니다. 이제는 이미 파싱이 끝난 URL에 fileSystemPath()를 요청하고, 그렇게 얻은 경로를 filePath.endsWithIgnoringASCIICase(".webarchive"_s)로 검사합니다. URL grammar가 전혀 개입하지 않는, 문자열에 대한 단순 suffix 비교입니다. 이 검사를 통과한 뒤에야 NSURL을 생성하며, 이때 -[NSURL initFileURLWithPath:]를 사용합니다. 이 initializer는 인자 전체를 경로로 취급하고 component 단위로 쪼개지 않습니다. 그 객체의 path.fileSystemRepresentation이 qtn_file_init_with_path()에 도달합니다. 그 아래의 quarantine error 처리 부분, 즉 ENOENT / QTN_NOT_QUARANTINED early return과 그 밑의 flag 검사는 그대로 유지되었습니다.
WebPageProxy.cpp의 두 호출 지점은 모두 URL을 전달하도록 조정되었습니다. loadFile에서는 URL fileURL { fileURLString } 생성이 PLATFORM(MAC) 블록 위로 끌어올려졌습니다. 그래서 quarantine gate와 그 아래의 protocolIsFile() 검사가 동일한 파싱 결과를 사용하게 됩니다. protocolIsFile()과 launchProcess의 상대적 순서는 변경되지 않았습니다. updateDataStoreForWebArchiveLoad에서는 인자가 requestURL.fileSystemPath()에서 requestURL 자체로 바뀌었습니다. 즉 파일시스템 경로를 URL로 파싱하는 함수에 그대로 넣던 변환이 삭제된 것입니다.
Regression test는 실제 webarchive를 hash#x.webarchive라는 이름의 파일로 기록합니다. 그다음 NSURLQuarantinePropertiesKey / kLSQuarantineTypeWebDownload를 설정해 web download 상황을 재현하고, -loadRequest:로 로드한 뒤 EXPECT_FALSE(loaded)를 확인합니다.
Background
UIProcess vs WebContent. WebKit은 브라우저를 여러 프로세스로 분리합니다. WebPageProxy는 browser(UI) 프로세스에 있으며 navigation policy 결정을 담당하고, 페이지 자체는 sandbox된 WebContent 프로세스에서 파싱되고 실행됩니다. WebPageProxy::loadFile()과 WebPageProxy::updateDataStoreForWebArchiveLoad()는 load path의 UIProcess 진입점이며, 문제가 된 quarantine gate는 PLATFORM(MAC)에서만 컴파일됩니다.
webarchive. Apple의 단일 파일 페이지 archive 형식으로, main resource와 subresource를 하나로 묶고 각각을 원래 가져온 URL과 함께 저장합니다. WebKit이 archive를 로드할 때는 기록된 그 URL들을 바탕으로 페이지를 재구성합니다. 이 점이 webarchive를 일반 로컬 HTML 파일과 구분하는 지점입니다.
macOS file quarantine. 인터넷을 다루는 애플리케이션이 기록한 파일에는 com.apple.quarantine extended attribute가 붙습니다. libquarantine C API — qtn_file_alloc(), qtn_file_init_with_path(), 그리고 flag accessor들 — 는 주어진 디스크 경로에 대해 그 기록을 읽습니다. QTN_NOT_QUARANTINED는 해당 파일에 기록이 없다는 뜻이고, ENOENT는 그런 파일이 아예 없다는 뜻입니다. 기록 안의 user-approval flag는 사용자가 파일 열기에 명시적으로 동의했음을 나타냅니다. Regression test에서 사용된 LaunchServices key인 NSURLQuarantinePropertiesKey와 kLSQuarantineTypeWebDownload는 Cocoa에서 이 기록을 읽고 쓰는 수단이며, 그중 kLSQuarantineTypeWebDownload는 파일이 web download로 유입되었음을 표시합니다.
-[NSURL initWithString:] vs -[NSURL initFileURLWithPath:]. 전자는 인자를 URL 문법으로 파싱하므로, #이나 ? 같은 reserved character가 fragment와 query component를 구분하는 delimiter로 동작합니다. 후자는 인자 전체를 파일시스템 경로로 취급하며 component 분리를 수행하지 않습니다.
WTF::URL과 URL::fileSystemPath(). URL은 WebKit의 파싱된 URL 타입입니다. fileSystemPath()는 이미 파싱된 file: URL을 대응하는 디스크 경로로 변환하며, 이 과정에서 percent-decoding을 적용합니다.
Analysis
이번 사례는 parser differential에 해당합니다. Predicate가 판단한 대상과 loader가 실제로 연 대상이 서로 다른 객체였습니다. 같은 문자열을 서로 다른 parser가 각각 해석한 결과였기 때문입니다.
Before: After:
"file:///tmp/d/hash#x.webarchive" URL{ "file:///tmp/d/hash#x.webarchive" }
└─► [NSURL initWithString:] └─► fileSystemPath()
path = "/tmp/d/hash" = "/tmp/d/hash#x.webarchive"
fragment = "x.webarchive" └─► endsWithIgnoringASCIICase(".webarchive")
└─► pathExtension == "" → match
→ != "webarchive" → return false └─► initFileURLWithPath: (no splitting)
ALLOW ───► load proceeds └─► qtn_file_init_with_path("/tmp/d/hash#x.webarchive")
→ quarantined, unapproved → BLOCK
왼쪽 열이 fix 이전의 경로입니다. -[NSURL initWithString:]은 문자열에 URL grammar를 적용하므로, hash#x.webarchive의 #이 path component를 끝내고 그 뒤 전체가 fragment가 됩니다. 그러면 해당 NSURL의 pathExtension은 빈 문자열이 되고, caseInsensitiveCompare:@"webarchive"는 실패하며, 함수는 첫 번째 return false로 빠집니다. quarantine attribute를 건드려보기도 전입니다. 여기서 extension 검사가 마지막 방어선조차 아닙니다. 설령 그 검사를 통과했다 해도 qtn_file_init_with_path()에는 /tmp/d/hash가 넘어갔을 것이고, 디스크에 존재하지 않는 경로이므로 ENOENT가 반환되어 똑같이 허용하는 결과로 이어집니다. 두 길 모두 "allow"로 수렴합니다.
이것이 결함의 후반부이며, 파싱 불일치를 false positive가 아니라 bypass로 바꿔놓는 지점입니다. 이 predicate의 모든 실패 branch가 같은 답으로 붕괴합니다.
if (!filePath.endsWithIgnoringASCIICase(".webarchive"_s))
return false; // 확장자가 다름 → 허용
...
if (quarantineError == ENOENT || quarantineError == QTN_NOT_QUARANTINED)
return false; // 파일이 없거나 기록이 없음 → 허용
"이 파일을 판단할 수 없었다"와 "이 파일은 안전함이 확인되었다"가 동일하게 인코딩되어 있습니다. 이런 식으로 만들어진 gate에서는, 어느 파일을 두고 이야기하는지에 대한 의견 차이가 곧 공격자에게 유리한 결정으로 바뀝니다.
두 호출 지점은 그 불일치를 사실상 필연으로 만들었습니다. loadFile은 클라이언트가 건넨 file: URL 문자열을 전달했습니다. 한편 updateDataStoreForWebArchiveLoad는 requestURL.fileSystemPath()를 전달했는데, 이는 scheme이 전혀 없는 percent-decoded 디스크 경로입니다. 하나의 const String& 파라미터가 양쪽을 다 받아들이고, 각각에 대해 URL 파싱을 돌렸던 셈입니다. 이 gate에 도달하기 위해 renderer를 장악할 필요는 없습니다. UIProcess에 위치하므로 사용자가 직접 파일을 여는 평범한 동작만으로 전달 경로가 완성되며, test case가 file: URL에 대한 -loadRequest:로 정확히 그 상황을 재현합니다.
이 bypass가 얻어내는 것은 memory state가 아닙니다. Bug title에 따르면 downstream primitive는 same-origin-policy bypass입니다. webarchive는 자기 main resource와 subresource가 귀속될 origin을 스스로 담고 있으므로, 승인되지 않은 archive가 그대로 로드되면 공격자가 선택한 origin 아래에서 script 실행을 얻게 될 가능성이 있습니다. 여기에는 cross-origin DOM 접근과 credential 탈취가 따라옵니다. 다만 콘텐츠 자체는 여전히 sandbox된 WebContent 프로세스 안에서 렌더링됩니다. 이 gate를 우회하는 것만으로 프로세스 경계를 넘지는 못하며, sandbox를 탈출하려면 별도의 버그가 필요합니다. 진입 비용은 사용자가 파일 하나를 다운로드하고 열도록 설득하는 것뿐입니다.
Fix 이후에는 URL::fileSystemPath()가 #을 포함한 완전한 경로를 반환합니다. Suffix 검사는 어떤 delimiter로도 잘라낼 수 없는 단순 문자열 비교이고, -[NSURL initFileURLWithPath:]는 component 분리를 하지 않습니다. 결과적으로 extension 검사와 quarantine 조회가 모두 호출자가 실제로 resolve한 그 파일을 대상으로 수행됩니다.
파일명의 # 하나 때문에 quarantine gate가 존재하지 않는 경로를 stat했고, 이 predicate의 "파일을 찾을 수 없음" branch는 "quarantine 대상 아님"과 같은 값, 즉 allow를 반환합니다.
Insight
핵심 결함은 파라미터 타입입니다. isQuarantinedAndNotUserApproved(const String&)는 한쪽 호출자에서 file: URL 문자열을, 다른 쪽에서 날 파일시스템 경로를 받아들인 뒤 양쪽 모두에 조용히 URL 파싱을 적용했습니다. requestURL.fileSystemPath()를 requestURL로 바꾼 한 줄이 이 버그 전체의 축소판입니다. WebKit이 String 타입의 URL·경로를 WTF::URL로 오랫동안 이전해 온 이유가 바로 이런 혼용을 타입 시스템에서 표현 불가능하게 만드는 데 있습니다. 그렇다면 security 결정 안에서 "URL이거나 어쩌면 경로"를 의미하며 아직 살아남아 있는 const String& 파라미터는 모두 동일한 결함의 후보입니다.
Audit directions
-
다시 파싱한 identifier로 계산되는 security 결정. Narrow:
Source/WebKit/UIProcess, 특히 Cocoa 하위 디렉터리에서initWithString:과 즉석NSURL/URL생성을 검색하고, 그 인자가fileSystemPath(),path(), 혹은 클라이언트가 건넨String에서 유래했는지 확인합니다. 그다음 실제로 파일시스템이나 LaunchServices 호출에 넘겨지는 표현과 비교해 보십시오.SandboxExtension::createHandle호출 지점, download destination 검증, 커스텀 URL scheme handler의 파일 매핑이 먼저 살펴볼 곳입니다. Wider: 검증과 사용이 서로 다른 변환 helper를 거치는 곳이라면 어디든 같은 유형이 나타납니다. 다시 유도된 이름을 기준으로 삼는 MIME/UTI sniffing, URL 문자열로부터 재구성되는 경로 allow-list, 문자열을 거쳐 왕복하는 security-scoped bookmark 데이터가 그렇습니다. 두 단계 모두에서 신호는 동일합니다. 한 줄은parseA(identifier)로 판단하는데 가까운 다른 줄은parseB(identifier)로 동작하고, 그 둘이 일치한다고 보장하는 코드는 어디에도 없는 상태입니다. Widest: 이는 "한 객체를 검사하고 다른 객체에 대해 동작한다"는 일반적인 parser-differential 유형이며, WebKit 밖에서도 성립합니다. Chromium의GURLvsbase::FilePath, Node의url.fileURLToPathvs 날 문자열 연결, JVM의java.net.URIvsjava.io.File이 그 예입니다. 코드베이스를 넘어 가져갈 invariant는 하나입니다. 검증한 바이트가 OS에 넘기는 바이트와 정확히 같아야 합니다. -
boolean security predicate의 error branch가 fail-open으로 떨어지는 패턴. Narrow:
WebPageProxy::loadFile과WebPageProxy::updateDataStoreForWebArchiveLoad에서 도달 가능한PLATFORM(MAC)helper의return false를 하나씩 읽어 봅니다.isQuarantinedAndNotUserApproved에서는 확장자가 일치하지 않는 경우와ENOENT,QTN_NOT_QUARANTINED가 모두 허용 쪽 값을 반환합니다. 이때 "판단할 수 없었다"가 "안전하다고 증명되었다"와 같은 반환값을 가져도 되는지 자문해 볼 필요가 있습니다. Wider: 같은 형태는 parse 실패나 IO 오류가 allow branch로 흡수되는 모든bool반환 gate에서 반복되는데, content filter 판단이나 app-bound-domain 검사, browsing warning 조회가 여기에 해당합니다. 판별 단서는 안전이 증명된 branch가 아니라 error branch 위에 얹혀 있는 이른return <allow>입니다. Widest: 판단이 불가능한 정책 질의를 허용 쪽 답으로 인코딩해서는 안 됩니다. 어떤 코드베이스든unwrap_or(false), 아무것도 하지 않는except: pass, null 검사가 기본적으로 allow로 떨어지는 구조가 전부 같은 부류입니다. 판단 기준은 "이 호출이 전혀 무관한 이유로 실패했더라도 사용자가 여전히 보호받는가"입니다. -
공격자가 조작할 수 있는 파일명 metacharacter. Narrow:
#,?,%,;와 중간에 삽입된 개행, 앞에 붙은-가 포함된 이름으로 webarchive gate와 download destination 경로(DownloadProxy의 destination 처리,WebPageProxy::loadFile)를 시험해 봅니다. 이때 정책 코드가 stat한 파일과 이후 실제로 열리는 파일이 동일한지 확인하는 것이 핵심입니다. Wider: 이름의 접미사나 이름의 특정 구간을 기준으로 동작을 결정하는 WebKit 기능은 모두 공격자가 제공한 이름에서 의미를 끌어옵니다.APIAttachment를 통한 attachment 처리, drag-and-drop file promise,<input type=file>관련 코드가 그런 예입니다. 판별 단서는 두 가지인데, 하나는 URL로 parse된 객체에pathExtension같은 URL 인식 accessor를 적용해 확장자를 계산하는 경우입니다. 다른 하나는 파일시스템 경로에 대한 단순 비교 대신, 첫 delimiter에서 멈추는 검색으로 접미사를 판정하는 경우입니다. Widest: 같은 부류의 filename metacharacter 문제는 upload handler와 MIME dispatcher, Windows alternate data stream 이름 규칙에서도 반복됩니다. 원격에서 정해진 이름은 데이터일 뿐 문법이 아니며, 이를 다시 문법으로 해석하는 계층은 모두 점검해 볼 만한 경계입니다. -
webarchive load에 대한 choke point 커버리지. Narrow: webarchive 처리로 이어질 수 있는 UIProcess 경로를 먼저 나열해 봅니다.
loadRequest, file request 계열 변형, webarchive MIME type을 사용하는loadData, session restore,file:webarchive로 향하는 back-forward navigation, webarchive 관련 SPI가 여기에 해당합니다. 그중 실제로isQuarantinedAndNotUserApproved를 조회하는 경로가 어디인지 확인해 볼 필요가 있습니다.updateDataStoreForWebArchiveLoad에 들어 있는!isSubstituteDataWebArchive조건 역시, 의도적으로 열어 둔 예외인지 아니면 검토되지 않은 면제인지 짚어 볼 가치가 있습니다. Wider: 이 부류는 "정책이 단일 choke point가 아니라 N개의 호출 지점에서 강제되는" 형태입니다. app-bound-domain 강제나 sandbox extension 발급도 같은 범주에 들어가는데, 새로운 load 진입점이 추가되면 검사가 조용히 누락됩니다. 판별 단서는 모든 경로가 반드시 거쳐야 하는 단일 함수가 아니라, 흩어진 몇몇 지점에서만 호출되는 security predicate입니다. Widest: 정책은 데이터가 반드시 통과하는 가장 좁은 choke point에 두어야 합니다. 같은 점검이 ingest API가 여러 개인 시스템 전반에 적용되는데, navigation 진입점이 여러 개인 엔진부터 우회 경로로 middleware를 건너뛸 수 있는 서버 프레임워크까지 모두 포함됩니다.