Distribution is a toolkit to pack, ship, store, and deliver container content. Prior to 3.1.0, distribution can restore read access in repo…
GitHub_M·NVD-CWE-noinfo·Published 2026-04-06
Distribution is a toolkit to pack, ship, store, and deliver container content. Prior to 3.1.0, 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. This vulnerability is fixed in 3.1.0.
Distribution is a toolkit to pack, ship, store, and deliver container content. Prior to 3.1.0, 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. This vulnerability is fixed in 3.1.0.
## 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)
## 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)
Distribución es un conjunto de herramientas para empaquetar, enviar, almacenar y entregar contenido de contenedores. Antes de la 3.1.0, distribución puede restaurar el acceso de lectura en el repositorio a después de una eliminación explícita cuando storage.cache.blobdescriptor: redis y storage.delete.enabled: true están ambos habilitados. La ruta de eliminación borra el descriptor de resumen compartido, pero deja una membresía obsoleta con alcance de repositorio, por lo que un Stat o Get posterior desde el repositorio b vuelve a poblar el descriptor compartido y hace que el blob eliminado sea legible desde el repositorio a de nuevo. Esta vulnerabilidad está corregida en la 3.1.0.
| Version | Type | Source | Base | Exp | Impact | Vector |
|---|---|---|---|---|---|---|
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Primary | cve.org | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Secondary | GHSA | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Secondary | ENISA EUVD | 7.5 | — | — | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Secondary | NVD | 7.5 | 3.9 | 3.6 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 3.1 | Secondary | NVD | 7.5 | 3.9 | 3.6 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |