S’initier à eBPF avec Aya (Partie 4)
Structure ton programme Aya avec les tail calls !
S’initier à eBPF avec Aya (Partie 4)
Je débute la programmation en eBPF avec Aya. L’idée de cette série d’articles est de vous en faire profiter également.
Dans les précédentes parties, nous avions créé un unique programme. Dans cette partie, nous allons voir toute la puissance d’eBPF. Nous allons créer des programmes eBPF et nous allons les faire communiquer entre eux. Nous allons ainsi utiliser les tail calls, un mécanisme permettant de chaîner les programmes eBPF.
Si vous préférez, il y a une version anglaise de cet article.
en vente sur AyaExpress (Code promo: TAILCALLS)
Tail calls : modulons le programme
Les appels de quoi ?
Comme on l’a vu dans la partie précédente, les programmes eBPF ont une limite au niveau de la pile mémoire (stack memory). Elle est limitée à 512 octets. Pour contourner cette limite, on utilise des maps eBPF. Mais cela peut encore s’avérer insuffisant quand le programme grandit. Par ailleurs, on pourrait certainement atteindre d’autres limites...
Ainsi au lieu de créer un monolithe avec un unique programme, on va diviser ce programme en plusieurs petits programmes qui communiqueront via des jumps. C’est ce qu’on appelle les tail calls ou enchaînement de programmes (je n’ai pas trouvé mieux comme traduction).
Voilà schématiquement le changement qui est possible d’effectuer :

ça vous rappelle pas un peu les micro-services tout ça ?
Comme c’est le cas depuis le début, nous avons un seul hook (le tracepoint syscall:sys_enter_execve par exemple). Dans le programme d’entrée, nous allons définir différents programmes qui vont continuer le travail et ainsi de suite. Grâce aux maps eBPF, les différents petits programmes eBPF pourront récupérer les valeurs de sorties créées par un autre programme.
Hormis l’intérêt d’aller au delà de limites imposées et de rester dans le noyau Linux, il y a également un intérêt d’ingénierie logiciel : de mieux structurer, de rendre le code plus clair et plus extensible. Dans un projet important, on pourrait imaginer également une répartition des programmes eBPF par équipe.
Quelques limites quand même…
Les programmes qui sont dans les tail calls ont une limite au niveau de la pile mémoire de 256 octets sauf le hook qui en a toujours 512 :

Le premier a 512 octets puis on est toujours limité à 256 pour tous les autres.
Edit : C’est un peu plus compliqué que ça. Merci à Dylan Reimerink de sa remarque.

Cette limitation s’applique uniquement en cas d’utilisation de fonctions en parallèle
Autre limitation plus anecdotique : pour éviter toutes boucles infinies de tail calls, on est limité à 32 jumps. Donc on ne pourra pas écrire plus de 33 programmes. On peut déjà bien s’amuser 😝
C’est quoi cette magie ?
Vous devez certainement vous demander comment ça marche. Pour activer les tail calls, il y a besoin d’une map dédiée à cette tâche. Elle est de type Program Array.

Il faut une version de noyau linux supérieure à 4.2
Comme son nom l’indique, elle doit être remplie d’une liste de programmes. Enfin pour être plus précis une liste de file descriptors (fd) de programmes. Lors du chargement d’un programme eBPF, un file descriptor est créé. C’est un entier qui identifie ce programme.

Qui a bien pu écrire ces programmes eBPF ?

