Complete masterclass blueprint on Linux Kernel Security Modules (LSM) and eBPF syscall hooking. Learn step-0 kernel memory architecture, BPF CO-RE, verifier constraints, and production C/libb
Master real-time Linux threat detection by compiling SigmaHQ rules into AST decision trees evaluated against live eBPF kernel tracepoints (sys_enter_execve) in C and Go.
6 min read
A masterclass on engineering a production-grade headless Python/C mobile dynamic analysis harness for Android ART internals, JNI method resolution, Dobby-style inline ARM64 hooking in C, and
Author: Syed Zada Abrar
Published: September 5, 2026
Category: Reverse Engineering & Kernel Security
Canonical URL:https://andraxpentester.in/articles/linux-kernel-lsm-ebpf-syscall-hooking
BLUF: Traditional Linux security auditing tools (such as auditd, syslog, or basic Kprobe-based EDR agents) suffer from a fundamental architectural flaw: Time-of-Check to Time-of-Use (TOCTOU) race conditions. Because Kprobes and Tracepoints execute asynchronously or inspect process state after a system call has already been dispatched to the virtual filesystem (VFS), an attacker can swap file descriptors, modify memory pages via process_vm_writev, or unshare namespaces before an alert is raised.
Linux Security Modules (LSM) combined with eBPF (BPF_PROG_TYPE_LSM, introduced in Linux Kernel 5.7) solve this vulnerability by offering synchronous, inline access control. BPF LSM hooks directly into the Linux kernel's Mandatory Access Control (MAC) decision points (security_file_open, security_bprm_check, security_socket_connect). When a BPF LSM program returns a negative error code (such as -EPERM or -EACCES), the kernel halts the syscall immediately, unwinds stack frames, and returns "Permission denied" to the caller without ever touching the target resource.
This masterclass blueprint provides a first-principles breakdown of Linux kernel memory hooks, BPF CO-RE (Compile Once – Run Everywhere) struct resolution, eBPF verifier constraints, and a complete, production-ready C/libbpf control plane harness for enforcing kernel security policies in 2026.
To understand why BPF LSM is revolutionary, we must first analyze how the Linux kernel processes system calls and why legacy hooking mechanisms consistently fall short in production security enforcement.
+-----------------------------------------------------------------------------------+
| USER SPACE: Process invokes execve("/bin/malware", argv, envp) |
+-----------------------------------------------------------------------------------+
|
v [Syscall Entry via INT 0x80 / SYSCALL]
+-----------------------------------------------------------------------------------+
| KERNEL SPACE: sys_execve() -> do_execveat_common() |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| LSM Security Check: security_bprm_check(bprm) |
| |
| +-----------------------------------------------------------------------------+ |
| | eBPF LSM Probe (BPF_PROG_TYPE_LSM) Attached via CO-RE | |
| | - Inspects bprm->filename, bprm->cred, process cgroup | |
| | - Evaluates security policy in kernel JIT memory space | |
| | | |
| | DECISION: | |
| | +--> Allow (Return 0) --> Continue binary loading to VFS | |
| | +--> Deny (Return -EPERM) --> Abort syscall, return EPERM to User Space | |
| +-----------------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------------+
Syscall Table Hooking (sys_call_table Overwriting):
In early Linux rootkit and security agent design, developers modified the memory address stored in the kernel's sys_call_table. Modern kernels neutralized this by marking kernel code pages read-only (CR0.WP bit), introducing Kernel Page Table Isolation (KPTI), and enforcing strict kernel module signing (CONFIG_MODULE_SIG). Overwriting syscall pointers causes immediate Kernel Oops or Panic.
Kprobes and Kretprobes:
Kprobes dynamically insert a breakpoint instruction (e.g., INT3 on x86_64) at any arbitrary kernel instruction address. While invaluable for observability, Kprobes run after critical security parameters have been parsed or before internal locks are established. Furthermore, Kprobes cannot safely reject system calls or mutate return values in standard kernels without triggering kernel instability.
Static LSM Modules (SELinux, AppArmor, Smack): Classic Linux Security Modules are compiled into the kernel binary or loaded early at boot. They rely on rigid security policy files compiled into binary formats. Updating an AppArmor profile or SELinux policy requires complex userspace tools and lacks the programmatic flexibility of writing arbitrary C logic to inspect dynamic kernel data structures.
eBPF LSM (BPF_PROG_TYPE_LSM):
Introduced in Linux 5.7 and backported to modern enterprise distributions (RHEL 9, Ubuntu 22.04/24.04 LTS, Arch Linux), eBPF LSM combines the safety and performance of the eBPF JIT compiler with the synchronous blocking authority of LSM hooks.
Inside the kernel, LSM hooks are maintained as linked lists of function pointers registered within the global security_hook_heads structure.
In kernel headers (include/linux/lsm_hooks.h), LSM hooks are defined using the LSM_HOOK macro within a union:
union security_list_options {
#define LSM_HOOK(RET, DEFAULT, NAME, ...) RET (*NAME)(__VA_ARGS__);
#include <linux/lsm_hook_defs.h>
#undef LSM_HOOK
void *lsm_func_addr;
};
When an application attempts a security-sensitive action (such as opening a file via vfs_open), the virtual filesystem executes security_file_open(file):
int security_file_open(struct file *file)
{
struct security_hook_list *hp;
hlist_for_each_entry(hp, &security_hook_heads.file_open, list) {
int rc = hp->hook.file_open(file);
if (rc)
return rc; // Synchronous abort if any hook returns non-zero!
}
return 0;
}
Because BPF LSM attaches directly to these hook lists, when security_file_open iterates over security_hook_heads.file_open, your eBPF JIT-compiled function is executed inline. Returning -EPERM immediately breaks the loop, preventing the kernel from opening the underlying inode.
Before compiling eBPF LSM programs, verify that your running Linux kernel is configured with BPF LSM support enabled.
Run the following commands in your terminal:
# Check if BPF LSM is active in the security module list
cat /sys/kernel/security/lsm
# Expected Output: lockdown,capability,landlock,yama,bpf
# Verify kernel compiled with CONFIG_BPF_LSM=y
grep CONFIG_BPF_LSM /boot/config-$(uname -r)
# Expected Output: CONFIG_BPF_LSM=y
# Verify Kernel BTF (BPF Type Format) is mounted
ls -l /sys/kernel/btf/vmlinux
# Expected Output: -r--r--r-- 1 root root ... /sys/kernel/btf/vmlinux
If bpf is not listed in /sys/kernel/security/lsm, append lsm=... to your GRUB boot options:
# Edit /etc/default/grub and update GRUB_CMDLINE_LINUX:
GRUB_CMDLINE_LINUX="lsm=landlock,lockdown,capability,yama,apparmor,bpf"
# Update GRUB and reboot:
sudo update-grub # On Ubuntu/Debian
# OR
sudo grub2-mkconfig -o /boot/grub2/grub.cfg # On RHEL/Fedora
sudo reboot
sudo apt-get update && sudo apt-get install -y \
clang \
llvm \
libbpf-dev \
bpftool \
linux-headers-$(uname -r) \
build-essential
lsm_monitor.bpf.c)Below is the production eBPF program written in C. It hooks two vital kernel LSM entry points:
SEC("lsm/bprm_check_security"): Intercepts process execution attempts and blocks unauthorized binaries (e.g., matching restricted path patterns or execution from /tmp).SEC("lsm/file_open"): Intercepts file open operations and blocks unauthorized reads against /etc/shadow.// lsm_monitor.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
char _license[] SEC("license") = "GPL";
#define EPERM 1
#define MAX_PATH_LEN 256
// lsm_monitor.h - Shared telemetry header between BPF kernel and User Space
#ifndef __LSM_MONITOR_H
#define __LSM_MONITOR_H
struct event_t {
unsigned int pid;
unsigned int uid;
char comm[16];
char filename[MAX_PATH_LEN];
unsigned int action; // 0 = Allowed, 1 = Blocked
};
#endif
// Ringbuffer map for event emission
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024); // 256 KB ringbuffer
} events SEC(".maps");
// eBPF LSM Hook: Intercept Process Execution
SEC("lsm/bprm_check_security")
int BPF_PROG(restrict_exec, struct linux_binprm *bprm)
{
u64 id = bpf_get_current_pid_tgid();
u32 pid = id >> 32;
u32 uid = bpf_get_current_uid_gid();
char filename[MAX_PATH_LEN] = {0};
// Read the binary path safely using BPF CO-RE helper
int len = bpf_d_path(&bprm->file->f_path, filename, sizeof(filename));
if (len < 0) {
return 0; // Allow if path resolution fails to avoid kernel panic
}
// Check if process execution is originating from restricted directory /tmp/malware
char target_prefix[] = "/tmp/malware";
bool is_blocked = true;
#pragma unroll
for (int i = 0; i < 12; i++) {
if (filename[i] != target_prefix[i]) {
is_blocked = false;
break;
}
}
if (is_blocked) {
// Reserve space in ringbuffer for alert
struct event_t *event = bpf_ringbuf_reserve(&events, sizeof(struct event_t), 0);
if (event) {
event->pid = pid;
event->uid = uid;
bpf_get_current_comm(&event->comm, sizeof(event->comm));
bpf_probe_read_kernel_str(event->filename, sizeof(event->filename), filename);
event->action = 1; // Blocked
bpf_ringbuf_submit(event, 0);
}
// Return negative error code -EPERM to synchronously abort execution!
return -EPERM;
}
return 0; // Allow execution
}
// eBPF LSM Hook: Intercept Sensitive File Access
SEC("lsm/file_open")
int BPF_PROG(protect_sensitive_files, struct file *file)
{
u64 id = bpf_get_current_pid_tgid();
u32 pid = id >> 32;
u32 uid = bpf_get_current_uid_gid();
// Allow root (UID 0) access
if (uid == 0) {
return 0;
}
char filename[MAX_PATH_LEN] = {0};
int len = bpf_d_path(&file->f_path, filename, sizeof(filename));
if (len < 0) {
return 0;
}
// Check for sensitive file path target "/etc/shadow"
char shadow_path[] = "/etc/shadow";
bool matches_shadow = true;
#pragma unroll
for (int i = 0; i < 11; i++) {
if (filename[i] != shadow_path[i]) {
matches_shadow = false;
break;
}
}
if (matches_shadow) {
struct event_t *event = bpf_ringbuf_reserve(&events, sizeof(struct event_t), 0);
if (event) {
event->pid = pid;
event->uid = uid;
bpf_get_current_comm(&event->comm, sizeof(event->comm));
bpf_probe_read_kernel_str(event->filename, sizeof(event->filename), filename);
event->action = 1; // Blocked
bpf_ringbuf_submit(event, 0);
}
// Abort open() syscall for /etc/shadow for non-root users
return -EPERM;
}
return 0;
}
lsm_monitor.c)To load and attach our eBPF LSM program into the kernel, we construct a C control plane application using libbpf.
// lsm_monitor.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include "lsm_monitor.skel.h"
static volatile bool exiting = false;
static void sig_handler(int sig)
{
exiting = true;
}
// Ringbuffer callback handler for real-time security events
static int handle_event(void *ctx, void *data, size_t data_sz)
{
const struct event_t *e = data;
printf("[SECURITY BLOCK] PID: %d | UID: %d | COMM: %s | FILE: %s | ACTION: %s\n",
e->pid, e->uid, e->comm, e->filename,
e->action == 1 ? "BLOCKED (-EPERM)" : "ALLOWED");
return 0;
}
int main(int argc, char **argv)
{
struct lsm_monitor_bpf *skel;
struct ring_buffer *rb = NULL;
int err;
// Set signal handlers for graceful shutdown
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
// Bump RLIMIT_MEMLOCK to allow BPF map allocation
struct rlimit rlim_new = {
.rlim_cur = RLIM_INFINITY,
.rlim_max = RLIM_INFINITY,
};
setrlimit(RLIMIT_MEMLOCK, &rlim_new);
// Open and load BPF skeleton
skel = lsm_monitor_bpf__open();
if (!skel) {
fprintf(stderr, "[-] Failed to open BPF skeleton\n");
return 1;
}
// Load BPF object into kernel (triggers Verifier checks)
err = lsm_monitor_bpf__load(skel);
if (err) {
fprintf(stderr, "[-] Failed to load and verify BPF skeleton: %d\n", err);
goto cleanup;
}
// Attach LSM probes to kernel security hook lists
err = lsm_monitor_bpf__attach(skel);
if (err) {
fprintf(stderr, "[-] Failed to attach LSM probes: %d\n", err);
goto cleanup;
}
// Set up ringbuffer consumer
rb = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, NULL, NULL);
if (!rb) {
fprintf(stderr, "[-] Failed to create ringbuffer consumer\n");
goto cleanup;
}
printf("[+] BPF LSM Security Daemon successfully running! Press Ctrl+C to exit.\n");
printf("[+] Monitoring process executions and /etc/shadow access...\n\n");
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* ms */);
if (err < 0 && err != -EINTR) {
fprintf(stderr, "[-] Error polling ringbuffer: %d\n", err);
break;
}
}
cleanup:
ring_buffer__free(rb);
lsm_monitor_bpf__destroy(skel);
printf("[+] BPF LSM Daemon detached and cleaned up successfully.\n");
return 0;
}
Execute the following Makefile steps:
# Step 1: Generate vmlinux.h from running kernel BTF
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
# Step 2: Compile eBPF C program to BPF bytecode
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I. -c lsm_monitor.bpf.c -o lsm_monitor.bpf.o
# Step 3: Generate C skeleton header file using bpftool
bpftool gen skeleton lsm_monitor.bpf.o > lsm_monitor.skel.h
# Step 4: Compile user-space loader binary linked against libbpf
gcc -g -O2 -Wall lsm_monitor.c -lelf -lbpf -o lsm_monitor
In Terminal 1 (Root Daemon):
sudo ./lsm_monitor
Output:
[+] BPF LSM Security Daemon successfully running! Press Ctrl+C to exit.
[+] Monitoring process executions and /etc/shadow access...
In Terminal 2 (Attacker Simulation):
# Test Case 1: Execute malicious payload in /tmp
cp /bin/ls /tmp/malware
/tmp/malware
# Shell Output:
# bash: /tmp/malware: Operation not permitted
# Test Case 2: Attempt non-root read of /etc/shadow
cat /etc/shadow
# Shell Output:
# cat: /etc/shadow: Operation not permitted
Daemon Telemetry Log Output (Terminal 1):
[SECURITY BLOCK] PID: 48219 | UID: 1000 | COMM: bash | FILE: /tmp/malware | ACTION: BLOCKED (-EPERM)
[SECURITY BLOCK] PID: 48255 | UID: 1000 | COMM: cat | FILE: /etc/shadow | ACTION: BLOCKED (-EPERM)
When building BPF LSM programs, the eBPF verifier enforces rigid static analysis constraints. Here are the top 3 verifier traps and their resolution:
bpf_d_path Helper Restrictionsbpf_d_path() on arbitrary pointers triggers helper call is not allowed in probe type.bpf_d_path is restricted exclusively to trusted kernel data pointers passed directly as arguments to LSM hooks or specific tracepoints. You cannot call bpf_d_path inside general Kprobes or XDP programs.struct file *file or struct path *path directly from the LSM hook definition in vmlinux.h.invalid loop instruction or stack depth 512 exceeded.char path[1024]) directly on the stack causes instant verifier rejection.#pragma unroll for string comparisons and store temporary large string buffers in BPF_MAP_TYPE_PERCPU_ARRAY scratch maps instead of stack memory variables.| Metric / Dimension | Kprobes (kprobe/kretprobe) | Tracepoints (tracepoint/) | XDP (BPF_PROG_TYPE_XDP) | eBPF LSM (BPF_PROG_TYPE_LSM) |
|---|---|---|---|---|
| Execution Domain | Dynamic instruction address | Static kernel probe points | Network driver / NIC ingress | Kernel Mandatory Access Control (MAC) |
| Hook Timing | Pre/Post arbitrary function | Pre/Post static kernel trace | Raw packet arrival | Inline before resource access |
| Synchronous Blocking? | ❌ No (Observability only) | ❌ No (Observability only) | ✅ Yes (Drop/Pass/Tx) | ✅ Yes (Returns -EPERM/-EACCES) |
| TOCTOU Race Resistance | ❌ Vulnerable | ❌ Vulnerable | N/A (Packets) | ✅ Immune (Inline state lock) |
| Kernel Overhead | Medium (Instruction patch) | Extremely low | Ultra-low (Zero copy) | Extremely low (Inline function pointer) |
| CO-RE / BTF Support | Partial (Requires offset lookup) | ✅ Full | ✅ Full | ✅ Full (vmlinux.h struct mapping) |
security_file_open, security_bprm_check) over Kprobes when developing security controls that must actively block unauthorized activity.-target bpf and include vmlinux.h generated via bpftool btf dump. This guarantees that your eBPF binary runs seamlessly across different kernel versions without recompilation.bpf_d_path returns an error code, return 0 (allow) in your BPF logic to prevent inadvertent denial of service or kernel deadlocks during path resolution failures.BPF_MAP_TYPE_RINGBUF provides multi-core memory submission with lower overhead and strict ordering compared to legacy perf ringbuffers.Share this article
17 min read
Deep technical masterclass on eBPF security engineering: building real-time kernel execution monitoring in C & Go with CO-RE, analyzing offensive rootkits, and hardening Linux systems.
15 min read
Sign in to leave a comment.