DEV Community

Cover image for Tracing your syscalls with Aya
Joseph Ligier
Joseph Ligier

Posted on • Edited on

Tracing your syscalls with Aya

I'm getting started with eBPF programming with Aya. The idea behind this series of articles is to get you started too.

In the previous part, we looked at a way to better structure an eBPF program by separating it into smaller eBPF programs. In this part, we'll put the finishing touches to that introduction by creating another eBPF program and having it communicate with the first one using eBPF maps.

This will allow us to learn tracing and review the basics of eBPF.

🇫🇷FYI, this is the English version of an article originally published in French.🇫🇷

tracing skill - Carefully trace along the dotted line then color the picture


Creation of a second eBPF program

Reminder

If you're jumping on the bandwagon, the initial program logs all binaries running on your computer. To do this, we used Tracepoint with the hook syscalls:sys_enter_execve. In part 3, we created a filter to prevent logging when certain binaries were executed. In the previous section, we used tail calls to better structure the code.

What are we going to do now?

We're going to create another Tracepoint program with the hook syscalls:sys_exit_execve. In the second part, we saw that Tracepoints in the syscalls category actually had two Tracepoints:

  • the input one: sys_enter_$syscall
  • the output one: sys_exit_$syscall

What this will allow us to do next:

  • Tracing: calculate the duration of each execve syscall.
  • Filtering: display only execve syscalls that have not had an error.

Diagram of tracing program

Project cloning

Although this follows on from part 4, I recommend that you retrieve the project:

git clone https://github.com/littlejo/aya-examples
cd aya-examples/tracepoint-binary-tail-calls/
RUST_LOG=info cargo run # Check it works before modification
Enter fullscreen mode Exit fullscreen mode

You can also do the Killercoda lab, which follows step-by-step the creation of tracing:

Killer coda screenshot

Let's create a "hello world" program

We're going to create a "hello world" program which is attached to the Tracepoint of category syscalls and name sys_exit_execve.

Diagram of the last part + hello world program

We're already going to create the program in kernel space. We'll create a file in the tracepoint-binary-ebpf/src directory.

We'll copy the hook.rs file and add _exit :

cp tracepoint-binary-ebpf/src/hook.rs tracepoint-binary-ebpf/src/hook_exit.rs
sed -i 's/tracepoint_binary/tracepoint_binary_exit/' tracepoint-binary-ebpf/src/hook_exit.rs
Enter fullscreen mode Exit fullscreen mode

We delete anything unrelated to the program for the sys_enter_execve syscall. We end up with :

use aya_ebpf::{
    macros::tracepoint, programs::TracePointContext,
};

use aya_log_ebpf::info;

#[tracepoint]
pub fn tracepoint_binary_exit(ctx: TracePointContext) -> u32 {
    match try_tracepoint_binary_exit(ctx) {
        Ok(ret) => ret,
        Err(ret) => ret as u32,
    }
}

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    info!(&ctx, "tracepoint sys_exit_execve called.");
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Then we'll add the line mod hook_exit; to the main.rs file so that cargo includes the file.
We're now going to modify the user space code to load this program. We therefore need to modify this file: tracepoint-binary/src/main.rs. We'll duplicate the Tracepoint loading and attachment code:

let program: &mut TracePoint = ebpf.program_mut("tracepoint_binary").unwrap().try_into()?;
program.load()?;
program.attach("syscalls", "sys_enter_execve")?;
Enter fullscreen mode Exit fullscreen mode

You can insert it, for example, just after the copied code.
And we'll adapt it with the program name: tracepoint_binary_exit and the attachment point which is sys_exit_execve:

let program: &mut TracePoint = ebpf.program_mut("tracepoint_binary_exit").unwrap().try_into()?;
program.load()?;
program.attach("syscalls", "sys_exit_execve")?;
Enter fullscreen mode Exit fullscreen mode

If it's not clear here's the modified code:

partial code of user land

