8 min read

Abusing eBPF, Part 1: What even is eBPF?

Abusing eBPF, Part 1: What even is eBPF?
rust && ebpf == linux fun

Welcome to a new series. The last one was about learning DNS while abusing its functionality to do unintuitive things. This one is about doing fun things to the Linux kernel, with the kernel's full cooperation.

We are going to abuse eBPF.

A quick note before we get going. Everything in this series is built and tested on lab boxes I own. Use this stuff on systems you have permission to use it on. That's the only warning you're getting, scroll on.

What is eBPF, actually

Well that is an interesting question. The BPF part was originally Berkley Packet Filter which got extended to eBFP (guess what the e stood for). Now eBPF does much much more than simple packet filtering so they've decided eBFP doesn't stand for anything.

Okay so what is it? eBPF continually evolves for our purposes it is a way to write code that runs sandboxed in the kernel.

The idea is straight forward. You write a tiny program. The kernel runs it through a verifier that proves your program will halt, won't read uninitialized memory, won't dereference arbitrary pointers, and won't loop forever. Once the verifier is happy, the kernel JITs your program to native instructions and attaches it to the events you request. Almost any event, a syscall, an incoming packet, a function entry, a kernel tracepoint. When that event fires, your code runs. In kernel context. Just where we want to be.

The eBPF site has nice diagram of "observability" and "networking" and "security.

What it leaves out, and what we are here to talk about, is that eBPF programs:

  • See every syscall the box makes.
  • Can rewrite userspace memory on the way out of a syscall (bpf_probe_write_user).
  • Can decide what happens to a packet before the network stack has touched it (XDP).
  • Can redirect a connection to a socket the attacker has stashed (sk_lookup).
  • Persist after the program that loaded them exits (pinning to bpffs).
  • Are basically invisible to people who only know about lsmod, kernel modules, and /proc/modules.

Astute readers like yourself may notice that this is a really good list if you happen to be building a rootkit.

Why a red teamer cares

A kernel module would do most of this too. Kernel modules have problems though. Distro kernels increasingly require signed modules. Lockdown LSM blocks unsigned loads. lsmod will rat you out. The build dance is a pain in the ass and version-specific. You also need a compiler toolchain on the box, or you cross-compile and hope the kernel headers match.

eBPF sidesteps almost all of it. The toolchain ships with the kernel. Programs are portable across kernel versions (kind of, we'll get there). You don't need CONFIG_MODULES. Lockdown affects some helpers but not the program loading itself. And lsmod doesn't list a single thing.

You do need CAP_SYS_ADMIN (or CAP_BPF on newer kernels) to load programs.

Why Rust, why aya

There are two ways to write eBPF in 2026. Either you use libbpf with C, or you use aya with Rust.

I picked aya, for a few reasons that matter for offensive work:

  • Single binary. aya compiles the eBPF program to an ELF object at build time and embeds it in the userspace binary with include_bytes_aligned!. Eventually one static musl-linked binary, no bpftool on the target, no .o files lying around.
  • Type sharing. The kernel-side program and the userspace loader are both Rust crates in the same workspace. The struct your eBPF code writes into a map is literally the same struct the userspace reads back. No header juggling.
  • It's Rust. Typical stuff about rust, ecosystem, borrow checker, type checking yada yada. I also don't want to deal with toolchain headaches and setting all of that up.

It's fine if you've never written eBPF or Rust before. We'll move slow and I'll show what does what.

Hello, kernel

Let's write a program. The goal: count every execve on the box and print the count once a second. It's the eBPF equivalent of "hello world," and it actually does something — execve is what fires when anything launches a process, so we get to watch the box's process churn in real time.

The repo lives at code/abusing-ebpf-part-1/ in the blog source. Clone it if you want to follow along.

The workspace

We need two crates: one that compiles to a BPF ELF and runs in the kernel, and one that compiles to a regular x86 binary and runs in userspace.

abusing-ebpf-part-1/
├── Cargo.toml                       # workspace
├── rust-toolchain.toml              # nightly
├── execve-counter-ebpf/             # kernel side
│   ├── Cargo.toml
│   └── src/main.rs
└── execve-counter/                  # userspace side
    ├── Cargo.toml
    ├── build.rs                     # compiles the ebpf crate
    └── src/main.rs

The workspace Cargo.toml:

[workspace]
resolver = "2"
members = ["execve-counter", "execve-counter-ebpf"]
default-members = ["execve-counter"]

[workspace.dependencies]
aya = { version = "0.13", default-features = false }
aya-build = { version = "0.1", default-features = false }
aya-ebpf = { version = "0.1", default-features = false }
anyhow = "1"
cargo_metadata = "0.23"
env_logger = "0.11"
libc = "0.2"
log = "0.4"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] }

[profile.release]
lto = true
strip = true
codegen-units = 1
panic = "abort"

default-members = ["execve-counter"] matters. The eBPF crate doesn't build for your host target — it builds for bpfel-unknown-none. If you cargo build from the workspace root without setting a default, cargo gets confused. We let build.rs in the userspace crate drive the eBPF build.

You also need nightly Rust and bpf-linker:

rustup install nightly
cargo install bpf-linker

The rust-toolchain.toml in the repo pins all that, so once you cd in, you're set.

The eBPF side

Here the eBPF crate's Cargo.toml names the binary counter-bpf. This fixes a naming collision, if we name the bin the same thing as the package, aya-build ends up trying to copy the compiled BPF object on top of its own intermediate target directory and you get a confusing "Is a directory" error.

execve-counter-ebpf/Cargo.toml

[package]
name = "execve-counter-ebpf"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
aya-ebpf = { workspace = true }

[[bin]]
name = "counter-bpf"
path = "src/main.rs"

execve-counter-ebpf/src/main.rs:

#![no_std]
#![no_main]

use aya_ebpf::{
    macros::{map, tracepoint},
    maps::Array,
    programs::TracePointContext,
};

#[map]
static COUNT: Array<u64> = Array::with_max_entries(1, 0);

#[tracepoint(category = "syscalls", name = "sys_enter_execve")]
pub fn on_execve(_ctx: TracePointContext) -> u32 {
    if let Some(ptr) = COUNT.get_ptr_mut(0) {
        unsafe { *ptr += 1 };
    }
    0
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}

That's the whole thing. Let's break it down.

#![no_std] and #![no_main] — there's no standard library in the eBPF runtime. No std::println!, no allocator, no threads. Anything you want has to come from aya_ebpf or core.

The #[map] attribute declares an eBPF map. Maps are the universal communication primitive the kernel side reads and writes them, the userspace side reads and writes them, and the kernel makes sure neither side blows up the other. Our map is a single 64-bit counter Array<u64>

The #[tracepoint(category = "syscalls", name = "sys_enter_execve")] attribute is the interesting bit. It tells aya "this function should run every time the sys_enter_execve tracepoint fires." Tracepoints are stable hook points in the kernel, they don't move between kernel versions the way function symbols do, which makes them ideal for portability.

The function body does the simplest possible thing: grab a pointer to slot 0 of the map, add one. The unsafe block is required because *ptr += 1 is not atomic — two CPUs incrementing at once will lose updates. For a real counter you'd use a PerCpuArray and sum at read time, or use atomic helpers. We are deliberately keeping it minimal. If your dev box does 200 execs/second and we lose three, this post will still work.

The #[panic_handler] is required boilerplate. eBPF programs can't actually panic, the verifier won't let them, but Rust insists you provide a handler. We give it unreachable_unchecked(), which compiles to nothing.

The userspace side

The userspace side has a few jobs. Build the eBPF crate at compile time and embed the result. At runtime, load that bytecode into the kernel, attach the program, and poll the map.

execve-counter/build.rs handles the build-time half:

use anyhow::{anyhow, Context as _};
use aya_build::{Package, Toolchain};

fn main() -> anyhow::Result<()> {
    let metadata = cargo_metadata::MetadataCommand::new()
        .no_deps()
        .exec()
        .context("cargo metadata")?;

    let ebpf = metadata
        .packages
        .into_iter()
        .find(|p| p.name.as_str() == "execve-counter-ebpf")
        .ok_or_else(|| anyhow!("execve-counter-ebpf package not found"))?;

    let root_dir = ebpf
        .manifest_path
        .parent()
        .ok_or_else(|| anyhow!("no parent for {}", ebpf.manifest_path))?;

    let package = Package {
        name: ebpf.name.as_str(),
        root_dir: root_dir.as_str(),
        ..Default::default()
    };

    aya_build::build_ebpf([package], Toolchain::default())?;
    Ok(())
}