Voilà la structure avec Aya
Ainsi grâce à cette map on va pouvoir démarrer un autre programme eBPF avec la fonction helper [bpf_tail_call](https://docs.ebpf.io/linux/helper-function/bpf_tail_call/) :

On rigole pas avec les droits d’auteur
Pour effectuer un jump, il suffit donc de connaître l’indice de la liste qui correspond à tel programme.
Nous allons voir comment faire cela avec notre programme “fil rouge” :
- comment créer la map ;
- comment remplir cette map de file descriptors de programmes ;
- comment effectuer les jumps grâce à cette map ;
Restructuration du code côté noyau
Récupération du projet
Si vous suivez attentivement les articles, vous pouvez reprendre le code précédent mais je vous conseille de cloner le repo et d’aller dans le répertoire dédié pour pouvoir mieux suivre la suite :
git clone https://github.com/littlejo/aya-examples
cd aya-examples/tracepoint-binary-maps/
cargo run # Vérifier également qu'il fonctionne
Vous pouvez également utiliser le lab killer coda :

Rappel
Si vous prenez le train en marche, le programme initial loggait tous les binaires qui sont exécutés sur votre ordinateur. Dans la précédente partie, nous avons également créé un filtre qui permet de ne pas logger lorsque certains binaires sont exécutés, je les ai externalisé dans un fichier tracepoint-binary/src/constant.rs :
pub const EXCLUDE_LIST: [&str; 2] = ["/usr/bin/ls", "/usr/bin/top"];
Vous pouvez tester avec la commande :
RUST_LOG=info cargo run
Diviser pour mieux régner
Comme vous l’imaginez, nous allons commencer à créer des programmes eBPF supplémentaires dans l’espace noyau. Le code va commencer à prendre de l’embonpoint. Il devient urgent de séparer le fichier tracepoint-binary-ebpf/src/main.rs pour éviter qu’il grossisse trop vite. La structure va être assez simple :
- Un fichier par programme noyau
- Un fichier
main.rsqui contient les inclusions des différents fichiers - Un fichier
common.rscontenant un fourre-tout (maps, constantes, …).
Actuellement, on a un unique programme, on va le mettre dans un fichier qui se nomme hook.rs car c’est le point d’entrée du programme.
Common
On va donc créer un fichier common.rs dans le même répertoire que main.rs. On va déjà y mettre les maps et les constantes :
const ZEROED_ARRAY: [u8; MAX_PATH_LEN] = [0u8; MAX_PATH_LEN];
#[map]
static BUF: PerCpuArray<[u8; MAX_PATH_LEN]> = PerCpuArray::with_max_entries(1, 0);
#[map]
static EXCLUDED_CMDS: HashMap<[u8; 512], u8> = HashMap::with_max_entries(10, 0);
Il faut rajouter la bibliothèque pour accéder à MAX_PATH_LEN :
use tracepoint_binary_common::MAX_PATH_LEN;
Il y a également la macro #[map] et les maps PerCpuArray et HashMap à rajouter :
use aya_ebpf::{
macros::map,
maps::{HashMap, PerCpuArray},
};
Du côté de main.rs, j’ai donc supprimé la constante et les maps que j’ai mis dans common.rs et j’ai rajouté :
mod common;
use crate::common::*;
Cela permet ainsi d’inclure le fichier common.rs dans le code principal.
J’ai mis une * pour ne pas m’embêter avec toutes les inclusions.
Je teste un cargo run . J’ai pas mal d’erreurs. Je regarde la première :

not accessible
Les maps et la constante ne sont pas accessibles. Ça veut dire qu’il faut les mettre en publique. Voici ce que devient le fichier common.rs :
use aya_ebpf::{
macros::map,
maps::{HashMap, PerCpuArray},
};
use tracepoint_binary_common::MAX_PATH_LEN;
pub const ZEROED_ARRAY: [u8; MAX_PATH_LEN] = [0u8; MAX_PATH_LEN];
#[map]
pub static BUF: PerCpuArray<[u8; MAX_PATH_LEN]> = PerCpuArray::with_max_entries(1, 0);
#[map]
pub static EXCLUDED_CMDS: HashMap<[u8; 512], u8> = HashMap::with_max_entries(10, 0);
On compile :

ça fonctionne
il y a quelques warnings, c’est pas grave. On nettoiera à la fin.
Hook
Maintenant on va créer un fichier hook.rs dans le même répertoire que main.rs contenant uniquement le programme Tracepoint :
#[tracepoint]
pub fn tracepoint_binary(ctx: TracePointContext) -> u32 {
match try_tracepoint_binary(ctx) {
Ok(ret) => ret,
Err(ret) => ret as u32,
}
}
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
let buf = BUF.get_ptr_mut(0).ok_or(0)?;
let filename = unsafe {
*buf = ZEROED_ARRAY;
let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
let filename_bytes = bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
if EXCLUDED_CMDS.get(&*buf).is_some() {
info!(&ctx, "No log for this Binary");
return Ok(0);
}
from_utf8_unchecked(filename_bytes)
};
info!(
&ctx,
"tracepoint sys_enter_execve called. Binary: {}", filename
);
Ok(0)
}
Au niveau bibliothèques et constantes, on a besoin maintenant de tout ce qui a dans main.rs:
use aya_ebpf::{
helpers::bpf_probe_read_user_str_bytes,
macros::{map, tracepoint},
maps::{HashMap, PerCpuArray},
programs::TracePointContext,
};
use aya_log_ebpf::info;
use core::str::from_utf8_unchecked;
use tracepoint_binary_common::MAX_PATH_LEN;
use crate::common::*;
const FILENAME_OFFSET: usize = 16;
Le main.rs va être assez vide, principalement ce qu’il faut pour déclarer le fichier hook.rs et common.rs :
#![no_std]
#![no_main]
mod common;
mod hook;
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
Ça permet de séparer tout ce qui n’est pas utile pour les développeurs (panic, no_main et no_std) mais qui a comme but de faire fonctionner le programme au niveau noyau.
On tente la compilation :

