[2] Validate connection access to DOMCache with DOMCacheIdentifier
Another site's cache will happily serve back what you wrote into it.
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
Source/WebKit/NetworkProcess/storage/CacheStorageCache.cpp
Patch Details
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가 실제로 소유하는지 재검증하지 않습니다.
Background
이 코드가 있는 위치.
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::ClientOrigin은 topOrigin(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입니다. CacheStorageCache는 Identified<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 소유인지 검증해야 합니다.
Analysis
이미 열려 있는 캐시를 다루는 핸들러들은 메시지에 DOMCacheIdentifier만 싣고 있었습니다. 특히 cacheStorageRetrieveRecords, cacheStorageRemoveRecords, cacheStorageRemoveCache는 IPC::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::findRecords는 url.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으로 이어질 가능성도 있습니다. CacheStorageRemoveCache와 CacheStorageRemoveRecords는 이 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로 확장될 수 있는 셈입니다.
Insight
이 검사가 이전에 존재할 수 없었던 이유는, 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 단위라는 의미이기도 합니다.
Audit directions
-
Identifier-as-capability — IPC 핸들러가 caller가 제공한
ObjectIdentifier를, 모든 origin/caller에 걸쳐 있는 key space를 가진 registry를 통해 resolve한 뒤, 결과에 대해 소유자를 다시 도출하지 않은 채 동작을 수행하는 패턴입니다. 지켜야 할 invariant는 resolution 뒤에는 반드시 ownership 검사가 뒤따라야 한다, ID는 attacker가 제공한 데이터일 뿐 capability handle이 아니기 때문이다라는 원칙입니다. 좁게 보면,NetworkStorageManager.cpp안에서m_idbStorageRegistry,m_fileSystemStorageHandleRegistry,m_storageAreaRegistry를 통해 lookup하는 나머지 핸들러들을 점검할 필요가 있습니다. 단서는 메시지에서 온 identifier만으로 registry lookup을 시작하면서isSiteAllowedForConnection이나originStorageManager(origin, ...)를 전혀 언급하지 않는 핸들러 본문입니다. 넓게 보면, WebKit이 cross-process 객체를 위해 process-global handle table을 유지하는 곳이면 어디든 같은 형태가 나타날 수 있습니다. GPU process의 remote-resource와 rendering-backend 캐시, media-player와RemoteDisplayListidentifier map 등이 해당하며, 코드 검색 시에는 message-receiver 클래스의 멤버로 존재하는HashMap<SomeIdentifier, WeakPtr<T>>(또는Ref<T>) 형태를 찾으면 됩니다. 가장 넓게 보면, 이것은 전형적인 IDOR/ambient-authority 취약점 계열이며 resource를 unforgeable handle이 아니라 위조 가능한 정수로 명명하는 모든 시스템에서 성립합니다. descriptor가 숫자로 경계를 넘어 전달되는 POSIX fd table, tenant별 scoping이 없는 REST/gRPC resource ID, Wayland object ID 등이 그 예이며, 가져가야 할 invariant는 caller가 값을 지정할 수 있는 ID라면, 그 ID는 authorization이 아니다라는 것입니다. -
Authorization context가 관련 없는 feature flag 조건에 따라 유지되는 문제. 여기서 invariant는 리소스를 소유하는 객체는 자신의 owner를 무조건 알고 있어야 하며, 그 사실을 디스크에 영속화할지 여부는 별개의 결정이라는 점입니다. 좁게는
Source/WebKit/NetworkProcess/storage/안에서std::optional<WebCore::ClientOrigin>또는UnifiedOriginStorageLevel에 조건부로 걸린 origin 인자를 받는 생성자와 factory를 검색해야 합니다.IDBStorageManager,FileSystemStorageManager,ServiceWorkerStorageManager,LocalStorageManager,SessionStorageManager가 이번에 수정된 manager와 동일한 계열이며, origin/identity 파라미터가std::optional이고 그 유일한 읽기 지점이 파일 기록이나 로깅 호출뿐이라는 점이 단서입니다. 넓게는, identity나 principal 필드가 특정 build/config/level 조건 하에서만 채워지는 클래스 전반을 살펴야 합니다. Long-lived manager나 session 객체 안에서std::optional<Origin>,std::optional<SecurityOriginData>,std::optional<RegistrableDomain>로 선언된 멤버를 검색하고, 한 생성자 분기에서만 값이 설정되고 다른 곳에서는 전혀 설정되지 않는 형태를 눈여겨봐야 합니다. 가장 넓게 보면, authorization 입력은 다른 목적으로 계산된 값을 조건부로 캐싱한 것이어서는 안 된다는 원칙이 적용됩니다. Audit logging이 켜져 있을 때만 tenant ID를 저장하는 multi-tenant 서비스나, request-scoped principal이 optional로 취급되는 framework 전반에 동일하게 해당됩니다. 일치 여부를 판단하는 단서는, 추가하려는 check를 필드가 일부 code path에서std::nullopt상태이기 때문에 작성할 수 없는 상황입니다. -
복합 key의 한 구성 요소만 검증하는 문제. 여기서 invariant는 check의 granularity가 key의 granularity와 일치해야 하며, 차이가 있다면 그것은 의도적이고 문서화된 policy 결정이어야 한다는 점입니다. 좁게는
NetworkStorageManager.cpp안의 모든isSiteAllowedForConnection호출 지점을 검토하여,RegistrableDomain을topOrigin에서 도출하는지clientOrigin에서 도출하는지 확인해야 합니다. 이번에 패치된 CacheStorage 핸들러는RegistrableDomain { origin->topOrigin }을 사용하므로, 이 파일 안의 다른 storage 타입들도 frame-origin 단위가 아니라 partition 단위로 강제되도록 의도된 것인지, 그리고 두 종류의 호출 지점이 서로 일치하는지 확인해야 합니다. 넓게는,ClientOrigin쌍이나 partition key, 혹은 (site, frame) tuple이 비교 이전에 한 구성 요소로 축소되는 지점 어디에서나 동일한 패턴이 나타날 수 있습니다..topOrigin이나.clientOrigin필드를 받는RegistrableDomain {생성 구문을 검색하고, 그 주변의 policy 의도를 함께 확인해야 합니다. 가장 넓게 보면, 복합 principal을 authorization 비교 이전에 prefix로 축소하면 권한 부여 범위가 은연중에 넓어진다는 원칙이며, 이는 모든 브라우저 엔진의 cookie 및 storage partitioning에서, 또한 tenant 기준으로는 필터링하지만 sub-scope 기준으로는 필터링하지 않는 multi-tenant row-level security에서 반복적으로 나타납니다. 일치 여부를 판단하는 단서는, access check의 인자 목록이 그 check가 보호하는 객체를 저장할 때 사용된 key보다 명백히 짧은 경우입니다. -
정상적으로 값이 비어 있을 수 있는 값에 대한
MESSAGE_CHECK가 악의 없이도 도달 가능한 connection-termination 경로를 만드는지 점검할 필요가 있습니다.CacheStorageCache::origin()은WeakPtr<CacheStorageManager> m_manager가 이미 해제된 경우std::nullopt를 반환하는데, 새로 추가된 check는 이 상태를 IPC violation으로 취급합니다. Lifetime을 추적해보면,CacheStorageManager는Vector<Ref<CacheStorageCache>>와m_removedCaches를 보유하는 반면,CacheStorageRegistry는WeakPtr<CacheStorageCache>만 보유합니다. 예를 들어 in-flight 상태인 비동기 store callback 도중protect()된 reference를 통해 cache가 자신의 manager보다 더 오래 살아남으면서도 registry를 통해서는 여전히 resolve 가능한 상황이 존재하는지 확인해야 합니다. 넓게 보면, 이 클래스는 메시지 내용이 아니라 객체의 lifetime에 종속된 상태를 두고 assert하는 IPC validator의 사례에 해당합니다. Code-search 관점에서는, 다른 객체의 liveness로부터 채워진WeakPtr나std::optional을 predicate 안에서 참조하거나 검사하는 모든MESSAGE_CHECK가 동일한 형태에 해당합니다. 다만 이 부분은 순수하게 정적으로 검증되지 않으며, race의 도달 가능 여부를 확인하려면 별도의 lifetime instrumentation이나 record가 in-flight 상태일 때 storage를 닫는 stress test가 필요합니다. -
NetworkProcess/storage의 메시지 정의 파일들에서, handler에 여전히IPC::Connection&을 전달하지 않는 CacheStorage 계열 메시지를 검색해야 합니다. 이번 패치에서 범위가 넓어진 세 개의 handler는 애초에 connection이 전달된 적이 없었기 때문에 제자리에서는 수정이 불가능했던 경우입니다.cacheStorageRepresentation은NetworkStorageManager.h에서 여전히 connection 정보 없이 남아 있습니다. 단서는// Message handlers for블록 안의 선언 중 첫 번째 파라미터가IPC::Connection&이 아닌 경우이며, 이런 handler들은 각각 caller와 무관하게 안전함이 입증되었거나 아니면 검증되지 않은 진입점 중 하나에 해당합니다. 이 구분은 짐작이 아니라 기록으로 남겨야 합니다.