← All reports

[2] Validate connection access to DOMCache with DOMCacheIdentifier

HighWebKit NetworkProcess storageAuthBypass

Another site's cache will happily serve back what you wrote into it.

5d1be2c

High — FileSystem 패치와 동일한 confused-deputy 형태가 이번에는 다른 storage type에서 재현된 사례입니다. 한 사이트에 국한된 renderer compromise가 모든 사이트의 캐시된 HTTP 응답에 도달하게 되며, write 방향의 문제도 그대로 남아 있습니다. 오염된 entry가 victim의 다음 방문 시 same-origin content로 그대로 서빙됩니다.

Cache Storage API(caches.open(), Cache.match/put/delete)는 각 사이트를 대신하여 request/response 쌍을 보관하는데, 실제 레코드는 network process가 들고 있고 모든 연산은 IPC를 거칩니다. 이 경계를 넘어 캐시를 식별하는 값이 DOMCacheIdentifier이며, 캐시가 열릴 때 발급되어 이후 메시지에서 그대로 인용됩니다. 신뢰 구조는 OPFS와 동일합니다. WebProcess에서 NetworkProcess로 향하는 IPC는 untrusted input으로 취급되어야 하고, network process는 자신이 resolve하는 객체가 sender의 site 소유가 맞는지 확인해야 합니다.

관전 포인트: compromise된 WebProcess가 identifier만으로 다른 사이트의 캐시를 지목해 저장된 HTTP 응답을 읽거나 삭제할 수 있고, 나아가 attacker가 작성한 entry를 써넣어 victim 사이트가 나중에 이를 스스로에게 서빙하도록 만들 수 있습니다.

NetworkStorageManager로 전달되는 CacheStorage 관련 메시지 다수는 DOMCache storage 연산을 요청할 때 DOMCacheIdentifier만 싣고 있었고, NetworkStorageManager는 sender process가 요청된 캐시에 실제로 접근 권한이 있는지 검사하지 않았습니다. 이 때문에 compromise된 process가 DOMCacheIdentifier를 위조해 다른 origin의 데이터에 접근할 수 있었습니다. 패치에서는 CacheStorageManager에 origin을 저장하고 CacheStorageCache에 origin accessor를 추가하여, NetworkStorageManager가 CacheStorage 메시지 핸들러 안에서 isSiteAllowedForConnection을 실행할 수 있도록 했습니다.

Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp

-void NetworkStorageManager::cacheStorageRetrieveRecords(WebCore::DOMCacheIdentifier cacheIdentifier, WebCore::RetrieveRecordsOptions&& options, WebCore::DOMCacheEngine::CrossThreadRecordsCallback&& callback)
+void NetworkStorageManager::cacheStorageRetrieveRecords(IPC::Connection& connection, WebCore::DOMCacheIdentifier cacheIdentifier, WebCore::RetrieveRecordsOptions&& options, WebCore::DOMCacheEngine::CrossThreadRecordsCallback&& callback)
{
RefPtr cache = m_cacheStorageRegistry->cache(cacheIdentifier);
if (!cache)
return callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal));
 
+ auto origin = cache->origin();
+ MESSAGE_CHECK_COMPLETION(origin && isSiteAllowedForConnection(connection.uniqueID(), WebCore::RegistrableDomain { origin->topOrigin }), connection, callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal)));
+
cache->retrieveRecords(WTF::move(options), WTF::move(callback));
}
...
void NetworkStorageManager::cacheStoragePutRecords(IPC::Connection& connection, ...)
if (!cache)
return callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal));
 
+ auto origin = cache->origin();
+ MESSAGE_CHECK_COMPLETION(origin && isSiteAllowedForConnection(connection.uniqueID(), WebCore::RegistrableDomain { origin->topOrigin }), connection, callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal)));
+
for (auto& record : records)
MESSAGE_CHECK_COMPLETION(record.responseBodySize >= CacheStorageDiskStore::computeRealBodySizeForStorage(record.responseBody), connection, ...);

Source/WebKit/NetworkProcess/storage/CacheStorageCache.cpp

+std::optional<WebCore::ClientOrigin> CacheStorageCache::origin() const
+{
+ if (RefPtr manager = m_manager.get())
+ return manager->origin();
+ return std::nullopt;
+}