ça fonctionne, reste plus qu’à nettoyer
Avec un léger nettoyage pour hook.rs , on se retrouve avec :
use aya_ebpf::{
helpers::bpf_probe_read_user_str_bytes,
macros::tracepoint,
programs::TracePointContext,
};
use aya_log_ebpf::info;
use core::str::from_utf8_unchecked;
use crate::common::*;
const FILENAME_OFFSET: usize = 16;
#[tracepoint]
pub fn tracepoint_binary(ctx: TracePointContext) -> u32 {
match try_tracepoint_binary(ctx) {
Ok(ret) => ret,
Err(ret) => ret as u32,
}
}
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
let buf = BUF.get_ptr_mut(0).ok_or(0)?;
let filename = unsafe {
*buf = ZEROED_ARRAY;
let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
let filename_bytes = bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
if EXCLUDED_CMDS.get(&*buf).is_some() {
info!(&ctx, "No log for this Binary");
return Ok(0);
}
from_utf8_unchecked(filename_bytes)
};
info!(
&ctx,
"tracepoint sys_enter_execve called. Binary: {}", filename
);
Ok(0)
}
Maintenant qu’on a mieux structuré le code. On va commencer la transition vers des programmes avec des tail calls.
Créons les fondations pour les tail calls
Que va-t-on faire ?
Dans la partie 2, nous avons créé un programme qui observait les différents binaires qui sont exécutés dans l’ordinateur. Dans la partie 3, nous avons rajouté un filtre pour ne pas voir certains binaires. Tout était dans un même programme eBPF.
Nous allons séparer les différentes parties du code en différents programmes eBPF :
- récupération de la données (hook)
- filtrage de la données (filter)
- affichage de la donnée (display)

en rouge : récupération de la données, en violet : filtrage et vert : affichage
Ce que nous allons faire comme transformation :

Les schémas sont plus clairs en plus avec les tail calls ?
On va donc avoir trois programmes eBPF.
- Dans le “meilleur” des cas, les programmes vont s’enchaîner de la manière suivante : hook ➡️ filter ➡️ display
- Si la donnée est filtrée, les programmes s’enchaîneront comme cela : hook ➡️ filter
Le programme eBPF global fera au final la même chose mais de façon segmentée. Si vous pensez peut-être qu’on utilise un bazooka pour tuer une mouche, vous n’avez pas tord. Mais ce code permettra d’être plus flexible pour la prochaine partie de la série d’article.
Un dernier détail, si vous avez bien suivi, nous devons créer une map eBPF pour les tail calls. Elle n’a pas été représentée dans le schéma précédent par simplification. En rajoutant cette map, nous avons le schéma suivant :

