Restrict ability for Network process to load files from temp directory
CVE: CVE-2026-43821 · Safari 26.6 · Released July 27, 2026 Impact: An app may be able to read files outside of its sandbox Apple's description: An access issue was addressed with improved access restrictions. Credit: Brian Carpenter
Medium 등급입니다. 이 diff 어디에도 memory corruption은 없고, 디렉토리 하위 트리 전체에 대해 authorization check가 누락되어 있던 문제입니다. 사실상 post-compromise 단계의 파일 유출 시나리오에 해당합니다. 이미 함락된 renderer가 read를 승인받아야 할 token을 전혀 소지하지 않은 채로, 브라우저가 temp directory에 저장해 둔 데이터를 그대로 꺼내올 수 있습니다.
WebKit의 multi-process 구조에서 filesystem 접근은 capability 기반으로 동작합니다. 어떤 path를 읽으려는 process는 sandbox extension을 소지하고 있어야 하며, 이 extension은 더 높은 권한을 가진 process가 발급해 IPC로 전달하는 token으로, 정확히 해당 path를 지칭합니다. Network process는 web content를 대신해 file:// read를 수행하는 주체이므로, 들어오는 load 요청이 주장하는 authorization을 실제로 갖추고 있는지 판단해야 하는 위치에 있습니다. iOS에서 이 판단이 이루어지는 지점이 바로 NetworkResourceLoader::isLocalFileLoadAllowed()인데, 여기에는 특정 디렉토리 트리 전체에 대해 token 요구 없이 무조건 "허용"으로 답하는 호환성 예외가 들어 있었습니다.
관전 포인트: iOS Safari에서는 network process에 resource-load 요청을 보낼 수 있는 무엇이든, read를 승인받아야 할 token을 한 번도 제시하지 않고도 브라우저 컨테이너의 temp directory 아래 있는 모든 파일을 읽을 수 있었습니다.
Source/WebKit/NetworkProcess/NetworkResourceLoader.cpp
Source/WebKit/NetworkProcess/NetworkResourceLoader.cpp
Patch Details
기능적으로 의미 있는 hunk는 하나뿐이며, NetworkResourceLoader::isLocalFileLoadAllowed(const URL&)의 PLATFORM(IOS_FAMILY) 블록 안에 위치합니다. 기존 예외 조건은 두 가지만 검사했습니다. 컨테이너 temp directory path가 비어 있지 않은지, 그리고 canonicalize된 요청 path가 그 아래에 위치하는지입니다. 이번 패치는 여기에 세 번째 조건 !WTF::IOSApplication::isMobileSafari()를 앞에 추가합니다. 이로써 host application이 iOS의 Safari인 경우에는 앞단의 return true에 더 이상 도달할 수 없게 됩니다. 이 predicate를 사용하려면 새로운 <wtf/cocoa/RuntimeApplicationChecksCocoa.h> include가 필요하며, PLATFORM(COCOA) 아래 기존 PathsBlockedForSandboxExtensions.h와 함께 추가되었습니다.
diff의 나머지는 모두 주석 텍스트입니다. 기존 주석은 이 예외가 JS fetch API를 쓰는 앱들을 위해 존재한다고 설명했는데, 수정된 주석에서는 "the fetch JS API or -[WKWebView loadHTMLString:baseURL:]"로 범위가 넓어졌습니다. 그리고 새로 추가된 FIXME: rdar://177160334 블록에는 진짜 fix의 방향이 기록되어 있습니다. WebPageProxy::loadDataWithNavigationShared에서 base URL에 대한 sandbox extension을 발급하는 방식이며, 그 변경은 규모가 크기 때문에 당분간은 이 예외에 의존한다는 설명이 명시적으로 붙어 있습니다. 이 commit에는 테스트가 동반되지 않습니다.
이번 변경의 형태는 명확히 짚어둘 필요가 있습니다. "누락된 check를 추가"하는 패턴이 아니기 때문입니다.
Before: After:
isLocalFileLoadAllowed(url) isLocalFileLoadAllowed(url)
├─ path under container tmp? ├─ host app is MobileSafari?
│ └─► return true (no ext) │ └─► fall through to ext check
└─ else ──► require extension ├─ path under container tmp?
│ └─► return true (no ext)
└─ else ──► require extension
허용 branch 자체는 그대로 남아 있습니다. 다만 그 앞에 client를 판별하는 predicate가 하나 추가되었을 뿐입니다.
Background
Process split. iOS에서 WebKit은 세 가지 process 역할로 작업을 분리해 실행합니다. UI process는 앱의 전체 권한을 가지며 사용자와 직접 접촉하는 유일한 process입니다. WebContent process는 신뢰할 수 없는 web content를 렌더링하며 강하게 sandbox됩니다. Network process는 WebContent를 대신해 load를 수행합니다. Resource load는 WebContent에서 Network process로 IPC 메시지 형태로 전달되며, Network process는 WebContent의 주장을 그대로 신뢰하지 않고 수신한 내용을 스스로 검증해야 합니다. 원격 공격자가 가장 먼저 손에 넣는 process가 WebContent이기 때문입니다.
Sandbox extensions. iOS와 macOS에서 sandbox extension은 capability token에 해당합니다. 더 높은 권한을 가진 process(대개 UI process)가 특정 filesystem path를 대상으로 이를 발급하고 IPC로 전달하며, 이를 통해 수신 측 sandbox process가 해당 path에 일시적으로 접근할 수 있게 됩니다. 대응되는 extension이 없으면 sandbox profile은 read를 그대로 거부합니다. 따라서 extension은 "권한을 가진 누군가가 이 path를 여기서 접근 가능하도록 의도했다"는 사실을 증명하는 산출물입니다.
NetworkResourceLoader와 local-file 판단. NetworkResourceLoader는 IPC로 load 요청을 받아 완료까지 진행시키는 per-resource load object입니다. 요청이 file:// URL을 대상으로 할 때, isLocalFileLoadAllowed()가 Network process의 서비스 여부를 결정하는 gate 역할을 합니다. 인접한 PathsBlockedForSandboxExtensions.h는 반대 방향의 개념을 담고 있습니다. extension이 절대 허용되어서는 안 되는 path들의 deny-list입니다.
Container temporary directory. 모든 iOS 앱은 tmp/ 하위 디렉토리를 가진 private container를 부여받습니다. NetworkProcess::containerTemporaryDirectory()는 Network process 관점에서 host application의 temp directory path를 반환합니다. web view를 embedding하는 third-party 앱의 경우, 이 디렉토리에는 앱 자신이 생성한 파일이 들어 있습니다. system browser의 경우에는 browser가 관리하는 데이터를 임시로 staging하는 영역에 해당합니다.
Check 안의 path idiom. FileSystem::realPath()는 symbolic link와 .. component를 해석해 path를 canonicalize합니다. 이어서 FileSystem::isAncestor(dir, path)는 canonicalize된 path가 dir 아래에 위치하는지 검사합니다. 두 함수는 함께 "이 path가 저 디렉토리 안에 있는가"를 판별하는 표준적인 idiom을 이룹니다.
WTF::IOSApplication::isMobileSafari(). WTF의 RuntimeApplicationChecks predicate 중 하나로, WebKit을 호스팅하는 application bundle이 무엇인지 알려줍니다. 이 계열의 predicate는 관례적으로 특정 앱에 한해 호환성 quirk나 동작 변경을 적용하는 범위를 정하는 데 사용됩니다.
-[WKWebView loadHTMLString:baseURL:]. 메모리상의 HTML string을 마치 baseURL에서 서빙된 것처럼 로드하는 WKWebView API입니다. 로드된 HTML 안의 subresource 참조는 이 base URL을 기준으로 resolve되므로, file:// base URL을 사용하면 로드된 HTML이 local file을 끌어올 수 있게 됩니다.
Analysis
Root cause는 호환성 예외가 client가 아니라 path 단위로 scope되어 있었다는 점입니다. Authorization flow는 다음과 같으며, 예외가 적용되는 branch를 표시했습니다.
WebContent ──IPC: load file:///…/tmp/x ──► Network process
│
▼
isLocalFileLoadAllowed(url)
│
┌─────────────────────────┴──────────────────┐
│ path under containerTemporaryDirectory? │
└─────────────────────────┬──────────────────┘
yes │ │ no
▼ ▼
return true ◄── no token require sandbox
(exempt branch) extension
이 박스에서 나가는 두 화살표는 모두 filesystem read로 이어지지만, 오른쪽 화살표만이 요청자가 무언가를 부여받았을 것을 요구합니다. 이 예외 자체는 실질적인 필요에서 비롯되었습니다. Third-party 앱이 자신의 tmp/ 디렉토리에 파일을 쓴 뒤 JS에서 fetch하는 경우, UI process를 거치는 navigation이 애초에 없었기 때문에 extension을 발급받을 방법이 없습니다. 결과적으로 WebContent는 forward할 token 자체를 갖지 못하고, 예외가 없다면 load가 그대로 실패하게 됩니다. 이 carve-out은 위치 자체를 권한의 증명으로 취급함으로써 그 문제를 덮었습니다.
문제는 이 carve-out이 어떤 predicate를 기준으로 삼았는가에 있습니다. 검사 대상은 오직 path뿐이었고, WebKit을 호스팅하는 application이 무엇인지, load가 file-scheme 문서에서 시작되었는지, UI process가 애초에 그 path를 접근 가능하도록 의도했는지는 전혀 확인하지 않았습니다. 누락된 invariant는 호환성 예외가 실제로 그것을 필요로 하는 client에게만 적용되어야 한다는 점입니다. Third-party embedder라면 "내 tmp directory 안의 파일"과 "내 web view가 읽어도 되는 파일"은 대체로 같은 집합이므로 이 trade-off는 합리적입니다. 하지만 system browser에서는 이 두 집합이 전혀 다릅니다. 그곳의 container temp directory는 어떤 embedding 앱이 자신의 web view를 위해 채워 넣은 scratch space가 아니라 browser가 관리하는 데이터를 위한 staging 영역이며, 그 아래의 모든 byte가 Network process에 resource-load IPC 메시지를 보낼 수 있는 대상이면 누구나 읽을 수 있는 상태가 되었습니다.
누가 이 메시지를 보낼 수 있는지가 impact를 가늠하는 데 중요합니다. 일반적인 HTTP-origin 문서는 file:// URL을 fetch할 수 없습니다. WebContent 안의 same-origin 및 scheme policy가 IPC에 도달하기 훨씬 전 단계에서 이를 차단하기 때문입니다. 현실적인 공격자는 이미 함락되어 스스로 구성한 load 요청을 보낼 수 있는 WebContent process입니다. 이런 공격자가 container temp directory 아래의 path를 읽어달라고 요청하면, realPath()가 이를 canonicalize하고 isAncestor()가 containment를 확인한 뒤 함수는 true를 반환하며, 실제 read는 Network process 안에서 이루어집니다. Network process의 sandbox는 WebContent의 sandbox와 별개이며, 일부 측면에서는 오히려 더 넓은 범위를 갖습니다. 읽힌 바이트는 평범한 resource-load response body 형태로 돌아옵니다.
여기서 얻어지는 것은 범위가 한정된 primitive입니다. host application의 container temp directory 아래에 있는 파일을 extension 없이 읽는 것입니다. Memory corruption도, write도, control-flow에 대한 영향도, 새로운 execution context도 없습니다. 이는 sandbox escape가 아니며, 이 파일들을 읽는 것 이상의 무언가를 하려면 여전히 별도의 취약점이 필요합니다. 실질적인 역할은 WebContent가 함락된 이후 단계에서의 information-disclosure 절차이며, 그 성과는 브라우저가 해당 디렉토리에 마침 무엇을 staging해 두었는지에 따라 제한됩니다. "An app may be able to read files outside of its sandbox"라는 설명이 가리키는 바가 바로 이것입니다.
Fix는 이 invariant를 완전히 제거하는 대신 범위를 좁히는 방식으로 복원합니다. !WTF::IOSApplication::isMobileSafari()는 trade-off가 가장 정당화되기 어려웠던 하나의 client를 제외시키며, third-party 호환성 경로는 이전과 동일하게 계속 동작합니다. FIXME: rdar://177160334가 이를 그대로 밝히고 있습니다. WebPageProxy::loadDataWithNavigationShared가 base URL에 대해 실제 sandbox extension을 발급할 수 있게 되기 전까지는, 나머지 모든 client에 대해 이 예외가 유지됩니다.
Directory-containment 검사가 capability token을 대신하고 있었기 때문에, loader에 도달할 수 있는 process라면 token을 소지하지 않고도 브라우저의 temp directory를 읽을 수 있었습니다.
Insight
이번 remediation에서 더 흥미로운 부분은 fix 자체의 방식입니다. 이 패치 이후 WebKit을 감사하는 사람이라면 두 가지를 유념할 필요가 있습니다. 첫째, RuntimeApplicationChecks predicate는 이제 단순한 렌더링 quirk가 아니라 security logic 그 자체를 좌우하는 요소가 되었습니다. "이 앱이 Safari인가"에 답하는 함수가 filesystem authorization 결정을 gate하고 있으며, 이는 WebKit의 security posture가 host application에 따라 달라지고 Safari가 아닌 모든 앱에는 상대적으로 약한 posture가 기본값으로 적용된다는 의미입니다. 둘째, 이런 carve-out은 처음에는 하나의 API를 겨냥해 작성되었다가, 같은 predicate에 도달하는 다른 모든 경로에 조용히 상속되는 경향이 있습니다. 이번 diff의 주석 수정 자체가 그 증거입니다. -[WKWebView loadHTMLString:baseURL:]가 원래 JS fetch를 위해 작성된 예외에 편승하고 있었다는 사실을 저자가 뒤늦게 발견한 흔적입니다.
Audit directions
-
호스팅 애플리케이션을 기준으로 삼는 security predicate, request 자체가 아니라. Authorization 결정이 runtime app check에 의존하게 되면, 코드베이스는 인식된 app 수만큼 서로 다른 security posture를 갖게 됩니다. 그리고 인식되지 않은 경우의 default는 대체로 permissive한 쪽입니다. Narrow:
Source/WebKit에서IOSApplication::과MacApplication::호출 지점을 grep해서, 단순히 레이아웃/외관을 다루는 quirk와 filesystem, IPC, extension 결정을 gating하는 부분을 구분해야 합니다.NetworkResourceLoader::isLocalFileLoadAllowed는 이제 후자에 속하며, 각 유사 사례마다 "매칭되지 않는 branch는 무엇을 허용하는가"라는 질문을 던져볼 필요가 있습니다. Wider: 동일한 형태는 문자 그대로 app check는 아니지만 client별로 policy를 완화하는 모든 지점에서 나타납니다. bundle identifier allowlist, linked-on-or-after version check (linkedOnOrAfter*), 그리고 legacy embedder를 위해 check를 비활성화하는WebPreferencescompatibility flag가 여기에 해당합니다. 코드 검색 결과에서의 tell은, 함수 이름에isAllowed,shouldBlock,canAccess,validate가 포함된 함수 내부에 client identity나 SDK version에 대한 boolean guard가 자리하고 있는 경우입니다. Widest: 재사용 가능한 invariant는 다음과 같습니다. compatibility exemption은 그것이 필요한 client에게만 범위가 한정되어야 하고, default branch는 restrictive한 쪽이어야 합니다. 이 원칙은 Chromium의 enterprise-policy 및 origin-trial override, Android의targetSdkVersion기반 permission 동작, 그리고 tenant별로 validation을 비활성화하는 모든 server-side feature flag에도 동일하게 적용됩니다. "어떤 population이 default로 취약한 경로를 타게 되며, 그 규모는 얼마나 큰가"라는 질문을 각 사례에 그대로 가져가야 합니다. -
Directory-containment check를 authorization으로 사용하는 패턴, 즉 path subtree에 속해 있다는 사실이 capability token 보유를 대신하는 경우입니다. 위험한 지점은 그 subtree의 내용물이 requester가 아닌 다른 주체에 의해 제어된다는 데 있습니다. 결과적으로 이 check는 requester가 직접 생성하지 않은 파일에 대한 read까지 authorize하게 됩니다. Narrow:
Source/WebKit/NetworkProcess와Source/WebKit/Shared에 있는 모든FileSystem::isAncestor와 path-prefix 비교를 점검하면서, 각각에 대해 해당 directory가 caller 기준으로 진짜 single-tenant인지 확인해야 합니다.containerTemporaryDirectory()의 호출 지점들과, 동일한 개념을 반대 방향의 deny-list 형태로 인코딩한PathsBlockedForSandboxExtensions부터 시작하는 것이 좋습니다. Wider: 이 class는 canonicalize한 뒤 비교하고 open하는 모든 순서를 포괄합니다.realPath를 호출한 뒤 나중에open()을 호출하는 구조는 check-then-use window에 해당하며, 그 두 단계 사이에 symlink나 directory swap이 발생하면 target이 subtree 밖으로 벗어날 수 있습니다. deny-list 방식과 allow-list 방식은 서로 다른 방식으로 실패하는데, deny-list의 경우 목록에 없는 path는 그대로 허용되기 때문입니다. tell은 path를 local로 resolve한 뒤, 사용 시점에 원본 string을 그대로 전달하거나 다시 resolve하는 함수입니다. Widest: 이것은 path-containment를 authorization으로 오용하는 일반적인 class에 해당하며,path.resolveprefix 비교 위에 구축된 Node.js sandbox, prefix test 이전에filepath.Clean을 사용하는 Go 서비스,os.path.commonprefix를 사용하는 Python 코드에서도 동일하게 나타납니다. 여기서 유지해야 할 invariant는 containment는 location을 증명할 뿐 permission을 증명하지 않으며, 그것도 오직 평가된 그 순간에만 유효하다는 것입니다. -
수신 process가 예상되는 capability token의 부재를 그대로 용인하는 패턴. 버그의 본질은 token이 없다는 사실 자체가 아니라, 그 부재에 대한 fallback이 존재한다는 점입니다. Narrow:
NetworkResourceLoader::isLocalFileLoadAllowed의 호출자들과 그 주변의 file-scheme 처리 로직을 추적해서, sandbox extension 없이 load가 진행되는 모든 branch를 나열해야 합니다. 그런 다음 각각에 대해 원래의 API보다 수명이 길어진 compatibility rationale이 남아 있는지 확인해야 합니다. tell은 "Some applications are relying on"으로 시작하는 comment이거나,return true옆에 미해결 상태로 남아 있는FIXME/radar reference입니다. Wider: 동일한 형태는 WebKit이 권한이 낮은 process로부터 resource identifier를 받아, token 없이 access를 재구성하는 모든 지점에서 나타납니다. blob 및 file-reference resolution (resolveBlobReferences), GPU process에서의 extension 소비, 그리고 extension이 없을 때 hard failure 대신 permissive default로 degrade하는 모든 handler가 이에 해당합니다. success path는 validation을 수행하는 반면 fallback path는 log를 남기고 그냥 허용해버리는 비대칭 구조를 찾아야 합니다. Widest: invariant는 capability token이 없을 때 안전한 fallback은 오직 거부뿐이다라는 것입니다. 이는 renderer가 제공한 identifier로부터 browser-side state를 재구성하는 Chromium의 Mojo message handler, capability 기반 OS API (Fuchsia handle, Landlock, seccomp-scoped fd), 그리고 "legacy client, no auth header" branch를 가진 모든 RPC layer에 동일하게 적용됩니다. 기억해둘 tell은, else-branch가 error가 아닌 다른 무언가로 되어 있는 permission check입니다.