SSRF past the metadata endpoint: Docker on 2375, kubelet on 10250, and the 50,000 hosts TeamTNT found
IMDSv2 closed the famous SSRF target. The internal network is still full of unauthenticated control planes that will run a container for anyone who asks, and crypto-mining crews have been scanning for them since 2020.
7 min read0 views
Most SSRF write-ups end at 169.254.169.254. Enforce IMDSv2 and you have closed the famous door.
The other doors are still open, and in a container estate they are frequently better. Cloud metadata gives you credentials. An exposed container control plane gives you arbitrary code execution on the node, as root, with the host filesystem one flag away, and no credential to steal, expire or rotate.
This is not theoretical or historical. TeamTNT, Kinsing and their successors have been scanning for exactly these ports since 2020; Trend Micro reported TeamTNT activity affecting tens of thousands of Kubernetes-related hosts, with attacks driven by simple internet-wide scans for open kubelet and Docker APIs.
Why internal control planes are unauthenticated
There is a consistent reason, and understanding it tells you where to look next.
Container tooling was designed for a trusted local socket. Docker's API speaks over /var/run/docker.sock, protected by Unix file permissions. The kubelet answers on localhost for the API server. etcd expects to talk to peers on a private network. In each case the security model is "you can only reach this if you are already on the machine, or on the trusted network."
Then someone needed remote access, or a CI runner, or a monitoring agent, and bound it to 0.0.0.0. The authentication that was never written is now the authentication that is missing, and the "trusted network" assumption is exactly what SSRF violates.
SSRF is a violation of network position. Every control that assumes "only trusted things can reach this" fails when an internet-facing application can be made to make requests on the attacker's behalf.
The targets
Docker daemon, 2375 (plain), 2376 (TLS). The most valuable single target in this list. The API is unauthenticated on 2375 by design; TLS with client certificates is the intended protection on 2376, and 2375 is what gets exposed. Attackers explicitly prefer 2375 for exactly this reason.
GET /version # confirm it, unauthenticated
GET /containers/json # what is running
POST /containers/create # start a container -- with any mounts you like
The escalation is a documented feature, not an exploit: create a container that bind-mounts the host root filesystem, run as privileged, and you have the node. There is no vulnerability to patch here. Exposing this API is granting root.
kubelet, 10250. The agent on every Kubernetes node. Historically it accepted unauthenticated requests, and --anonymous-auth=true still appears in real clusters:
GET /pods # every pod on the node, with env vars
POST /run/{ns}/{pod}/{container} # execute a command in a container
GET /exec/... # interactive
/pods alone is often the whole engagement: pod specifications routinely include environment variables holding database passwords, API keys and cloud credentials. /run is the reason TeamTNT built tooling around this port. It is remote code execution in any container on the node.
Also check 10255, the read-only port. It requires no authentication by design and exposes /pods and /metrics. It is deprecated and disabled by default now, and it is still out there.
Kubernetes API server, 6443, 8080. Port 8080 is the old insecure port, unauthenticated, and though removed in modern versions, it persists in long-lived clusters. Even on 6443, check whether anonymous access is bound to anything useful, misconfigured ClusterRoleBindings granting rights to system:anonymous or system:unauthenticated are common.
etcd, 2379. The entire cluster state, including every Secret, in plaintext unless encryption at rest is configured. Read access to etcd is read access to every credential in the cluster.
Everything else on the internal network. Elasticsearch (9200), Redis (6379), Consul (8500), Jenkins, Spark master (8080), Hadoop YARN (8088), Prometheus (9090), Grafana, internal admin panels, .internal service endpoints, and the CI system, which usually holds deployment credentials for production. Consul's and Jenkins' APIs both offer straightforward paths to command execution when unauthenticated.
The protocol problem
The interesting SSRF question is what you can speak, not what you can reach.
Plain HTTP SSRF reaches HTTP services. That covers Docker, kubelet, Elasticsearch, Consul and most of the list. But some primitives let you reach further:
gopher://, where supported, lets you write arbitrary bytes to a TCP socket. That reaches Redis, memcached, SMTP, and any line-based protocol. Modern libcurl builds often exclude it, but check rather than assume.- Redis is unusually forgiving: it accepts inline commands and ignores lines it does not understand, so even a plain HTTP POST whose body contains Redis commands can be partially executed. The classic outcome is writing a cron entry or an SSH key.
- Redirects turn a filtered fetch into an unfiltered one, and are the most common bypass in practice.
- DNS rebinding defeats validate-then-fetch code: the name resolves to a public address when validated and to an internal one when connected. Any check that resolves separately from the connection has this hole.
Testing
Order matters, because the first two are quiet and the third is not.
- Establish that SSRF exists at all, blind or otherwise, using a collaborator host. A DNS lookup is enough.
- Port-scan internally by timing. The response-time difference between a closed port (immediate refusal) and a filtered one (timeout) is usually stark enough to map the internal network through a blind SSRF.
- Probe the known control-plane ports on
127.0.0.1, on the node's own address, on the pod CIDR, and on the service CIDR. In Kubernetes, rememberkubernetes.default.svc, and that a mounted service account token at/var/run/secrets/kubernetes.io/serviceaccount/tokenis often reachable by other means. - Confirm before escalating.
GET /versionon Docker orGET /podson the kubelet confirms exposure without changing anything. On an authorised engagement, the confirmation is the finding, creating a container is a separate step that needs explicit agreement, because it changes the state of production infrastructure.
Fixing it
Network policy first, because it is the control that does not depend on every component being configured correctly. Default-deny egress for application workloads. An application that talks to a database and an payment provider should not be able to open a connection to the node's own kubelet port. In Kubernetes this is a NetworkPolicy; in a service mesh it is an egress rule; in a VPC it is a security group. It closes the entire class at once, including the services you have not thought of.
Then, per component:
- Docker: do not expose the daemon over TCP. If you must, TLS with client certificates, bound to a specific interface, never
0.0.0.0. Treat access to the socket as equivalent to root on the host, including when mounting it into a CI container. - kubelet:
--anonymous-auth=false,--authorization-mode=Webhook, and--read-only-port=0. Verify on a live node rather than in the intended configuration. - API server: no insecure port; audit every binding to
system:anonymousandsystem:unauthenticated. - etcd: client and peer TLS with certificate authentication, plus encryption at rest for Secrets.
- Everything else: authentication on internal services. "It is on the private network" has not been a security boundary since the first SSRF.
And fix the SSRF itself, with an allowlist of destinations rather than a blocklist of addresses.
Detection
- Application containers connecting to
2375,2376,6443,10250,10255,2379,6379,9200. Your web application has no reason to speak to any of these. This is a narrow, high-signal rule and it is one of the best available in a container estate. - Kubelet access logs from a pod IP rather than from the API server.
- Container creation with
Privileged: true,PidMode: host, or a bind mount of/. Alert on it unconditionally. Legitimate uses exist, are rare, and are known in advance. - Sudden egress to mining pool ports or domains. This is still, overwhelmingly, what the automated crews do once they land.
- New pods in
kube-systemthat no deployment created. - Outbound connections from a process that has never made one before, which is the generic version of most of the above.
Take this away
The metadata service was never the point. It was the most convenient thing on the other side of a boundary that SSRF ignores.
The question worth asking of any application is not "can this reach the metadata endpoint?" It is: what else can this host reach, and does any of it assume that only trusted things can connect? In a container estate, the answer is usually "a control plane that will run whatever you ask."
Further reading
Was this useful?
Comments
Loading comments…