Runtime detection and observability
Tetragon: kernel module and BPF loads, the secure way
The most dangerous code on a node is code that becomes part of the kernel, because after that the kernel stops telling you the truth. A kernel module or a BPF program loaded at 3 a.m. deserves an alert at 3:00:01.
The short answer
Apply Tetragon TracingPolicies that hook do_init_module and security_kernel_read_file for explicit module loads, security_kernel_module_request for automatic ones, free_module for unloads, and bpf_check plus the BPF map hooks for BPF programs. Alert on any load that is not from your known agents, and ship events off the node immediately.
On this page
What goes wrong
Kernel modules and BPF programs run inside the kernel. A malicious module can hide files, processes and network connections from every tool on the host. Malicious BPF programs can read and change data flowing through the kernel. Both are standard parts of modern rootkits.
On a Kubernetes node, loading either needs high privilege: a privileged
container, CAP_SYS_MODULE, or CAP_BPF/CAP_SYS_ADMIN. That is exactly
what an attacker has after a container escape.
The load itself is a short, rare event. On a stable node it almost never happens after boot, except from agents you installed (Cilium, Tetragon, monitoring). That makes it a very clean signal, if you record it.
What the docs say
Monitoring kernel modules helps to identify processes that load kernel modules to add features, to the operating system, to alter host system functionality or even hide their behaviour.
Source: Tetragon docs, Monitor Linux Kernel Modules
Some exploits or rootkits may hide modules by unlinking them from the kernel module state without unloading it.
Source: Tetragon examples, monitor-kernel-modules.yaml
The second quote explains why the load hooks matter more than the unload
hook: a rootkit can vanish from lsmod without ever unloading, but it cannot
get in without passing through do_init_module.
The secure configuration
Kernel modules:
# Kernel module loads and unloads (from the Tetragon examples, trimmed).
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: monitor-kernel-modules
spec:
kprobes:
# Automatic loading because a feature was requested (e.g. a socket family).
- call: "security_kernel_module_request"
syscall: false
return: true
args:
- index: 0
type: "string"
returnArg:
index: 0
type: "int"
# finit_module(): gives the full path of the module file.
- call: "security_kernel_read_file"
syscall: false
return: true
args:
- index: 0
type: "file"
- index: 1
type: "int"
returnArg:
index: 0
type: "int"
selectors:
- matchArgs:
- index: 1
operator: "Equal"
values: ["2"] # READING_MODULE
# Both init_module() and finit_module() end here.
- call: "do_init_module"
syscall: false
args:
- index: 0
type: "module"
- call: "free_module"
syscall: false
args:
- index: 0
type: "module"BPF programs and maps:
# BPF program loads, map creation and perf events (from the Tetragon examples).
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: bpf-loads
spec:
kprobes:
# Every BPF program passes the verifier.
- call: "bpf_check"
syscall: false
args:
- index: 1
type: "bpf_attr"
# First step of attaching a kprobe program.
- call: "security_perf_event_alloc"
syscall: false
args:
- index: 0
type: "perf_event"
# Map creation: the function name changed in Linux 6.9.
- call: "security_bpf_map_alloc"
syscall: false
args:
- index: 0
type: "bpf_map"
ignore:
callNotFound: true
- call: "security_bpf_map_create"
syscall: false
args:
- index: 0
type: "bpf_map"
ignore:
callNotFound: truekubectl apply -f kernel-modules.yaml -f bpf-loads.yamlThen make sure the events reach your log pipeline. Tetragon's Helm chart
ships an export deny list that drops every event from the host and from
kube-system, which is where a module loaded over a node shell, or a rogue
BPF loader in a system pod, would come from. The filter applies to the JSON
file only; tetra getevents still shows everything, which hides the gap
while you test:
# tetragon-values.yaml
tetragon:
exportDenyList: |-
{"health_check":true}Expect more volume from the host and system pods, and filter in the pipeline instead.
Then reduce what is allowed in the first place:
- On nodes, set
kernel.modules_disabled = 1after boot if the node never needs new modules. It is one-way until the next reboot. - Keep
kernel.unprivileged_bpf_disabledat1or2. - Do not give workloads
CAP_SYS_MODULE,CAP_BPForprivileged: true.
Alert on every module event and every bpf_check event whose process is not
on an allow list. The allow list is longer than your agents: the container
runtime and systemd load small BPF programs too (Prove it, step 3).
Prove it
Run on a lab cluster (kind nodes share one Ubuntu 6.8 kernel, so the modules are real). Both policies loaded; on 6.8, Tetragon skipped the newer map hook as intended:
$ tetra tracingpolicy list
ID NAME STATE FILTERID NAMESPACE SENSORS KERNELMEMORY MODE NPOST NENFORCE NMONITOR
7 monitor-kernel-modules enabled 0 (global) generic_kprobe 702.63 kB monitor_only 0 0 0
8 bpf-loads enabled 0 (global) generic_kprobe 526.02 kB monitor_only 1 0 0
$ kubectl -n kube-system logs ds/tetragon -c tetragon | grep bpf_map
level=info msg="kprobe call ignored because it was not found" policy=bpf-loads sensor=generic_kprobe idx=0 call=security_bpf_map_create
level=info msg="Added kprobe" return=false function=security_bpf_map_alloc override=false enforcer=false1. A module load and unload, from a node debug pod:
$ kubectl debug node/lab-worker2 -i --image=busybox:1.37 --profile=sysadmin -- chroot /host modprobe dummy
$ kubectl debug node/lab-worker2 -i --image=busybox:1.37 --profile=sysadmin -- chroot /host rmmod dummy
$ tetra getevents -o compact
syscall default/node-debugger-lab-worker2-nd6rm /usr/sbin/modprobe do_init_module
syscall default/node-debugger-lab-worker2-kntpq /usr/sbin/rmmod free_moduleThe module name is in the JSON event ("module_arg":{"name":"dummy",...}).
There was no security_kernel_read_file event with the file's path. The
module is compressed (dummy.ko.zst) and the node has kmod 30, which
decompresses in user space and calls init_module() with a memory buffer, so
the kernel never reads a file. From the kmod 31 release notes:
Previously kmod would fallback to the older init_module() when using compressed modules since there wasn't a way to instruct the kernel to uncompress it on load or check if the kernel supported it or not.
Source: kmod NEWS, kmod 31
With kmod 31 or later on a kernel 6.4 or later, finit_module() is used and
the path appears. Either way, do_init_module catches the load: build the
alert on it.
2. From the node itself, the event is live but not exported:
$ docker exec lab-worker2 modprobe dummy # a shell on the node, no pod
$ tetra getevents -o compact
syscall lab-worker2 /usr/sbin/modprobe do_init_module
syscall lab-worker2 /usr/sbin/rmmod free_moduleWith the chart's default exportDenyList ({"namespace":["", "cilium", "kube-system"]}), only the debug-pod events reached the file. After
setting it to {"health_check":true}:
{"time":"2026-09-25T01:55:23.067782356Z","fn":"do_init_module","bin":"/usr/sbin/modprobe","pod":"node-debugger-lab-worker2-nd6rm","node":"lab-worker2","module":"dummy"}
{"time":"2026-09-25T01:55:24.414776068Z","fn":"free_module","bin":"/usr/sbin/rmmod","pod":"node-debugger-lab-worker2-kntpq","node":"lab-worker2","module":"dummy"}
{"time":"2026-09-25T02:00:37.636299888Z","fn":"do_init_module","bin":"/usr/sbin/modprobe","pod":"(host)","node":"lab-worker2","module":"dummy"}
{"time":"2026-09-25T02:00:37.783727641Z","fn":"free_module","bin":"/usr/sbin/rmmod","pod":"(host)","node":"lab-worker2","module":"dummy"}3. The BPF baseline. 90 seconds of kprobe events on one node while its Cilium agent restarted, counted by function and binary:
595 bpf_check /usr/bin/cilium-agent kube-system
281 security_bpf_map_alloc /usr/bin/cilium-agent kube-system
25 bpf_check /usr/local/sbin/runc host
8 security_perf_event_alloc /usr/bin/cilium-agent kube-system
5 security_bpf_map_alloc /usr/local/sbin/runc host
5 bpf_check /usr/lib/systemd/systemd host{"fn":"bpf_check","bin":"/usr/lib/systemd/systemd","pod":null,"prog":{"ProgType":"BPF_PROG_TYPE_CGROUP_DEVICE","InsnCnt":55,"ProgName":"sd_devices"}}
{"fn":"bpf_check","bin":"/usr/local/sbin/runc","pod":null,"prog":{"ProgType":"BPF_PROG_TYPE_SOCKET_FILTER","InsnCnt":3}}runc loads programs for every container it starts, and systemd for cgroup
device control. An allow list with only the Cilium and Tetragon agents would
alert on every pod start. Match on binary and program type, and build the
list from a week of events on your own nodes.
4. The node settings:
$ sysctl kernel.unprivileged_bpf_disabled kernel.modules_disabled
kernel.unprivileged_bpf_disabled = 1
kernel.modules_disabled = 0Mistakes people make
Testing with tetra getevents and trusting the export
tetra getevents is not filtered. The JSON export is, and by default it drops
the host and kube-system. Test the file your SIEM reads, not the live
stream.
Relying on lsmod
A rootkit can hide from lsmod and /proc/modules. Record loads as they
happen, and ship the events off the node before the module can interfere.
Alerting on every BPF load
Cilium and Tetragon load many BPF programs on start. Without an allow list per binary, the alert fires on every rollout and gets muted.
Leaving automatic module loading unwatched
A request for an unusual socket family can make the kernel load a rarely
used, possibly vulnerable module. security_kernel_module_request shows
which process asked.
Detection only
If nodes never need new modules, disable loading after boot. Detection is for what you cannot prevent.
Checklist
- The kernel module policy (load, read, request, unload hooks) is applied.
- The BPF policy is applied.
- Alerts ignore only an explicit allow list of agent binaries.
- Events are shipped off the node as they happen.
- Workloads do not get
CAP_SYS_MODULE,CAP_BPForprivileged: true. - Module loading is disabled after boot where the node allows it.
- Unprivileged BPF is disabled.
Once malicious code is in the kernel, every other sensor can be lied to. The load is your one honest moment. Catch it.
H2-CTDE
Learn it on a live range
Kernel-level detection with eBPF, in Runtime Detection and Response: a real host in your browser, and every objective checked on the machine.
Start freeThe Secure Way
More on runtime detection and observability
Tetragon, alerting as code, multi-tenant logs and knowing when a sensor goes quiet.
All runtime detection and observability guides