TL;DR: Gitea's container registry implements the OCI Distribution Specification, which authenticates clients with a bearer token issued by a dedicated token service. On affected versions, that token service issued a valid, signed JWT to requesters presenting no credentials at all. The token was honest about what it represented, carrying
UserID: -1and an emptyScope, but no registry read endpoint ever consulted those fields. Catalog listing, tag enumeration, manifest retrieval and blob download all accepted it. Any unauthenticated party on the internet could enumerate every container repository on an instance, including those marked private, and pull their layers.
Affected: Gitea ≤ 1.25.5, Forgejo ≤ 9.0.2, and any fork carrying the same registry implementation.
Rating: CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N (8.2 High), for Gitea and Forgejo alike. The impact stays inside the registry's own authorisation boundary, which is what holds scope unchanged and the score below Critical.

How NoScope got there
A note on what follows. NoScope's assessment produces artefacts: requests, responses, the controlled-environment reproduction, the code review that came after. This section reconstructs the reasoning path from them.
It started from the specification, not the interface. Gitea's container registry is not somewhere you arrive by clicking. It is a machine-facing API spoken by docker pull, and the web UI barely acknowledges it. A methodology that works outward from the rendered interface does not reach it. A methodology that enumerates the documented protocol surfaces an application exposes does, because the OCI Distribution Specification is public and it defines a fixed, small set of endpoints: /v2/, a token service, /v2/_catalog, /v2/{name}/tags/list, /v2/{name}/manifests/{reference}, /v2/{name}/blobs/{digest}. That is the surface, and it is knowable before you send a single request.
The auth handshake is two components that have to agree. OCI registries do not check credentials at the resource. An unauthenticated request returns 401 with a WWW-Authenticate header naming a token service; the client fetches a token from that service and retries. The token service and the endpoints it protects are separate pieces of code that must hold the same view of who the requester is. Anywhere two components have to agree about identity is somewhere worth testing whether they actually do.
So: ask for a token you should not be given.
GET /v2/token?service=container_registry&scope=registry:catalog:*
No Authorization header. No credentials of any kind. The expected outcomes are a 401, or a token scoped to nothing. What came back was HTTP 200 and a valid JWT.
Read what the token says about itself. This is the step that turns an oddity into a finding. JWTs are self-describing, so decoding it costs nothing and tells you what the server believes. The payload carried UserID: -1 and Scope: "". That matters more than it first appears. The server was not confused and the token was not forged. The token service correctly identified the requester as nobody, and correctly recorded that it had granted nothing. Every fact needed to deny the request downstream was sitting in the token. The open question was whether anything downstream ever read it.

Test every endpoint separately rather than assuming they share a gate. The intuitive model of a registry is one authorisation check guarding a family of read operations. Under that model, testing _catalog tells you about tags, manifests and blobs too. That model is an assumption, and the finding lives precisely in it being wrong. Each endpoint in the specification was exercised independently against the same anonymous token:
GET /v2/_catalog→ the full repository list, includingnoscope/private-registry-test(private: true) andnoscope-lowpriv/private-container-test, a repository belonging to a separate low-privilege account.GET /v2/{repository}/tags/list→["latest"], for the private repository.GET /v2/{repository}/manifests/latest→ the complete manifest, including layer digests.GET /v2/{repository}/blobs/sha256:84f...f6d652→ HTTP 200, 10,240 bytes.