We've just created the “hello world” for the Tracepoint syscalls:sys_exit_execve. Let's test it:

RUST_LOG=info cargo run
Enter fullscreen mode Exit fullscreen mode

Output if you connect with ssh

The code we've just created isn't really rocket science: it's just copy-paste and value modification. We could create functions if we wanted to avoid duplicate code. We're done in user space until the end of the part. Now we're going to play in kernel space. So we'll only be modifying code in the tracepoint-binary-ebpf/src/ directory.

Return of the Jedi

As we saw in part 2, we can retrieve data from a tracepoint. Let's take a look at what the following command does:

cat /sys/kernel/debug/tracing/events/syscalls/sys_exit_execve/format
Enter fullscreen mode Exit fullscreen mode

Output of the command

The only field I find interesting is ret. It is offset at 16. This gives the return code of the execve system call.
How to display it? We'll have to follow the example of the other hook program in unsafe mode:

let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
Enter fullscreen mode Exit fullscreen mode

So we'll have something like :

let ret = unsafe { ctx.read_at::<????????>(16)? };
Enter fullscreen mode Exit fullscreen mode

The problem is now to find the data type.

We had this for the previous example:

field:const char * filename;    offset:16;      size:8; signed:0;
Enter fullscreen mode Exit fullscreen mode
  • const char * is a bit equivalent to *const u8 so it's consistent.

But here we have :

field:long ret; offset:16;      size:8; signed:1;
Enter fullscreen mode Exit fullscreen mode
  • So we need to find the equivalent of long in Rust.
  • signed:1 so it's already an integer of type i?? .

According to this site, long corresponds to 8 bytes. So we'd have i64.
So for hook_exit.rs :

let ret = unsafe { ctx.read_at::<i64>(16)? };
Enter fullscreen mode Exit fullscreen mode

This gives us :

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let ret = unsafe { ctx.read_at::<i64>(16)? };
    info!(&ctx, "tracepoint sys_exit_execve called. ret: {}", ret);
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

We can now test :

output with man and ls

Note that the man command fails, but that syscall execve worked, which is normal:

  • execution of the binary was successful
  • man returns code 1 because it has no arguments

Code: Return

By the way, how do you get return codes for execve other than 0? I counted 3 cases of error:

  • The binary doesn't exist (code: -2):

Output: -2

  • The binary exists but is not executable (code: -13):

Output: -13

  • The binary is executable but doesn't have the right libraries, e.g. a binary from a Mac(code: -8):

Output: -8

Note that using i64 looks a bit like overkill 😅


Tracing execve syscall

Now that we've created a second eBPF program, let's play with the first. In part 1, I said it was possible to create programs for tracing, so let's take a look at how to calculate the time taken by a syscall execve between its input (sys_enter_execve) and its output (sys_exit_execve).

Creating timers

The first thing to find is a way to measure time at a given moment. In eBPF, there's a helper function for this: bpf_ktime_get_ns(). It displays the time in nanoseconds since the system was started (So it's not the unix time):

Documentation of bpf_ktime_get_ns()

To use it in Rust, there is the Aya library which makes a binding :

Documentation of Aya library

So to use it, you need to add the library for each file it uses:

use aya_ebpf_bindings::helpers::bpf_ktime_get_ns;
Enter fullscreen mode Exit fullscreen mode

We add the function at the beginning of each program starting with hook. For the file hook.rs :

fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "hook {}", t); //we modify to have the timestamp
[...]
Enter fullscreen mode Exit fullscreen mode

and for the file hook_exit.rs :

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "exit {}", t);
[...]
Enter fullscreen mode Exit fullscreen mode

Now let's test:

RUST_LOG=debug cargo run
Enter fullscreen mode Exit fullscreen mode

Error output: missed to download a library

We forgot to tell Cargo to download the aya_ebpf_binding library.
So we need to modify the file: tracepoint-binary-ebpf/Cargo.toml :

