DEV Community

Cover image for My first Aya program
Joseph Ligier
Joseph Ligier

Posted on • Edited on

1

My first Aya program

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

In this section, we'll create our first Aya program. We'll also see that there are different types of eBPF programs.

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

Aya the bee 2


Example of Hello world Program

I assume you have an operating environment as defined in Part 1. You can also follow this step-by-step lab on creating an Aya program:

Killer koda screenshot

Types of eBPF programs

To generate an Aya program, we'll use cargo generate :

cargo generate https://github.com/aya-rs/aya-template
Enter fullscreen mode Exit fullscreen mode

We're going to answer a few questions:

  • Project Name: the name of the project (I put test-aya)
  • The second question is more interesting:

Which type of eBPF programs?

In fact, there are different types of eBPF programs. As we saw in Part 1, I wrote that there are 3 types of eBPF software:

  • Network
  • Observability and Tracing
  • Security

The choices presented to generate the eBPF program are, in fact, parts of the Linux kernel that can be modified or supervised by eBPF. So you need to think carefully about this before jumping headlong into programming.

As I wrote in Part 1, Aya is not yet fully mature compared with other solutions. Here's one reason why: although it may seem as if there are already a lot of program types available, there are still quite a few missing:

Meta: Add Support For All BPF Program Types #205

  • [x] BPF_PROG_TYPE_SOCKET_FILTER
  • [x] BPF_PROG_TYPE_KPROBE
  • [x] BPF_PROG_TYPE_SCHED_CLS
  • [ ] #206
  • [x] BPF_PROG_TYPE_TRACEPOINT
  • [x] BPF_PROG_TYPE_XDP
  • [x] #207
  • [x] BPF_PROG_TYPE_CGROUP_SKB
  • [x] #208
  • [ ] #209
  • [ ] #210
  • [ ] #211
  • [x] BPF_PROG_TYPE_SOCK_OPS
  • [x] BPF_PROG_TYPE_SK_SKB
  • [x] #212
  • [x] BPF_PROG_TYPE_SK_MSG
  • [x] BPF_PROG_TYPE_RAW_TRACEPOINT
  • [x] #213
  • [ ] #214
  • [x] BPF_PROG_TYPE_LIRC_MODE2
  • [ ] #215
  • [ ] #216
  • [x] #217
  • [ ] #218
  • [ ] #219
  • [x] BPF_PROG_TYPE_TRACING
  • [ ] #220
  • [x] BPF_PROG_TYPE_EXT
  • [x] BPF_PROG_TYPE_LSM
  • [x] #223
  • [ ] #221

What's more, as the Linux kernel continues to evolve, there will certainly be other types of eBPF programs available in the near future.

Time to choose

I tested a few types of eBPF program with Aya before writing this article. To study each type, you can quickly spend days: "I don't understand, it doesn't work" is certainly the phrase I'm uttering the most at the moment. There aren't many similarities between eBPF program types. If you've already played with XDP programs, you'll have other problems with Kprobe programs, for example. For this article, we'll be looking at TracePoint eBPF programs.

TracePoint

Tracepoints are, as the name suggests, places in the Linux code that have been marked (see recommendations). They are mainly used for tracing and debugging the Linux kernel.

code to add in Linux kernel for Tracepoint

All these points are accessible in the /sys/kernel/debug/tracing/available_events file:

A slice of this file

They are in the format:

[Category]:[Name]

Just in time, this is the supplementary question we ask:

Which tracepoint category?

We're going to choose syscalls because everyone knows what a syscall is. Does everyone?

Syscalls

When a program is run, it asks the kernel to perform primary functions called syscalls (system calls).

Syscalls are interfaces between user and kernel space

To view the syscalls performed when a command is run, you can use the strace program. For example, you can use:

strace -c ls command output

Which syscall to use

tracepoint name choice

To find available tracepoint names, simply issue this command:

grep syscalls: /sys/kernel/debug/tracing/available_events
Enter fullscreen mode Exit fullscreen mode

