← All reports

Remove blanket storage-root file path allow from blob access enforcement

HighNetwork process — blob registration IPC and the IndexedDB storage stackAuthBypass

CVE: CVE-2026-43732 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: A path handling issue was addressed with improved validation. Credit: Nan Wang (@eternalsakura13)

5be1236 | Bugzilla 313085

High. renderer가 어떤 파일을 읽을 수 있는 blob으로 바꿀 수 있는지 결정하는 게이트가, "storage root 아래에 있다"는 조건을 "너에게 건네준 파일이다"라는 조건의 대체물로 받아들이고 있었습니다. 그런데 이 storage root에는 해당 세션의 모든 origin이 가진 영구 데이터가 들어 있습니다. Memory corruption은 없지만, renderer 하나를 장악하기만 하면 cross-origin storage read로 곧바로 이어지는 구조입니다.

WebKit에서 디스크에 영구 저장된 웹사이트 데이터를 직접 다루는 주체는 Network process뿐입니다. Sandbox 안에 있는 WebContent process는 해당 파일을 스스로 열지 못하고, IPC를 통해 경로를 지정한 뒤 되돌려받는 방식으로만 접근할 수 있습니다. 이런 비대칭 구조가 성립하려면, Network process가 자신에게 전달된 모든 경로에 대해 한 가지 질문에 정확히 답할 수 있어야 합니다. 바로 "이 파일을 내가 너에게 건네준 게 맞는가?"라는 질문입니다. NetworkConnectionToWebProcess::isFilePathAllowed()가 file-backed blob 등록 과정에서 바로 이 질문에 답하는 지점인데, 이번 commit 이전에는 훨씬 느슨한 다른 질문에 답하고 있었습니다.

관전 포인트: IPC를 위조할 수 있는 renderer라면, 다른 origin의 IndexedDB blob payload나 Origin Private File System 콘텐츠를 포함해 세션의 storage root 아래에 있는 어떤 파일이든 자신이 읽을 수 있는 blob으로 바꿀 수 있었습니다.

Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp

-bool NetworkConnectionToWebProcess::isFilePathAllowed(NetworkSession& session, String path)
+bool NetworkConnectionToWebProcess::isFilePathAllowed(String path)
{
path = FileSystem::lexicallyNormal(path);
auto parentPath = FileSystem::parentPath(path);
while (parentPath != path) {
 
- if (m_allowedFilePaths.contains(path) || parentPath == session.storageManager().path() || parentPath == session.storageManager().customIDBStoragePath())
+ if (m_allowedFilePaths.contains(path))
return true;
path = parentPath;
parentPath = FileSystem::parentPath(path);
}
if (blobFileAccessEnforcementEnabled() && shouldCheckBlobFileAccess())
 
- MESSAGE_CHECK(isFilePathAllowed(*session, fileBackedPath));
+ MESSAGE_CHECK(isFilePathAllowed(fileBackedPath));

Source/WebKit/NetworkProcess/storage/IDBStorageConnectionToClient.cpp

+static Vector<String> resultBlobFilePaths(const WebCore::IDBResultData& resultData)
+{
+ Vector<String> paths;
+ switch (resultData.type()) {
+ case WebCore::IDBResultType::GetRecordSuccess:
+ case WebCore::IDBResultType::OpenCursorSuccess:
+ case WebCore::IDBResultType::IterateCursorSuccess:
+ paths.appendVector(resultData.getResult().value().blobFilePaths());
+ for (auto& record : resultData.getResult().prefetchedRecords())
+ paths.appendVector(record.value.blobFilePaths());
+ break;
+ case WebCore::IDBResultType::GetAllRecordsSuccess:
+ for (auto& value : resultData.getAllResult().values())
+ paths.appendVector(value.blobFilePaths());
+ break;
+ default:
+ break;
+ }
+ return paths;
+}
+
+template<typename Message>
+void IDBStorageConnectionToClient::sendResultWithBlobFileAccess(WebIDBResult&& result)
+{
+ auto blobFilePaths = resultBlobFilePaths(result.resultData());
+ RefPtr networkStorageManager = m_networkStorageManager.get();
+ if (!networkStorageManager) {
+ IPC::Connection::send(m_connection, Message(WTF::move(result)), 0);
+ return;
+ }
+ networkStorageManager->allowAccessToBlobFilesForProcess(m_identifier, WTF::move(blobFilePaths), [connection = m_connection, result = WTF::move(result)]() mutable {
+ IPC::Connection::send(connection, Message(WTF::move(result)), 0);
+ });
+}
 
void IDBStorageConnectionToClient::didGetRecord(const WebCore::IDBResultData& resultData)
{
 
- IPC::Connection::send(m_connection, Messages::WebIDBConnectionToServer::DidGetRecord(prepareGetResult(resultData)), 0);
+ sendResultWithBlobFileAccess<Messages::WebIDBConnectionToServer::DidGetRecord>(prepareGetResult(resultData));
}

Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp

+void NetworkStorageManager::allowAccessToBlobFilesForProcess(WebCore::ProcessIdentifier processIdentifier, Vector<String>&& filePaths, CompletionHandler<void()>&& completionHandler)
+{
+ assertIsCurrent(workQueue());
+
+ if (filePaths.isEmpty())
+ return completionHandler();
+
+ RunLoop::mainSingleton().dispatch([protectedThis = Ref { *this }, processIdentifier, filePaths = crossThreadCopy(WTF::move(filePaths)), completionHandler = WTF::move(completionHandler)] mutable {
+ RefPtr process = protectedThis->m_process.get();
+ if (!process)
+ return protectedThis->workQueue().dispatch(WTF::move(completionHandler));
+ process->allowFilesAccessFromWebProcess(processIdentifier, filePaths, [protectedThis = WTF::move(protectedThis), completionHandler = WTF::move(completionHandler)] mutable {
+ protectedThis->workQueue().dispatch(WTF::move(completionHandler));
+ });
+ });
+}
 
`LayoutTests/ipc/register-file-backed-blob-path-validation.html`
```diff
+ localStorage.setItem('seed', '1');
+ let storagePath = await generalStoragePath();
+ let targetPath = storagePath + '/salt';
+ let blobURL = URL.createObjectURL(new Blob([new Uint8Array([0])]));
+ CoreIPC.Networking.NetworkConnectionToWebProcess.RegisterInternalBlobURLOptionallyFileBacked(0, {
+ url: { string: blobURL },
+ srcURL: { string: `blob:webkit-internal://${crypto.randomUUID()}` },
+ fileBackedPath: targetPath,
+ contentType: 'application/octet-stream',
+ });
+ let size = await blobSize(blobURL);
+ log('Unauthorized file-backed blob registration rejected: ' + (size === 1 ? 'PASS' : 'FAIL (BlobSize=' + size + ')'));

