eBPF Malware Techniques Series:

  1. eBPF Malware Techniques Part 1 - Introduction
  2. eBPF Malware Techniques Part 2 - Setting Appropriate Hooks
  3. eBPF Malware Techniques Part 3 - Hiding BPF Traces
  4. eBPF Malware Techniques Part 4 - Hiding Processes

1. Introduction

In my previous post, you were introduced to the 3 different major frameworks that eBPFs have to offer. I also briefly talked about how the logic of its hooks.

To begin crafting your own eBPF-based malware, you need to understand where and what to hook on to. You don’t just throw an eBPF program into the void and hope it latches onto something juicy. No — you need a plan, a strategy, and most importantly, knowledge of the landscape.

Thus, today’s focus will be on understanding the different types of hooks available, and when to use each to best achieve your goals:

  1. tracepoints,
  2. kprobe / kretprobe
  3. fentry / fexit
  4. uprobe / uretprobe

Also, this part of the walkthrough will be purely based on the libbpf framework with snippets taken from libbpf-bootstrap and bcc/libbpf-tools.

2. Tracepoints

A tracepoint is a statically defined hook embedded within the Linux kernel. These are intentionally placed by kernel developers to expose important events or state changes — such as when a syscall is entered or exited, a scheduler event occurs, or memory is allocated.

Each tracepoint has a stable name, a well-defined structure, and exposes typed arguments that your eBPF programs can read. This makes them incredibly useful for observability and introspection.

2.1 Checking Available Tracepoints

A great starting point is to look at /sys/kernel/debug/tracing/available_events:

$ cat /sys/kernel/debug/tracing/available_events

...
sched:sched_prepare_exec
sched:sched_process_exec
sched:sched_process_fork
...
syscalls:sys_exit_openat
syscalls:sys_enter_openat
...
Checking for Available Tracepoints

This command prints out all tracepoints currently available in your kernel. It’s basically a buffet of potential targets. You’ll see categories like syscalls, irq, sched, kmem, and more.

If your goal is to monitor or manipulate user-level process behavior, the syscall tracepoints are particularly juicy. Why is this so? Let’s take a look at a simple example of the ls process with strace.

$ strace ls

