VDB
KO
HIGH 7.5

GHSA-f2g3-hh2r-cwgc

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

Quick fix

GHSA-f2g3-hh2r-cwgc — github.com/distribution/distribution/v3: upgrade to the fixed version with the command below.

go get github.com/distribution/distribution/v3@v3.1.0

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: ab67ffa0bda3712991194841d0fde727464feeb9 - 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/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/cache/redis/redis.go#L212-L226 - related callsites: - https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/cache/cachedblobdescriptorstore.go#L66-L76 - https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/registry/storage/linkedblobstore.go#L218-L224 - https://github.com/distribution/distribution/blob/ab67ffa0bda3712991194841d0fde727464feeb9/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.enabled: true`. 2. the same digest exists in both `repo a` and `repo b`. 3. the operator deletes the blob from `repo a` and expects repository-local access to be revoked. 4. `repo a` correctly returns `blob unknown` immediately after the delete. 5. an anonymous or unprivileged user requests the same digest from `repo b`, which still legitimately owns it and repopulates the shared descriptor. 6. a later request for the digest from `repo a` succeeds again because stale repo-a membership was never revoked from redis.

# PoC

attachment: `poc.zip`

the attached PoC is a deterministic integration harness using `miniredis` and the pinned distribution source tree.

## steps to reproduce

canonical:

```bash unzip -q -o poc.zip -d poc cd poc make canonical ```

expected output:

```text [CALLSITE_HIT]: repositoryScopedRedisBlobDescriptorService.Clear->upstream.Clear->repositoryScopedRedisBlobDescriptorService.Stat [PROOF_MARKER]: repo_a_access_restored=true repo_a_delete_miss=true repo_b_peer_warm=true [IMPACT_MARKER]: repo_a_post_delete_read=true confidentiality_boundary_broken=true ```

control:

```bash unzip -q -o poc.zip -d poc cd poc make control ```

expected control output:

```text [CALLSITE_HIT]: repositoryScopedRedisBlobDescriptorService.Clear->repositoryScopedRedisBlobDescriptorService.Stat [NC_MARKER]: repo_a_access_restored=false repo_b_peer_warm=true ```

# expected vs actual

- expected: 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.

# impact

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

# remediation

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

Are you affected?

Enter the version of the package you're using.

Affected packages

Go / github.com/distribution/distribution/v3
Introduced in: 0 Fixed in: 3.1.0
Fix go get github.com/distribution/distribution/v3@v3.1.0
Go / github.com/distribution/distribution
Introduced in: 0

No fixed version published yet for github.com/distribution/distribution (go modules). Pin to a known-safe version or switch to an alternative.

References