이번 변경에서 수정된 11개 파일은 세 그룹으로 나뉩니다. validator 자체, validator를 강화하기 전에 먼저 구축되어야 했던 grant 발급 로직, 그리고 regression test와 이를 위한 test 전용 IPC입니다.

The validator. isFilePathAllowed()에서 NetworkSession& 파라미터가 완전히 제거되었습니다. 더 이상 session의 storage 경로를 참조하지 않으므로 session 자체가 필요 없어진 것입니다. 상위 디렉터리로 거슬러 올라가는 순회 로직은 그대로 남지만, loop 내부의 조건은 세 가지 disjunction에서 m_allowedFilePaths.contains(path) 단일 검사로 축소되었습니다. registerInternalFileBlobURL()(non-Cocoa 빌드에서는 pathreplacementPath를 모두 검사)과 registerInternalBlobURLOptionallyFileBacked()의 세 호출 지점도 인자 하나만 받는 새 signature에 맞춰 수정되었습니다. MESSAGE_CHECK wrapping 자체는 달라지지 않아서, 거부된 path는 여전히 보내는 쪽 connection을 종료시킵니다.

The grants. 일괄 허용을 없애려면 정당한 path 각각에 대해 grant가 개별적으로 발급되어야 합니다. diff의 상당 부분이 바로 이 retrofit 작업입니다. IDBStorageConnectionToClient에는 resultBlobFilePaths()라는 free function이 추가되었습니다. 이 함수는 blobFilePaths()를 담을 수 있는 네 가지 IDBResultType variant에서 값을 뽑아냅니다. 대상은 GetRecordSuccess, OpenCursorSuccess, IterateCursorSuccess(각각의 prefetchedRecords() 포함), GetAllRecordsSuccess입니다. 또한 template<typename Message> sendResultWithBlobFileAccess(WebIDBResult&&)가 추가되어, grant를 발급한 뒤 그 completion handler 안에서 Message를 전송합니다. didGetRecord, didGetAllRecords, didOpenCursor, didIterateCursor는 각각 직접적인 IPC::Connection::send 호출에서 이 template을 거치는 한 줄짜리 호출로 재작성되었습니다. generateIndexKeyForRecord도 동일한 방식이 수동으로 적용되어, 여섯 개의 인자를 sendResult lambda로 캡처하고 먼저 value.blobFilePaths()에 대한 grant를 발급하도록 바뀌었습니다.

