Safeguard
Vulnerability Analysis•

CVE-2026-85747: The Docker Distribution Client Fix That Has No Version Number

CVE-2026-85747: distribution's registry client skipped digest checks. The fix for github.com/docker/distribution has no tag. Lab tests, pins, checker.

Safeguard Security Team
Security Research
13 min read

Pulling an image by digest is supposed to guarantee you get exactly the bytes you asked for, whatever the registry does. CVE-2026-85747 shows that the guarantee depends on the client. The registry client in the CNCF distribution project, the code behind the registry image, fetched manifests by digest and never hashed what came back. A malicious or compromised registry, or anyone in the middle of the connection, could answer a digest-pinned request with different bytes, and the client returned them as if they matched.

The fix shipped in distribution v3.1.2 on 24 September 2026. But most Go code that uses this client imports the old module path, github.com/docker/distribution, and that path has no tagged fix. This post is for teams that run the registry image as a pull-through cache or maintain Go tools that fetch images. It covers what the bug is, what we measured against four client versions and two other libraries, where scanners miss it, and a script that finds affected code and deployments.

The short answer

CVE-2026-85747 affects github.com/distribution/distribution/v3 before 3.1.2 and every version of github.com/docker/distribution up to 2.8.3. If you run the registry image as a pull-through cache, move to registry:3.1.2 or later. The registry:2 tag on Docker Hub hasn't been updated since February 2025. If your Go code imports github.com/docker/distribution/registry/client, there is no tagged release with the fix. Pin the fix commit on the release/2.8 branch, go get github.com/docker/distribution@db037680f2ac, or check the digest of every manifest yourself. GitHub's database lists no patched version for the old path, the Go vulnerability database had no entry on 17 October, and OSV misses at least one unfixed pseudo-version.

What the bug is

A manifest names an image's config and layers by digest, so the manifest's own digest pins everything below it. When a client asks for /v2/<name>/manifests/sha256:..., the registry returns the bytes and a Docker-Content-Digest header. That header comes from the server, so it proves nothing. The only check that counts is hashing the body.

The advisory, GHSA-685w-q87j-wqw3, shows that (*manifests).Get in registry/client skipped that step. It read the body, parsed it and returned it. It also flags the registry's bundled pull-through cache, which calls the same function and writes the result to local storage, so one bad answer from upstream gets cached and served to every client of the mirror.

Kris Kennaway reported it and wrote the fix. On release/2.8, the merged change hashes the body when the caller asked by digest and returns manifest digest mismatch if it doesn't match. A second commit does the same for blobs.Get. The fix leaves blobs.Open unverified on purpose, because it streams bytes before the whole blob has been read. Its new comment tells callers that need integrity to use Get instead.

GitHub's advisory rates it medium, CVSS 3.1 score 6.8. The attack complexity is high because the attacker needs to control the registry or the connection to it. A compromised upstream, a hijacked DNS name, a malicious mirror or a broken TLS setup all count.

The fix that has no version

The project published the advisory and merged the fixes on 23 September, then released v3.1.2 the next day. v3 moved the client to internal/client, so other Go modules can't import it. Code that uses the client as a library has to import the old path, and the old path's last tag is v2.8.3, from October 2023.

The release/2.8 branch got the fix too, in commit db037680f2ac, but the commit has no tag. Go can still fetch it as a pseudo-version:

$ go get github.com/docker/distribution@db037680f2ac
$ grep docker/distribution go.mod
	github.com/docker/distribution v2.8.4-0.20260923142519-db037680f2ac+incompatible

That gap shows up in every database we checked on 17 October:

Sourcegithub.com/docker/distributiondistribution/v3
GitHub reviewed advisoryaffected up to 2.8.3, no patched versionfixed in 3.1.2
OSVaffected up to and including 2.8.3fixed in 3.1.2
Go vulnerability database (updated 8 October)no entryno entry

Two consequences. Dependabot has no patched version to propose for the old path, and govulncheck is silent on both. And OSV's range, "last affected 2.8.3", treats every later version as fixed, including release/2.8 pseudo-versions that predate the fix. We queried OSV for v2.8.4-0.20240111071203-5d5c60f77c01+incompatible, a release/2.8 commit from January 2024, and got no results. Our lab below shows that version accepts substituted bytes.

What we measured

We built a local registry in Go that answers any digest request with a different manifest and echoes the requested digest in the header. Then we pointed six client configurations at it. Everything ran on one machine with Go 1.27.1, and nothing touched a real registry.

// Package lying serves a registry that answers every digest-pinned manifest
// request with different bytes, while echoing the requested digest back in
// the Docker-Content-Digest header.
package lying

