Skip to content
All posts
KubernetesdetectioncontainersATT&CK

Detecting container escape without drowning in privileged-pod alerts

Why "this pod is privileged" is posture and not detection, and how correlating the mount set with the subsequent exec turns it into a real signal.

Ali Awwad

Co-founder & CTO · 11 August 2026 · 4 min read

Every Kubernetes security tool will tell you which of your pods are privileged. That list is useful exactly once — the first time you read it — and after that it is a backlog item that generates an alert every time the same DaemonSet restarts.

Privileged is a posture fact. Escape is an event. Conflating them is why so many teams have their container security alerts routed to a channel nobody reads.

What an escape actually looks like

The most common single-step escape on Kubernetes has three ingredients:

  1. A container running with privileged: true or CAP_SYS_ADMIN
  2. A hostPath mount that covers the node root, or a mounted CRI socket
  3. An execution that reaches through that mount into the host

Ingredient 1 alone is a policy violation. Ingredients 1 and 2 together are a policy violation you should fix this sprint. All three, in sequence, within a couple of minutes, is an intrusion in progress.

The third ingredient is the one most tooling does not observe, because it requires seeing an execve and knowing which device and mount point the executed inode came from.

The correlation

Falak emits a 990001 Container Lifecycle event on container start, carrying the full mount set and security context:

{
  "class_uid": 990001,
  "activity_id": 2,
  "container": { "name": "toolbox", "image": { "name": "alpine:3.20" } },
  "k8s": { "namespace": "batch", "pod_name": "debug-runner-7f9c" },
  "security_context": {
    "privileged": true,
    "host_pid": true,
    "added_capabilities": ["CAP_SYS_ADMIN", "CAP_SYS_PTRACE"]
  },
  "mounts": [
    { "source": "/", "destination": "/host", "mode": "rw",
      "type": "hostPath", "is_host_path": true, "is_sensitive": true }
  ]
}

And a 1007 Process Activity event on the exec, carrying the resolved executable path and — critically — the device and mount point of its inode:

{
  "class_uid": 1007,
  "activity_id": 1,
  "process": {
    "name": "nsenter",
    "cmd_line": "nsenter --target 1 --mount --uts --ipc --net --pid -- /bin/bash",
    "file": { "path": "/host/usr/bin/nsenter", "mount_point": "/host", "device": 66306 }
  },
  "device": { "container": { "privileged": true } }
}

The rule joins them:

detection:
  privileged_mount:
    class_uid: 990001
    activity_id: 2
    security_context.privileged: true
    mounts.is_host_path: true
  escape_exec:
    class_uid: 1007
    activity_id: 1
    process.name:
      - nsenter
      - chroot
      - unshare
    process.cmd_line|contains: '--target 1'
  condition: privileged_mount and escape_exec within 120s by device.k8s.pod_name

by device.k8s.pod_name is the part that matters. Without it, a privileged DaemonSet starting on one node and an unrelated nsenter on another would correlate. With it, both halves must be the same pod.

Why not just alert on nsenter

Because nsenter --target 1 is also how node-problem-detector works, how several CNI installers work, and how the drain hooks on at least two managed Kubernetes distributions work. Alerting on it alone produces a steady trickle of alerts that are all legitimate, and the fourth time an engineer investigates one they stop investigating them.

The correlation is what makes it rare. A pod that was just started with a host root mount and then execs into PID 1 is a much smaller set than either half.

The other escape paths

nsenter is the loudest. Three quieter ones worth covering:

Mounted CRI socket. A container with /var/run/docker.sock or the containerd socket can ask the runtime to start a new privileged container. The signal is not an exec — it is a connection to a unix socket that is a sensitive mount:

detection:
  sock_mount:
    class_uid: 990001
    mounts.destination|endswith:
      - '/docker.sock'
      - '/containerd.sock'
      - '/crio.sock'
  condition: sock_mount
level: high

This one is mostly posture — but it is posture worth alerting on once per pod spec change, because there is essentially no legitimate reason for a workload to have it.

Writing to the host filesystem through the mount. Less dramatic than nsenter, more common in practice: dropping a systemd unit or an SSH key onto the node through a hostPath mount. This is caught by the ordinary persistence rules, because the file event carries mount_point: "/host" and the persistence path classification still applies.

Kernel exploitation. A container with CAP_SYS_MODULE can load a kernel module, which is game over. The module_load probe catches it as a 1005 Module Activity event, and a module load originating from a container namespace is unambiguous.

What this costs

Almost nothing. 990001 events fire on container lifecycle transitions — a few hundred per node per day on a busy cluster, against millions of file and process events. The correlation window is two minutes and keyed on pod name, so the state the correlation engine holds is bounded and small.

The expensive part of container security is file and network telemetry. Escape detection is cheap, and it is the detection most likely to matter.

The honest limitation

This catches escape attempts that use a recognisable escape mechanism. A kernel 0-day that escapes without an exec, a module load or a mount traversal will not trip these rules — it will trip whatever the payload does next, which is usually noisier than the escape itself.

That is a general property worth internalising: runtime detection rarely catches the exploit. It catches what the exploit was for.

Related reading