NetworkStorageManager::allowAccessToBlobFilesForProcess()는 새로 추가된 grant chokepoint입니다. 먼저 storage work queue 위에서 실행 중인지 assert하고, path 목록이 비어 있으면 바로 종료합니다. 그 다음 main run loop로 넘어가 NetworkProcess::allowFilesAccessFromWebProcess()를 호출한 뒤, 다시 work queue로 돌아와 completion handler를 실행합니다. IDB 쪽에서 이 함수에 도달하려면 기존에 없던 ownership 관계가 필요했습니다. 이를 위해 IDBStorageRegistryThreadSafeWeakRef<NetworkStorageManager> m_manager와 manager를 인자로 받는 explicit 생성자가 추가되었고, NetworkStorageManager의 생성자도 lazyInitialize 시점에 *this를 전달하도록 변경되었습니다.

File System Access 경로에도 동일한 원칙이 적용되었습니다. NetworkStorageManager::getFile()은 이제 IPC::Connection&을 인자로 받아 요청한 NetworkConnectionToWebProcess를 찾을 수 있게 되었습니다. 이전처럼 handle->path()로 곧바로 completion하는 대신, main thread로 dispatch해서 webConnection->allowAccessToFile(path)를 호출하고, 그 다음에야 work queue로 돌아와 completion을 수행합니다.

The test. 새로 추가된 [EnabledBy=AllowTestOnlyIPC] GeneralStoragePathForTesting() -> (String path) message와 그 handler를 통해, layout test가 런타임에 storage root를 알아낼 수 있게 되었습니다. register-file-backed-blob-path-validation.html은 storage를 seed한 뒤 root를 요청하고, <storageRoot>/salt — test의 origin이 아니라 storage layer 소유의 파일 — 위에 file-backed blob을 등록하려 시도합니다. 등록이 거부되고, BlobSize가 디스크에 있는 salt 파일의 크기가 아니라 test가 만든 1바이트 in-memory blob을 그대로 보고하면 test는 통과합니다.

The process split. WebKit은 신뢰할 수 없는 web content를 sandbox된 WebContent process에서 실행하고, networking과 디스크상의 website data storage는 별도의 Network process가 담당합니다. WebContent는 storage 디렉터리를 직접 열 수 없으며, 디스크에서 필요한 것이 있으면 반드시 IPC를 통해 요청해야 합니다.

File-backed blobs. Blob이 반드시 in-memory buffer로 뒷받침될 필요는 없습니다. 디스크상의 파일을 참조하는 형태도 가능합니다. WebContent는 blob URL과 filesystem path를 함께 Network process에 보내서 이를 등록합니다(RegisterInternalFileBlobURL, RegisterInternalBlobURLOptionallyFileBacked). Network process는 그 path를 BlobDataFileReferenceWithSandboxExtension으로 감싸고, 이후 해당 blob에 대한 read 요청은 이 파일로부터 처리됩니다.

MESSAGE_CHECK. WebKit의 IPC validation macro입니다. assert된 조건이 false이면 해당 message는 invalid로 취급되어 보내는 쪽 connection이 종료됩니다. 권한이 높은 process가 권한이 낮은 process로부터 받은 인자를 검증하는 표준적인 방식입니다.

The per-connection allowlist. NetworkConnectionToWebProcessm_allowedFilePaths를 유지합니다. 이는 해당 WebContent connection에 명시적으로 grant된, lexical하게 정규화된 path의 집합입니다. allowAccessToFile()이 여기에 항목을 추가합니다. 파일 단위 capability를 저장하는 곳입니다.

The general storage directory. NetworkStorageManager::path()는 session별 root로, 그 아래에 session의 모든 persisted website data가 위치합니다. origin별 하위 디렉터리와 salt 같은 storage layer 자체 파일들이 함께 이 root 밑에 놓입니다. customIDBStoragePath()는 custom IndexedDB path가 설정된 경우 사용되는 대체 root입니다. 둘 다 공유 container에 해당하며, session 내 모든 origin의 데이터가 이 아래에 위치합니다.

