koti.dev
← The Runbook
Mastering Kubernetes the Right Way · DAY 31 / 35

The hostPID Footgun: One Line That Shows You Every Process on the Node

A pod with hostPID: true is not a pod, it is a node-wide observation deck. Here is exactly what an attacker sees.

KV
Koti Vellanki19 Apr 20264 min read
kubernetessecurity
The hostPID Footgun: One Line That Shows You Every Process on the Node

A pentest report lands in my inbox at 9AM on a Thursday with one screenshot. It is the output of ps -ef from inside a debug pod, and it lists every process on the node. kubelet, containerd, sshd, the other tenant's workloads, everything. I check the manifest and right near the top: hostPID: true. Six months earlier somebody had copy-pasted it from a monitoring DaemonSet because the README had it. Nobody noticed in review. Nobody noticed in audit. One line had silently turned a regular application pod into a full-spectrum view of the node, and it had been that way in prod since October.

The scenario

DAY 31 · SECURITY · PID NAMESPACE

Two containers. One PID namespace. One of them expected to be PID 1.

shareProcessNamespace: true merges both containers into a single PID namespace. Container A's entrypoint becomes PID 1. Container B's process expected to be PID 1 and relies on that for signal handling. The collision breaks Container B silently.

FIGURE31 / 35
PID namespace collision — shareProcessNamespace breaks sidecar expecting PID 1A pod sets shareProcessNamespace: true. Container A runs nginx as PID 1. Container B runs a sidecar tool that expects to be PID 1. When Container B uses kill 0, it hits nginx instead of its own process group and breaks.POD · default nsshareProcessNamespace: trueCONTAINER Aentry: nginxpid: 1PID 1 = process group leaderignores SIGTERM by default1CONTAINER B · sidecarSCentry: my-toolpid: 17expected: 1kill 0 → hits nginx2PID NAMESPACEshared across containersPID 1 = nginxPID 17 = my-tool→ kill 0 hits nginx3
1

Container A legitimately holds PID 1

nginx starts first and acquires PID 1 in the shared namespace. PID 1 has special kernel signal behaviour — it ignores SIGTERM unless the application explicitly handles it (man signal(7)). This is expected for Container A.

2

Container B collides — it expected to be PID 1

The sidecar tool uses kill 0 to signal its own process group, assuming it is PID 1. Instead it receives PID 17. The kill 0 hits nginx's process group and either has no effect or triggers unexpected shutdown.

3

The shared namespace is a deliberate tradeoff

shareProcessNamespace: true gives containers visibility into each other's processes — useful for debugging sidecars. But any tool that assumes it owns PID 1, or uses PID-relative signalling, will break. Remove the flag unless you need cross-container ps visibility.

Kubernetes
PID collision
Process boundary
◆ koti.dev / runbook
A pod with shareProcessNamespace: true — Container B's process collides with Container A's PID 1.
A pod has shareProcessNamespace set to true. Container A runs nginx as PID 1. Container B runs a sidecar tool that expects to be PID 1 and uses kill 0 to signal its own process group. Because PID 1 is nginx, the kill hits the wrong process and Container B breaks.
pod.spec.shareProcessNamespace — kubectl explain pod.spec.shareProcessNamespace · kind v0.22.0, Kubernetes 1.30.0

This one is short, nasty, and in the repo.

bash
git clone https://github.com/vellankikoti/troubleshoot-kubernetes-like-a-pro.git cd troubleshoot-kubernetes-like-a-pro/scenarios/pid-namespace-collision ls
bash

description.md, issue.yaml, fix.yaml. The issue yaml adds exactly one field, hostPID: true, to an otherwise normal busybox pod. Nothing else is privileged. That is what makes it so easy to miss.

Reproduce the issue

bash
kubectl apply -f issue.yaml kubectl get pod pid-namespace-collision-pod
bash
plaintext
NAME READY STATUS RESTARTS AGE pid-namespace-collision-pod 1/1 Running 0 10s

Normal pod. Running. Ready. kubectl describe shows no security warnings. kubectl get pod -o yaml is the only place the field shows up, and you have to be looking for it.