Confirm the private flag was genuinely set, and that the boundary crossed was a real one. "Private repository was readable" and "repository was not actually private" produce identical output if you are not careful about the test environment. The controlled instance held a repository explicitly flagged private under one account and a second private repository under an unrelated low-privilege account. Both were returned to an anonymous requester. The failure was not a visibility toggle that had not taken effect; it was a boundary between two accounts that did not exist.
Follow it to content, not just to metadata. A great many registry findings stop at enumeration and report an information leak. The distance between listing repository names and retrieving a layer is the distance between an embarrassment and a full compromise, and it is only crossable by continuing to pull. That last request, the blob at 10,240 bytes and HTTP 200, is what moved this from an information leak to confirmed retrieval of private image content, and it is what the confidentiality impact rests on.
The full impact was then reproduced and confirmed by our research team in a controlled environment against a Gitea instance configured as a typical maintainer would configure it. The default configuration was sufficient. The code review described in the next section, the decision on how and when to disclose, and the assessment of the patch were all human work.
Why it looked correct from the outside
An anonymous token being issued by a container registry is not, in itself, anomalous. It is correct. Public images are a first-class registry feature and anonymous pulls are how they work. Docker Hub does exactly this. An assessment that flagged anonymous token issuance as a bug would be wrong, and would be wrong on every registry it ever touched.
The defect is not that anonymous tokens exist. It is that the identity the token carries is never consulted again.
That distinction is what let this survive. Absences do not announce themselves. A review reads what the code does, and there is nothing at the blob endpoint to read: no check to find suspicious, no comparison to scrutinise, just a handler that returns a layer. You only see it if you arrive at that endpoint asking a question the code does not answer.
And there is a check. That is the part worth sitting with. ReqContainerAccess() exists, it rejects anonymous requesters, and it is wired to the catalog endpoint. A reviewer tracing the catalog path finds a permission gate exactly where they expect one, forms the reasonable impression that the registry's reads are guarded, and moves on. The presence of a plausible check in one place is precisely what makes its absence elsewhere invisible.
The root cause, in the code
The file-level confirmation below comes from our review of Forgejo's forgejo development branch, which carries the upstream implementation. The copyright headers still read "The Gitea Authors", and the path was unchanged at the time of review. Gitea's behaviour was confirmed by reproduction rather than by source review, and the two matched exactly.
Four findings, in routers/api/packages/container/container.go, routers/api/packages/api.go and models/packages/container/search.go:
1. The token service promotes anonymous requesters. Authenticate() issues a token bound to the built-in ghost user when ctx.Doer == nil and RequireSignInView is not enabled. This is the UserID: -1 in the payload. The default configuration takes this path.
2. The one gate that exists is conditional and narrowly mounted. ReqContainerAccess() rejects ghost users only when RequireSignInView=true, which is off by default. It is applied to _catalog. It is not applied to the manifest, blob or tag-list endpoints.
3. The read routes carry no permission middleware at all. In the route table, HeadBlob, GetBlob, HeadManifest, GetManifest and GetTagList are mounted bare. Only write and delete operations are wrapped in reqPackageAccess(perm.AccessModeWrite). Reads were never gated, because the design assumed reads did not need to be.
4. Catalog filtering is by owner, not by package. GetRepositories() applies BuildCanSeeUserCondition(actor), which filters on whether the owner is visible to the requester. It does not filter on the package's own private flag. A private package under a public owner passes the filter.