import (
	"crypto/sha256"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

var Pinned = []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}`)
var Swapped = []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[],"annotations":{"note":"not the pinned manifest"}}`)

func Digest(b []byte) string { return fmt.Sprintf("sha256:%x", sha256.Sum256(b)) }

func Server() *httptest.Server {
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/v2/" {
			w.WriteHeader(200)
			return
		}
		i := strings.LastIndex(r.URL.Path, "/manifests/")
		if i < 0 {
			http.NotFound(w, r)
			return
		}
		requested := r.URL.Path[i+len("/manifests/"):]
		w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
		w.Header().Set("Docker-Content-Digest", requested)
		w.Header().Set("Content-Length", fmt.Sprint(len(Swapped)))
		if r.Method == http.MethodHead {
			return
		}
		w.Write(Swapped)
	}))
}

func Verdict(client string, got []byte, err error) {
	switch {
	case err != nil:
		fmt.Printf("%-48s REJECTED: %v\n", client, err)
	case Digest(got) == Digest(Pinned):
		fmt.Printf("%-48s got the pinned manifest\n", client)
	default:
		fmt.Printf("%-48s ACCEPTED different bytes (%s...)\n", client, Digest(got)[:19])
	}
}

The docker/distribution test is the same program built against three versions of the module, with a version constant in a separate one-line file naming each:

package main

import (
	"context"
	"net/http"

	"example.com/lab/lying"
	"github.com/distribution/reference"
	_ "github.com/docker/distribution/manifest/ocischema" // registers the OCI manifest type
	"github.com/docker/distribution/registry/client"
	"github.com/opencontainers/go-digest"
)

func main() {
	srv := lying.Server()
	defer srv.Close()
	label := "github.com/docker/distribution " + version
	name, _ := reference.WithName("library/app")
	repo, err := client.NewRepository(name, srv.URL, http.DefaultTransport)
	if err != nil {
		panic(err)
	}
	ms, _ := repo.Manifests(context.Background())
	m, err := ms.Get(context.Background(), digest.Digest(lying.Digest(lying.Pinned)))
	var payload []byte
	if err == nil {
		_, payload, _ = m.Payload()
	}
	lying.Verdict(label, payload, err)
}

And the other two libraries, go-containerregistry and oras-go:

package main

import (
	"context"
	"io"
	"strings"

	"example.com/lab/lying"
	"github.com/google/go-containerregistry/pkg/name"
	ggcr "github.com/google/go-containerregistry/pkg/v1/remote"
	orascontent "oras.land/oras-go/v2/content"
	"oras.land/oras-go/v2/registry/remote"
)

func main() {
	srv := lying.Server()
	defer srv.Close()
	host := strings.TrimPrefix(srv.URL, "http://")
	pinned := lying.Digest(lying.Pinned)
	ctx := context.Background()

	// go-containerregistry: remote.Get on a digest reference.
	ref, _ := name.ParseReference(host+"/library/app@"+pinned, name.Insecure)
	desc, err := ggcr.Get(ref)
	var b []byte
	if err == nil {
		b = desc.Manifest
	}
	lying.Verdict("go-containerregistry v0.22.1 remote.Get", b, err)

	// oras-go: FetchReference by digest, then read the body.
	repo, _ := remote.NewRepository(host + "/library/app")
	repo.PlainHTTP = true
	_, rc, err := repo.FetchReference(ctx, pinned)
	b = nil
	if err == nil {
		b, err = io.ReadAll(rc)
		rc.Close()
	}
	lying.Verdict("oras-go v2.6.4 FetchReference + io.ReadAll", b, err)

	// oras-go: the same, but read through content.ReadAll, which checks the
	// bytes against the returned descriptor.
	d, rc, err := repo.FetchReference(ctx, pinned)
	b = nil
	if err == nil {
		b, err = orascontent.ReadAll(rc, d)
		rc.Close()
	}
	lying.Verdict("oras-go v2.6.4 FetchReference + content.ReadAll", b, err)
}

Results, with digests shortened:

github.com/docker/distribution v2.8.3            ACCEPTED different bytes (sha256:8fd1dd44ceb9...)
github.com/docker/distribution release/2.8 @ 5d5c60f77c01 (Jan 2024) ACCEPTED different bytes (sha256:8fd1dd44ceb9...)
github.com/docker/distribution release/2.8 @ db037680f2ac REJECTED: manifest digest mismatch: requested sha256:f20c43161d73... but received content does not match
go-containerregistry v0.22.1 remote.Get          REJECTED: manifest digest: "sha256:8fd1dd44ceb9..." does not match requested digest: "sha256:f20c43161d73..."
oras-go v2.6.4 FetchReference + io.ReadAll       ACCEPTED different bytes (sha256:8fd1dd44ceb9...)
oras-go v2.6.4 FetchReference + content.ReadAll  REJECTED: mismatched digest