[package]
name = "tracepoint-binary-ebpf"
version = "0.1.0"
edition = "2021"

[dependencies]
tracepoint-binary-common = { path = "../tracepoint-binary-common" }
aya-ebpf = { workspace = true }
aya-log-ebpf = { workspace = true }
aya-ebpf-bindings = "0.1.1" #You just need to add this line
[build-dependencies]
which = { workspace = true }

[[bin]]
name = "tracepoint-binary"
path = "src/main.rs"
Enter fullscreen mode Exit fullscreen mode

Let's test again:

RUST_LOG=debug cargo run
Enter fullscreen mode Exit fullscreen mode

Typing ls then man in another console finds :

Output of cargo run with ls and man and timestamp

Transmission between eBPF programs

Now that we have the timestamp of the two Tracepoints, all we have to do is calculate the difference. We could imagine user processing afterwards, but that's not interesting. Instead, we'll use an eBPF map to transmit the timestamp to the other eBPF program.

Diagram of transmission between program

What type of eBPF map? We could imagine a map with array type with a single value. But if there are two binaries running at almost the same time, aren't we running the risk of false tracings?

Diagram with array type

A map with per cpu array type may seem attractive. But what if the input Tracepoint isn't on the same CPU as the output one? I tried. Now I have a comment in the code that says never again.

Diagram with per cpu array type

So we need to find another type of map to communicate between two separate eBPF programs.

One solution would be to create a dictionary with a single entry for the sys_enter_execve and sys_exit_execve tracepoint. That way, there would be no possibility of stepping on each other's toes.

Diagram with hash type

But what could possibly link them? The process identifier (the famous PID) which executes the program.

To retrieve the PID, use the helper function bpf_get_current_pid_tgid() :

Aya Documentation of bpf_get_current_pid_tgid

Please note: this function does not retrieve the pid/tgid of the eBPF program, but rather the program being executed.

For the rest, I arbitrarily use tgid but it's not necessarily the right choice.

If you want to know the difference between tgid and pid, there's this excellent blog article by Yuki Nakamura (I've looked into it: he's not Aya's brother):

Yuki Nakamura blog

So we go with a HashMap with the tgid as input? It might work in the short term, but remember that eBPF maps have a fixed size. It would therefore fill up pretty quickly. So we'd need a way of purging at the end of processing to avoid this. Another solution would be to find a map that would do the job: LruHashMap. As we saw in the section on maps, it automatically deletes the least recent dictionary entries.

eBPF documentation about LruHashMap

Aya documentation about LruHashMap

In the common.rs file, add the following map:

#[map]
pub static T_ENTER: LruHashMap<u32, u64> = LruHashMap::with_max_entries(16, 0);
Enter fullscreen mode Exit fullscreen mode

Don't forget to update the libraries to be used:

use aya_ebpf::{
    macros::map,
    maps::{HashMap, PerCpuArray, ProgramArray, LruHashMap},
    programs::TracePointContext,
};
Enter fullscreen mode Exit fullscreen mode

To fill the map, in the hook.rs file, simply add to the try_tracepoint_binary() function:

let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
T_ENTER.insert(&tgid, &t, 0)?;
Enter fullscreen mode Exit fullscreen mode

This gives us :

fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "main {}", t);
    let buf = BUF.get_ptr_mut(0).ok_or(0)?;
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    T_ENTER.insert(&tgid, &t, 0)?;
    unsafe {
        *buf = ZEROED_ARRAY;
        let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
        bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
    };

    try_tail_call(&ctx, 0);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to add the library :

use aya_ebpf::helpers::bpf_get_current_pid_tgid;
Enter fullscreen mode Exit fullscreen mode

To retrieve the map from the hook_exit.rs file on the try_tracepoint_binary_exit() function side:

let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
let t_enter = unsafe { T_ENTER.get(&tgid).ok_or(0)? };
Enter fullscreen mode Exit fullscreen mode