We can see that they are of the : sys_(enter|exit)_$name_of_syscall

  • enter : starting of syscall
  • exit : closing of syscall

We'll arbitrarily choose execve, which is the syscall that executes a program and the one that starts the syscall. So we have sys_enter_execve.

All questions and answers of the MCQ

Test

Now we're going to test the generated program directly:

cd test-aya
RUST_LOG=info cargo run
Enter fullscreen mode Exit fullscreen mode

This will take a little while: the time it takes to download and compile all the dependencies. Now you can go and have a cup of coffee!

Once your program has been demarched, run commands on another terminal, e.g.: ls
On the cargo run terminal, you'll see the following message every time you type a command:

[INFO test_aya] tracepoint sys_enter_execve called
Enter fullscreen mode Exit fullscreen mode

So every time the syscall execve is called, this message will be displayed. We've just created the "Hello World" of the Tracepoint eBPF program (syscall:sys_enter_execve). So, for an eBPF program to start up, it needs an event (event-driven) that tells it to run: this is what we call a hook.

hook schema


Anatomy of a Hello world Aya program

Let's take a look at what the cargo generate command has generated. As we saw in part 1, there are two parts to an eBPF program: kernel space and user space.

Kernel space

The eBPF program source code can be found here: test-aya-ebpf/src/main.rs. It is compiled first.
Here are its contents:

#![no_std]
#![no_main]

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

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

fn try_test_aya(ctx: TracePointContext) -> Result<u32, u32> {
    info!(&ctx, "tracepoint sys_enter_execve called");
    Ok(0)
}

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

If you've been following part 1, there's one thing that should already shock you about Rust code: there is no main() function. This is a prerequisite for writing an eBPF program. To overcome this problem, we use the notation: #![no_main]

Another more disturbing prerequisite is that the standard library (std) is forbidden. Only the core library and all libraries that use it are allowed. This means you can't use the println! macro. To tell Rust not to use the standard: #![no_std]

But then how are we going to display anything if println! isn't possible? That's where Aya comes to the rescue: use aya_log_ebpf::info;
This library can be used to display messages if the RUST_LOG environment variable is set. It also includes warn, error, debug and trace (see documentation).
Thanks to this library, you can display :
info!(&ctx, "tracepoint sys_enter_execve called");

We're going to discard this part of the program:

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

Don't panic! We'll never change it. It's the code that makes the program work.
All that's left is to explain :

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

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

fn try_test_aya(ctx: TracePointContext) -> Result<u32, u32> {
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Two aya_ebpf libraries are used, including one for the tracepoint macro:

#[tracepoint] //here
pub fn test_aya(ctx: TracePointContext) -> u32 {
...
}
Enter fullscreen mode Exit fullscreen mode

In other words, we're going to create an eBPF tracepoint program with the function test_aya().

The pub keyword is to say that the function is public; it's not really “important”, it's just to make the Aya framework work. If we remove these unwanted elements:

use aya_ebpf::programs::TracePointContext;

fn test_aya(ctx: TracePointContext) -> u32 {
    match try_test_aya(ctx) {
        Ok(ret) => ret,
        Err(ret) => ret,
    }
}

fn try_test_aya(ctx: TracePointContext) -> Result<u32, u32> {
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

We're back to a few things we've already seen in part 1.
Things to remember:

  • the test_aya() function is attached to the tracepoint program.
  • All the work will be done in the try_test_aya() function.
  • ctx: this is the context variable that will enable us to go beyond a hello world program.

As you can see, there's no mention of syscalls for the moment. I want this Tracepoint program to be attached to an execve syscall. Good timing: let's take a look at the user-space code now.

User space

In this article, we won't be modifying the user space code. It can be found in this file: test-aya/src/main.rs.
I'll simplify its code for a better understanding:

use aya::programs::TracePoint;
use log::{debug, warn};
use tokio::signal;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut ebpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
        env!("OUT_DIR"),
        "/test-aya"
    )))?; //1
    if let Err(e) = aya_log::EbpfLogger::init(&mut ebpf) {
        warn!("failed to initialize eBPF logger: {}", e);
    } //2
    let program: &mut TracePoint = ebpf.program_mut("test_aya").unwrap().try_into()?; //3
    program.load()?; //4
    program.attach("syscalls", "sys_enter_execve")?; //5

    let ctrl_c = signal::ctrl_c();
    println!("Waiting for Ctrl-C...");
    ctrl_c.await?;
    println!("Exiting...");

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This Rust code is a little more traditional: you can use the standard library, as evidenced by the println! and the main() function.
I've commented out the important parts of the code with numbers:

  • 1: We'll load the previously compiled eBPF code into an ebpf variable
  • 2: We'll start displaying the eBPF logs (for example, the info! from the previous code) in the user space
  • 3: Convert the main function of the eBPF code into Tracepoint code
  • 4: Load the main program function (the test_aya() function)
  • 5: Attach it to the tracepoint syscalls:sys_enter_execve