Underneath all four is one architectural fact, and it is the reason the first three could not have been fixed by adding a middleware call: the read endpoints do not enforce repository visibility because there is no repository visibility for them to enforce. Container visibility in this implementation is not derived from the associated repository at all. A package's effective visibility depends entirely on the visibility of the user or organisation that created it. There is no per-package or per-repository visibility binding, so the private flag an operator set on a repository was never connected to the packages served out of it. There was nothing to check against.
Detection
We recommended to the maintainer team that an indicator-of-compromise primitive accompany the advisory, and we would still encourage it. In the meantime, what an operator can check themselves:
The signature is a /v2/ read sequence with no authenticated session preceding it: a /v2/token request carrying no Authorization header, followed by requests to /v2/_catalog, /v2/*/manifests/* or /v2/*/blobs/* from the same source. Reverse-proxy and web-server access logs are the practical place to look; a single source walking catalog → tags → manifest → blob in order is the shape of the chain above.
Two honest caveats. Anonymous pulls of genuinely public images produce the same log pattern, so this signal is only clean on instances that were never intended to serve anything publicly. For those, any anonymous /v2/ blob read merits investigation. And the identity inside the token is not surfaced in default logging, so absence of evidence here is genuinely not evidence of absence. If you held sensitive images on an affected version and reachable from the internet, we would treat exposure as unquantifiable rather than as disproven, and rotate accordingly.
What the scale data actually measured
The full enumeration is in the original post; the technical detail worth adding is what the numbers do and do not represent.
Shodan reported 34,144 hosts matching http.html:Gitea, a query that only catches instances still broadcasting the default HTML marker and therefore misses custom branding, stripping reverse proxies, Forgejo's own identifiers and anything unindexed. Across a 100-host sample, 93 returned an anonymous JWT, which extrapolates to a floor of roughly 31,750 instances. Of a 9,500-host geo-resolved subset, 52% ran on cloud or VPS infrastructure and 68% on the default port.
The 93% figure measures one thing precisely: the proportion of hosts on which the vulnerable code path was active and issuing anonymous tokens. Our probe stopped there. It did not attempt tags, manifests or blobs against third-party hosts, and no image content was retrieved from any host we do not own. The complete chain was only ever run in our own environment.
There is a nuance the non-technical post did not carry. A follow-up catalog probe found that the overwhelming majority of reachable instances returned empty catalogs. The registry is enabled by default whether or not anyone uses it. So the vulnerable code path being near-universal and the data exposure being near-universal are two different claims, and only the first is supported. The exposure concentrates on the subset of instances where images had actually been pushed. That subset is where the sector sample in the original post came from: systems management, healthcare, aerospace and industrial manufacturing, retail, ISPs, EdTech, enterprise CMS. Repository names alone were sufficient to classify every one of them, which is its own quiet finding: you can read an organisation's internal architecture off a catalog listing without pulling a single layer.
We are not publishing the enumeration tooling. The chain above is four HTTP requests and needs no help from us; a scanner built to run it across tens of thousands of hosts is a different artefact with a different purpose.
Remediation
Update to Gitea v1.26.2. The maintainer team assigned CVE-2026-27771 and credited NoScope in that release.
If you cannot update immediately, set [service].REQUIRE_SIGNIN_VIEW=true in app.ini. This causes ReqContainerAccess() to reject the ghost user and closes the anonymous token path at its origin. The trade-off is real and worth stating plainly: it requires authentication for all content access, so it is unsuitable for operators who intentionally serve public images. Those operators need the code-level fix.
Forks should assume they are affected. Forgejo was confirmed affected through our own testing and source review, as documented above. Any fork carrying this registry implementation should treat itself as affected until its maintainers verify otherwise.
On the patch, and this is our assessment rather than the maintainers': v1.26.2 is scoped to interface changes that make package visibility clearer to users. From the Gitea maintainer team:
"I created PR #37610 to add some UI changes so users can understand a package's visibility more clearly. Until standalone package permissions are implemented, this seems like the best approach for now. Currently, users can change a package's visibility by changing the visibility of the owner (user or organization). There is no separate way to manage package permissions independently at the moment."
That is a candid description of the position, and we would not characterise it as a deflection. Binding package visibility to repository visibility is a substantial piece of work touching the permissions model, and the maintainers have been straightforward that it is pending rather than done. But it does mean the architectural root cause described above is still open. Updating to v1.26.2 remains the right action. For instances holding genuinely sensitive images and not intentionally serving public ones, REQUIRE_SIGNIN_VIEW=true remains the more complete protection until standalone package permissions land. Read the advisory and the release notes and decide which of those describes your instance.
Why we are publishing the mechanism now
A withheld mechanism protects operators only while they are still patching. Past that point it protects nobody and costs the people who need it most: the operators trying to work out whether they were hit, the maintainers of the forks, the reviewers who will look at the next registry implementation and now know what to ask it.
The reasoning path in this post is the part we would most like read. The conclusion is not exotic; it is a missing authorisation check, the oldest bug there is. What matters is where it sat. Behind a machine-facing protocol the interface barely acknowledges. In a feature whose anonymous access is legitimate and expected. One endpoint away from a permission check that genuinely exists. Nothing about that surface asks to be looked at, and the finding required exercising it anyway, from every angle it supports, on the possibility that the parts do not agree with each other.
That is the work. Comprehensive coverage of a mature platform is hard for reasons that have nothing to do with anyone's competence: functionality accrues across years of releases, each one widening the surface, and every settled-looking corner still has to be asked the same questions as the new ones. This is what that approach produces in practice.
About NoScope
CVE-2026-27771 is one example of what NoScope's autonomous pentesting agent finds in practice. A 4-year-old flaw, sitting in plain sight, across 30,000+ production deployments, found by systematically exercising every angle of the attack surface.
If you want to know what an attacker would find before they find it themselves, that's what NoScope is built for.
Get in touch: noscope.com/contact, LinkedIn, X
Read the original disclosure: CVE-2026-27771: NoScope Discovered 30,000+ Gitea Instances Exposing Private Container Images for 4 Years
Advisory: CVE-2026-27771
Release Notes: Gitea v1.26.2