You can easily tell the difference now:

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let t_enter = unsafe { T_ENTER.get(&tgid).ok_or(0)? };
    debug!(&ctx, "exit {}", t);
    let ret :i64 = unsafe { ctx.read_at(16)? };
    info!(&ctx, "tracepoint sys_exit_execve called. ret: {}, duration: {}", ret, t - t_enter);
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Add dependency recovery:

use aya_ebpf::helpers::bpf_get_current_pid_tgid;
use crate::common::*;
Enter fullscreen mode Exit fullscreen mode

We can now test:

RUST_LOG=info cargo run
Enter fullscreen mode Exit fullscreen mode

Output of cargo with duration


Merge logs

Diagram: How to retrieve BUF map?

eBPF map type problem

Now that we've managed to retrieve the duration of the execve syscall, it would be nice to have everything on the same line: binary name, duration and return code. The output tracepoint program must therefore be able to retrieve the buffer filled by the input tracepoint program. The problem is the map type BUF : PerCpuArray. As we saw in the previous section, the two different eBPF programs may not point to the same CPU. So we need to change the map type. We'll take the same type of map as before: LruHashMap with tgid as the common value. In the common.rs file, we replace :

#[map]
pub static BUF: PerCpuArray<[u8; MAX_PATH_LEN]> = PerCpuArray::with_max_entries(1, 0);
Enter fullscreen mode Exit fullscreen mode

by:

#[map]
pub static BUF: LruHashMap<u32, [u8; MAX_PATH_LEN]> = LruHashMap::with_max_entries(16, 0);
Enter fullscreen mode Exit fullscreen mode

We'll need to modify all the code that uses the BUF map.

Hook file conversion

Let's start by modifying the hook.rs file:

fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "main {}", t);
    let buf = BUF.get_ptr_mut(0).ok_or(0)?; //TO_CHANGE
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    T_ENTER.insert(&tgid, &t, 0)?;
    unsafe {
        *buf = ZEROED_ARRAY; //TO_CHANGE
        let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
        bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
    };

    try_tail_call(&ctx, 0);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

To replace let buf = BUF.get_ptr_mut(0).ok_or(0)?;, we'll use the equivalent function for Hash :

This gives us :

let buf = BUF.get_ptr_mut(&tgid).ok_or(0)?;
Enter fullscreen mode Exit fullscreen mode

To reset the buffer, we'll use the function to add a key and a value to a hash map:

Aya documentation

We'll then have :

BUF.insert(&tgid, &ZEROED_ARRAY, 0)?;
Enter fullscreen mode Exit fullscreen mode

Which gives us :

fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "main {}", t);
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    T_ENTER.insert(&tgid, &t, 0)?;
    BUF.insert(&tgid, &ZEROED_ARRAY, 0)?; //CHANGED
    let buf = BUF.get_ptr_mut(&tgid).ok_or(0)?; //CHANGED
    unsafe {
        let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
        bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
    };

    try_tail_call(&ctx, 0);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Converting the filter file

The main code of the filter.rs file to be modified:

fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "filter");
    let buf = BUF.get(0).ok_or(0)?; //TO_CHANGE

    let is_excluded = unsafe { EXCLUDED_CMDS.get(buf).is_some() };

    if is_excluded {
        debug!(&ctx, "No log for this Binary");
        return Ok(0);
    }
    try_tail_call(&ctx, 1);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

To retrieve the buffer, use the get function:

get function documentation

This must be replaced by :

let tgid = (bpf_get_current_pid_tgid() >> 32) as u32; //we retrieve the tgid
let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //we access to the data using tgid
Enter fullscreen mode Exit fullscreen mode

The main code of filter.rs thus becomes :

use aya_ebpf::helpers::bpf_get_current_pid_tgid; //Don't forget library

[...]

fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "filter");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32; //ADD
    let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //CHANGED

    let is_excluded = unsafe { EXCLUDED_CMDS.get(buf).is_some() };

    if is_excluded {
        debug!(&ctx, "No log for this Binary");
        return Ok(0);
    }

    try_tail_call(&ctx, 1);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Converting the display file