The rest of the code is for program operation, such as not quitting the program before the logs start.

Injects of eBPF program

If you're new to Rust, there are certainly some parts of the code that may seem obscure to you. That's a good thing! The next section is dedicated to Rust.


Rust, it's getting complicated!

Before modifying the code, I think it's worth reviewing the Rust language a little more theoretically. I didn't want to scare you too much in part 1 😃

Rust video game

Types

In Part 1, we looked at variable declaration and modification. But we didn't talk much about types. This is important because, for example, all functions must be filled in with the various types.
There are two types:

  • scalar types
  • compound types

Scalar types

Let's take a look at scalar types: for integers, for example, we can define very precisely how many bits they are encoded in:

integers types

It's often optional to declare the variable with its type, but to remove any ambiguity, we do it this way:

let my_variable :i64 = 2055;
Enter fullscreen mode Exit fullscreen mode

With println!, you can display the contents in a different way, for example, you can convert to hexadecimal:

println!("{:x}", ma_variable);
Enter fullscreen mode Exit fullscreen mode

That's the magic of the macro! There's also this kind of possibility with info! in Aya. For example, you can convert a number into an IP or MAC address: very useful for eBPF networking.

Let's finish with scalar types and talk about casting, i.e. modifying the type. Let's take a look at this example:

fn addition(x: u32, y: u32) -> u32 {
    x + y
}

fn main() {
    let a: u16 = 10;
    let b: u16 = 20;

    let res = addition(a, b); //❌ ERROR: u16 != u32
    println!("{}", res);
}
Enter fullscreen mode Exit fullscreen mode

This program won't work because the arguments require u32 and not u16. It is of course possible to change the function directly, but you don't always have access to the function as you would in a library. How do I change the code? Use the keyword as:

fn addition(x: u32, y: u32) -> u32 {
    x + y
}

fn main() {
    let a: u16 = 10;
    let b: u16 = 20;

    let res = addition(a as u32, b as u32); // come as you are
    println!("{}", res);
}
Enter fullscreen mode Exit fullscreen mode

Compound types

Now let's talk about compound types, for example how to represent integer arrays:

let _my_array: [i8; 2] = [0, 2]; // An array of 2 entries of type i8
Enter fullscreen mode Exit fullscreen mode

An array has a fixed number of entries: you can't add a number after the fact. There are also dynamic arrays in Rust, such as Vec. However, eBPF programs (on the kernel side) cannot use them: they require arrays with a number of entries already defined before compilation. The strategy is therefore to evaluate the maximum number an array can have by filling it with 0.

let _zeros_array = [0u8; 256]; // An array of 256 entries of 0 of type u8
Enter fullscreen mode Exit fullscreen mode

And what about strings?

The default string in Rust is an array (a slice, to be precise - I'll get a slap on the wrist if I don't) with a fixed number of u8 entries (and therefore UTF-8 encoding). It is not, as in other programming languages, an array of characters (char). The notation is &str.

Thus, it's not possible to concatenate in this way:

   let _toto = "aaaa";
   let _tutu = "bbbb";
   let _toto = _toto + _tutu;
