Fentry / Fexit
Note
Full code for the example in this chapter is available on GitHub.
What are fentry and fexit programs?
Fentry and fexit programs attach to the entry and exit of a kernel function
through a BPF trampoline. Compared to kprobes, they have
lower overhead and, because they are BTF-aware, they let the verifier reason
about kernel types directly — so kernel pointers can be dereferenced without
calling bpf_probe_read_kernel. A fexit program also has access to the
function’s return value in the same context, without needing a separate
return-side program the way kprobes do with kretprobes.
Note
Fentry and fexit require a kernel built with BTF (
CONFIG_DEBUG_INFO_BTF=y) and a kernel version of at least 5.5.
Example project
To illustrate fentry and fexit with Aya, let’s write a program which attaches
to kernel_clone — the function the kernel uses to create new
processes and threads — and prints the PID of the caller on entry and the PID
of the newly created child on exit.
Design
For this demo program, we are going to rely on aya-log to print PIDs from the BPF program.
The kernel_clone function is also called for thread creation, so to keep the
output focused on process creation we filter out callers that pass the
CLONE_THREAD flag.
eBPF code
- From the
kernel_clonesignature, we see thatstruct kernel_clone_args *argsis the only function parameter. We access it from theFEntryContext/FExitContexthandles viactx.arg(0). - Because fentry/fexit programs run inside a BPF trampoline, the verifier
treats the argument pointer as a typed kernel pointer and we can dereference
it directly — no
bpf_probe_read_kernelcall is required. - In the fexit program, the return value of
kernel_clone(the child PID) is exposed as the slot after the original arguments, so we read it withctx.arg(1). - We check the
flagsfield forCLONE_THREAD, and for process-creating calls we print the parent PID (and, on exit, the child PID) using the aya-loginfo!macro.
Here’s how the eBPF code looks like:
#![no_std]
#![no_main]
#![allow(linker_messages)]
use aya_ebpf::{
helpers::bpf_get_current_pid_tgid,
macros::{fentry, fexit},
programs::{FEntryContext, FExitContext},
};
use aya_log_ebpf::info;
#[allow(
clippy::all,
dead_code,
improper_ctypes_definitions,
non_camel_case_types,
non_snake_case,
non_upper_case_globals,
unnecessary_transmutes,
unsafe_op_in_unsafe_fn,
)]
#[rustfmt::skip]
mod vmlinux;
use vmlinux::kernel_clone_args;
const CLONE_THREAD: u64 = 0x00010000;
#[fentry]
pub fn fentry_clone(ctx: FEntryContext) -> u32 {
let args_ptr: *const kernel_clone_args = ctx.arg(0);
let flags = unsafe { (*args_ptr).flags };
if flags & CLONE_THREAD == 0 {
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
info!(&ctx, "Process creation is started by: {}", pid);
}
0
}
#[fexit]
pub fn fexit_clone(ctx: FExitContext) -> u32 {
let args_ptr: *const kernel_clone_args = ctx.arg(0);
let flags = unsafe { (*args_ptr).flags };
let return_value: i32 = ctx.arg(1);
if flags & CLONE_THREAD == 0 {
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
if return_value < 0 {
info!(
&ctx,
"Process creation by {} failed. errno: {}", pid, -return_value
);
} else {
info!(
&ctx,
"New process is created by: {} child id: {}", pid, return_value
);
}
}
0
}
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
Userspace code
The purpose of the userspace code is to load the eBPF program and attach the
fentry and fexit handlers to kernel_clone. Both program types need a
Btf handle: Btf::from_sys_fs() reads the running kernel’s BTF
from /sys/kernel/btf/vmlinux, aya uses it to translate the function name
to its BTF type id, and the kernel uses that id to find the function’s
address and signature, so it can build a trampoline and verify your
argument accesses.
Here’s how the code looks like:
use aya::{
Btf,
programs::{FEntry, FExit},
};
use aya_log::EbpfLogger;
use log::{info, warn};
use tokio::signal;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
env_logger::init();
// This will include your eBPF object file as raw bytes at compile-time and load it at
// runtime. This approach is recommended for most real-world use cases. If you would
// like to specify the eBPF program at runtime rather than at compile-time, you can
// reach for `Ebpf::load_file` instead.
let mut bpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
env!("OUT_DIR"),
"/fentry-fork"
)))?;
match EbpfLogger::init(&mut bpf) {
Err(e) => {
// This can happen if you remove all log statements from your eBPF program.
warn!("failed to initialize eBPF logger: {e}");
}
Ok(logger) => {
let mut logger = tokio::io::unix::AsyncFd::with_interest(
logger,
tokio::io::Interest::READABLE,
)?;
tokio::task::spawn(async move {
loop {
let mut guard = logger.readable_mut().await.unwrap();
guard.get_inner_mut().flush();
guard.clear_ready();
}
});
}
}
let btf = Btf::from_sys_fs()?;
let program: &mut FEntry =
bpf.program_mut("fentry_clone").unwrap().try_into()?;
program.load("kernel_clone", &btf)?;
program.attach()?;
let program: &mut FExit =
bpf.program_mut("fexit_clone").unwrap().try_into()?;
program.load("kernel_clone", &btf)?;
program.attach()?;
let ctrl_c = signal::ctrl_c();
info!("Waiting for Ctrl-C...");
ctrl_c.await?;
info!("Exiting...");
Ok(())
}
Running the program
$ RUST_LOG=info cargo run
[2026-05-07T10:00:00Z INFO fentry_fork] Waiting for Ctrl-C...
[2026-05-07T10:00:05Z INFO fentry_fork] Process creation is started by: 12345
[2026-05-07T10:00:05Z INFO fentry_fork] New process is created by: 12345 child id: 67890