The main code of display.rs to be modified :

fn try_tracepoint_binary_display(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "display");
    let buf = BUF.get(0).ok_or(0)?; //TO_CHANGE
    let cmd = &buf[..];
    let filename = unsafe { from_utf8_unchecked(cmd) };
    info!(
        &ctx,
        "tracepoint sys_enter_execve called. Binary: {}", filename
    );
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

In a similar way to filter.rs, you'll get :

use aya_ebpf::helpers::bpf_get_current_pid_tgid; //Don't forget the library

fn try_tracepoint_binary_display(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "display");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32; //ADD
    let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //CHANGED
    let cmd = &buf[..];
    let filename = unsafe { from_utf8_unchecked(cmd) };

    info!(
        &ctx,
        "tracepoint sys_enter_execve called. Binary: {}", filename
    );
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

You can recompile now, it works.
As you can see, converting an eBPF map isn't terribly complicated, but it's still best to get the right type right from the start.

Retrieving a map in the hook_exit.rs program

Now that we've converted the eBPF map, we can retrieve the data in the hook_exit.rs program:

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let t_enter = unsafe { T_ENTER.get(&tgid).ok_or(0)? };
    debug!(&ctx, "exit {}", t);
    let ret :i64 = unsafe { ctx.read_at(16)? };
    info!(&ctx, "tracepoint sys_exit_execve called. ret: {}, duration: {}", ret, t - t_enter); //TO_CHANGE
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Just copy the right lines from the display.rs program:

let buf = unsafe { BUF.get(&tgid).ok_or(0)? };
let cmd = &buf[..];
let filename = unsafe { from_utf8_unchecked(cmd) };
Enter fullscreen mode Exit fullscreen mode

And modify info! accordingly.

The end result is:

use core::str::from_utf8_unchecked; // Don't forget the library

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let t_enter = unsafe { T_ENTER.get(&tgid).ok_or(0)? };
    let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //PASTE
    let cmd = &buf[..]; //PASTE
    let filename = unsafe { from_utf8_unchecked(cmd) }; //PASTE
    let ret :i64 = unsafe { ctx.read_at(16)? };
    debug!(&ctx, "exit {}", t);
    info!(&ctx, "tracepoint sys_exit_execve called. Binary: {}, ret: {}, duration: {}", filename, ret, t - t_enter); //CHANGED
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Let's test:

RUST_LOG=info cargo run
Enter fullscreen mode Exit fullscreen mode

Output of ssh

After an ssh connection, we see that it launches binaries that don't exist.

But that's a bit of a shame, we've already created an eBPF program that displays this. And I'm not interested in the commands we've filtered and those with return codes other than 0. Why don't we reuse the filter and display programs instead?


Code enhancement

Grouping eBPF maps

To reuse the various eBPF programs, simply add this line :

try_tail_call(&ctx, 0);
Enter fullscreen mode Exit fullscreen mode

to the hook_exit.rs program. But the display program has no access to the duration or return code. So you need to create new eBPF maps:

#[map]
pub static DURATION: LruHashMap<u32, u64> = LruHashMap::with_max_entries(16, 0);

#[map]
pub static RET: LruHashMap<u32, i64> = LruHashMap::with_max_entries(16, 0);
Enter fullscreen mode Exit fullscreen mode

Uhh, isn’t that starting to be a lot of maps?

aya code with all maps

Accessing an eBPF map has a cost that could end up degrading the computer's performance. For our little program, it's transparent, but if the program continues to grow with more eBPF maps, it's going to show. The BUF, T_ENTER, DURATION and RET maps are structurally identical. Why not create a PROGRAM map to centralize the state of the eBPF program and replace these maps? To do this, we'll create a structure:

struct ProgramState {
    t_enter: u64, //Entry hook know that
    t_exit: u64, //Exit hook know that
    buffer: [u8; MAX_PATH_LEN], //Entry hook know that
    ret: i64, //Exit hook know that
}
Enter fullscreen mode Exit fullscreen mode

I didn't set duration but rather t_exit. This will be subtracted in the display program. The program doesn't have to do much, it can just do this 😝
If you remember from part 2, to stick to C, we need to use aligned structures. So we're going to put this in common.rs :

#[repr(C)]
pub struct ProgramState {
    pub t_enter: u64,
    pub t_exit: u64,
    pub buffer: [u8; MAX_PATH_LEN],
    pub ret: i64,
}
Enter fullscreen mode Exit fullscreen mode

I've added pub everywhere so that other files can access the structure and its contents, but I haven't found anything better.
We'll now have just one eBPF map, as opposed to the 4 we had before:

#[map]
pub static PROGRAM: LruHashMap<u32, ProgramState> = LruHashMap::with_max_entries(16, 0);
Enter fullscreen mode Exit fullscreen mode

Before deleting the “old” maps, I advise you to keep them to avoid compilation errors:

#[map]
pub static T_ENTER: LruHashMap<u32, u64> = LruHashMap::with_max_entries(16, 0);

#[map]
pub static BUF: LruHashMap<u32, [u8; MAX_PATH_LEN]> = LruHashMap::with_max_entries(16, 0);
Enter fullscreen mode Exit fullscreen mode

To sum up what we want to do in this section, it's a map that will centralize all information:

Diagram of all ebpf programs and program map

Modifying hook entry

For the entry program, we have two things to do:

  • insert: initialize the entry with the tgid
  • fill: fill this entry with known values for entry time and buffer.

diagram of all ebpf programs and program map: hook.rs

The main hook.rs code is currently as follows:

fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "hook {}", t);
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    BUF.insert(&tgid, &ZEROED_ARRAY, 0)?; //TO_CHANGE
    T_ENTER.insert(&tgid, &t, 0)?; //TO_CHANGE
    let buf = BUF.get_ptr_mut(&tgid).ok_or(0)?; //TO_CHANGE
    unsafe {
        let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
        bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?; //TO_CHANGE
    }

    try_tail_call(&ctx, 0);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

The first thing to do is to create the default entry in the map. We'll then modify the values we retrieve (t_enter and buffer).

We'll create a constant in hook.rs:

const INIT_STATE: ProgramState = ProgramState {
    t_enter: 0,
    t_exit: 0,
    buffer: ZEROED_ARRAY,
    ret: 0,
};
Enter fullscreen mode Exit fullscreen mode

A constant must be created to prevent the 512-byte limit from being exceeded.
It is placed next to the FILENAME_OFFSET constant.
To initialize, we'll use the following function:

insert aya documentation

This gives :

PROGRAM.insert(&tgid, &INIT_STATE, 0)?;
Enter fullscreen mode Exit fullscreen mode

Now we'll modify the values of the tgid key with the following function:

get_ptr_mut aya documentation

To retrieve *mut V :

let program_ptr = PROGRAM.get_ptr_mut(&tgid).ok_or(0)?;
Enter fullscreen mode Exit fullscreen mode

We'll get :

let program_state = unsafe { &mut *PROGRAM.get_ptr_mut(&tgid).ok_or(0)? };
Enter fullscreen mode Exit fullscreen mode

So, to modify the t_enter entry, we need to do :

program_state.t_enter = t;
Enter fullscreen mode Exit fullscreen mode

Then, to modify the buffer, do :

bpf_probe_read_user_str_bytes(filename_src_addr, &mut program_state.buffer)?;
Enter fullscreen mode Exit fullscreen mode

We end up with :

fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    debug!(&ctx, "main {}", t);
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    PROGRAM.insert(&tgid, &INIT_STATE, 0)?; //CHANGED
    let program_state = unsafe { &mut *PROGRAM.get_ptr_mut(&tgid).ok_or(0)? }; //CHANGED
    program_state.t_enter = t; //CHANGED
    unsafe {
        let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
        bpf_probe_read_user_str_bytes(filename_src_addr, &mut program_state.buffer)?; //CHANGED
    };

    try_tail_call(&ctx, 0);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

We have to do the same for the other eBPF programs.

filter

For the filter program, only the buffer needs to be retrieved from the PROGRAM map:

diagram with all ebpf programs and maps: filter

The main code of the filter program:

fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "filter");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //TO_CHANGE

    let is_excluded = unsafe { EXCLUDED_CMDS.get(buf).is_some() }; //TO_CHANGE

    if is_excluded {
        debug!(&ctx, "No log for this Binary");
        return Ok(0);
    }
    try_tail_call(&ctx, 1);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Just replace the buf variable :

let buf = unsafe { BUF.get(&tgid).ok_or(0)? };
Enter fullscreen mode Exit fullscreen mode

with :

let program = unsafe { PROGRAM.get(&tgid).ok_or(0)? };
Enter fullscreen mode Exit fullscreen mode

and therefore modify the code :

let is_excluded = unsafe { EXCLUDED_CMDS.get(buf).is_some() };
Enter fullscreen mode Exit fullscreen mode

with :

let is_excluded = unsafe { EXCLUDED_CMDS.get(&program.buffer).is_some() };
Enter fullscreen mode Exit fullscreen mode

We end up with the code :

fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "filter");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;

    let is_excluded = unsafe {
        let program = PROGRAM.get(&tgid).ok_or(0)?; //CHANGED
        EXCLUDED_CMDS.get(&program.buffer).is_some() //CHANGED
    };

    if is_excluded {
        debug!(&ctx, "No log for this Binary");
        return Ok(0);
    }

    try_tail_call(&ctx, 1);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

display

For the display program, as for the filter program, only the buffer needs to be retrieved from the PROGRAM map:

diagram with all ebpf programs and maps: display

The main code of display :

fn try_tracepoint_binary_display(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "display");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //TO_CHANGE
    let cmd = &buf[..]; //TO_CHANGE
    let filename = unsafe { from_utf8_unchecked(cmd) };
    info!(
        &ctx,
        "tracepoint sys_enter_execve called. Binary: {}", filename
    );
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

In the same way as for the filter program, we end up with :

fn try_tracepoint_binary_display(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "display");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let program = unsafe { PROGRAM.get(&tgid).ok_or(0)? }; //CHANGED
    let cmd = &program.buffer[..]; //CHANGED
    let filename = unsafe { from_utf8_unchecked(cmd) };

    info!(
        &ctx,
        "tracepoint sys_enter_execve called. Binary: {}", filename
    );
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

exit

For the exit program, we need to fill in the map values: the exit time and its return. We're also going to clean up the code for the display part, as the display program will do the work.

diagram with all ebpf programs and maps: exit

The main hook_exit code :

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let t_enter = unsafe { T_ENTER.get(&tgid).ok_or(0)? }; //TO_CLEAN
    let buf = unsafe { BUF.get(&tgid).ok_or(0)? }; //TO_CLEAN
    let cmd = &buf[..]; //TO_CLEAN
    let filename = unsafe { from_utf8_unchecked(cmd) }; //TO_CLEAN
    let ret :i64 = unsafe { ctx.read_at(16)? };
    debug!(&ctx, "exit {}", t);
    info!(&ctx, "tracepoint sys_exit_execve called. Binary: {}, ret: {}, duration: {}", filename, ret, t - t_enter); //TO_CHANGE
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

For hook_exit, you need to modify the map as follows:

let program_state = unsafe { &mut *PROGRAM.get_ptr_mut(&tgid).ok_or(0)? };
program_state.t_exit = t;
program_state.ret = ret;
Enter fullscreen mode Exit fullscreen mode

And I've cleaned up everything that didn't concern data retrieval and map filling.
This gives :