Au niveau de l’espace utilisateur, le programme va remplir la map JUMP_TABLE en expliquant que l’indice 0 correspond au programme filter et l’indice 1 correspond au programme display.
Au niveau de l’espace noyau, on va utiliser une fonction tail call qui va dire : “je veux aller au programme qui a telle indice”.
Procédé
Dans cette section, on ne va pas tout faire en même temps. On va déjà créer les fondations.
Dans l’espace noyau, on va créer :
- la map dédiée aux tail calls
- les programmes eBPF qui seront tous identiques (on va juste changer les noms)
Dans l’espace utilisateur, on va :
- récupérer la map dédiée aux tail calls
- charger les programmes eBPF qu’on vient de créer
- remplir la map dédiée aux tail calls avec les programmes chargés
La prochaine section sera consacrée à la modification des différents programmes eBPF et aux fameux jumps entre les programmes.
Cette manière de faire permettra qu’à chaque étape le code soit compilable et fonctionnel.
Création de la map eBPF dédiée aux tail calls
Nous allons créer une map eBPF de type ProgramArray.
En voici la documentation :

Il faut le faire dans l’espace noyau dans le fichier qui contient les map eBPF donc tracepoint-binary-ebpf/src/common.rs .
On va donc la créer de cette manière :
#[map]
pub static JUMP_TABLE: ProgramArray = ProgramArray::with_max_entries(2, 0);
On a 2 entrées :
- Programme filter : filtrage de la donnée
- Programme display : affichage de la donnée
On met les *flags* à zéro pour ne pas s’embêter.

C’est quoi numa_node ?
Ne pas oublier de rajouter la bibliothèque pour ce type de map :
use aya_ebpf::{
macros::map,
maps::{HashMap, PerCpuArray, ProgramArray},
};
N’hésitez pas à vérifier que le code compile avec cargo run .
Création des programmes eBPF
Pour créer les programmes eBPF nous allons copier soigneusement le fichier tracepoint-binary-ebpf/src/hook.rs.
Pour le programme filter, on le nomme filter.rs :
cp tracepoint-binary-ebpf/src/hook.rs tracepoint-binary-ebpf/src/filter.rs
- on rajoute
_filteraux fonctions permettant ainsi d’avoir le même programme mais avec un nom différent :
sed -i 's/tracepoint_binary/tracepoint_binary_filter/' tracepoint-binary-ebpf/src/filter.rs
De même pour le programme display, on le nomme display.rs :
cp tracepoint-binary-ebpf/src/hook.rs tracepoint-binary-ebpf/src/display.rs
sed -i 's/tracepoint_binary/tracepoint_binary_display/' tracepoint-binary-ebpf/src/display.rs
Vous voyez tout l’intérêt de la restructuration précédente.
Il ne faut pas oublier dans le fichier main.rs de rajouter ces nouveaux fichiers :
#![no_std]
#![no_main]
mod common;
mod hook;
mod filter;
mod display;
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
N’hésitez pas à vérifier que les 3 programmes eBPF compilent bien avec cargo build.
On a donc créé la map dédiée aux tail calls et on a 3 programmes eBPF identiques. On les modifiera après, c’est juste pour avoir des programmes fonctionnels avec une nomenclature différente.
Récupération de la map eBPF dans l’espace utilisateur
Nous allons donc maintenant travailler dans l’espace utilisateur pour charger les programmes et remplir la map eBPF qu’on vient de créer. On va donc modifier le fichier tracepoint-binary/src/main.rs.
Il faut rajouter du code avant la ligne suivante :
let ctrl_c = signal::ctrl_c();
On va voir la documentation côté utilisateur concernant la map ProgramArray :