go-containerregistry checks by default. oras-go's FetchReference returns a descriptor carrying the requested digest and the raw body stream, and leaves the check to the caller. Reading the stream with io.ReadAll takes whatever arrives, while oras-go's own content.ReadAll compares the bytes with the descriptor and refuses. That's a design choice, not a bug, but code that reads the stream directly gets no protection from the digest.

Then the pull-through cache. We built the registry from source at v3.1.1 and v3.1.2, configured each as a proxy for the lying registry, and requested the pinned manifest twice:

v3.1.1 request 1: HTTP 200 body sha256:8fd1dd44ceb9
v3.1.1 request 2: HTTP 200 body sha256:8fd1dd44ceb9
cached manifest files: 1
v3.1.2 request 1: HTTP 500 body sha256:c499e181f5f5
v3.1.2 request 2: HTTP 500 body sha256:c499e181f5f5
cached manifest files: 0

v3.1.1 served the substituted manifest for a request pinned to a different digest and stored it in its cache. v3.1.2 returned an error, logged manifest digest mismatch, and stored nothing. We couldn't build v2.8.3 from source without its vendored dependencies, so we didn't test the registry:2 image as a proxy. The advisory says the bundled proxy calls the same unverified client.

Find affected code and deployments

This script walks a directory tree. It reads go.mod files and compares docker/distribution pseudo-versions with the fix commit's timestamp, and flags distribution/v3 before 3.1.2. It lists Go files that import the old registry/client or registry/proxy packages, and notes calls to Open. In Compose, Kubernetes and Terraform files it flags registry image tags before 3.1.2, and it reports registry configurations that run a pull-through cache. It reads files only and uses the Python standard library.

#!/usr/bin/env python3
"""Find code and deployments affected by CVE-2026-85747 (distribution
registry client and pull-through cache do not verify digest-pinned content).

Usage: python3 distribution_85747_check.py [path ...]
Reads files only. Standard library only. Exit status 1 if anything is flagged.
"""
import os
import re
import sys

V2 = "github.com/docker/distribution"
V3 = "github.com/distribution/distribution/v3"
# The fix landed on release/2.8 on 23 Sep 2026 with no tag. Pseudo-versions
# carry the commit time, so anything from that commit on includes it.
V2_FIX_TIME = "20260923142519"
V3_FIX = (3, 1, 2)
SKIP_DIRS = {".git", "node_modules", "vendor", ".terraform"}
DEPLOY_EXT = (".yml", ".yaml", ".tf", ".json", ".toml")
IMAGE_RE = re.compile(r"(?:docker\.io/)?(?:library/)?(?:registry|distribution/distribution):([\w.\-]+)")


def semver(v):
    m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", v)
    return tuple(int(x) for x in m.groups()) if m else None


def v2_status(version):
    m = re.search(r"-0\.(\d{14})-[0-9a-f]{12}", version)
    if m and m.group(1) >= V2_FIX_TIME:
        return "fixed (release/2.8 pseudo-version from %s)" % m.group(1)[:8]
    return "AFFECTED, no tagged fix: pin the release/2.8 fix or move off the client"


def image_status(tag):
    v = semver(tag)
    if tag in ("latest", "3", "3.1") or (v and v >= V3_FIX):
        return None
    if tag.startswith("2") or (v and v < V3_FIX) or tag in ("3.0",):
        return "AFFECTED image tag registry:%s, use 3.1.2 or later" % tag
    return None


def scan_gomod(path, text, out):
    for mod, ver in re.findall(r"^\s*(?:require\s+)?(\S+)\s+(v\S+)", text, re.M):
        if mod == V2:
            out.append((path, "%s %s: %s" % (mod, ver, v2_status(ver))))
        elif mod == V3:
            v = semver(ver)
            if v and v < V3_FIX:
                out.append((path, "%s %s: AFFECTED, upgrade to v3.1.2" % (mod, ver)))


def scan_go(path, text, out):
    for pkg in ("registry/client", "registry/proxy"):
        if '"%s/%s"' % (V2, pkg) in text:
            note = "imports %s/%s" % (V2, pkg)
            if pkg == "registry/client" and re.search(r"\.Open\(ctx", text):
                note += "; calls Open(), which stays unverified even after the fix"
            out.append((path, note))