...
openat(AT_FDCWD, ".", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
fstat(3, {st_mode=S_IFDIR|0750, st_size=798, ...}) = 0
getdents64(3, 0x557cefd81710 /* 46 entries */, 32768) = 1496
getdents64(3, 0x557cefd81710 /* 0 entries */, 32768) = 0
close(3)
...
Truncated Output of Strace on ls Process

We can see that the ls process is using the getdents64 syscall for directory listing. If your goal was to hide certain files/directories, then you will have to target syscalls:sys_enter_getdents64 and syscalls:sys_exit_getdents64. (Further exploration done in later part of this series)

Using the libbpf framework, your hookpoint will look something similar to this:

SEC("tp/syscalls/sys_enter_getdents64")
int handle_getdents_enter(struct trace_event_raw_sys_enter *ctx) {
  ...
}

SEC("tp/syscalls/sys_exit_getdents64")
int handle_getdents_exit(struct trace_event_raw_sys_exit *ctx) {
  ...
}
Example of getdents64 Hooks

2.2 Understanding Tracepoint Args

Each tracepoint has its arguments format stored in the following format:

/sys/kernel/debug/tracing/events/<tp_type>/<tp_name>/format
Tracepoint Format Path

To illustrate this, I am using back syscalls:sys_enter_getdents64 as my example.

$ cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_getdents64/format

name: sys_enter_getdents64
ID: 908
format:
	field:unsigned short common_type;	offset:0;	size:2;	signed:0;
	field:unsigned char common_flags;	offset:2;	size:1;	signed:0;
	field:unsigned char common_preempt_count;	offset:3;	size:1;	signed:0;
	field:int common_pid;	offset:4;	size:4;	signed:1;

	field:int __syscall_nr;	offset:8;	size:4;	signed:1;
	field:unsigned int fd;	offset:16;	size:8;	signed:0;
	field:struct linux_dirent64 * dirent;	offset:24;	size:8;	signed:0;
	field:unsigned int count;	offset:32;	size:8;	signed:0;

print fmt: "fd: 0x%08lx, dirent: 0x%08lx, count: 0x%08lx", ((unsigned long)(REC->fd)), ((unsigned long)(REC->dirent)), ((unsigned long)(REC->count))
Format of syscalls:sys_enter_getdents64

The first argument of the getdents64 syscall only starts after int __syscall_nr. So essentially, we are really only concerned with the following syscall parameters:

  1. unsigned int fd – The file descriptor for the directory being read
  2. struct linux_dirent64 *dirent – A pointer to the buffer where directory entries will be stored
  3. int count – The maximum number of bytes to read into the buffer

These arguments are passed to the tracepoint handler and can be accessed in your eBPF program via the args array of the trace_event_raw_sys_enter struct.

That’s why, when writing your eBPF handler for this tracepoint, it may look something like this:

SEC("tp/syscalls/sys_enter_getdents64")
int handle_getdents_enter(struct trace_event_raw_sys_enter *ctx) {
  ...
  unsigned int fd = ctx->args[0];
  struct linux_dirent64 *dirent = (struct linux_dirent64 *)ctx->args[1];
  unsigned int count = ctx->args[2];
  ...
}
Accessing Arguments Through trace_event_raw_sys_enter Struct

Alternatively, you can also define your own struct for cleaner argument access:

// Self-Defined Struct
struct sys_enter_getdents64_format {
    unsigned long long h;
    int32_t __syscall_nr;
    uint64_t fd;
    struct linux_dirent64 *dirent;
    uint64_t count;
};

SEC("tp/syscalls/sys_enter_getdents64")
int handle_getdents_enter(struct sys_enter_getdents64_format *format) {
  ...
  uint64_t fd = format->fd;
  struct linux_dirent64 *dirent = format->dirent;
  uint64_t count = format->count;
  ...
}
Accessing Arguments Through Self-Defined Struct

3. Kprobe / Kretprobe

So far, we’ve explored tracepoints — stable and well-documented hooks that expose syscall arguments cleanly. But what if you want more power or need to hook internal kernel functions that don’t have tracepoints? This is where kprobes come in.

A kprobe lets you dynamically instrument almost any kernel function — not just syscalls or tracepoints. When that function is called, your kprobe hook gets executed right at the start (before the actual function logic runs).

A kretprobe is its sibling: it runs after the kernel function finishes executing, giving you access to the return value.

3.1 Checking Available Kprobes

The good news is — the kernel exposes a full list of available kprobe targets through the ftrace interface. Just run:

$ cat /sys/kernel/debug/tracing/available_filter_functions
Listing Available Kprobe Targets

This will spit out thousands of functions, all which are fair game for kprobes, assuming your kernel was built with CONFIG_KPROBES and BPF support.

For example, to find file deletion logic, you might want to search for functions that contain the “unlinkat” substring:

$ grep -i unlink /sys/kernel/debug/tracing/available_filter_functions

...
vfs_unlink
do_unlinkat
...
Listing Kprobe Targets Related to File Deletion

3.2 Understanding Kprobe Args

In order to start writing out eBPF kprobe hooks, we need to identify the function prototype / definition of our kprobe target from the kernel source.

Using do_unlinkat as an example, its function prototype is found in “fs/internal.h”.

...
int do_unlinkat(int dfd, struct filename *name);
...
Function Prototype of do_unlinkat()

And with that, we are able to write our kprobe / kretprobe hook pairs as such:

SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat_entry, int dfd, struct filename *name) {
  ...
}

SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret) {
  ...
}
Example of Kprobe/Kretprobe on do_unlinkat()

BPF_KPROBE is actually a macro that simplifies defining the function signature for a kprobe, by defining the lower-level struct pt_regs *ctx so you don’t have to manually extract arguments from CPU registers.

This macro is defined in “bpf_tracing.h”, which is generated during the build process (typically src/.output/bpf/bpf_tracing.h or a similar path, depending on your setup).

The macro expects two parts:

  1. The first argument is your handler function name — this can be anything you choose, as long as it matches the function signature.
  2. The following arguments must match the parameters of the kernel function you’re hooking onto.

4. Fentry / Fexit

Now that you’ve seen how to use kprobes and kretprobes, let’s talk about their modern, more efficient cousins: fentry and fexit.

Fentry eBPF Docs:

  • Fentry programs are similar in function to a kprobe attached to a functions first instruction. This program type is invoked before control passes to the function to allow for tracing/observation.
  • Kprobes do not have to be attached at the entry point of a function, kprobes can be installed at any point in the function, whereas fentry programs are always attached at the entry point of a function.
  • Fentry programs are attached using a BPF trampoline which causes less overhead than kprobes

If you’re targeting a kernel function like do_unlinkat(), you can hook onto it using fentry (which runs at function entry) and fexit (which runs at function return) — just like kprobe and kretprobe.

Here’s what that looks like:

SEC("fentry/do_unlinkat")
int BPF_PROG(do_unlinkat_entry, int dfd, struct filename *name) {
  ...
}

SEC("fexit/do_unlinkat")
int BPF_PROG(do_unlinkat_exit, int dfd, struct filename *name, long ret) {
  ...
}
Example of Fentry/Fexit on do_unlinkat()

There are lots of similarities to kprobes, the only difference being the macro and the function exit handler’s arguments. Instead of BPF_KPROBE, we have to use BPF_PROG which is designed for fentry, fexit and BTF-enabled tracepoints (not covered in this series).

It does the same convenience wrapping — but this time around an unsigned long long *ctx argument, which holds the function arguments as a generic array. Again, you write the function prototype as if you’re calling the real kernel function, and the macro handles the underlying casting.

5. Uprobe & Uretprobe

So far, we’ve mainly explored kernel-space hooks — tracing functions inside the Linux kernel using tracepoints, kprobe/kretprobe, fentry/fexit. But, what if you want to observe user-space applications instead?

That’s where uprobe and uretprobe come in.

Uprobes allow you to hook onto the entry and exit of user-space functions — much like kprobes, but for binaries like /usr/bin/bash or shared libraries like /lib64/libc.so.6. This is incredibly useful for directly messing around with user-space applications. You may think of this as ptrace on steroids as this uprobes can apply to all processes, including new ones that either run the binary or calls the target function from the shared library.

A classic example is bashreadline — where we trace user-entered shell commands by hooking onto the readline() function, which is used by interactive shells to read user input.

A full example of this can be found in the libbpf-tools bashreadline program from the iovisor repository. As the example they provided only showed the usage of uretprobe, I have taken the liberty to show you what it will look like if you included a uprobe for it.

At a glance, a simplified version of what these hooks look like:

SEC("uprobe")
int BPF_UPROBE(printline, const char *prompt) {
  ...
}

SEC("uretprobe")
int BPF_URETPROBE(printret, const void *ret) {
  ...
};
Example of uprobe/uretprobe

In the original libbpf-tools example, you’ll notice that the program uses slightly more descriptive section names like:

SEC("uretprobe/readline")
Bashreadline Original Section Header

But this is purely a naming convention for clarity! The section string could be just SEC("uretprobe") or SEC("uprobe") — the real magic happens at attach time, when you tell the kernel which binary and which offset to monitor.

This is usually done by using a helper function to resolve the function’s offset inside the ELF binary — for example:

func_off = get_elf_func_offset(readline_so_path, 
                   find_readline_function_name(readline_so_path));
Getting Function Offset of readline() / internal_readline_teardown()"

And then attach our uprobe and uretprobe like so:

obj->links.printline = bpf_program__attach_uprobe(obj->progs.printline, 
                      false,  // false = uprobe (function entry)
                      -1,     // -1 = all processes
                      readline_so_path, func_off);

obj->links.printret = bpf_program__attach_uprobe(obj->progs.printret, 
                      true,  // true = uretprobe (function exit)
                      -1,    // all processes
                      readline_so_path, func_off);
Getting Function Offset of readline() / internal_readline_teardown()"

So even though your SEC() string might look like “uretprobe/readline”, it’s the bpf_program__attach_uprobe() call that actually binds the hook to a specific binary and offset — not the section name.

This separation makes the program more reusable and modular. You could write a single generic uprobe BPF program, and at runtime attach it to any ELF function in any user-space binary, as long as you know its offset.

One last interesting detail worth mentioning - Even though you’re using BPF_UPROBE and BPF_URETPROBE in your eBPF programs, under the hood these are simply aliases for BPF_KPROBE and BPF_KRETPROBE.

Here’s the exact snippet from bpf_tracing.h:

/* BPF_UPROBE and BPF_URETPROBE are identical to BPF_KPROBE and BPF_KRETPROBE,
 * but are named way less confusingly for SEC("uprobe") and SEC("uretprobe")
 * use cases.
 */
#define BPF_UPROBE(name, args...)      BPF_KPROBE(name, ##args)
#define BPF_URETPROBE(name, args...)   BPF_KRETPROBE(name, ##args)
Macro Aliases for uprobe/uretprobe

As stated by the author in the comments, this is just to reduce confusion when writing/reviewing code.

6. Conclusion

n this post, you’ve learned how to choose the right eBPF hook for your goals. Whether you’re aiming to monitor or manipulate behavior, understanding the strengths of each hook type is essential.

Tracepoints are stable, kernel-defined spots ideal for observing system calls and structured events — perfect for tasks like monitoring directory listings.

Kprobes / Kretprobes let you hook any kernel function dynamically, even ones without tracepoints. They’re your go-to when you need to monitor internal kernel logic.

Fentry / Fexit are modern, more efficient alternatives to kprobes. They offer lower overhead and a cleaner way to hook function entry and exit — if BTF data is available on your target system.

Uprobes / Uretprobes let you hook user-space binaries and libraries, giving you visibility into application-level logic — think of it as tracing processes beyond the kernel, like reading user commands from Bash.

Mastering these hooks is the foundation for crafting reliable and stealthy eBPF-based malware or tools. In the next post, I will be sharing some stealth techniques exhibited by nysm.


eBPF Malware Techniques Series:

  1. eBPF Malware Techniques Part 1 - Introduction
  2. eBPF Malware Techniques Part 2 - Setting Appropriate Hooks
  3. eBPF Malware Techniques Part 3 - Hiding BPF Traces
  4. eBPF Malware Techniques Part 4 - Hiding Processes