l’exemple aide bien !
Il faut donc ajouter :
let map = ebpf.take_map("JUMP_TABLE").unwrap();
let mut tail_call_map = ProgramArray::try_from(map)?;
Je préfère l’écrire en deux instructions car c’est à mon sens plus compréhensible :
- On récupère la map
- On la converti en ProgramArray
Mais vous pouvez également écrire :
let mut tail_call_map = ProgramArray::try_from(ebpf.take_map("JUMP_TABLE").unwrap())?;
Il ne faut pas oublier la bibliothèque au début du programme :
use aya::maps::ProgramArray;
Chargement des programmes et de la map eBPF
On va continuer de travailler à la suite dans le fichier tracepoint-binary/src/main.rs. Maintenant qu’on a récupéré la map eBPF, on va charger les programmes eBPF qu’on vient de créer et on va dire dans la map eBPF à quoi correspond l’indice 0 et l’indice 1.
On va continuer à suivre l’exemple dans la documentation :

On va donc tester ce bout de code :
let prog_0: &TracePoint = ebpf.program("tracepoint_binary_filter").unwrap().try_into()?;
let prog_0_fd = prog_0.fd().unwrap();
tail_call_map.set(0, &prog_0_fd, 0);
Avec cargo run on a l’erreur suivante :

Bon ça ne marche pas bien. La documentation n’est pas bonne. Je vois l’erreur :
thread 'main' panicked at tracepoint-binary/src/main.rs:58:33:
called `Result::unwrap()` on an `Err` value: NotLoaded
Donc il veut que les programmes soient chargées. Je teste donc :
let prog_0: &TracePoint = ebpf.program("tracepoint_binary_filter").unwrap().try_into()?;
prog_0.load()?;
let prog_0_fd = prog_0.fd().unwrap();
tail_call_map.set(0, &prog_0_fd, 0);

Il faut donc que prog_0 soit mutable :
let prog_0: &mut TracePoint = ebpf.program_mut("tracepoint_binary_filter").unwrap().try_into()?;
prog_0.load()?;
let prog_0_fd = prog_0.fd().unwrap();
tail_call_map.set(0, &prog_0_fd, 0);
Ça fonctionne !

Il y a quand même des warnings
On peut évidemment faire un copier-coller du code précédent pour le deuxième programme. Mais on peut également faire une petite boucle pour rendre un peu plus concis et lisible :
let prg_list = ["tracepoint_binary_filter", "tracepoint_binary_display"];
for (i, prg) in prg_list.iter().enumerate() {
{
let program: &mut TracePoint = ebpf.program_mut(prg).unwrap().try_into()?;
program.load()?;
let fd = program.fd().unwrap();
tail_call_map.set(i as u32, fd, 0)?;
}
}
Ce qu’il faut retenir pour les jumps :
- l’indice 0 de la map correspond au programme
tracepoint_binary_filter - l’indice 1 de la map correspond au programme
tracepoint_binary_display
Pour des tail calls plus complexes, il faudrait trouver un moyen simple pour s’y retrouver.
Maintenant on peut compiler le code. Ça devrait fonctionner. Si vous testez le programme, seul le programme hook est lancé. Les programmes filter et display ne feront rien pour l’instant mais ils sont chargés et la map des tail calls a été remplie par ces programmes.

Le code principal qu’on a modifié côté utilisateur depuis le début des articles
Voilà ce qu’on a fait schématiquement :

c’est maintenant la partie en bas qu’il faut modifier
On a mis en place toutes les briques, il ne reste plus qu’à modifier les différents programmes pour :
- qu’ils fassent uniquement ce qu’on lui demande (récupérer la donnée, filtrer ou afficher)
- créer la communication entre chaque programme
Mettons en place les tails calls
Il faut donc transformer le schéma précédent en :

Modification du programme récupération de la données (hook)
La partie code espace utilisateur est donc finie. Nous allons nous concentrer sur l’espace noyau. Et ici en particulier sur le programme qui récupère le nom du binaire :