IndexedDB blob storage. Blob이나 File 객체를 담은 IndexedDB value는 record에 inline으로 저장되지 않고 디스크상의 별도 파일로 persist됩니다. record를 다시 읽어올 때, 결과로 만들어지는 IDBResultData는 해당 파일들을 가리키는 blobFilePaths()를 함께 담고 있으며, Network process는 이를 WebContent에 전달해 반대편에서 blob을 재구성할 수 있게 합니다. prefetchedRecords()는 server가 iteration에 앞서 미리 보내는 추가 cursor record들로, 각각 자신만의 value와 blob path를 갖습니다. IDBStorageConnectionToClient는 IndexedDB server의 callback(didGetRecord, didOpenCursor 등)을 WebContent의 WebIDBConnectionToServer로 향하는 IPC message로 변환하는 Network process측 객체입니다.

Threading. NetworkStorageManager는 storage 작업을 전용 work queue 위에서 수행합니다. assertIsCurrent(workQueue())가 있는 이유입니다. 반면 NetworkProcessNetworkConnectionToWebProcess의 상태 — m_allowedFilePaths를 포함해서 — 는 main thread가 소유합니다. storage 쪽 코드에서 connection의 allowlist를 변경하려면 이 경계를 넘어갔다가 다시 돌아와야 합니다.

IPC testing API. IPCTestingAPIEnabled가 활성화되면 layout test는 page script에서 raw IPC message를 보낼 수 있는 window.IPC / CoreIPC 객체를 갖게 됩니다. regression test가 실제 renderer compromise 없이도 compromise된 renderer처럼 동작할 수 있는 이유입니다.

근본 원인은 authorization predicate의 granularity 불일치에 있습니다. 실제 자원은 특정 object 단위인데, check는 container 단위로 이름을 매긴 것입니다.

  isFilePathAllowed("<storageRoot>/<otherOrigin>/idb/blob-42")

  Before:                              After:
  walk up parents ──┐                  walk up parents ──┐
    m_allowedFilePaths.contains? no    m_allowedFilePaths.contains? no
    parent == storageRoot?  ── YES ──► allow       (disjunct deleted)
    parent == customIDBPath?           ...
                                       reach fs root ──► MESSAGE_CHECK fails

loop는 path를 filesystem root 방향으로 거슬러 올라가면서, 각 단계마다 한 가지가 아니라 세 가지 질문을 던졌습니다. 이 중 두 가지 — parentPath == session.storageManager().path()parentPath == session.storageManager().customIDBStoragePath() — 는 identity가 아니라 containment를 검사하는 조건이었습니다. 순회가 root까지 계속 이어지기 때문에, "이 path의 조상 중 하나가 storage root인가?"라는 질문은 결국 "이 path가 storage root 아래 어딘가에 있는가?"라는 질문과 동일해집니다. 그런데 storage root는 session 내 모든 origin의 persisted data를 담는 공용 container입니다. 원래 Network process가 이 파일을 당신에게 건네준 적이 있는가를 의미했어야 할 predicate가, 어느새 이 파일이 공용 상자 안에 있는가로 변질된 셈입니다.

여기서부터 mechanism은 간단합니다. 원하는 fileBackedPath를 실어 RegisterInternalBlobURLOptionallyFileBacked를 보낼 수 있는 WebContent process라면, storage root 아래의 아무 path나 지정할 수 있습니다. 다른 origin의 IndexedDB blob payload, Origin Private File System 파일, storage layer 자체의 salt 등이 모두 대상이 될 수 있습니다. 이런 path를 지정해도 MESSAGE_CHECK는 그대로 통과합니다. Network process는 그 path 위에 BlobDataFileReferenceWithSandboxExtension을 구성하고, 이후 해당 blob URL을 read할 때마다 요청한 process에게 파일 내용이 그대로 반환됩니다. 함께 추가된 layout test는 정확히 이 경로를 따라갑니다.

  1. session의 general storage directory가 디스크에 존재하도록 storage를 seed합니다.
  2. 새로 추가된 GeneralStoragePathForTesting message로 root를 알아냅니다.
  3. 평범한 1바이트 in-memory blob을 만들고 그 blob URL을 가져옵니다.
  4. RegisterInternalBlobURLOptionallyFileBacked를 보내 해당 URL이 <storageRoot>/salt를 가리키도록 재지정합니다.
  5. BlobSize를 조회합니다. 크기가 더 이상 1이 아니라면 재지정이 성립했고, 디스크상의 파일을 읽을 수 있었다는 뜻입니다.