CacheStorageManager는 이제 소유 origin을 WebCore::ClientOrigin m_origin 멤버로 무조건 저장하고, origin() const를 통해 노출합니다. 기존에는 origin marker 파일을 쓸지 여부만 판단하기 위해 존재하던 std::optional<WebCore::ClientOrigin> 타입의 생성자 파라미터가, 순수한 const WebCore::ClientOrigin&와 새로 추가된 enum class ShouldWriteOriginFile : bool { No, Yes }로 대체되었습니다. OriginStorageManager::StorageBucket::cacheStorageManager도 이에 맞춰 origin을 항상 전달하도록 바뀌었고, m_level < UnifiedOriginStorageLevel::Standard 검사는 새로 추가된 ShouldWriteOriginFile 인자로 흡수되었습니다. 그 결과 이전에는 origin이 버려지던 Standard level에서도 origin이 유지됩니다.

CacheStorageCache::origin() const가 새로 추가되어 WeakPtr<CacheStorageManager> m_manager를 통해 요청을 전달하며, manager가 이미 사라진 경우에는 std::nullopt를 반환합니다.

cacheStorageRemoveCache, cacheStorageReference, cacheStorageDereference, cacheStorageRetrieveRecords, cacheStorageRemoveRecords, cacheStoragePutRecords 여섯 개의 메시지 핸들러에는 m_cacheStorageRegistry->cache(cacheIdentifier) lookup 직후 origin && isSiteAllowedForConnection(connection.uniqueID(), RegistrableDomain { origin->topOrigin })에 대한 MESSAGE_CHECK/MESSAGE_CHECK_COMPLETION이 추가되었습니다. 이 중 cacheStorageRemoveCache, cacheStorageRetrieveRecords, cacheStorageRemoveRecords는 이번 패치 이전에는 IPC::Connection& 파라미터 자체가 없었으며, NetworkStorageManager.h에서 시그니처가 확장되었습니다.

opaque identifier를 capability처럼 취급하는 패턴입니다. IPC 핸들러가 caller가 제공한 ID를 전역 범위의 registry를 통해 resolve하면서도, resolve된 객체를 caller가 실제로 소유하는지 재검증하지 않습니다.

이 코드가 있는 위치. NetworkStorageManager는 storage IPC의 network-process 측 진입점이며, 그 아래의 CacheStorage 스택은 CacheStorageRegistry, CacheStorageManager, CacheStorageCache, OriginStorageManager로 구성됩니다. WebContent와 NetworkProcess 간 신뢰 경계에 위치하며, site 단위 storage isolation을 강제하는 지점 중 하나입니다.

Cache Storage API. caches.open(name)Cache 객체를 반환하며, 그 안의 레코드(request/response 쌍)는 network process가 디스크나 메모리에 저장합니다. Cache.match, Cache.put, Cache.delete는 각각 별도의 IPC round trip으로 NetworkStorageManager에 전달됩니다.

Origin key. WebCore::ClientOrigintopOrigin(top-level document의 origin, 즉 storage partition)과 clientOrigin(실제로 storage를 사용하는 frame이나 worker의 origin)의 쌍입니다. 최신 WebKit의 storage는 단일 origin이 아니라 이 쌍을 key로 사용합니다. WebCore::RegistrableDomain은 origin에서 도출되는 eTLD+1 "site"이며, process-per-site isolation과 storage site-validation 검사는 바로 이 site 단위로 동작합니다.

DOMCacheIdentifier와 registry. DOMCacheIdentifier는 캐시가 열릴 때 network process에서 발급되어 WebProcess로 전달되는 ObjectIdentifier입니다. CacheStorageCacheIdentified<WebCore::DOMCacheIdentifier>를 상속하므로, 이 identifier가 이후 모든 메시지에서 캐시의 identity 역할을 합니다. CacheStorageRegistry는 세션 단위 테이블(HashMap<DOMCacheIdentifier, WeakPtr<CacheStorageCache>>)로, identifier를 live cache로 resolve합니다. 이 테이블은 세션 내 모든 origin에 걸쳐 있으며, origin 단위 분리는 registry가 아니라 한 단계 위인 OriginStorageManager/CacheStorageManager에서 이루어집니다.

isSiteAllowedForConnection. 특정 WebProcess connection이 특정 site의 storage에 접근할 수 있는지를 판단하는 NetworkStorageManager의 predicate입니다. NetworkStorageManager에 설정된 storageSiteValidationEnabled 모드의 실질적인 강제 지점입니다.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit의 IPC validation 매크로입니다. 검사에 실패하면 해당 메시지를 malformed로 간주하고 sender connection을 종료합니다. _COMPLETION variant는 sender에게 응답을 돌려줘야 하는 핸들러를 위해, 종료 전에 지정된 completion handler를 먼저 실행합니다.

