GHSA-f2g3-hh2r-cwgc Distribution: stale blob access resurrection via repo-scoped redis descriptor cache invalidation
summary:distribution can restore read access in `repo a` after an explicit delete when `storage.cache.blobdescriptor: redis` and `storage.delete.enabled: true` are both enabled. the delete path clears the shared digest descriptor but leaves stale repo-scoped membership behind, so a later `Stat` or `Get` from `repo b` repopulates the shared descriptor and makes the deleted blob readable from `repo a` again.SeverityHIGHjustification: this is a repo-local authorization bypass after explicit delete, with concrete confidentiality impact and no requirement for write access after the delete event. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5). CWE-284.affected versionrepository: https://github.com/distribution/distributioncommit: ab67ffa0bda3712991194841d0fde727464feeb9affected versions: \<= 3.0.x, \<= 2.8.x when redis blob descriptor cache and delete are both enabledaffected file:https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/cache/redis/redis.go#L212-L226related callsites:https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/cache/cachedblobdescriptorstore.go#L66-L76https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/linkedblobstore.go#L218-L224https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/linkedblobstore.go#L396-L403detailsthe backend access model is repository-link based: once `repo a` deletes its blob link, later reads from `repo a` should continue returning `ErrBlobUnknown` even if the same digest remains linked in `repo b`.the issue is the split invalidation path in the redis cache backend:`linkedBlobStore.Delete` calls `blobAccessController.Clear` during repository delete handling.`cachedBlobStatter.Clear` forwards that invalidation into the cache layer.`repositoryScopedRedisBlobDescriptorService.Clear` checks that the digest is a member of `repo a`, but then only calls `upstream.Clear`.`upstream.Clear` deletes the shared digest descriptor and does not remove the digest from the repository membership set for `repo a`.when `repo b` later stats or gets the same digest, the shared descriptor is recreated.`repositoryScopedRedisBlobDescriptorService.Stat` for `repo a` accepts the stale membership and now trusts the repopulated shared descriptor, restoring access in the repository that already deleted its link.this creates a revocation gap at the repository boundary. the blob is briefly inaccessible from `repo a` right after delete, which confirms the backend link was removed, and then becomes accessible again only because stale redis membership survived while a peer repository repopulated the shared descriptor.attack scenarioan operator runs distribution with `storage.cache.blobdescriptor: redis` and `storage.delete.enabled: true`.the same digest exists in both `repo a` and `repo b`.the operator deletes the blob from `repo a` and expects repository-local access to be revoked.`repo a` correctly returns `blob unknown` immediately after the delete.an anonymous or unprivileged user requests the same digest from `repo b`, which still legitimately owns it and repopulates the shared descriptor.a later request for the digest from `repo a` succeeds again because stale repo-a membership was never revoked from redis.PoCattachment: `poc.zip`the attached PoC is a deterministic integration harness using `miniredis` and the pinned distribution source tree.steps to reproducecanonical:expected output:control:expected control output:expected vs actualexpected: after `repo a` deletes its blob link, later reads from `repo a` should keep returning `blob unknown` even if `repo b` still references the same digest and warms cache state.actual: `repo a` first returns `blob unknown`, then `repo b` repopulates the shared descriptor, and `repo a` serves the deleted digest again through stale repo-scoped redis membership.impactthe confirmed impact is repository-local confidentiality failure after explicit delete. an operator can remove sensitive content from `repo a`, observe revocation working immediately after the delete, and still have the same content become readable from `repo a` again as soon as `repo b` refreshes the shared descriptor for that digest.this is not a claim about global blob deletion. the bounded claim is that repository-local revocation fails, which breaks the expectation that deleting a blob link from one repository prevents further reads from that repository.remediationthe safest fix is to make redis invalidation revoke repo-scoped state together with the backend link deletion. in practice that means removing the digest from the repository membership set, deleting the repo-scoped descriptor hash, and keeping that cleanup atomic enough that peer-repository warming cannot restore access in the repository that already deleted its link.[poc.zip](https://github.com/user-attachments/files/25813827/poc.zip)
[PR_DESCRIPTION.md](https://github.com/user-attachments/files/25813828/PR_DESCRIPTION.md)
[attack_scenario.md](https://github.com/user-attachments/files/25813829/attack_scenario.md)
CVSS v3.1 score: 7.5