Nous modifions donc le fichier tracepoint-binary-ebpf/src/hook.rs .
La partie du programme hook que nous allons modifier est :
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
let buf = BUF.get_ptr_mut(0).ok_or(0)?;
let filename = unsafe {
*buf = ZEROED_ARRAY;
let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
let filename_bytes = bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
if EXCLUDED_CMDS.get(&*buf).is_some() {
info!(&ctx, "No log for this Binary");
return Ok(0);
}
from_utf8_unchecked(filename_bytes)
};
info!(
&ctx,
"tracepoint sys_enter_execve called. Binary: {}", filename
);
Ok(0)
}
Nous allons supprimer l’affichage (c’est le programme display qui va s’en charger) :
info!(
&ctx,
"tracepoint sys_enter_execve called. Binary: {}", filename
);
Nous allons également supprimer le filtre (c’est le programme filter qui va s’en charger) :
if EXCLUDED_CMDS.get(&*buf).is_some() {
info!(&ctx, "No log for this Binary");
return Ok(0);
}
Ce qui nous fait :
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
let buf = BUF.get_ptr_mut(0).ok_or(0)?;
let filename = unsafe {
*buf = ZEROED_ARRAY;
let filename_src_addr = ctx.read_at::<*const u8>(FILENAME_OFFSET)?;
let filename_bytes = bpf_probe_read_user_str_bytes(filename_src_addr, &mut *buf)?;
from_utf8_unchecked(filename_bytes)
};
Ok(0)
}
les variables filename et filename_bytes ne sont plus utilisées dans ce programme, on a donc :
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "hook");
let buf = BUF.get_ptr_mut(0).ok_or(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)?;
}
Ok(0)
}
On a rajouté du debug pour vérifier qu’on est bien dans le programme hook. Il faut donc rajouter la bibliothèque pour le debug :
use aya_log_ebpf::debug;
Rien d’extraordinaire pour l’instant.
Maintenant, on va voir comment on dit au programme hook d’aller au programme filter. En suivant l’exemple de la documentation :

L’exemple m’a l’air bien compliqué… On va rajouter juste après bpf_probe_read_user_str_bytes :
JUMP_TABLE.tail_call(ctx, 0);
- 0 étant l’indice du tableau de la map
JUMP_TABLEqui correspond au programme filter qu’on a défini dans l’espace utilisateur
Je vois l’erreur suivante :

Donc on doit mettre :
JUMP_TABLE.tail_call(&ctx, 0);
Ça fonctionne :

Je suis en mode debug
On passe bien du programme hook au programme filter.
Par contre on a un warning :

Je n’aime pas trop les let _ = .
On va regarder la signature de la méthode tail_call() :

Donc elle renvoie Result<!, c_long> . Pas super clair tout ça. Ce qui nous importe c’est de savoir s’il y a une erreur dans Result<>. Il y a une méthode pour ça :

On va donc rajouter ces lignes là :
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 0)};
if res.is_err() {
error!(&ctx, "hook: tail_call failed");
}
- 0 est le premier programme tail call qu’on a défini dans l’espace utilisateur, ça correspond au programme filter.
la macro error n’est pas importée, il faut donc rajouter cette bibliothèque, en factorisant, on a :
use aya_log_ebpf::{debug,error};
On se retrouve alors avec :
use aya_ebpf::{
helpers::bpf_probe_read_user_str_bytes,
macros::tracepoint,
programs::TracePointContext,
};
use aya_log_ebpf::{debug,error};
use crate::common::*;
const FILENAME_OFFSET: usize = 16;
#[tracepoint]
pub fn tracepoint_binary(ctx: TracePointContext) -> u32 {
match try_tracepoint_binary(ctx) {
Ok(ret) => ret,
Err(ret) => ret as u32,
}
}
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "hook");
let buf = BUF.get_ptr_mut(0).ok_or(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)?;
}
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 0)};
if res.is_err() {
error!(&ctx, "hook: tail_call failed");
}
Ok(0)
}
C’est tout pour le programme hook. On a fait le plus dur ! Le reste est du même acabit.
Modification du programme filtrage (filter)

Pour filter, on doit récupérer le contenu de la map BUF :
let buf = BUF.get(0).ok_or(0)?; //buf est une référence à la liste de 512 entrées