이 primitive가 무엇이고 무엇이 아닌지 짚어볼 필요가 있습니다. write 경로는 없습니다. attacker가 지정한 path는 오직 read를 위한 BlobDataFileReferenceWithSandboxExtension::create()로만 흘러 들어갑니다. sandbox escape도 아닙니다. Network process 안에서 코드가 실행되는 것도 아니고, storage tree 바깥의 무언가에 도달하게 되는 것도 아닙니다. 이 primitive의 가치는 post-compromise 단계에 있습니다. 어떤 방식으로든 확보한 renderer의 foothold가, persist된 user data에 대한 cross-origin read access로 이어지는 것입니다. 이는 정확히 "조작된 web content 처리 과정에서 민감한 사용자 정보가 노출될 수 있다"는 형태에 해당합니다. 일반적인 JavaScript는 여기에 도달할 수 없는데, script는 fileBackedPath를 직접 제어할 수 없기 때문입니다. WebContent 안에서 IPC를 위조할 수 있는 능력, 혹은 활성화된 IPC testing surface가 전제 조건입니다.

그렇다면 애초에 왜 이 두 directory disjunct가 존재했는가 하는 의문이 남습니다. 올바른 mechanism은 이미 같은 predicate 안에 존재하고 있었습니다. allowAccessToFile()이 한 번에 하나씩 채워 넣는 m_allowedFilePaths가 그것입니다. 두 disjunct는 아무도 작성하지 않은 grant를 대신하고 있었던 셈입니다. IndexedDB는 실제로 blob file path를 WebContent에 돌려주는데, File이 database를 왕복하고도 살아남는 방식이 바로 이것입니다. 그리고 이 path들은 storage root 아래에 위치합니다. 그래서 "root 아래 무엇이든 허용"이라는 거친 조건 하나만으로도, IDB result 경로에 별도의 grant를 심지 않고도 기능이 그대로 동작했습니다. disjunct를 지우는 것 자체는 한 줄이지만, 패치의 나머지 부분은 그동안 미뤄뒀던 대가를 치르는 작업입니다. resultBlobFilePaths()는 어떤 result type이 path를 담고 있는지 나열하고, allowAccessToBlobFilesForProcess()는 work-queue/main-thread 경계를 넘나들며 그 path들을 connection의 allowlist에 반영하며, getFile()은 File System Access에 대해 동일한 역할을 합니다. 이 작업이 끝난 뒤에야 엄격한 check가 실제 사용 환경에서도 살아남을 수 있습니다.

sendResultWithBlobFileAccess 내부의 순서는 다시 한번 눈여겨볼 가치가 있습니다. grant 발급은 비동기적입니다. work queue에서 main run loop로 넘어가 allowFilesAccessFromWebProcess()를 호출한 뒤 다시 work queue로 돌아오는 과정을 거칩니다. 반면 path를 담은 reply는 단순한 IPC::Connection::send입니다. reply를 먼저 보내고 grant가 나중에 언제든 도착하도록 두었어도 컴파일은 되고 test도 통과했겠지만, 이는 잘못된 설계입니다. WebContent가 아직 권한을 부여받지 못한 path를 잠깐이나마 쥐게 되고, 자신의 blob 등록 요청이 grant 도착과 race할 수 있기 때문입니다. 대신 send는 completion handler 안에 위치합니다. grant가 완료되는 것을 관찰하는 바로 그 continuation이 값을 건네주는 continuation과 같은 것입니다. getFile()도 반대 방향에서 동일한 형태를 취합니다. allowAccessToFile(path)가 main thread에서 먼저 실행된 뒤에야 work queue로 되돌아가 path와 함께 completion합니다.

"이 path가 storage root 아래에 있는가?"를 묻는 access-control check은 파일이 아니라 container에 대해 묻고 있는 것이며, storage root에는 모든 origin의 데이터가 함께 담겨 있습니다.

이는 거친 allow가 흔히 거치는 life cycle입니다. directory containment가 곧 authorization을 의미한다는 착각에서 비롯된 것이 아니라, 한 번도 구현되지 않은 per-object grant의 자리를 대신 채워 넣은 placeholder에 가깝습니다. 그리고 누군가 "그 디렉터리 아래에 또 무엇이 있는가"를 묻기 전까지는 그대로 살아남습니다. 알아볼 수 있는 단서는, validator 안에 이미 정확한 fine-grained mechanism이 거친 조건과 나란히 존재한다는 점입니다. 이번 경우에는 m_allowedFilePaths.contains(path)가 두 directory 비교와 같은 if 안에 함께 있었습니다. 이런 조합을 발견했다면, 거친 disjunct는 대체로 아무도 grant를 연결하고 싶어하지 않았던 단 하나의 기능을 떠받치고 있을 가능성이 높습니다. 그리고 이를 고치는 진짜 작업은 check를 지우는 것이 아니라, 정당한 path를 만들어내는 모든 지점을 찾아내는 것입니다.