def scan_deploy(path, text, out):
    for tag in sorted(set(IMAGE_RE.findall(text))):
        msg = image_status(tag)
        if msg:
            out.append((path, msg))
    if re.search(r"^\s*proxy:\s*$", text, re.M) and re.search(r"^\s*remoteurl:", text, re.M):
        out.append((path, "registry config runs a pull-through cache (proxy.remoteurl)"))


def walk(roots):
    for root in roots:
        if os.path.isfile(root):
            yield root
            continue
        for d, dirs, files in os.walk(root):
            dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
            for f in files:
                yield os.path.join(d, f)


def main(argv):
    out = []
    for path in walk(argv or ["."]):
        base = os.path.basename(path)
        if base == "go.mod" or base.endswith(".go") or base.startswith("Dockerfile") \
                or base.endswith(DEPLOY_EXT):
            try:
                text = open(path, encoding="utf-8", errors="replace").read()
            except OSError:
                continue
            if base == "go.mod":
                scan_gomod(path, text, out)
            elif base.endswith(".go"):
                scan_go(path, text, out)
            else:
                scan_deploy(path, text, out)
    for path, msg in out:
        print("%s: %s" % (path, msg))
    if not out:
        print("nothing found")
    return 1 if any(not m.startswith(V2 + " ") or "AFFECTED" in m for _, m in out) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

On a test tree with a policy controller on v2.8.3, a tool on the January 2024 pseudo-version, a tool on the fixed pseudo-version, and a Docker Hub mirror running registry:2:

$ python3 distribution_85747_check.py fx
fx/policy-controller/go.mod: github.com/docker/distribution v2.8.3+incompatible: AFFECTED, no tagged fix: pin the release/2.8 fix or move off the client
fx/policy-controller/fetch.go: imports github.com/docker/distribution/registry/client
fx/legacy/go.mod: github.com/docker/distribution v2.8.4-0.20240111071203-5d5c60f77c01+incompatible: AFFECTED, no tagged fix: pin the release/2.8 fix or move off the client
fx/tool/go.mod: github.com/docker/distribution v2.8.4-0.20260923142519-db037680f2ac+incompatible: fixed (release/2.8 pseudo-version from 20260923)
fx/mirror/compose.yaml: AFFECTED image tag registry:2, use 3.1.2 or later
fx/mirror/config.yml: registry config runs a pull-through cache (proxy.remoteurl)

The timestamp comparison assumes the pseudo-version comes from the release/2.8 branch, which is the only branch that builds under the old module path. A fork or a replace directive needs checking by hand.

What to do

  1. Upgrade pull-through caches to registry:3.1.2 or later. If one ran an older version against an upstream you don't fully trust, clear its cache, since anything it stored was never checked.

  2. Pin the release/2.8 fix in Go code that imports github.com/docker/distribution/registry/client: go get github.com/docker/distribution@db037680f2ac. Then plan a move to a maintained client.

  3. Verify digests yourself wherever you can't upgrade. After Get, hash the manifest's payload with the requested digest's own algorithm and compare. Validate comes first because, as the fix's own comment notes, Verifier panics on an unsupported algorithm:

    if err := dgst.Validate(); err != nil {
        return err
    }
    _, payload, err := m.Payload()
    if err != nil {
        return err
    }
    v := dgst.Verifier()
    v.Write(payload)
    if !v.Verified() {
        return fmt.Errorf("manifest digest mismatch for %s", dgst)
    }
    

    Schema1 signed manifests are the exception. They're addressed by their signature-stripped form, which the fix handles and this snippet doesn't.

  4. Read streams through a verifying reader. With oras-go, use content.ReadAll or a VerifyReader, not io.ReadAll. With docker/distribution, use blobs.Get, not blobs.Open, when the digest is your integrity check.

  5. Don't rely on your scanner for this one. Until the Go database adds it and OSV's range covers the release/2.8 pseudo-versions, search go.mod files directly.

We covered the wider risks of mirrors in package registry mirroring security. A cache that doesn't check what it stores passes on whatever its upstream sends.

The fair view

This takes a privileged position to exploit. An attacker has to control the registry you pull from or the network path to it, and HTTPS with certificate checks closes most of the path. And in our tests, go-containerregistry, the library behind tools such as crane, already checked by default. The maintainers fixed both branches, credited the reporter, and documented the remaining Open limitation clearly in the code.

But digest pinning exists for exactly that threat: a registry you can't fully trust. Policy controllers, mirrors and CI tools pin by digest so they don't have to trust the server, and a client that skips the hash quietly turns the pin back into trust. The release/2.8 fix exists. It just has no version number, so the tools that tell teams what to upgrade can't point anyone to it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.