Enter fullscreen mode Exit fullscreen mode

However, if we convert _toto to String, it works:

   let _toto = "aaaa"; //_toto is of type &str
   let _tutu = "bbbb"; //_tutu is of type &str
   let _toto = _toto.to_owned() + _tutu; //_toto is of type String
Enter fullscreen mode Exit fullscreen mode

String is the equivalent of Vec. This makes it possible to have arrays of u8 dynamically.

Structures

Structures are a mixture of different types. The keyword is struct. For example, to create a simple role-playing game :

struct Person {
    name: String,
    age: u32,
}

fn main() {
    let person = Person {
        name: String::from("Alice"),
        age: 30,
    };

    println!("Name: {}, Age: {}", person.name, person.age);
}
Enter fullscreen mode Exit fullscreen mode

If you find initialization a little complicated, you can create methods with the impl keyword:

struct Person {
    name: String,
    age: u32,
}

impl Person {
    fn new(name: &str, age: u32) -> Person {
        Person { name: String::from(name), age }
    }

    fn old(&mut self) { //👴🏻
        self.age += 1;
    }

    fn change_name(&mut self, name: &str) {
        self.name = String::from(name);
    }
}

fn main() {
    let mut person = Person::new("Julia", 40);

    println!("Before: Name: {}, Age: {}", person.name, person.age);

    person.age();
    person.changer_name("Julian");

    println!("After: Name: {}, Age: {}", person.name, person.age);
}
Enter fullscreen mode Exit fullscreen mode

As you can see, thanks to the structures, the programming language reads almost naturally. In a way, it's like an object language.
To give you a more realistic example, take a look at the code generated in the user area above:

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

TracePoint is actually a structure. Let's read the documentation:

TracePoint Aya documentation

What can be deduced from the doc, the approximate code:

struct TracePoint {
[I don't know]
}

impl TracePoint {
    pub fn load(&mut self) -> Result<(), ProgramError> {
        [see the source code]
    }

