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

GHSA-2v5f-5r6w-p67r

LOW

GHSA-2v5f-5r6w-p67r is a low-severity (CVSS 3.5) CWE-636 vulnerability in github.com/modelcontextprotocol/registry. O3 Security confirms whether GHSA-2v5f-5r6w-p67r is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

MCP Registry: OCI validator skips ownership check on upstream rate limits

Also known asCVE-2026-45781GO-2026-5008
Published
May 19, 2026
Updated
May 21, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Aug 16, 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-2v5f-5r6w-p67r.

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
0.00%0.24%0.47%0.71%0.0%0.2%0.2%0.2%Jun 26Aug 26Aug 26

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-2v5f-5r6w-p67r plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 360,399 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

1 pkg affected
🐹github.com/modelcontextprotocol/registry

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

OCI ownership validation fails open on upstream rate limits, allowing attacker to claim arbitrary public OCI images under their own namespace

Severity: Low (re-scored post-triage; see Maintainer triage note below) Affected: modelcontextprotocol/registry main branch at commit fe0cb3b (current HEAD as of 2026-05-09). Live deployment: https://registry.modelcontextprotocol.io (per repo README). Route: GitHub private security advisory (per repo SECURITY.md).


Title

OCI ownership validation skips label-match check when upstream OCI registry returns HTTP 429, letting any authenticated publisher bind their io.github.<user>/* namespace to OCI images they do not control.

Summary

internal/validators/registries/oci.go:104-119 fails open on http.StatusTooManyRequests: when the registry's anonymous fetch to the upstream OCI registry is rate-limited, ValidateOCI returns nil and the publish is accepted without ever running the io.modelcontextprotocol.server.name label-match check at lines 122-141. That label check is the only cross-system ownership proof the registry applies to OCI packages — every other registry type (NPM, PyPI, NuGet, MCPB) treats a non-200 upstream response as a hard error.

The fail-open trigger is attacker-controllable. The registry uses authn.Anonymous against Docker Hub, which is rate-limited to 100 manifest pulls per 6 hours per egress IP, and the production NGINX rate limit allows 180 publishes/minute (3 RPS, burst 540) per source IP. A single attacker from a single IP can exhaust the registry's shared anonymous quota in roughly 33 seconds, then submit a final publish that points packages[].identifier at a Docker Hub image they do not own. The validator hits the 429 fail-open branch, returns nil, and the registry stores a record under the attacker's namespace claiming the unrelated image as its package payload, with no label proof in evidence.

The fail-open is also reached without an attacker present. Docker Hub routinely 429s busy egress IPs during organic traffic, so publishes during those windows skip OCI ownership validation silently.

Vulnerable code

internal/validators/registries/oci.go:97-142:

img, err := remote.Image(ref, remote.WithAuth(authn.Anonymous), remote.WithContext(timeoutCtx))
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        return fmt.Errorf("OCI image validation timed out after 30 seconds for '%s'. The registry may be slow or unreachable", pkg.Identifier)
    }

    var transportErr *transport.Error
    if errors.As(err, &transportErr) {
        switch transportErr.StatusCode {
        case http.StatusTooManyRequests:
            // Rate limited - skip validation to avoid blocking publishers
            // This is intentional: we prioritize UX over strict validation during high traffic
            log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier)
            return nil                                              // <-- FAIL-OPEN
        case http.StatusNotFound:
            return fmt.Errorf("OCI image '%s' does not exist in the registry", pkg.Identifier)
        case http.StatusUnauthorized, http.StatusForbidden:
            return fmt.Errorf("OCI image '%s' is private or requires authentication. Only public images are supported", pkg.Identifier)
        }
    }
    return fmt.Errorf("failed to fetch OCI image: %w", err)
}

// Get the image config which contains labels
configFile, err := img.ConfigFile()
if err != nil {
    return fmt.Errorf("failed to get image config: %w", err)
}

// Validate the MCP server name label
if configFile.Config.Labels == nil {
    return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName)
}

mcpName, exists := configFile.Config.Labels["io.modelcontextprotocol.server.name"]
if !exists {
    return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName)
}

if mcpName != serverName {
    return fmt.Errorf("OCI image ownership validation failed. Expected annotation 'io.modelcontextprotocol.server.name' = '%s', got '%s'", serverName, mcpName)
}

The fail-open returns before any of the three label-match guards run.

The validator is reached on every publish per internal/service/registry_service.go:151-158, gated by cfg.EnableRegistryValidation, which defaults to true in internal/config/config.go:18.

Reachability and authorization

POST /v0/publish (and /v0.1/publish) is registered with bearer-JWT auth in internal/api/handlers/v0/publish.go:30-50. JWTs are issued by /v0/auth/github-at (internal/api/handlers/v0/auth/github_at.go:46-67), which exchanges any GitHub OAuth access token for a 5-minute registry JWT carrying Permission{Action: Publish, ResourcePattern: "io.github.<login>/*"}. Any free GitHub account can mint such a JWT, so the publish path is reachable to anyone on the internet at the cost of a GitHub account.

Trigger conditions

  • internal/validators/registries/oci.go:97: anonymous Docker Hub auth, subject to the 100 manifest-pulls/6h/IP unauthenticated rate limit Docker Hub publishes.
  • deploy/pkg/k8s/registry.go:330-331: production NGINX limits incoming requests to 180/minute per source IP with a 3× burst multiplier (540).
  • A single source IP at 3 RPS exhausts the registry's anonymous Docker Hub quota in roughly 33 seconds. Each /publish against an allowlisted OCI identifier in internal/validators/registries/oci.go:29-42 (docker.io / registry-1.docker.io / index.docker.io / ghcr.io / quay.io / mcr.microsoft.com / *.pkg.dev / *.azurecr.io) consumes one slot, including publishes that go on to fail with the missing-annotation error after the manifest is fetched.
  • Once Docker Hub starts returning 429, every subsequent publish hits the fail-open branch until the quota replenishes.

Attacker chain

  1. Free GitHub account attackerPOST /v0/auth/github-at → registry JWT with Permission{Action: Publish, ResourcePattern: "io.github.attacker/*"}.
  2. From a single IP, send ~100 publishes whose packages[].identifier references real public Docker Hub images that lack the io.modelcontextprotocol.server.name label (e.g. docker.io/library/alpine:latest, docker.io/library/nginx:latest, …). Each publish fails with "OCI image is missing required annotation" but consumes one anonymous-quota slot from the registry's shared egress IP.
  3. While the egress IP is rate-limited by Docker Hub, submit the final publish: name = "io.github.attacker/<typo-squat-name>", packages[].registryType = "oci", packages[].identifier = "docker.io/<reputable-org>/<reputable-image>:<tag>".
  4. ValidateOCI calls remote.Image(ref, authn.Anonymous, …); Docker Hub returns 429; transportErr.StatusCode == http.StatusTooManyRequests matches the fail-open branch; ValidateOCI returns nil; ValidatePackage returns nil; validateRegistryOwnership returns nil; the publish proceeds and CreateServer writes the record. The registry now publishes a server record under io.github.attacker/<typo-squat-name> that asserts the reputable image as its package payload, without ever inspecting that image's labels.

Boundary delta

Starting capabilityAfter exploit
IdentityHolder of a fresh io.github.<attacker> GitHub accountSame
Publish scopeio.github.<attacker>/* onlyio.github.<attacker>/* only (unchanged)
OCI claim scopeOCI images the attacker controls and has labelled with io.modelcontextprotocol.server.name = io.github.<attacker>/<name>Any public OCI image at any allowlisted registry, regardless of label

The attacker's namespace stays bounded. What changes is that the registry's claim "this OCI image is the package payload of this MCP server" is no longer backed by any cross-system proof. The label check at oci.go:122-141 is the only ownership proof for OCI packages; bypassing it lets a publisher under io.github.attacker/* bind a server record to an unrelated image such as docker.io/microsoft/<some-tool>:latest without ever touching that image. Combined with how MCP clients render server-list entries — image identifier shown next to the namespace — the result is typo-squat / impersonation in registry search and discovery surfaces, with the actual image content delivered untouched from its real owner.

The same fail-open is reached without any attacker action whenever Docker Hub rate-limits the registry's egress IP for organic reasons. In that mode, the OCI ownership check is effectively non-functional for the duration of the limit window, even for legitimate publishers.

Cross-validator comparison (negative control)

The other registry-type validators do not fail-open on rate-limit responses:

  • internal/validators/registries/npm.go:72-74if resp.StatusCode != http.StatusOK { return error }.
  • internal/validators/registries/pypi.go:76-78 — same shape; 429 surfaces as "PyPI package '%s' not found (status: %d)".
  • internal/validators/registries/nuget.go:253 — non-OK response paths return "NuGet README request returned status %d", the publish fails closed.
  • internal/validators/registries/mcpb.go:84-91 — a HEAD that does not return 200 or a 3xx with Location is treated as inaccessible.

OCI is the only validator that converts an upstream rate-limit into a successful ownership attestation.

Suggested fix

Two options, either alone, or both for defence-in-depth:

  1. Remove the fail-open. Replace
    case http.StatusTooManyRequests:
        log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier)
        return nil
    
    with an error of the same shape the other validators use (return fmt.Errorf("OCI registry is currently rate-limiting validations for '%s'; please retry shortly", pkg.Identifier)). The handler call sites in validateRegistryOwnership already propagate the error to a 400 response.
  2. Replace authn.Anonymous at internal/validators/registries/oci.go:97 with an authenticated token whose quota is isolated from organic anonymous traffic to the registry's egress IP. Docker Hub authenticated pulls are 200/6h per token; ghcr.io / quay.io / *.pkg.dev / *.azurecr.io each have their own auth flows. This removes the easy attacker-side trigger and reduces organic fail-open windows.

If a fail-open path is retained for UX reasons, queue the publish for re-validation when the upstream registry recovers, instead of marking it accepted on first attempt.

Proof of concept

The refreshed PoC drives the publish path, not only the validator branch:

service.CreateServer
  -> validators.ValidatePublishRequest
  -> registries.ValidateOCI
  -> database.CreateServer

It runs inside the checked-out module, uses the real service and validator code, and substitutes only the database with a minimal in-memory implementation so the proof can run without a local Postgres stack. To keep the proof localhost-only, the runner temporarily adds the in-process mock OCI host to the unexported OCI allowlist. It does not contact Docker Hub, the production registry, or any external service.

To run:

bash outputs/poc-evidence/2026-05-12-mcp-registry-publish-path/run.sh

Captured transcript:

=== modelcontextprotocol/registry publish-path OCI 429 fail-open PoC ===
Path exercised: service.CreateServer -> validators.ValidatePublishRequest -> registries.ValidateOCI -> DB CreateServer

--- negative control: upstream 404 ---
[setup] temporarily allowlisted mock OCI host 127.0.0.1:39067 for localhost-only proof
[setup] publish identifier=127.0.0.1:39067/reputable-org/reputable-image:latest
[mock-oci] GET /v2/ -> 404
[publish] rejected: registry validation failed for package 0 (127.0.0.1:39067/reputable-org/reputable-image:latest): OCI image '127.0.0.1:39067/reputable-org/reputable-image:latest' does not exist in the registry

--- BUG: upstream 429 ---
[setup] temporarily allowlisted mock OCI host 127.0.0.1:40487 for localhost-only proof
[setup] publish identifier=127.0.0.1:40487/reputable-org/reputable-image:latest
[mock-oci] GET /v2/ -> 429
[memdb] AcquirePublishLock(io.github.attacker/typosquat-tool)
[memdb] CreateServer stored name=io.github.attacker/typosquat-tool version=1.0.1 package=127.0.0.1:40487/reputable-org/reputable-image:latest
[publish] accepted/stored packages=[{"registryType":"oci","identifier":"127.0.0.1:40487/reputable-org/reputable-image:latest","transport":{"type":"stdio"}}]
PUBLISH_PATH_RESULT: ACCEPTED_UNVERIFIED_OCI_PACKAGE_AFTER_429

Exit code 0. SHA-256 values:

acf7121111c19acaca1c99a3c08079213794ffc4feb63e545ec814bd6cd85984  transcript.txt
340e7a81740e9f14cadc144d4e640a1d497ce3e6696a3d9ea99d63e05c5edd71  publish_path_runner.go
c970f08d6b79852308ad931da85dd64a65fe373d3c988018de09a7e4c7c345a4  run.sh

The end-to-end attacker flow against production was not executed. No publish was sent against registry.modelcontextprotocol.io. No attacker namespace was registered on the live service. The local proof shows the critical property: when the actual publish validator sees an OCI 429, the service proceeds to create a server record containing the unverified OCI package identifier.

Severity rationale

Maintainer triage (2026-05-13): after review the maintainer settled on Low (3.5, CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N). Impact stays within the attacker's own namespace and image bytes delivered to clients are unchanged. See the comment thread for reasoning. Reporter's original write-up preserved below.

Medium. Auth-bypass class — the attacker bypasses the only ownership proof for OCI packages, and the fail-open trigger is attacker-controllable from a single IP at modest cost. The blast radius is bounded to publication misrepresentation under the attacker's own namespace; the actual image content stays under its rightful owner. Combined with normal MCP-client search and discovery surfaces, this is sufficient for impersonation / typo-squat where the rendered image identifier implies authorship the registry could not actually attest.

The fail-open also activates under normal traffic when Docker Hub rate-limits the egress IP, so the OCI ownership check is in practice intermittent rather than absent — both modes are bug states.

Disclosure preferences

Report through the GitHub Security Advisory process per repo SECURITY.md. Happy to keep details private until a fix is in motion. If a public GHSA / CVE / release note is published, please credit the report to Ryan Vonbrubeck / @dodge1218.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/modelcontextprotocol/registryall versions1.7.9

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/modelcontextprotocol/registry. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update github.com/modelcontextprotocol/registry to 1.7.9 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-2v5f-5r6w-p67r is resolved across your whole dependency graph.

  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.

  4. How O3 protects you

    O3 pinpoints whether GHSA-2v5f-5r6w-p67r is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-2v5f-5r6w-p67r. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

# OCI ownership validation fails open on upstream rate limits, allowing attacker to claim arbitrary public OCI images under their own namespace Severity: Low (re-scored post-triage; see Maintainer triage note below) Affected: `modelcontextprotocol/registry` main branch at commit `fe0cb3b` (current HEAD as of 2026-05-09). Live deployment: `https://registry.modelcontextprotocol.io` (per repo README). Route: GitHub private security advisory (per repo SECURITY.md). --- ## Title OCI ownership validation skips label-match check when upstream OCI registry returns HTTP 429, letting any authenticat
O3 Security · Impact-Aware SCA

Is GHSA-2v5f-5r6w-p67r in your dependencies?

O3 detects GHSA-2v5f-5r6w-p67r across Go dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.

GHSA-2v5f-5r6w-p67r: registry (Low 3.5) | O3 Security