I’ve just revisited a core concept on k8s, but through a different perspective using a great lab at the iximiuz labs platform (highly recommend the platform!). The lab is about creating an invisible pod on a kubernetes cluster using the static pod concept, and making it to not to turn into mirror pods, so not properly registered within kubernetes / etcd.
Static pods are created / managed by kubelet process on the node, so on your initial setup create those pods / resources, and their specification comes from files below the /etc/kubernetes/manifests.
Mirror pods are static pods where the kubelet api could register within the cluster through the API, so registered on etcd and completely visible as regular app pod.
When you add or change yamls at the /etc/kubernetes/manifests directoty, the kubelet container is monitoring the path and applying those changes.
The scenario to create the invisble pod:
- Create a specification below
/etc/kubernetes/manifest. - Point that pod / resource to a specific and existing namespace.
- You can still remove the namespace within k8s, so pod is not visible anymore.
- But container within the node is still running, and run by the container engine you’re using on your cluster as all the other core static pods, becoming invisble on issuing
kubectl get po -A, and / or even calling the kukubelet API. - If you recreate the namespace, kubelet will be able again to create the
mirror pod, so the internal registry within the cluster.
This situation could used to exploit and run malicious pods without detection, even though the level of control of the cluster the attacker needs to implement this is high.
Generating the invisible pod
Let’s recreate the scenario and check how we can check if we have an invisible pod running on our cluster.
I’m doing this test using minikube on podman.
I’ve create my cluster with 2 nodes with the following:
minikube start --container-runtime=containerd --cni=cilium --nodes=2 --memory=1.9G --driver=podman
PS: Minimal resources for minikube on podman per node is of ~1.8GB.
Let’s create an intial namespace just to see the static pod getting registered and generating the mirror pod:
kubectl create ns invisible
Let me generate now the spec of the pod, it will be just a pod running ubuntu with a loop and sleep.
kubectl -n invisible run invisible --image=ubuntu --dry-run=client -o yaml -- bash -c "while true; do echo \"\$(date '+%Y-%m-%dT%H:%M:%S') logging my app.\"; sleep 20; done"
And here’e the yaml generated:
apiVersion: v1
kind: Pod
metadata:
labels:
run: invisible
name: invisible
namespace: invisible
spec:
containers:
- args:
- bash
- -c
- while true; do echo "$(date '+%Y-%m-%dT%H:%M:%S') logging my app."; sleep 20;
done
image: ubuntu
name: invisible
resources: {}
dnsPolicy: ClusterFirst
restartPolicy: Always
status: {}
Let’s go into one of the nodes and create a new manifest file with the spec above.
kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
minikube Ready control-plane 6d6h v1.35.1 192.168.49.2 <none> Debian GNU/Linux 12 (bookworm) 6.12.13-200.fc41.aarch64 containerd://2.2.1
minikube-m02 Ready <none> 6d6h v1.35.1 192.168.49.3 <none> Debian GNU/Linux 12 (bookworm) 6.12.13-200.fc41.aarch64 containerd://2.2.1
I’ll create the manifest file at the minikube node.
Checking the containers on podman and getting into the interactive terminal:
podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3aabe3074548 gcr.io/k8s-minikube/kicbase:v0.0.50 6 days ago Up 47 minutes 127.0.0.1:34653->22/tcp, 127.0.0.1:34969->2376/tcp, 127.0.0.1:43805->5000/tcp, 127.0.0.1:42649->8443/tcp, 127.0.0.1:35845->32443/tcp minikube
89c5bc7ef99c gcr.io/k8s-minikube/kicbase:v0.0.50 6 days ago Up 47 minutes 127.0.0.1:37373->22/tcp, 127.0.0.1:44849->2376/tcp, 127.0.0.1:44893->5000/tcp, 127.0.0.1:34337->8443/tcp, 127.0.0.1:33685->32443/tcp minikube-m02
podman exec -ti minikube bash
We can see that we have already other manifest files at the same location, and those are for core k8s components, that follow the same concept described above about static pods / resources.
root@minikube:/# ls -ltr /etc/kubernetes/manifests/
total 24
-rw-------. 1 root root 2655 Apr 2 12:56 etcd.yaml
-rw-------. 1 root root 1727 Apr 2 12:56 kube-scheduler.yaml
-rw-------. 1 root root 3483 Apr 2 12:56 kube-controller-manager.yaml
-rw-------. 1 root root 4171 Apr 2 12:56 kube-apiserver.yaml
And on the same path I’ve just create the new manifest file with the content above:
root@minikube:/#
root@minikube:/# ls -ltr /etc/kubernetes/manifests/invisible.yaml
-rw-r--r--. 1 root root 367 Apr 8 19:14 /etc/kubernetes/manifests/invisible.yaml
root@minikube:/#
root@minikube:/#
root@minikube:/# cat /etc/kubernetes/manifests/invisible.yaml
apiVersion: v1
kind: Pod
metadata:
labels:
run: invisible
....
We can check all static pods / resources running on containerd directly within the node as well:
crictl ps
crictl ps
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
b4fbde18cb4b9 b933e0ff0a239 2 minutes ago Running invisible 1 74300e85310b4 invisible-minikube invisible
990f8dc771f4f e08f4d9d2e6ed 52 minutes ago Running coredns 1 2fcfa78602322 coredns-7d764666f9-hvt4c kube-system
ed57489e6b976 6aa9be0a1bb61 52 minutes ago Running registry-proxy 1 c9dc6a14b5687 registry-proxy-kftff kube-system
599667072e137 5d91beb063a96 52 minutes ago Running trivy-webhook 1 458bff93a31ed trivy-webhook-7dc78c4567-c2z2g default
08f56108c1918 2f5ec5015badd 52 minutes ago Running registry 1 7b349aac54cbd registry-7984dc5f99-fhsl4 kube-system
11fca1d898ffb ba04bb24b9575 52 minutes ago Running storage-provisioner 2 b42983b17115c storage-provisioner kube-system
c201b9851144a 6a7b645c8d911 52 minutes ago Running cilium-agent 1 d37fdea215655 cilium-m6z8p kube-system
cd196199212f8 ae63e266f5b88 53 minutes ago Running cilium-envoy 1 dc57dc4eb6fc1 cilium-envoy-7mlk4 kube-system
338b82609ef21 b77c3cf681abd 53 minutes ago Running cilium-operator 1 5885f7bded9f6 cilium-operator-8dd6995d5-jhc4t kube-system
55ba17b03d1cf 2d611d040292f 53 minutes ago Running kube-proxy 1 1c4a1e6bb9691 kube-proxy-qcwb2 kube-system
57ecfbea0c67e 7459ea0017638 53 minutes ago Running kube-apiserver 1 88ce85471b044 kube-apiserver-minikube kube-system
743b0c40f3da2 4cad4f996631b 53 minutes ago Running kube-scheduler 1 f4b6ef5692b8e kube-scheduler-minikube kube-system
0443fb134f8fb 271e49a0ebc56 53 minutes ago Running etcd 1 bf2b110e627e3 etcd-minikube kube-system
d6c1ab808351b 464c974e702ea 53 minutes ago Running kube-controller-manager 1 ffda53ea21706 kube-controller-manager-minikube kube-system
On the first line we can see our container running as part of the containerd, together with other core components of k8s.
crictl logs -f b4fbde18cb4b9
crictl logs -f b4fbde18cb4b9
2026-04-08T19:15:17 logging my app.
2026-04-08T19:15:37 logging my app.
2026-04-08T19:15:57 logging my app.
2026-04-08T19:16:17 logging my app.
2026-04-08T19:16:37 logging my app.
2026-04-08T19:16:57 logging my app.
2026-04-08T19:17:17 logging my app.
2026-04-08T19:17:37 logging my app.
2026-04-08T19:17:57 logging my app.
2026-04-08T19:18:17 logging my app.
2026-04-08T19:18:37 logging my app.
...
Ps: crictl is a universal CRI comand line for interacting with container engines, in our case containerd, and it has docker / podman compatible syntax.
Let’s check again the pods registered, or if this pod is now registered as a mirror:
k -n invisible get po
NAME READY STATUS RESTARTS AGE
invisible-minikube 1/1 Running 1 2m9s
We can see our pod registered as a mirror pod too, and present only on our minikube node.
Let’s now remove the namespace and check internally in the node if our pod is still running or not.
k delete ns invisible
Now we see no sign of the namespace and no sign of our pod as well:
k get ns
NAME STATUS AGE
apps Active 6d6h
cilium-secrets Active 6d6h
default Active 6d6h
kube-node-lease Active 6d6h
kube-public Active 6d6h
kube-system Active 6d6h
k get po
NAMESPACE NAME READY STATUS RESTARTS AGE
default trivy-webhook-7dc78c4567-c2z2g 1/1 Running 1 (60m ago) 5d11h
default trivy-webhook-7dc78c4567-clhlv 1/1 Running 1 (60m ago) 5d11h
kube-system cilium-envoy-7mlk4 1/1 Running 1 (60m ago) 6d6h
kube-system cilium-envoy-b4mdf 1/1 Running 1 (60m ago) 6d6h
kube-system cilium-m6z8p 1/1 Running 1 (60m ago) 6d6h
kube-system cilium-operator-8dd6995d5-jhc4t 1/1 Running 1 (60m ago) 6d6h
kube-system cilium-v552z 1/1 Running 1 (60m ago) 6d6h
kube-system coredns-7d764666f9-hvt4c 1/1 Running 1 (60m ago) 6d6h
kube-system etcd-minikube 1/1 Running 1 (60m ago) 6d6h
kube-system kube-apiserver-minikube 1/1 Running 1 (60m ago) 6d6h
kube-system kube-controller-manager-minikube 1/1 Running 1 (60m ago) 6d6h
kube-system kube-proxy-qcwb2 1/1 Running 1 (60m ago) 6d6h
kube-system kube-proxy-xkzln 1/1 Running 1 (60m ago) 6d6h
kube-system kube-scheduler-minikube 1/1 Running 1 (60m ago) 6d6h
kube-system registry-7984dc5f99-fhsl4 1/1 Running 1 (60m ago) 6d6h
kube-system registry-proxy-kftff 1/1 Running 1 (60m ago) 6d6h
kube-system registry-proxy-t9lg2 1/1 Running 1 (60m ago) 6d6h
kube-system storage-provisioner 1/1 Running 2 (59m ago) 6d6h
Kubelet will keep on monitoring the manifests, and apply changes, but on this case as there’s no namespace, it’s not able to register the mirror pod.
But at the same time I could not see any logs about the pod, so it’s really hard to monitor.
Let’s see internally on the node if our container is still running.
podman exec -ti minikube bash
crictl ps
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
b4fbde18cb4b9 b933e0ff0a239 15 minutes ago Running invisible 1 74300e85310b4 invisible-minikube invisible
990f8dc771f4f e08f4d9d2e6ed About an hour ago Running coredns 1 2fcfa78602322 coredns-7d764666f9-hvt4c kube-system
ed57489e6b976 6aa9be0a1bb61 About an hour ago Running registry-proxy 1 c9dc6a14b5687 registry-proxy-kftff kube-system
599667072e137 5d91beb063a96 About an hour ago Running trivy-webhook 1 458bff93a31ed trivy-webhook-7dc78c4567-c2z2g default
08f56108c1918 2f5ec5015badd About an hour ago Running registry 1 7b349aac54cbd registry-7984dc5f99-fhsl4 kube-system
11fca1d898ffb ba04bb24b9575 About an hour ago Running storage-provisioner 2 b42983b17115c storage-provisioner kube-system
c201b9851144a 6a7b645c8d911 About an hour ago Running cilium-agent 1 d37fdea215655 cilium-m6z8p kube-system
cd196199212f8 ae63e266f5b88 About an hour ago Running cilium-envoy 1 dc57dc4eb6fc1 cilium-envoy-7mlk4 kube-system
338b82609ef21 b77c3cf681abd About an hour ago Running cilium-operator 1 5885f7bded9f6 cilium-operator-8dd6995d5-jhc4t kube-system
55ba17b03d1cf 2d611d040292f About an hour ago Running kube-proxy 1 1c4a1e6bb9691 kube-proxy-qcwb2 kube-system
57ecfbea0c67e 7459ea0017638 About an hour ago Running kube-apiserver 1 88ce85471b044 kube-apiserver-minikube kube-system
743b0c40f3da2 4cad4f996631b About an hour ago Running kube-scheduler 1 f4b6ef5692b8e kube-scheduler-minikube kube-system
0443fb134f8fb 271e49a0ebc56 About an hour ago Running etcd 1 bf2b110e627e3 etcd-minikube kube-system
d6c1ab808351b 464c974e702ea About an hour ago Running kube-controller-manager 1 ffda53ea21706 kube-controller-manager-minikube kube-system
root@minikube:/#
And we can still see the container running within the node.
crictl logs -f b4fbde18cb4b9
crictl logs -f b4fbde18cb4b9
2026-04-08T19:15:17 logging my app.
2026-04-08T19:15:37 logging my app.
2026-04-08T19:15:57 logging my app.
2026-04-08T19:16:17 logging my app.
2026-04-08T19:16:37 logging my app.
2026-04-08T19:16:57 logging my app.
2026-04-08T19:17:17 logging my app.
2026-04-08T19:17:37 logging my app.
2026-04-08T19:17:57 logging my app.
2026-04-08T19:18:17 logging my app.
2026-04-08T19:18:37 logging my app.
2026-04-08T19:18:57 logging my app.
2026-04-08T19:19:17 logging my app.
2026-04-08T19:19:37 logging my app.
2026-04-08T19:19:57 logging my app.
2026-04-08T19:20:17 logging my app.
2026-04-08T19:20:37 logging my app.
2026-04-08T19:20:57 logging my app.
2026-04-08T19:21:17 logging my app.
2026-04-08T19:21:37 logging my app.
2026-04-08T19:21:57 logging my app.
2026-04-08T19:22:17 logging my app.
2026-04-08T19:22:37 logging my app.
2026-04-08T19:22:57 logging my app.
2026-04-08T19:23:17 logging my app.
2026-04-08T19:23:37 logging my app.
2026-04-08T19:23:57 logging my app.
2026-04-08T19:24:17 logging my app.
2026-04-08T19:24:37 logging my app.
2026-04-08T19:24:57 logging my app.
2026-04-08T19:25:17 logging my app.
2026-04-08T19:25:37 logging my app.
2026-04-08T19:25:57 logging my app.
2026-04-08T19:26:17 logging my app.
2026-04-08T19:26:37 logging my app.
2026-04-08T19:26:57 logging my app.
2026-04-08T19:27:17 logging my app.
2026-04-08T19:27:37 logging my app.
2026-04-08T19:27:57 logging my app.
2026-04-08T19:28:17 logging my app.
2026-04-08T19:28:37 logging my app.
2026-04-08T19:28:57 logging my app.
2026-04-08T19:29:17 logging my app.
2026-04-08T19:29:37 logging my app.
2026-04-08T19:29:57 logging my app.
2026-04-08T19:30:17 logging my app.
2026-04-08T19:30:37 logging my app.
2026-04-08T19:30:57 logging my app.
2026-04-08T19:31:17 logging my app.
2026-04-08T19:31:37 logging my app.
2026-04-08T19:31:57 logging my app.
2026-04-08T19:32:17 logging my app.
2026-04-08T19:32:37 logging my app.
...
So container is still running, and we can’t actually see the pod within k8s using kubectl.
k get po -A
NAMESPACE NAME READY STATUS RESTARTS AGE
default trivy-webhook-7dc78c4567-c2z2g 1/1 Running 1 (70m ago) 5d11h
default trivy-webhook-7dc78c4567-clhlv 1/1 Running 1 (70m ago) 5d11h
kube-system cilium-envoy-7mlk4 1/1 Running 1 (70m ago) 6d6h
kube-system cilium-envoy-b4mdf 1/1 Running 1 (70m ago) 6d6h
kube-system cilium-m6z8p 1/1 Running 1 (70m ago) 6d6h
kube-system cilium-operator-8dd6995d5-jhc4t 1/1 Running 1 (70m ago) 6d6h
kube-system cilium-v552z 1/1 Running 1 (70m ago) 6d6h
kube-system coredns-7d764666f9-hvt4c 1/1 Running 1 (70m ago) 6d6h
kube-system etcd-minikube 1/1 Running 1 (70m ago) 6d6h
kube-system kube-apiserver-minikube 1/1 Running 1 (70m ago) 6d6h
kube-system kube-controller-manager-minikube 1/1 Running 1 (70m ago) 6d6h
kube-system kube-proxy-qcwb2 1/1 Running 1 (70m ago) 6d6h
kube-system kube-proxy-xkzln 1/1 Running 1 (70m ago) 6d6h
kube-system kube-scheduler-minikube 1/1 Running 1 (70m ago) 6d6h
kube-system registry-7984dc5f99-fhsl4 1/1 Running 1 (70m ago) 6d6h
kube-system registry-proxy-kftff 1/1 Running 1 (70m ago) 6d6h
kube-system registry-proxy-t9lg2 1/1 Running 1 (70m ago) 6d6h
kube-system storage-provisioner 1/1 Running 2 (69m ago) 6d6h
So if an attacker can get and inject something on your manifest directory, and generate a new pod with a non-existent namespace, the pod can start and keep on running.
That shows how important is to lock up access directly to nodes / cluster, and audit the activity within it.
In this case the attacker has to have access to generate a static pod, so access to the nodes directly, and one way to have it secure on GCP for example is to have private clusters, so no external IPs, firewall rules, and controlling even egreess with NAT or just allowing intenal traffic / GCP VPC / subnet.
How to best monitor or check for invisible pods
- Telemetry within the node - shows what’s running in terms of internal containers within containerd.
- No errors are spawned from the kubelet, so hard to actually find the issue.
kubeletctl- project is not being maintained anymore and I had errors with current k8s versions.- Query directly the kubelet API - shows all pods including the static / invisible ones.
- You have to get all certs to do the
curlcall directly to the api:kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt kubectl config view --raw --minify --flatten -o jsonpath='{.users[0].user.client-certificate-data}' | base64 -d > client.crt kubectl config view --raw --minify --flatten -o jsonpath='{.users[0].user.client-key-data}' | base64 -d > client.key - Use the certs now to do your curl call, but anyway there’s no list of the pod, only an error from it (Ps: change your ip / port if needed, on my case it’s minikube on podman):
curl -v --cacert ca.crt --cert client.crt --key client.key https://127.0.0.1:42649/api/v1/pods - In my case I could find some small errors for a different static pod, but it showed nothing on the invisble one.
- You have to get all certs to do the
- In summary:
- Have an audit methodology that can check what’s running on your nodes, not just internally registered in the cluster.
- Run most nodes on cloud platforms on private mode, no exposing any IP nodes externally.
- Restricting access to the kubelet APIs, and access to the nodes directly is imperative to avoid the case above.
- Check resources getting created with any
securityContextandprivilegedrequirements, as on those are the ones that could be even generating something on the fly. - Audit container images, as compromise images running on high privileged pods / resources as explained above.