T étant une liste de 512 entrées
De la même manière que pour hook, nous allons enlever les choses inutiles.
Ce qui nous fait pour le code principale de filter.rs :
fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "filter");
let buf = BUF.get(0).ok_or(0)?;
let is_excluded = unsafe {
EXCLUDED_CMDS.get(buf).is_some()
};
if is_excluded {
debug!(&ctx, "No log for this Binary");
return Ok(0);
}
Ok(0)
}
Pour lui dire d’aller dans le programme display, il faut également rajouter :
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 1) };
if res.is_err() {
error!(&ctx, "filter: tail_call failed");
}
- 1 étant l’indice du tableau de la map
JUMP_TABLEqui correspond au programme display qu’on a défini dans l’espace utilisateur
Pour compliquer un peu, on pourrait imaginer que si c’est une commande filtrée il irait dans un autre programme pour subir un autre traitement. Je vous donne libre cours à votre imagination…
Au final on se retrouve alors avec :
use aya_ebpf::{
macros::tracepoint,
programs::TracePointContext,
};
use aya_log_ebpf::{debug,error};
use crate::common::*;
#[tracepoint]
pub fn tracepoint_binary_filter(ctx: TracePointContext) -> u32 {
match try_tracepoint_binary_filter(ctx) {
Ok(ret) => ret,
Err(ret) => ret as u32,
}
}
fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "filter");
let buf = BUF.get(0).ok_or(0)?;
let is_excluded = unsafe {
EXCLUDED_CMDS.get(buf).is_some()
};
if is_excluded {
debug!(&ctx, "No log for this Binary");
return Ok(0);
}
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 1) };
if res.is_err() {
error!(&ctx, "filter: tail_call failed");
}
Ok(0)
}
Regardons ce qu’on a fait en testant en mode debug :
RUST_LOG=debug cargo run

Voilà les 3 programmes s’enchaînent bien
- J’ai lancé la commande
ls: hook ➡️ filter - J’ai lancé la commande
man: hook ➡️ filter ➡️ display
C’est exactement ce qu’on voulait !
Modification du programme d’affichage (display)

On finit par le programme le plus simple !
Pour le dernier programme, on va juste récupérer ce qu’il y a dans le buffer de la map et le transformer en &str comme nous l’avons vu dans la partie précédente. On va donc modifier le fichier display.rs :
fn try_tracepoint_binary_display(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "display");
let buf = BUF.get(0).ok_or(0)?;
let cmd = &buf[..];
let filename = unsafe { from_utf8_unchecked(cmd) };
info!(&ctx, "tracepoint sys_enter_execve called. Binary: {}", filename);
Ok(0)
}
Maintenant on va tester en mode debug :
RUST_LOG=debug cargo run