UnifiedOriginStorageLevel. 세션의 storage가 unified per-origin directory 구조를 얼마나 채택하는지를 제어하는 설정값입니다. Standard보다 낮은 level에서는 feature별 디렉토리가 여전히 자체 origin marker 파일을 두어 어떤 origin이 해당 디렉토리를 소유하는지 기록합니다.

Threat model. WebProcess에서 NetworkProcess로 향하는 IPC는 untrusted input입니다. 장악된 renderer는 임의의 필드값을 가진 메시지를 자유롭게 보낼 수 있으므로, 모든 핸들러는 자신이 resolve하는 객체가 sender 소유인지 검증해야 합니다.

이미 열려 있는 캐시를 다루는 핸들러들은 메시지에 DOMCacheIdentifier만 싣고 있었습니다. 특히 cacheStorageRetrieveRecords, cacheStorageRemoveRecords, cacheStorageRemoveCacheIPC::Connection&조차 전달받지 않았으며, identifier를 registry를 통해 곧바로 CacheStorageCache로 resolve하면서도 sending WebProcess가 그 origin에 접근 권한이 있는지는 전혀 확인하지 않았습니다. 같은 파일 안의 origin-scoped 핸들러들(cacheStorageOpenCache, cacheStorageAllCaches, lockCacheStorage, unlockCacheStorage)은 메시지에서 ClientOrigin을 받아 originStorageManager(origin, ...)를 거치므로 기존 site-validation 경로의 적용을 받지만, identifier로만 keying되는 핸들러들은 이 경로를 완전히 우회하고 있었습니다.