Debug the hard way

I want to see exactly what an attacker would see, so I exec in.

bash
kubectl exec -it pid-namespace-collision-pod -- sh / $ ps -ef | head -10
bash
plaintext
PID USER TIME COMMAND 1 root 0:02 /sbin/init 342 root 0:01 /usr/lib/systemd/systemd-journald 891 root 0:12 /usr/bin/kubelet --config=/var/lib/kubelet/config.yaml 1023 root 0:51 /usr/bin/containerd 1456 root 0:00 /pause 1789 root 0:03 /usr/bin/sshd -D 2104 1000 0:00 /app/payments-api 2311 root 0:02 /coredns -conf /etc/coredns/Corefile

That is not "my container's processes." That is the node's entire process table. Kubelet is there. Containerd is there. Another tenant's payments-api is there, running as UID 1000, and I can see its command line. If that command line has a secret in an env var rendered via /proc/<pid>/environ, I can read it.

bash
/ $ cat /proc/2104/environ 2>/dev/null | tr '\0' '\n' | head
bash
plaintext
DB_PASSWORD=s3cr3t-prod-pass STRIPE_SECRET=sk_live_...

There it is. A pod that has no privileged: true, no extra capabilities, no hostNetwork, just hostPID: true, has exfiltrated another workload's secrets by reading /proc. And the kernel is cooperating because from its point of view, this is all one namespace.

bash
kubectl get pod pid-namespace-collision-pod -o jsonpath='{.spec.hostPID}'
bash
plaintext
true

Why this happens

The Linux kernel has a thing called the PID namespace. By default, every container gets its own, and inside that namespace PID 1 is your container's entrypoint and you cannot see processes outside it. It is the foundation of container isolation. When you set hostPID: true, Kubernetes tells the runtime to put the pod in the host's PID namespace instead of a fresh one. The container now shares the namespace with init, kubelet, and every other process on the node.

The field exists for legitimate reasons. Monitoring agents like node-exporter need to see host processes to report on them. Some debugging DaemonSets need it. The problem is that the field is a single boolean that gets copy-pasted around, and it does not require any privileged capability to use. Admission controllers that look for privileged: true miss it. Linters that only check runAsUser miss it. OPA policies that only block hostNetwork miss it. The only thing that catches it is a rule that specifically blocks hostPID on non-system namespaces.

And once you have PID namespace access, you have everything. /proc/<pid>/environ leaks env vars. /proc/<pid>/cmdline leaks flags. /proc/<pid>/root can even let you walk into another container's filesystem if capabilities line up. It is a single line of yaml and it collapses the entire isolation model.

The fix

bash
kubectl delete pod pid-namespace-collision-pod kubectl apply -f fix.yaml
bash

The diff is just one line removed.

diff
spec: - hostPID: true containers: - name: busybox
diff

Verify the PID namespace is isolated again.

bash
kubectl exec pid-namespace-collision-fixed-pod -- ps -ef
bash
plaintext
PID USER TIME COMMAND 1 1000 0:00 sh -c echo 'PID namespace isolated' && sleep 3600 7 1000 0:00 sleep 3600

Two processes. That is what a container's process table should look like. The fix is easy. Preventing the regression is the hard part. I enforce a Pod Security Admission baseline profile on every namespace that is not explicitly system, and the baseline profile bans hostPID, hostNetwork, and hostIPC outright.

The lesson

  1. hostPID: true is not a tuning knob. It is a security boundary removal. Treat it like privileged: true.
  2. Pod Security Admission baseline profile blocks hostPID, hostNetwork, and hostIPC for free. Turn it on in every namespace that is not kube-system.
  3. Audit /proc access the way you would audit secret access. If a pod can read /proc/<other-pid>/environ, it has your secrets, full stop.

Day 31 of 35 — tomorrow we drop below Kubernetes into the kernel itself, where SELinux and AppArmor are quietly denying syscalls your pod thinks it made.

◆ Newsletter

Get the next post in your inbox.

Real Kubernetes lessons from seven years in production. One email when a new post drops. No spam. Unsubscribe in one click.