On met bien en évidence les différents programmes qui sont utilisés
Sans le mode debug, nous ne verrions pas la différence avec le programme créé dans la précédente partie.
Fonction inline
Pour finir, nous allons factoriser le code pour faire les jumps :
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 1) };
if res.is_err() {
error!(&ctx, "filter: tail_call failed");
}
Il est utilisé deux fois (dans hook.rs et dans filter.rs ) et pour un autre projet ça pourrait être pas mal d’avoir déjà ce bout de code. On va le mettre dans le fichier common.rs .
C’est quoi une fonction inline ?
Pour des questions de performance, il est déconseillé de créer trop de fonctions. C’est un peu compliqué mais quand on fait appel à une fonction, il y a un JMP (Jump) au niveau assembleur et donc ça coûte cher si on cherche des performances (coût en cycle CPU) et à minimiser les instructions comme c’est le cas en eBPF. Pour résoudre ce problème, on peut inliner sa fonction. Ça permet au compilateur de remplacer automatiquement la fonction dans le code principal et ainsi cela est transparent pour le développeur.
Pour inliner une fonction en Rust, il suffit de rajouter, au dessus de la fonction, le code suivant :
#[inline(always)]
Création d’une fonction inline
Que mettre en entrée de cette fonction ?
- ctx : le contexte du Tracepoint
- index : l’indexe du programme
Cette fonction ne retourne rien : res ne nous intéresse pas.
Donc la signature est quelques choses comme :
fn try_tail_call(ctx: TracePointContext, index: u32) {
[...]
}
On aurait donc :
fn try_tail_call(ctx: TracePointContext, index: u32) {
let res = unsafe { JUMP_TABLE.tail_call(&ctx, index) };
if res.is_err() {
error!(&ctx, "tail_call failed");
}
}
Cette fonction est totalement valide. Cependant pour les problèmes d’emprunt, il est plus intéressant d’utiliser la référence de TracePointContext :
fn try_tail_call(ctx: &TracePointContext, index: u32) {
let res = unsafe { JUMP_TABLE.tail_call(ctx, index) };
if res.is_err() {
error!(ctx, "tail_call failed");
}
}
- Comme on veut inliner la fonction pour des questions de performance, on va avoir dans le fichier
common.rs:
#[inline(always)]
pub fn try_tail_call(ctx: &TracePointContext, index: u32) {
let res = unsafe { JUMP_TABLE.tail_call(ctx, index) };
if res.is_err() {
error!(ctx, "exit: tail_call failed");
}
}
Le fichier common.rs complet avec les bibliothèques :
use aya_ebpf::{
macros::map,
maps::{HashMap, PerCpuArray, ProgramArray},
programs::TracePointContext,
};
use aya_log_ebpf::error;
use tracepoint_binary_common::MAX_PATH_LEN;
pub const ZEROED_ARRAY: [u8; MAX_PATH_LEN] = [0u8; MAX_PATH_LEN];
#[map]
pub static BUF: PerCpuArray<[u8; MAX_PATH_LEN]> = PerCpuArray::with_max_entries(1, 0);
#[map]
pub static EXCLUDED_CMDS: HashMap<[u8; 512], u8> = HashMap::with_max_entries(10, 0);
#[map]
pub static JUMP_TABLE: ProgramArray = ProgramArray::with_max_entries(2, 0);
#[inline(always)]
pub fn try_tail_call(ctx: &TracePointContext, index: u32) {
let res = unsafe { JUMP_TABLE.tail_call(ctx, index) };
if res.is_err() {
error!(ctx, "tail_call failed");
}
}
Ainsi dans le programme hook, on va remplacer le code :
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 0) };
if res.is_err() {
error!(&ctx, "filter: tail_call failed");
}
par : try_tail_call(&ctx, 0);
Ce qui nous fait :
fn try_tracepoint_binary(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "hook");
let buf = BUF.get_ptr_mut(0).ok_or(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)
}
et dans le programme filter :
let res = unsafe { JUMP_TABLE.tail_call(&ctx, 1) };
if res.is_err() {
error!(&ctx, "filter: tail_call failed");
}
par try_tail_call(&ctx, 1);
Ce qui nous fait :
fn try_tracepoint_binary_filter(ctx: TracePointContext) -> Result<u32, i64> {
debug!(&ctx, "filter");
let buf = BUF.get(0).ok_or(0)?;
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)
}
Ne pas oublier de tester qu’il continue de compiler.
Le code du programme hook et filter est quand même plus facile à lire et ça nous a permis de voir la création d’une fonction inlinée 😆
Le code
Le code complet est dans le repo git habituel :
C’est tout pour cette partie. Nous avons vu pas mal de choses dans cette section tail calls eBPF : création et remplissage de la map dédiée, des jumps et la spécialisation de programmes eBPF. Nous avons vu également les fonctions inlines et un peu de restructuration de code Rust.
Dans la dernière partie, nous allons introduire un autre programme eBPF et nous allons voir comment le faire interagir avec celui qu’on a déjà créé.

메타데이터
- post_id
- 3f6db9d8a13f
- slug
- sinitier-à-ebpf-avec-aya-partie-4-3f6db9d8a13f
- url
- https://medium.com/@littel.jo/sinitier-%C3%A0-ebpf-avec-aya-partie-4-3f6db9d8a13f
- canonical_url
- https://medium.com/@littel.jo/sinitier-%C3%A0-ebpf-avec-aya-partie-4-3f6db9d8a13f
- author_url
- https://medium.com/@littel.jo
- status
- ok
- fetched_at
- 2026-06-10 09:45:17