cargo_metadata runs cargo metadata for the workspace and gives us back the packages. We grab the one we care about, build an
aya_build::Package pointing at its directory, and hand it to aya_build::build_ebpf along with a default toolchain. aya-buil
d shells out to nightly cargo with the BPF target and linker, produces an ELF, and drops it in OUT_DIR. We pick it up in main .rs with include_bytes_aligned!.

You'll need build-dependencies for this:

[build-dependencies]
anyhow = { workspace = true }
aya-build = { workspace = true }
cargo_metadata = { workspace = true }

execve-counter/src/main.rs:

use std::time::Duration;

use aya::{maps::Array, programs::TracePoint, Ebpf};
use log::{info, warn};

const EBPF_OBJ: &[u8] =
    aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/counter-ebpf"));

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    env_logger::init();
    bump_memlock_rlimit();

    let mut ebpf = Ebpf::load(EBPF_OBJ)?;

    let program: &mut TracePoint = ebpf
        .program_mut("on_execve")
        .ok_or_else(|| anyhow::anyhow!("program not found"))?
        .try_into()?;
    program.load()?;
    program.attach("syscalls", "sys_enter_execve")?;
    info!("attached. counting execve calls — Ctrl+C to stop.");

    let map = Array::<_, u64>::try_from(
        ebpf.map("COUNT").ok_or_else(|| anyhow::anyhow!("map missing"))?,
    )?;

    let mut ticker = tokio::time::interval(Duration::from_secs(1));
    loop {
        tokio::select! {
            _ = ticker.tick() => {
                let n = map.get(&0, 0).unwrap_or(0);
                info!("execve count = {n}");
            }
            _ = tokio::signal::ctrl_c() => {
                info!("shutting down.");
                break;
            }
        }
    }
    Ok(())
}

The shape: Ebpf::load parses the ELF, finds the programs and maps. program_mut("on_execve") grabs our tracepoint by name. load() runs the verifier (this is the moment of truth on real programs — your program either passes or you have a bad day). attach("syscalls", "sys_enter_execve") hooks it up to the tracepoint.

After that, we wrap the COUNT map as an Array<_, u64> and read slot 0 once a second. The actual *ptr += 1 is happening in the kernel on every exec; we're just reading the result.

bump_memlock_rlimit is one of those rite-of-passage details. eBPF maps used to count against the calling process's RLIMIT_MEMLOCK, and the default rlimit is usually 64KB which is nothing. Newer kernels use a different accounting model and this is no longer strictly required, but it's still polite. The function in the repo just calls setrlimit to infinity.

Run it

cargo build --release
sudo RUST_LOG=info ./target/release/execve-counter

You should see:

[INFO  execve_counter] attached. counting execve calls — Ctrl+C to stop.
[INFO  execve_counter] execve count = 0
[INFO  execve_counter] execve count = 0

Now open another terminal and run something. Anything.

ls
cat /etc/hostname
bash -c 'for i in $(seq 1 100); do true; done'

Watch the first terminal:

[INFO  execve_counter] execve count = 4
[INFO  execve_counter] execve count = 5
[INFO  execve_counter] execve count = 107

Hello, kernel.

If you get a "permission denied" or a verifier error on load, you probably aren't root. eBPF needs CAP_BPF (newer kernels) or CAP_SYS_ADMIN. sudo is the easy path.

What we just did

That tiny program is doing something genuinely surprising if you stop and look at it. We compiled a Rust function to a custom bytecode, the kernel verified it terminates and is memory-safe, JITed it to native, and started running it inside the kernel on every execve syscall on the box. We did not load a module. We did not patch a kernel function. We did not even need root in a really privileged sense — CAP_BPF alone gets you here.

Now imagine that instead of incrementing a counter, your tracepoint handler walked the syscall's arguments, decided that this execve was running ps, and rewrote the buffer it would return so that certain PIDs vanished.

That's where we're going. Next post: we hide PIDs from ls /proc by hooking getdents64 and rewriting the directory entries. ps, top, and pgrep all lose their minds. It is very fun.

Until then, the code is here https://github.com/syndrowm/abusing-ebfp. Ping me on X or LinkedIn if I messed something up, and subscribe if you want Part 2 in your inbox when it drops.