두 번째 원인은 한 단계 아래에 있었습니다. OriginStorageManager::StorageBucket::cacheStorageManager는 origin을 std::optional로 전달했는데, 이 값은 m_level < UnifiedOriginStorageLevel::Standard일 때만 채워졌습니다. 유일한 소비처가 StorageUtilities::writeOriginToFile였기 때문입니다. 결과적으로 Standard level에서는 CacheStorageManager가 자신이 어느 origin에 속하는지 전혀 알지 못했고, DOMCacheIdentifier만으로는 authorization 검사 자체를 표현할 방법이 없었습니다.

  Before:                                After:
  cacheStorageRetrieveRecords(id)        cacheStorageRetrieveRecords(conn, id)
    └─► registry->cache(id)                └─► registry->cache(id)
          └─► cache->retrieveRecords()          └─► cache->origin()
                (any origin's records)                └─► isSiteAllowedForConnection?
                                                            ├─ no  ─► MESSAGE_CHECK fails,
                                                            │          connection terminated
                                                            └─ yes ─► retrieveRecords()

취약한 핸들러들은 IPC message receiver이지, 일반 웹 콘텐츠가 직접 도달할 수 있는 primitive가 아닙니다. 일반적인 JavaScript는 origin-checked된 cacheStorageOpenCache를 통해서만 자신의 캐시에 대한 DOMCacheIdentifier를 얻을 수 있습니다. 현실적인 attacker는 이미 임의의 IPC를 보낼 수 있는 WebProcess이며, 이는 정확히 isSiteAllowedForConnection이 방어하도록 설계된 모델입니다.

패치 이전 코드를 따라가 보면, read 방향은 세 단계로 진행됩니다. 먼저 attacker의 process가 victim site의 live CacheStorageCache를 지목하는 DOMCacheIdentifier를 확보합니다. identifier space는 CacheStorageRegistry::m_caches 안에 하나의 평평한 namespace로 존재하므로, identifier가 counter 기반으로 생성된다면 적당한 정수 범위를 순회하는 것만으로 enumeration이 가능할 수 있습니다. 게다가 조회 실패에 대한 rate limiting이나 lockout이 전혀 없어서, 미스가 나더라도 if (!cache) early return을 통해 Error::Internal을 반환할 뿐 connection은 그대로 살아 있습니다. 다음으로 attacker는 그 identifier와 request.url()이 null인 RetrieveRecordsOptions를 담아 CacheStorageRetrieveRecords를 보냅니다. CacheStorageCache::findRecordsurl.isNull() 분기를 타면서 매칭되는 레코드 하나가 아니라 캐시 안의 모든 레코드를 반환합니다. 마지막으로 cache->retrieveRecords(...)가 완료되면서 callback이 victim의 CrossThreadRecord 벡터를, responseBody까지 포함해서, attacker의 connection으로 그대로 실어 보냅니다.

write 방향은 이 흐름의 거울상입니다. victim의 identifier와 attacker가 작성한 CrossThreadRecord entry를 담아 CacheStoragePutRecords를 보내면 됩니다. 패치 이전 이 핸들러에 남아 있던 유일한 검증은 record.responseBodySize >= computeRealBodySizeForStorage(record.responseBody) 크기 검사뿐이었고, record의 request URL이나 캐시를 sender의 site와 연결짓는 검증은 전혀 없었습니다. victim 사이트가 이후 caches.match()를 호출하면, network process는 오염된 key에 대해 attacker의 Response를 victim의 origin으로 그대로 반환하게 됩니다. 만약 오염된 entry가 victim 사이트가 캐시에서 불러오는 script나 document라면, victim context에서의 script execution으로 이어질 가능성도 있습니다. CacheStorageRemoveCacheCacheStorageRemoveRecords는 이 write 계열의 파괴적인 변형이고, CacheStorageReference/CacheStorageDereference는 외부 connection이 victim 캐시의 m_cacheRefConnections bookkeeping을 교란하고, 이론적으로는 자신이 소유하지 않은 캐시에 대해 removeUnusedCache를 유발할 가능성도 있습니다.

취약한 핸들러는 NetworkProcess 안, NetworkStorageManager의 work queue에서 실행되고, attacker는 WebContent process에 위치합니다. sandbox escape는 아니며, network process 내부에서의 code execution을 의미하지도 않습니다. 이 취약점이 무너뜨리는 것은 multi-process 분리가 보장하려던 isolation 그 자체입니다. 패치 이후에는 동일한 시도가 MESSAGE_CHECK를 통해 해당 connection의 종료로 이어집니다.

이 취약점은 WebContent와 NetworkProcess 사이의 신뢰 경계, 그리고 site 단위 storage isolation 모델을 약화시킵니다. 보안 모델은 WebProcess connection이 자신이 hosting하도록 허용된 site의 storage에만 도달할 수 있다는 전제 위에 서 있고, 이 invariant를 강제하는 것이 바로 isSiteAllowedForConnection입니다. 패치 이전에는 DOMCacheIdentifier로만 keying되는 모든 CacheStorage 연산이 이 검사를 우회하고 있었습니다. 임의의 IPC를 network process로 보낼 수 있는 attacker는 다른 사이트의 캐시된 HTTP 응답을, credential이 포함된 API 응답이나 개인정보를 포함해서, 읽어낼 수 있었습니다. 동시에 attacker가 선택한 Response 객체를 다른 사이트의 캐시에 써넣을 수도 있었는데, 이는 이후의 정상적인 방문에서 same-origin content로 그대로 서빙되는 persistent poisoning primitive에 해당합니다. 결과적으로 별도의 memory-safety 버그 없이도, 단일 사이트 renderer compromise가 cross-site data theft와 persistent cross-site content control로 확장될 수 있는 셈입니다.

이 검사가 이전에 존재할 수 없었던 이유는, origin이 micro-optimization 차원에서 버려지고 있었기 때문입니다. m_level < UnifiedOriginStorageLevel::Standard일 때만 origin이 materialize되었는데, 그 시점에는 origin의 유일한 소비처가 marker 파일이었기 때문입니다. 이는 access-control 버그에서 반복적으로 나타나는 패턴입니다. authorization context가 처음 필요로 했던 feature에 맞춰 조건부로만 저장되고, 나중에 authorization 자체가 필요해지는 시점에는 그 필드가 비어 있는 경우입니다. 패치가 "내 origin을 알고 있는가"(항상 참)와 "origin 파일을 쓸 것인가"(ShouldWriteOriginFile)를 분리한 것이 이 문제를 일반화해서 바로잡은 지점입니다. 별도로 짚어둘 점은, guard가 origin->topOrigin만 검사한다는 사실입니다. Cache Storage는 ClientOrigin 쌍 전체로 keying되는데도 그렇습니다. 이는 하나의 process가 cross-site subframe을 포함한 페이지 전체를 정당하게 hosting하는 process-per-site 모델과는 일관되지만, 결국 enforcement의 granularity가 frame origin이 아니라 partition 단위라는 의미이기도 합니다.