    pub fn attach(&mut self, category: &str, name: &str) -> Result<...,...>{
        [see the source code]
    }
}
Enter fullscreen mode Exit fullscreen mode

Generic Functions

Previously, we had to cast to have the same type:

fn addition(x: u32, y: u32) -> u32 {
    x + y
}

fn main() {
    let a: u16 = 10;
    let b: u16 = 20;

    let res = addition(a as u32, b as u32); //Here
    println!("{}", res);
}
Enter fullscreen mode Exit fullscreen mode

Using generic function, we don't need to cast:

fn addition<T>(x: T, y: T) -> T
where
    T: std::ops::Add<Output = T>,
{
    x + y
}

fn main() {
    let a: u16 = 10;
    let b: u16 = 20;

    let res = addition(a, b);
    println!("{}", res);
}
Enter fullscreen mode Exit fullscreen mode

This way, you won't have to think about which type you need to use the addition function.
The function is inevitably a little more complicated to write. We won't be writing any in this section. But for use via a library, it's pure bliss (I'm exaggerating a little bit).

Soon, we'll see a generic function:
Example of generic function

Result

We've been seeing Result<...,...> a lot since part 1. Let's talk about it in a little more detail.
Result is an enumeration. We haven't seen what it is, but to put it simply: Result lets you create functions that can result in a success or an error. This is elegant when used with match or ? as we saw in Part 1.

Documentation of Result

As with generic functions, T and E can be of any type (within certain constraints).
As a reminder, here's a simple example of a function that returns Result :

fn get_some_value(value :u32) -> Result<u32, u32> {
    match value > 10 {
        true => Ok(value),
        false => Err(0),
    }
}

const NUM :u32 = 55;

fn main() -> Result<(), u32> {
   let v = get_some_value(NUM)?;
   println!("{}", v);
   Ok(())
}
Enter fullscreen mode Exit fullscreen mode

In this example, only integers greater than 10 are displayed.

Option

Option is another enumeration quite similar to Result: the function returns either Some(T) or None. It's an optional value.

Option documentation

To retrieve the T value, there's no equivalent to the question mark with Result. However, a trick is to convert the Option type with ok_or(...) to Result and then add the question mark.
Here's an equivalent of the previous code with a function that returns Option<T> :

fn get_some_value(value :u32) -> Option<u32> {
    match value > 10 {
        true => Some(value),
        false => None,
    }
}

const NUM :u32 = 55;

fn main() -> Result<(), ()> {
   let v = get_some_value(NUM).ok_or(())?;
   println!("{}", v);
   Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Another solution to retrieve this value is to use unwrap() method:

fn get_some_value(value :u32) -> Option<u32> {
    match value > 10 {
        true => Some(value),
        false => None,
    }
}

const NUM :u32 = 55;

fn main() -> Result<(), ()> {
   let v = get_some_value(NUM).unwrap(); //No need of "?" now
   println!("{}", v);
   Ok(())
}
Enter fullscreen mode Exit fullscreen mode

But there's a big BUT: it's not possible to use unwrap() in kernel space code. Why not? To cut a long story short: unwrap() can panic, and an eBPF program can't afford that:

Extract of Aya book

The piece of code that we discarded in the kernel space above :

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

It can be used to manage the case of unwrap(), for example.

Memory Management

When a variable is created in a program, the compiler must take care of finding where in RAM to add the variable, as well as deleting it to avoid so-called memory leaks. For fixed variables such as integers or non-dynamic arrays, variables are stored in stacks, so there are no problems with releasing them. Thus, because of the restrictions in kernel space programs, there are no clean-up problems in eBPF. Dynamic variables, on the other hand, are stored in heaps, and that's where the problems come in.

In C, we let the developer do this with malloc and free. If he forgets to free memory, it often goes unnoticed, but is a potential source of bugs.

In other languages (such as Python or Go), the garbage collector takes care of cleaning up automatically, without developer intervention. However, this is done at the expense of performance.

In Rust, we use the notion of ownership to solve the problem.

Let's take an example:

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1;

    println!("{}", s1);
}
Enter fullscreen mode Exit fullscreen mode
  • s1 has the String type, which is dynamic

This program cannot compile. Why?

  • s1 will reserve memory space in the heap:let s1 = String::from("Hello");
  • s2 will retrieve the ownership of this memory space: let s2 = s1; Thus s1 has lost ownership of this memory space:

Error of ownership

This means that no two (or more) variables can have the same memory space. Rust will automatically release a variable once it has left the scope.

Borrowing

The most elegant solution is to use a reference (Keyword: &). These references are also known as safe pointers:

fn main() {
    let s1 = String::from("Hello");
    let s2 = &s1;

    println!("{}", s1);
    println!("{}", s2);
}
Enter fullscreen mode Exit fullscreen mode

This will display Hello twice.
We say that s2 borrows s1's value, but s1 retains its property.
Let's take one last example to show you that this isn't always obvious:

fn print_length(s: String) {
    println!("Length: {}", s.len());
}

fn main() {
    let s = String::from("Hello");
    print_length(s);

    println!("{}", s);
}
Enter fullscreen mode Exit fullscreen mode

This code doesn't compile either. Why?
s will reserve a memory space in the heap:
let s = String::from("Hello");
The print_length() function will take the property of this memory space:
print_length(s);
At the end of this processing, Rust will delete this memory space:

fn print_length(s: String) {
 println!("Length: {}", s.len());
}
Enter fullscreen mode Exit fullscreen mode

So the display of s crashes.

To solve this problem simply use this borrowing system:

fn print_length(s: &String) { // We add an "&"
   println!("Longueur: {}", s.len());
}

fn main() {
    let s = String::from("Hello");
    print_length(&s); // We don't forget to also add it

    println!("{}", s);
}
Enter fullscreen mode Exit fullscreen mode

When you're not used to it, you often make the mistake. But the error messages are self-explanatory and therefore easy to correct.

Unsafe

The unsafe keyword is very useful when working at low level. It's the sysadmin equivalent of sudo. By default, Rust has protections, notably for reading memory. The unsafe keyword lets you tell Rust: "Don't worry, I know what I'm doing! If you set unsafe anywhere, Rust will remind you with a warning, or if you don't set unsafe anywhere, Rust will tell you to set it if that's what you really want to do.

Example of warning if you use safe and it is useless

Raw pointers

Let's finish this busy section with raw pointers. The notion is similar to the C pointer. It lets you know the address of the variable. There are two types of raw pointers:

  • If data of type T cannot change : *const T
  • If data of type T can change: *mut T

To convert to a raw pointer, use the as_ptr() function:

as_ptr() function documentation

Let's give an example:

fn main() {
    let a = "toto";
    let memory_location = a.as_ptr() as usize;
    println!("toto is on the memory address: {:x}", memory_location);

    let a = 12;
    let memory_location = &a as *const i32 as usize;
    println!("12 is on the memory address {:x}", memory_location);
}
Enter fullscreen mode Exit fullscreen mode

That's all for this Rust part. I hope it wasn't too complicated and dense to understand. If it was, don't worry: let us guide you through the rest, and we'll review each point for a concrete case.


Improving the program

What are we going to do?

We've already created a program that every time a binary is executed, it displays a few things in the logs. It would be nice to be able to see which binary is being executed. So we're going to create a little program that will log all the binaries that are executed on the machine. It might be handy to see this for security reasons.

Reminder

To do this, you'll need to modify the aya code in kernel space test-aya-ebpf/src/main.rs
This is the main part you'll need to modify:

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

This is based on the ctx variable, which is a TracePointContext structure.
To do this, we'll take a look at the documentation:

ebpf Aya Documentation

The library is located at the program level:
use aya_ebpf::programs::TracePointContext;
This shows all the documentation for each type of eBPF program:

List of contexts for each program

This shows the functions that can be used with TracePointContext:

TracePointContext documentation

We're not interested in the first function, which is used to create a TracePointContext. It's probably used in the macro. That leaves us with the second function: read_at(). This function reads the tracepoint at a certain offset. How do we find this offset?

You need to look in the syscall tracepoint file. To find it, go to: /sys/kernel/debug/tracing/events/[category]/[name]. And the file name is format.
So the file is here: /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format.

Format file for sys_enter_execve tracepoint

We'll try to retrieve the filename with offset 16:

fn try_test_aya(ctx: TracePointContext) -> Result<u32, u32> {
    let filename = ctx.read_at::<u64>(16);
    info!(&ctx, "tracepoint sys_enter_execve called");
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

I wasn't sure what to put in place of T, so I arbitrarily set the type to u64.
If you try to compile :

unsafe compilation error

It works better now with unsafe:

fn try_test_aya(ctx: TracePointContext) -> Result<u32, u32> {
    let filename = unsafe {ctx.read_at::<u64>(16)};
    info!(&ctx, "tracepoint sys_enter_execve called");
    Ok(0)
}
Enter fullscreen mode Exit fullscreen mode

Now let's try using this variable. The result of the read_at function is Result(u64, i64). The aim is to retrieve u64. Just use the question mark (?).
So we'll get :

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

Nevertheless, compilation won't work because if it makes an error, the read_at function returns an i64 and my final function returns a u32.

read_at function documentation

We have to change:

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

Similarly, the Tracepoint function must always return a u32. We must therefore cast the type to u32 :

pub fn test_aya(ctx: TracePointContext) -> u32 {
    match try_test_aya(ctx) {
        Ok(ret) => ret,
        Err(ret) => ret as u32, //cast
    }
}
Enter fullscreen mode Exit fullscreen mode

With these type-matching modifications, it should compile. Let's have a look at the contents of filename. Here's the whole file now if you're lost :

#![no_std]
#![no_main]

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

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

fn try_test_aya(ctx: TracePointContext) -> Result<u32, i64> {
    let filename = unsafe {ctx.read_at::<u64>(16)?};
    info!(&ctx, "tracepoint sys_enter_execve called {}", filename); // the modif
    Ok(0)
}

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

If we run a cargo run again, here's what we see:

[INFO  test_aya] tracepoint sys_enter_execve called 94803283704040
[INFO  test_aya] tracepoint sys_enter_execve called 94803283704112
[INFO  test_aya] tracepoint sys_enter_execve called 94173001563176
[INFO  test_aya] tracepoint sys_enter_execve called 824637710416
[INFO  test_aya] tracepoint sys_enter_execve called 94865232898256
[INFO  test_aya] tracepoint sys_enter_execve called 824638316624
[INFO  test_aya] tracepoint sys_enter_execve called 94865232898256
Enter fullscreen mode Exit fullscreen mode

These numbers are not file names. This was to be expected, given that the type is u64. What I notice is that when I run a command: I'm always number 94865232898256 if it's the server it's the other numbers, if I change user I get a different number.
Well... that's not much help. Shall we give up? The documentation isn't complete, it doesn't say what it's really for.
I look at the source code of the read_at function:

pub unsafe fn read_at<T>(&self, offset: usize) -> Result<T, i64> {
        bpf_probe_read(self.ctx.add(offset) as *const T)
    }
Enter fullscreen mode Exit fullscreen mode

And what does bpf_probe_read do?

bpf_probe_read documentation

In this way, an address in memory is accessed. Now we need to think about how to successfully read this address.
I'll take a look at the helper functions:

helper functions documentation

I have the feeling that this is this function that i need:

bpf_probe_read_user_str_bytes documentation

Let's add these lines and see what happens:

    let mut buf = [0u8; 16]; //let's create a array of u8
    let _my_str_bytes = unsafe { aya_ebpf::helpers::bpf_probe_read_user_str_bytes(filename, &mut buf)? };
Enter fullscreen mode Exit fullscreen mode

Typing error compilation

So all we have to do is change the u64 type to *const u8 (a raw pointer) and we'll change the variable names to be consistent with the documentation:

    let filename_src_addr = unsafe {ctx.read_at::<*const u8>(16)?};
    let mut buf = [0u8; 16];
    let _filename_bytes :&[u8] = unsafe { aya_ebpf::helpers::bpf_probe_read_user_str_bytes(filename_src_addr, &mut buf)? };

    info!(&ctx, "tracepoint sys_enter_execve called {}", filename_src_addr as u64); //We also cast
Enter fullscreen mode Exit fullscreen mode

The compilation works! Now we have a byte-coded file name. Now we need to figure out how to convert it to &str.

Google search for that

That's what I want, but as I said earlier, eBPF doesn't accept the standard library (std).
Miracle! It also exists in the core library :

from_utf8 documentation

So we add:
let _filename = core::str::from_utf8(_filename_bytes);
Let's see if it compiles:

Compilation work but problem with the verifier

So it compiled fine. But the eBPF verifier is not happy at all. The verifier is a kernel protection system that prevents an eBPF program from being launched if it considers it dangerous for the kernel. What we see in the screenshot is JIT code (Just In Time code).

verifier diagrams

The explanation is at the end:
Permission denied

In the documentation, there's another function that might suit us. Perhaps from_utf8 makes too many checks that are not compatible with the eBPF verifier?

alternative from_utf8 uncheck

Let's add _unchecked and unsafe:

let _filename = unsafe { core::str::from_utf8_unchecked(_filename_bytes) };
Enter fullscreen mode Exit fullscreen mode

It compiles!
By modifying the log line:

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

I connect with ssh on the server where the program is installed, I see all the binaries that are executed :

It works: we can see binary but there is a problem

The code works, and if you clean it up afterwards, you'll get a code similar to this one

complete code

You can find the program in GitHub.


That's all for this part. I hope you enjoyed it. It was much more technical than the first part. In the next part, we'll be looking at a few things we've already mentioned: the eBPF map. This will, for example, solve the problem we have with the program: file names are truncated. With an eBPF map, we can solve the problem!

AWS Q Developer image

Your AI Code Assistant

Generate and update README files, create data-flow diagrams, and keep your project fully documented. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs