Nous avons vu ce qu’était un programme de type uProbe dans la première partie : ça peut être un moyen de sonder une bibliothèque.
Nous allons vérifier cela avec un programme Aya qui va récupérer les différents arguments de la fonction execve()
de la Libc.
Je suppose que vous êtes déjà dans un environnement pour développer avec Aya et que vous avez installé bpftrace. Si ce n’est pas le cas, vous pouvez utiliser le lab Killercoda :
Que va-t-on vraiment faire ?#
Libc#
Contrairement à la précédente partie où on a attaché notre programme eBPF à un programme, là nous l’attachons à une bibliothèque partagée.
La libc est la bibliothèque standard du C. Donc à chaque fois qu’un programme en C (ou en C++) est exécuté, potentiellement, un programme eBPF pourra être lancé.
libc
tout au long du chapitre. Pour être plus précis, il faudrait parler de la glibc (GNU C Library) l’implémentation la plus répandue dans les distributions GNU/Linux (Comme Debian ou Red Hat). Mais il y a d’autres implémentations comme musl (notamment pour Alpine Linux) ou ulibc qui sont plus légères et adaptées pour les systèmes embarqués.La fonction execve#
execve
est un appel système (un syscall) du noyau Linux. Mais c’est aussi le nom d’une fonction de la libc
qui fait appel à ce même syscall (un wrapper). Ainsi, à chaque fois que la fonction execve()
de la libc
sera appelée, notre programme eBPF de type uProbe sera lancé.
Les arguments de la fonction execve#
Nous devons également récupérer les différents arguments de la fonction execve()
.
Pour trouver ses arguments, on peut évidemment regarder dans le code source de la libc. Mais il y a plus simple :
man execve
La partie qui nous intéresse est la suivante :
int execve(const char *pathname, char *const _Nullable argv[],
char *const _Nullable envp[]);
On voit que la fonction a trois arguments :
pathname
: le nom de la commande avec le chemin complet (exemple/bin/bash
). Il est de typeconst char *
(équivalent en Rust à*const u8
).argv
: un tableau d’arguments de la commande. Il est de typechar *const _Nullable[]
(équivalent en Rust à*const *const u8
)argv[0]
: le nom de la commandeargv[1]
: la première option- etc.
envp
: un tableau de variables d’environnement de la commande. Il est de typechar *const _Nullable[]
(équivalent en Rust à*const *const u8
).
_Nullable
indique simplement que la valeur peut être NULL
.Comment déclencher le programme eBPF ?#
Prenons un exemple simple. Si tu lances une commande dans un terminal par exemple ls
, que va-t-il se passer ?
- Grâce à la variable d’environnement
PATH
, le shell (par exemple le bash) va trouver le bon chemin pour trouver où se trouve le binairels
:
/usr/bin/ls
- Pour exécuter le binaire, le shell va alors appeler la fonction
execve()
de la libc :
execve("/usr/bin/ls", ["ls"], ["PATH=/bin:/usr/bin", ...])
- Le programme eBPF sera enfin déclenché.
Voici un petit résumé de tout cela :
Il y a évidemment d’autres programmes que des shells qui appellent la fonction execve()
de la libc comme systemd
pour le démarrage des différents programmes d’un système Linux.
Ainsi nous allons créer un programme très proche de celui qu’on avait créé avec le Tracepoint sys_enter_execve
lors des articles d’initiation à eBPF mais celui-ci sera attaché au niveau utilisateur à la fonction execve()
de la libc.
Générons un programme Aya de type uProbe#
Nous avons donc déjà les réponses aux deux questions :
🤷 Target to attach the (u|uret)probe? (e.g libc):
🤷 Function name to attach the (u|uret)probe? (e.g getaddrinfo):
Voyons comment créer un programme eBPF hello world pour ce point d’attache.
Testons avec bpftrace#
Vérifions d’abord que ça fonctionne avec la ligne de commande bpftrace
:
sudo bpftrace -e \
'uprobe:libc:execve { printf("Hello execve\n"); }'
uprobe
: le type de programme eBPFlibc
: le nom de la bibliothèqueexecve
: la fonction à debugger{ printf("Hello execve\n"); }
: le code bpftrace
À chaque fois qu’on lance une commande sur un autre terminal, on voit bien Hello execve
.
Maintenant faisons-le avec Aya.
Génération et compilation du programme Aya#
La commande cargo generate
suivante permet ainsi de génerer le programme eBPF :
cargo generate --name test-uprobe-2 \
-d program_type=uprobe \
-d uprobe_target=libc \
-d uprobe_fn_name=execve \
https://github.com/aya-rs/aya-template
uprobe_target
et uprobe_fn_name
), vous pouvez regarder le fichier test.sh dans le repo aya-template.Maintenant compilons le et installons le dans le noyau Linux :
cd test-uprobe-2/
RUST_LOG=info cargo run
Test du programme#
Sur un autre terminal, lancer un programme quelconque :
ls
Sur le terminal cargo run
vous verrez :
[INFO test_uprobe] function execve called by libc
Dans la partie précédente, on était resté à ce point concernant les uProbes. Regardons comment récupérer les différentes arguments de la fonction execve()
. Commençons par le premier : le nom du binaire.
Récupérons le nom du binaire#
Testons avec bpftrace#
Avant de modifier le code Aya, regardons comment on fait avec bpftrace
. C’est un poil plus compliqué qu’un simple hello world.
Pour récupérer le premier argument, on utilise arg0
:
sudo bpftrace -e \
'uprobe:libc:execve { printf("%d\n", arg0); }'
On récupère l’adresse où se trouve le première argument. Comment le “convertir” en chaîne de caractères ? Il suffit d’utiliser la fonction str()
:
sudo bpftrace -e \
'uprobe:libc:execve { printf("%s\n", str(arg0)); }'
Maintenant qu’on a le brouillon avec bpftrace
, regardons comment l’implémenter avec Aya.
Modifions le code Aya#
Nous devons modifier la fonction suivante du fichier test-uprobe-2-ebpf/src/main.rs
:
fn try_test_uprobe_2(ctx: ProbeContext) -> Result<u32, u32> {
info!(&ctx, "function execve called by libc");
Ok(0)
}
Il faut donc chercher à manipuler la variable ctx
. Voici la documentation :
Il n’y a qu’une méthode qui nous intéresse :
Le premier élément est le nom du binaire qui est exécuté.
Il faut donc rajouter un truc comme ça :
let arg0: *const u8 = ctx.arg(0).ok_or(1u32)?;
*const u8
car le premier argument est de type const char *
en C (cf man execve
)Pour “convertir” ce pointeur en chaîne de caractère on va le faire de façon similaire qu’on avait fait avec les Tracepoints dans l’article d’initiation à la création de programme eBPF avec Aya.
Ainsi on se retrouve avec le code suivant :
fn try_test_uprobe_2(ctx: ProbeContext) -> Result<u32, i64> {
let arg0: *const u8 = ctx.arg(0).ok_or(1u32)?;
let mut buf = [0u8; 128];
let filename = unsafe {
let filename_bytes = bpf_probe_read_user_str_bytes(arg0, &mut buf)?;
from_utf8_unchecked(filename_bytes)
};
info!(&ctx, "function execve called by libc {}", filename);
Ok(0)
}
À l’époque ce n’était pas très clair dans mon esprit.
Expliquons en détail ce code :
- la fonction helper
bpf_probe_read_user_str_bytes()
permet de lire l’adresse mémoire depuis l’espace utilisateur et de récupérer son contenu avec un slice d’octets. On a besoin d’un buffer pour cela. from_utf8_unchecked()
permet de convertir un slice d’octets en un&str
(la version sans vérification car sinon le verifier eBPF n’accepte pas)
Pour finir, voici un petit schéma qui explique comment récupérer une chaine de caractères depuis l’espace utilisateur :
bpf_probe_read_user_str_bytes
qui prend tout son sens pour une uProbe.Testons maintenant la modification#
Vérifions que le code fonctionne toujours :
RUST_LOG=info cargo run
Sur un autre terminal, lançons une commande quelconque :
ls
Sur le terminal cargo run
vous verrez :
[INFO test_uprobe_2] function execve called by libc /usr/bin/ls
Nous étions resté à ce niveau là pour les articles d’initiation à eBPF avec Aya. Mais nous aurions également pu aller plus loin en récupérant les options de la commande et ses variables d’environnement. Voyons comment le faire.
Récupérons les options de la commande#
Testons avec bpftrace#
Avant de le faire avec Aya, nous allons regarder comment le faire avec bpftrace. Pour récupérer le deuxième argument, on doit utiliser arg1
.
Comme arg1
est un pointeur de pointeur. On ne peut pas utiliser directement la fonction str()
. Il faut déréférencer arg1 pour avoir un seul pointeur.
Pour cela, il suffit d’utiliser *
.
Ce qui nous fait :
bpftrace -e \
'uprobe:libc:execve { printf("%s\n", str(*arg1)); }'
On récupère alors le premier élément du tableau qui est le nom de la commande. Il faut donc se déplacer dans le tableau si on veut récupérer les différentes options. Chaque élément a une taille de 8 octets (uniquement valable en 64 bits).
Pour aller au deuxième élément du tableau, c’est à dire à la première option, il suffit de se déplacer de 8 octets (en ajoutant 8) :
bpftrace -e \
'uprobe:libc:execve { printf("%s\n", str(*(arg1+8))); }'
Vous allez voir que la difficulté va être sensiblement la même en Rust.
Modifions le code Aya#
Avec Aya, pour récupérer le deuxième argument, il faut rajouter ce bout de code :
let argv: *const *const u8 = ctx.arg(1).ok_or(1u32)?;
Comment récupérer la nième option de la commande ? Il faut utiliser la fonction add pour pouvoir décaler son pointeur vers la bonne adresse mémoire :
Par exemple, pour récupérer la première option, on va décaler de 1 :
let argv1 = argv.add(1);
add()
permet de se déplacer d’addresse mémoire de 1 par 1 sans tenir compte de l’architecture.Par contre argv1
est encore de structure *const *const u8
. Il faut maintenant déréférencer pour obtenir *const u8
.
Il y a une fonction toute prête pour cela :
bpf_probe_read_user
permet de lire le contenu stocké dans le pointeur depuis l’espace utilisateur et de renvoyer une copie de sa valeur.On a donc :
let argv1_deref: *const u8 = bpf_probe_read_user(argv1)?;
Maintenant que argv1_deref
est de structure *const u8
, il faut le transformer en &str
. On se retrouve alors avec un code similaire à la récupération du nom du binaire. Ça serait probablement utile de créer une fonction pour un projet “sérieux”.
Voici le code complet pour récupérer la première option :
let argv: *const *const u8 = ctx.arg(1).ok_or(0u32)?; //arg1
let mut buf = [0u8; 16];
let argname = unsafe {
let argv1 = argv.add(1); //arg1+8
let argv1_deref: *const u8 = bpf_probe_read_user(argv1)?; //*(arg1+8)
let argname_bytes = bpf_probe_read_user_str_bytes(argv1_deref, &mut buf)?;
from_utf8_unchecked(argname_bytes) //str(*(arg1+8))
};
info!(&ctx, "function execve called by libc {}", argname); //printf("%s\n", str(*(arg1+8)));
bpftrace
.Testons maintenant la modification#
Vérifions que le code fonctionne toujours :
RUST_LOG=info cargo run
Sur un autre terminal, lançons une commande avec une option :
ls -lrt
Sur le terminal cargo run
vous verrez :
[INFO test_uprobe_2] function execve called by libc /usr/bin/ls
[INFO test_uprobe_2] function execve called by libc -lrt
C’est le comportement qu’on voulait.
Si on lance une commande sans option que se passe-t-il ?
man
Sur le terminal cargo run
vous ne verrez que :
[INFO test_uprobe_2] function execve called by libc /usr/bin/man
Que s’est-il passé ?
Cette partie du code ne s’est pas affichée :
info!(&ctx, "function execve called by libc {}", argname);
Comme la commande n’a pas d’argument, cette partie du code est partie en erreur :
let argname = unsafe {
let argv1 = argv.add(1); //arg1+8
let argv1_deref: *const u8 = bpf_probe_read_user(argv1)?; //*(arg1+8)
let argname_bytes = bpf_probe_read_user_str_bytes(argv1_deref, &mut buf)?;
from_utf8_unchecked(argname_bytes) //str(*(arg1+8))
};
Et donc le programme est parti en erreur et n’a jamais parcouru le dernier info
.
Cet épisode est maintenant terminé ! Nous avons vu comment récupérer les arguments d’une fonction d’un programme en C notamment pour des chaînes de caractères et des tableaux de chaînes de caractère et comment les afficher.
Dans le prochain épisode, nous allons voir comment profiler une fonction d’un programme.