fn try_tracepoint_binary_exit(ctx: TracePointContext) -> Result<u32, i64> {
    let t = unsafe{ bpf_ktime_get_ns() };
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    debug!(&ctx, "exit {}", t);
    let ret :i64 = unsafe { ctx.read_at(16)? };

    let program_state = unsafe { &mut *PROGRAM.get_ptr_mut(&tgid).ok_or(0)? }; //ADDED
    program_state.t_exit = t; //ADDED
    program_state.ret = ret; //ADDED

    debug!(&ctx, "tracepoint sys_exit_execve called. ret: {}", ret);
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Now all the maps are filled. The hardest part is done! All that's left now is to use the PROGRAM map.
Don't forget to clean up the maps:

#[map]
pub static T_ENTER: LruHashMap<u32, u64> = LruHashMap::with_max_entries(16, 0);

#[map]
pub static BUF: LruHashMap<u32, [u8; MAX_PATH_LEN]> = LruHashMap::with_max_entries(16, 0);
Enter fullscreen mode Exit fullscreen mode

They're no longer useful.


Adding features

Now that we've grouped the maps together, let's see how easy it is to add new features.

Modifying tail calls

We currently have 4 programs:

  • hook
  • hook_exit
  • filter
  • display

The tail call workflow is :

  • hook ➡️ filter ➡️ display

It's not possible to keep this because you have to wait for hook_exit to finish if you want to get the return code.

So we need to change it to :

  • hook_exit ➡️ filter ➡️ display

In schematic form, this transformation looks like this:

Diagram with new workflow

Cut the following line from hook.rs and paste it into hook_exit.rs:

try_tail_call(&ctx, 0);
Enter fullscreen mode Exit fullscreen mode

And paste it into hook_exit.rs.

Filter return code

Now we can make full use of the PROGRAM map.
In the filter.rs file, simply check whether the return code is different from 0 :

fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "filter");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;

    let program = unsafe { PROGRAM.get(&tgid).ok_or(0)? };

    let is_excluded = unsafe {
        EXCLUDED_CMDS.get(&program.buffer).is_some()
    };

    if is_excluded || program.ret != 0 { //CHANGED
        debug!(&ctx, "No log for this Binary");
        return Ok(0);
    }

    try_tail_call(&ctx, 1);

    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

You can test in debug mode:

RUST_LOG=debug cargo run
Enter fullscreen mode Exit fullscreen mode

output of cargo run in debug mode

Add duration to display

All that remains is to add the syscall duration. We need to modify the display.rs file. To do this, we'll calculate the duration:

let duration = program.t_exit - program.t_enter;
Enter fullscreen mode Exit fullscreen mode

This gives :

fn try_tracepoint_binary_display(ctx: TracePointContext) -> Result<u32, i64> {
    debug!(&ctx, "display");
    let tgid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let program = unsafe { PROGRAM.get(&tgid).ok_or(0)? };
    let cmd = &program.buffer[..];
    let filename = unsafe { from_utf8_unchecked(cmd) };
    let duration = program.t_exit - program.t_enter;

    info!(
        &ctx,
        "tracepoint sys_*_execve called. Binary: {}, Duration: {}ns", filename, duration
    );
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

We can test:

RUST_LOG=info cargo run
Enter fullscreen mode Exit fullscreen mode

Output of cargo run with an ssh connection

Schematically, here's what we've done in the end:

final diagram with ebpf programs and maps

The git repo of the final code:

https://github.com/littlejo/aya-examples/tree/main/tracepoint-binary-final


The introduction to eBPF with Aya is over. Of course, there are still many paths to take to become an eBPF expert. I hope you've enjoyed it, and that you now have a clearer idea of what eBPF is and what it can do with a little more work.
To create this introduction, I used the following sources:

Liz Rice book: Learning eBPF

For many of the illustrations:

Buzzing across space

I strongly advise you to read them if you want to know more!

A very nice crab

Top comments (0)