Track Linux Syscalls with Rust and eBPF

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
// In syscall_ebpf-ebpf/src/main.rs

// Define a map with up to 10 entries to store counts keyed by syscall ID
#[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
// In syscall_ebpf-ebpf/src/main.rs

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

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

#[kprobe]
pub fn open_counter(ctx: ProbeContext) -> u32 {
increment_syscall_count(&ctx, 2); // ID 2 for open
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
// In syscall_ebpf-ebpf/src/main.rs

fn increment_syscall_count(ctx: &ProbeContext, syscall_id: u32) {
unsafe {
// Get a mutable pointer to the count for the given syscall_id.
let count = SYSCALL_COUNTS.get_ptr_mut(&syscall_id);
if let Some(count) = count {
// If the key exists, increment the count.
*count += 1;
} else {
// If the key doesn't exist, insert a new entry with count 1.
// The unwrap_or_else is a simple way to handle potential errors on insertion.
SYSCALL_COUNTS.insert(&syscall_id, &1, 0).unwrap_or_else(|_| ());
}

// Log that a syscall was called, including its ID and the Process ID (PID).
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{
// Use '0' as an arbitrary ID for the 'read' syscall
increment_syscall_count(&ctx, 0);
0
}

#[kprobe]
pub fn write_counter(ctx: ProbeContext) -> u32 {
// Use '1' as an arbitrary ID for the 'write' syscall
increment_syscall_count(&ctx, 1);
0
}

#[kprobe]
pub fn open_counter(ctx: ProbeContext) -> u32{
// Use '2' as an arbitrary ID for the 'open' syscall
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
// syscall_ebpf/src/main.rs

use anyhow::{anyhow, Context}; // Import Context for the .context() method
use aya::maps::HashMap;
use aya::programs::KProbe;
use aya::{include_bytes_aligned, Ebpf}; // Use modern `Ebpf` type
use aya_log::EbpfLogger; // Use modern `EbpfLogger` type
use log::{info, warn};
use tokio::signal;
use tokio::time::{self, Duration};

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// This part loads the eBPF bytecode. It requires the file to exist.
// The conditional compilation (`#[cfg]`) handles both debug and release builds.
#[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);
}

// Attach to the 'read' syscall
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");

// Attach to the 'write' syscall
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");

// Attach to the 'open' syscall
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");

// **FIXED LINE:** Convert the Option from map_mut() into a Result using .context()
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