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.🇫🇷
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.
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
You can also do the Killercoda lab, which follows step-by-step the creation of tracing:
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
.
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
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)
}
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")?;
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")?;
If it's not clear here's the modified code:
We've just created the “hello world” for the Tracepoint syscalls:sys_exit_execve
. Let's test it:
RUST_LOG=info cargo run
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
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)?;
So we'll have something like :
let ret = unsafe { ctx.read_at::<????????>(16)? };
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;
-
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;
- So we need to find the equivalent of long in Rust.
-
signed:1
so it's already an integer of typei??
.
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)? };
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)
}
We can now test :
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):
- The binary exists but is not executable (code: -13):
- The binary is executable but doesn't have the right libraries, e.g. a binary from a Mac(code: -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):
To use it in Rust, there is the Aya library which makes a binding :
So to use it, you need to add the library for each file it uses:
use aya_ebpf_bindings::helpers::bpf_ktime_get_ns;
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
[...]
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);
[...]
Now let's test:
RUST_LOG=debug cargo run
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"
Let's test again:
RUST_LOG=debug cargo run
Typing ls
then man
in another console finds :
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.
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?
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.
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.
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() :
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):
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.
In the common.rs
file, add the following map:
#[map]
pub static T_ENTER: LruHashMap<u32, u64> = LruHashMap::with_max_entries(16, 0);
Don't forget to update the libraries to be used:
use aya_ebpf::{
macros::map,
maps::{HashMap, PerCpuArray, ProgramArray, LruHashMap},
programs::TracePointContext,
};
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)?;
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)
}
Don't forget to add the library :
use aya_ebpf::helpers::bpf_get_current_pid_tgid;
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)? };
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)
}
Add dependency recovery:
use aya_ebpf::helpers::bpf_get_current_pid_tgid;
use crate::common::*;
We can now test:
RUST_LOG=info cargo run
Merge logs
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);
by:
#[map]
pub static BUF: LruHashMap<u32, [u8; MAX_PATH_LEN]> = LruHashMap::with_max_entries(16, 0);
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)
}
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)?;
To reset the buffer, we'll use the function to add a key and a value to a hash map:
We'll then have :
BUF.insert(&tgid, &ZEROED_ARRAY, 0)?;
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)
}
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)
}
To retrieve the buffer, use the get
function:
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
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)
}
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)
}
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)
}
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)
}
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) };
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)
}
Let's test:
RUST_LOG=info cargo run
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);
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);
Uhh, isn’t that starting to be a lot of 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
}
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,
}
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);
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);
To sum up what we want to do in this section, it's a map that will centralize all information:
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.
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)
}
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,
};
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:
This gives :
PROGRAM.insert(&tgid, &INIT_STATE, 0)?;
Now we'll modify the values of the tgid key with the following function:
To retrieve *mut V
:
let program_ptr = PROGRAM.get_ptr_mut(&tgid).ok_or(0)?;
We'll get :
let program_state = unsafe { &mut *PROGRAM.get_ptr_mut(&tgid).ok_or(0)? };
So, to modify the t_enter
entry, we need to do :
program_state.t_enter = t;
Then, to modify the buffer, do :
bpf_probe_read_user_str_bytes(filename_src_addr, &mut program_state.buffer)?;
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)
}
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:
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)
}
Just replace the buf
variable :
let buf = unsafe { BUF.get(&tgid).ok_or(0)? };
with :
let program = unsafe { PROGRAM.get(&tgid).ok_or(0)? };
and therefore modify the code :
let is_excluded = unsafe { EXCLUDED_CMDS.get(buf).is_some() };
with :
let is_excluded = unsafe { EXCLUDED_CMDS.get(&program.buffer).is_some() };
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)
}
display
For the display program, as for the filter program, only the buffer needs to be retrieved from the PROGRAM
map:
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)
}
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)
}
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.
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)
}
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;
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)
}
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);
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:
Cut the following line from hook.rs
and paste it into hook_exit.rs
:
try_tail_call(&ctx, 0);
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)
}
You can test in debug mode:
RUST_LOG=debug cargo run
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;
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)
}
We can test:
RUST_LOG=info cargo run
Schematically, here's what we've done in the end:
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:
For many of the illustrations:
I strongly advise you to read them if you want to know more!
Top comments (0)