Track Linux Syscalls with Rust and eBPF

6 min read Original article ↗

This blog post explains how to track Linux system calls (read, write, and open) using Rust and eBPF (extended Berkeley Packet Filter). We will walk through the entire process—from basic definitions, setting up the project, writing eBPF programs, to loading and running them in user space with Rust. The goal is to provide a clear, beginner-friendly guide with well-structured explanations and code examples.


Basic Definitions

Before diving into the code, let’s clarify some key concepts:

  • Syscall (System Call): A mechanism used by programs to request services from the kernel, such as reading or writing files.
  • read calls: System calls that read data from a file descriptor.
  • write calls: System calls that write data to a file descriptor.
  • open calls: System calls that open files or devices.
  • Kprobe: A kernel feature that allows attaching custom handlers to almost any kernel function entry point.
  • kretprobe: Similar to kprobe but attaches to function return points.
  • User space: The memory space where user applications run.
  • Kernel space: The protected memory space where the operating system kernel runs.

Installation Steps

  1. Install Essential Build Tools, LLVM, and Clang
    eBPF programs are typically compiled with LLVM and Clang. You can install these along with other essential build tools using your distribution’s package manager. For Debian/Ubuntu systems, run:

    1
    2
    sudo apt update
    sudo apt install -y build-essential llvm clang
  2. Install Rust using rustup
    The recommended way to install Rust is with rustup, the official toolchain manager. It manages your Rust versions and associated tools.

    1
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

    Follow the on-screen prompts, choosing the default installation is usually sufficient. After installation, make sure to configure your current shell by running source "$HOME/.cargo/env".

  3. Install the Rust Nightly Toolchain
    The Aya library, which we’ll be using, requires features that are only available in the nightly version of Rust. Install it alongside your stable toolchain:

    1
    rustup install nightly
  4. Install cargo-generate
    We will use cargo-generate to create a new project from the official Aya template. This makes bootstrapping a new eBPF project much easier.

    1
    cargo install cargo-generate

With the environment now set up, you are ready to create your first eBPF project.


Setting Up the Codebase

To start, follow the Rust eBPF development setup instructions provided by the Aya project:

  1. Generate a new eBPF project template:

    1
    cargo generate -a aya-rs/aya-template -n syscall_ebpf

    During the prompts:

    • Select kprobe as the type of eBPF program.
    • Attach the kprobe to __x64_sys_open syscall (you will add others later).

Project Structure Overview

The generated project will have three main parts:

  • syscall_ebpf: Contains the main Rust code for user-space logging and interaction.
  • syscall_ebpf_common: Defines shared data structures between user and kernel space.
  • syscall_ebpf-ebpf: Contains the eBPF program logic running inside the kernel.

Writing the eBPF Program Logic

1. Creating a Map to Store Syscall Counts

We use a HashMap in eBPF to keep track of how many times each syscall is called.

1
2
3
4
5



#[map]
static mut SYSCALL_COUNTS: HashMap<u32, u64> = HashMap::<u32, u64>::with_max_entries(10, 0);

The #[map] macro declares this map for eBPF to manage in kernel space.

2. Defining Counter Functions for Each Syscall

We attach kprobes to syscall entry points and increment counters accordingly. We assign arbitrary IDs for our syscalls:

  • 0 for read
  • 1 for write
  • 2 for open
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


#[kprobe]
pub fn read_counter(ctx: ProbeContext) -> u32 {
increment_syscall_count(&ctx, 0);
0
}

#[kprobe]
pub fn write_counter(ctx: ProbeContext) -> u32 {
increment_syscall_count(&ctx, 1);
0
}

#[kprobe]
pub fn open_counter(ctx: ProbeContext) -> u32 {
increment_syscall_count(&ctx, 2);
0
}

3. Incrementing Syscall Counts

This helper function updates the count for the given syscall ID.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


fn increment_syscall_count(ctx: &ProbeContext, syscall_id: u32) {
unsafe {

let count = SYSCALL_COUNTS.get_ptr_mut(&syscall_id);
if let Some(count) = count {

*count += 1;
} else {


SYSCALL_COUNTS.insert(&syscall_id, &1, 0).unwrap_or_else(|_| ());
}


info!(ctx, "Syscall {} called by PID {}", syscall_id, bpf_get_current_pid_tgid() >> 32);
}
}
  • We fetch a mutable pointer to the current count.
  • If it exists, we increment it; otherwise, we insert an initial count of 1.
  • We log the syscall ID and the calling process’s PID for real-time visibility.

4. Panic Handler in eBPF

eBPF programs cannot unwind on panic, so we must provide a minimal handler that loops forever.

1
2
3
4
5
6
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}

5. Complete eBPF Program Code

Here is the full source code for syscall_ebpf-ebpf/src/main.rs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#![no_std]
#![no_main]
#![allow(warnings)]


