GitHub Security Advisory: GHSA-f2g3-hh2r-cwgc
Package: github.com/distribution/distribution (GO)
Severity: HIGH (CVSS 7.5)
CWE: CWE-284: Improper Access Control
Vulnerable versions: <= 2.8.3
Patched in: unpatched

Summary: Distribution: stale blob access resurrection via repo-scoped redis descriptor cache invalidation

Details:
## 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.

## Severity

HIGH

justification: 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 version

- repository: https://github.com/distribution/distribution
- commit: ab67ffa0bda[PII_REDACTED]841d0fde727464feeb9
- affected versions: \<= 3.0.x, \<= 2.8.x when redis blob descriptor cache and delete are both enabled
- affected file:
  - https://github.com/distribution/distribution/blob/ab67ffa0bda[PII_REDACTED]841d0fde727464feeb9/registry/storage/cache/redis/redis.go#L212-L226
- related callsites:
  - https://github.com/distribution/distribution/blob/ab67ffa0bda[PII_REDACTED]841d0fde727464feeb9/registry/storage/cache/cachedblobdescriptorstore.go#L66-L76
  - https://github.com/distribution/distribution/blob/ab67ffa0bda[PII_REDACTED]841d0fde727464feeb9/registry/storage/linkedblobstore.go#L218-L224
  - https://github.com/distribution/distribution/blob/ab67ffa0bda[PII_REDACTED]841d0fde727464feeb9/registry/storage/linkedblobstore.go#L396-L403

# details

the 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:

1. `linkedBlobStore.Delete` calls `blobAccessController.Clear` during repository delete handling.
2. `cachedBlobStatter.Clear` forwards that invalidation into the cache layer.
3. `repositoryScopedRedisBlobDescriptorService.Clear` checks that the digest is a member of `repo a`, but then only calls `upstream.Clear`.
4. `upstream.Clear` deletes the shared digest descriptor and does not remove the digest from the repository membership set for `repo a`.
5. when `repo b` later stats or gets the same digest, the shared descriptor is recreated.
6. `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 scenario

1. an operator runs distribution with `storage.cache.blobdescriptor: redis` and `storage.delete.enabl