Detecting cluster attacks
Audit log, runtime rules, honeytokens.
A motion sensor nobody wired to an alarm is just a plastic box on the wall. Kubernetes ships with two of them, and both are off by default. The API server, the front desk that every command in the cluster passes through, can record who did what. Runtime tools can watch what actually happens inside your running containers. Neither one saves you unless something is watching the feed and knows what a break-in looks like. Prevention locks the doors you know about. Detection is how you find out that someone walked through a door you forgot to lock.
Switch on the audit log
Think of the API server as a front desk that every request in the cluster has to walk up to. Create a Pod, read a Secret (a Secret is the Kubernetes object that stores passwords, tokens, and keys), bind a new role, open a shell in a container: all of it arrives as a request at that one desk. The audit log is the desk's logbook. It records who asked, what they wanted, the source address the request came from, and whether it was allowed. The catch is that the logbook starts empty, and it stays empty until you hand the API server a Policy that spells out what to write down. Each rule in that Policy picks a level. None throws the event away. Metadata logs the headline: who, what, when, from where. Request adds the object the user submitted. RequestResponse piles the server's full reply on top of that.
apiVersion: audit.k8s.io/v1kind: PolicyomitStages: ["RequestReceived"]rules:# Full request body for the attack-adjacent writes.- level: Requestverbs: ["create"]resources:- group: "rbac.authorization.k8s.io"resources: ["clusterrolebindings", "rolebindings"]# Exec and attach: capture who ran what, where.- level: Requestresources:- group: ""resources: ["pods/exec", "pods/attach"]# Secrets at Metadata only (never log the payload).- level: Metadataresources:- group: ""resources: ["secrets"]# Drop the high-volume noise so the log stays readable.- level: Noneresources:- group: ""resources: ["events", "endpoints"]
You wire the API server to that file with two flags, set in its own manifest at /etc/kubernetes/manifests/kube-apiserver.yaml: --audit-policy-file and --audit-log-path. Where that Policy lives matters more than it looks. It's a plain file on the control-plane node, the machine that runs the cluster's brain, not an object inside Kubernetes. So an attacker with ordinary API access can't run kubectl delete to switch your logging off and slip into the dark. Someone who gets a foothold on the control-plane machine itself can, which is the whole reason you copy every line off the box the moment it's written. The API server records each event late, once the request has finished and the outcome is known, so the log tells you not just what was attempted but whether it worked. Don't trust the config file to prove logging is on. Read the running process.
ps -ef | grep '[k]ube-apiserver' | grep -o -- '--audit-[^ ]*'
--audit-policy-file=/etc/kubernetes/audit/policy.yaml--audit-log-path=/var/log/kubernetes/audit/audit.log--audit-log-maxage=30--audit-log-maxbackup=10
Every entry is a single line of JSON (a plain-text format of key-value pairs). A few fields carry almost all the meaning. user.username tells you who. verb and objectRef.resource say what they touched. sourceIPs shows where the request came from. An exec shows up as the resource pods with the subresource exec, and the command the person ran rides along inside requestURI. Pull just the exec events out of the pile and the difference between routine and hostile jumps out.
jq -c 'select(.objectRef.subresource=="exec")| {t:.requestReceivedTimestamp, user:.user.username,ns:.objectRef.namespace, pod:.objectRef.name,ip:.sourceIPs[0], uri:.requestURI}' \/var/log/kubernetes/audit/audit.log
{"t":"2026-07-16T14:22:07Z","user":"[email protected]","ns":"staging","pod":"web-7c9d8","ip":"203.0.113.8","uri":"/api/v1/namespaces/staging/pods/web-7c9d8/exec?command=sh&container=web&stdin=true&tty=true"}{"t":"2026-07-16T03:12:07Z","user":"system:serviceaccount:kube-system:coredns","ns":"payments","pod":"payments-db-0","ip":"10.8.1.44","uri":"/api/v1/namespaces/payments/pods/payments-db-0/exec?command=sh&stdin=true&tty=true"}
Read those two lines the way a night guard reads a logbook. The first is a named engineer, jane, opening a shell in staging in the middle of the afternoon from an office address. Boring. Exactly what you want. The second is a service account, and that phrase is worth a second: a service account is the identity Kubernetes hands to a program instead of a person. This one is coredns, the pod that runs the cluster's internal phone book, turning names like payments-db into addresses. It has one job, and opening a shell is not it. Yet here it is, dropping into the payments namespace at three in the morning from an address inside the pod network. Same verb, exec, and a completely different story. The tells stack up fast: the actor is a machine that should never do this, the target is where the money lives, the source is inside the cluster, and the hour is when nobody's watching.
When the audit log goes blind
The audit log stops at the container's edge. It will faithfully tell you that jane ran exec with the command sh. It cannot see one keystroke she types after that shell opens. Worse, it never sees a process that a compromised container starts on its own, because nothing ever asked the API server for permission. The audit log is the badge reader on a door: it logs the swipe, and it has no clue what you do once you're inside the room. For that you need a camera in the room. Falco is that camera. It's an open-source tool that hooks into the Linux kernel through eBPF (extended Berkeley Packet Filter, a safe way to run small inspector programs down where the operating system itself lives) and matches live system calls against rules like this one.
- rule: Shell In Containerdesc: A shell was spawned inside a running containercondition: >spawned_process and containerand proc.name in (shell_binaries)and not container.image.repository in (allowed_shell_images)output: >Shell in container (user=%user.name pod=%k8s.pod.namens=%k8s.ns.name image=%container.image.repositoryproc=%proc.name parent=%proc.pname cmd=%proc.cmdline)priority: WARNINGtags: [container, shell, mitre_execution, T1059]
Load that rule and the instant a shell starts inside any container, Falco writes a line. No API call needed, because it isn't watching the API. It's watching the kernel. Here's what it caught at eleven minutes past three that morning, inside a payments pod.
03:11:41.882 Warning Shell in container (user=root pod=payments-api-5f7c9ns=payments image=registry.internal/payments-api proc=shparent=payments-server cmd=sh -i)
Look at the parent field: payments-server, which is the application itself. A normal shell in a container has a parent like the container runtime, the plumbing that runs when someone types kubectl exec. This shell's parent is the app process, which means the running program spawned its own shell and no human asked it to. That's the shape of a reverse shell: a compromised program opens an outbound connection back to the attacker and hands them a command line inside your container, like a burglar who props a back window open and phones a friend to climb through. The audit log shows nothing for this, because the app never made an API request. Only the camera in the room saw it. One more trap earns its keep here. A honeytoken is a decoy you would never touch on purpose, like a marked twenty left on the dresser to find out if the cleaner takes it. You create a Secret that nothing in the cluster ever reads, give it a name too tempting to skip, and raise an alarm on any read at all. Real workloads never look at it, so a single get is about as close to proof as detection gets: someone is going through your drawers for credentials. Wire it to the audit log and you also learn which identity and which address reached for the bait.
kubectl -n payments create secret generic aws-prod-creds \--from-literal=access_key_id=AKIA0000EXAMPLE \--from-literal=secret_access_key=wJalrEXAMPLEKEYjq -c 'select(.objectRef.resource=="secrets"and .objectRef.name=="aws-prod-creds" and .verb=="get")| {t:.requestReceivedTimestamp, who:.user.username, ip:.sourceIPs[0]}' \/var/log/kubernetes/audit/audit.log
secret/aws-prod-creds created{"t":"2026-07-16T03:12:58Z","who":"system:serviceaccount:kube-system:coredns","ip":"10.8.1.44"}
Now stand back and line the timestamps up. At 3:11:41 the payments-api app spawned a shell on its own, which Falco caught and the audit log could not. Cross-check the audit log for a matching pods/exec on that pod and you find nothing, which is the point: nobody opened that shell through the front desk. Seconds later the same pod's traffic, from 10.8.1.44, starts acting as the coredns service account. At 3:12:07 that identity execs into the database pod. At 3:12:58 it reads a canary Secret it has no reason to know exists. A coredns token showing up from a payments pod address is a token that has been stolen and replayed, because coredns doesn't run there and never will. Any one of these alerts on its own is a maybe. Stacked together across two sensors, they tell one story: a foothold in payments-api, a stolen token, and a hunt for cloud keys, all inside about a minute. That stacking is the entire reason you run both layers instead of picking one.
When one of these fires for real, the time for tuning rules is over and a different clock starts. You have to freeze the pod without tipping off whoever's inside it, grab the token they used, and map everything that token can still reach before they get there first. That's a different job from watching the feed, and it's exactly where the next lesson picks up.
Try this
Work through “When the audit log goes blind” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: logging Secrets at full detail turns your audit log into a Secret store. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.