use aya_ebpf::{macros::kprobe, programs::ProbeContext, helpers::bpf_get_current_pid_tgid};
use aya_log_ebpf::info;
use aya_ebpf::maps::HashMap;
use aya_ebpf::macros::map;

#[map]
static mut SYSCALL_COUNTS: HashMap<u32, u64> = HashMap::<u32,u64>::with_max_entries(10, 0);

fn increment_syscall_count(ctx: &ProbeContext, syscall_id:u32) {
unsafe {
let count = SYSCALL_COUNTS.get_ptr_mut(&syscall_id);
if let Some(count) =count {
*count +=1;
}else{
SYSCALL_COUNTS.insert(&syscall_id, &1, 0).unwrap_or_else(|_| ());
}
info!(ctx,"Syscall {} called by PID {}",syscall_id, bpf_get_current_pid_tgid()>>32);
}
}
#[kprobe]
pub fn read_counter(ctx: ProbeContext) -> u32{

increment_syscall_count(&ctx, 0);
0
}

#[kprobe]
pub fn write_counter(ctx: ProbeContext) -> u32 {

increment_syscall_count(&ctx, 1);
0
}

#[kprobe]
pub fn open_counter(ctx: ProbeContext) -> u32{

increment_syscall_count(&ctx, 2);
0
}

#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}

#[link_section = "license"]
#[no_mangle]
static LICENSE: [u8; 13] = *b"Dual MIT/GPL\0";

Building the eBPF Program

Create a .cargo/config.toml file inside the syscall_ebpf-ebpf directory with the following content to target the eBPF architecture:

1
2
3
4
5
[build]
target = "bpfel-unknown-none"

[target.bpfel-unknown-none]
rustflags = ["-C", "panic=abort"]

Then build the program using the nightly toolchain:

1
cargo +nightly build --release -Z build-std=core

Writing the User-Space Rust Program

This program loads the eBPF bytecode, attaches the kprobes to the kernel functions, and periodically reads the syscall counts from the eBPF map to display them.

Here is the complete code for syscall_ebpf/src/main.rs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76


use anyhow::{anyhow, Context};
use aya::maps::HashMap;
use aya::programs::KProbe;
use aya::{include_bytes_aligned, Ebpf};
use aya_log::EbpfLogger;
use log::{info, warn};
use tokio::signal;
use tokio::time::{self, Duration};

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {


#[cfg(debug_assertions)]
let mut bpf = Ebpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/syscall_ebpf"
))?;
#[cfg(not(debug_assertions))]
let mut bpf = Ebpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/syscall_ebpf"
))?;

if let Err(e) = EbpfLogger::init(&mut bpf) {
warn!("failed to initialize eBPF logger: {}", e);
}


let read_prog: &mut KProbe = bpf.program_mut("read_counter").unwrap().try_into()?;
read_prog.load()?;
read_prog.attach("__x64_sys_read", 0)?;
info!("Attached kprobe to __x64_sys_read");


let write_prog: &mut KProbe = bpf.program_mut("write_counter").unwrap().try_into()?;
write_prog.load()?;
write_prog.attach("__x64_sys_write", 0)?;
info!("Attached kprobe to __x64_sys_write");


let open_prog: &mut KProbe = bpf.program_mut("open_counter").unwrap().try_into()?;
open_prog.load()?;
open_prog.attach("__x64_sys_open", 0)?;
info!("Attached kprobe to __x64_sys_open");


let counts_map_generic = bpf
.map_mut("SYSCALL_COUNTS")
.context("Failed to find the SYSCALL_COUNTS map")?;
let mut counts_map: HashMap<_, u32, u64> = HashMap::try_from(counts_map_generic)?;

info!("Waiting for Ctrl-C to exit...");

let mut interval = time::interval(Duration::from_secs(2));
loop {
tokio::select! {
_ = interval.tick() => {
let read_count = counts_map.get(&0, 0).unwrap_or(0);
let write_count = counts_map.get(&1, 0).unwrap_or(0);
let open_count = counts_map.get(&2, 0).unwrap_or(0);

println!("---------------------------------");
println!("Read calls: {}", read_count);
println!("Write calls: {}", write_count);
println!("Open calls: {}", open_count);
}
_ = signal::ctrl_c() => {
info!("Exiting...");
break;
}
}
}

Ok(())
}

2. Running the Program

Run the user-space program with elevated privileges and specify your network interface (replace enp2s0 with your interface):

1
RUST_LOG=info sudo -E cargo run -- -i enp2s0

You should see output similar to:

alt text


Next Steps

This example demonstrates a simple but powerful way to track syscalls with Rust and eBPF. You can extend this foundation to:

  • Parse and analyze network packets.
  • Use tracepoints for more detailed kernel events.
  • Build custom logging and monitoring tools for security or performance analysis.

Stay tuned for more advanced eBPF tutorials!


References