Cet article est un tutoriel pour réaliser un programme eBPF en utilisant un tracepoint. Si vous n’êtes pas familié avec l’eBPF, vous pouvez vous référer à notre introduction à l’eBPF, ou à la documentation officiel

Qu’est-ce qu’un tracepoint ? Comment le créer avec eBPF ? Quelles sont les étapes à suivre ?

Note : Cet article est aussi disponible en anglais 🇬🇧.

Objectif

Dans cet article, nous allons voir le fonctionnement de l’eBPF grâce à un exemple pratique. Nous allons chercher à masquer un process identifier (PID) aux yeux d’un utilisateur sur un système linux. Cet article introduit les concepts autour de l’utilisation des tracepoints dans un programme eBPF et du fonctionnement de l’appel système (syscall) getdents64.

Le syscall en question va nous servir à masquer l’existence d’un PID pour l’utilisateur, c’est-à-dire lorsqu’il utilise la commande ps -ef.

Ce sujet est découpé en 2 parties, ce premier article aborde les concepts généraux autour du syscall getdents64 et de son fonctionnement en apportant une résolution théorique sous forme de pseudo-code. L’article suivant est axé exclusivement sur la résolution concrète avec du code en C, que vous pourrez retrouver sur le blog la semaine prochaine.

Définition

Un tracepoint eBPF est un point d’ancrage dans le noyau Linux où l’on peut attacher des programmes eBPF pour surveiller des événements spécifiques du système. Il s’agit de point d’attache prédéfini permettant de collecter et observer le comportement du noyau, et dans notre cas, un appel système.

Approche théorique

Sur les systèmes Unix respectant les normes POSIX, un processus est représenté sous forme de répertoire dans /proc qui a pour nom son propre PID. Ainsi, si un processus se lance avec le PID 1234, cela va créer un dossier /proc/1234 dans lequel se trouvera la donnée relative au processus. Donc si nous arrivons à masquer l’existence de ce dossier, le processus ne sera pas visible pour l’utilisateur.

Théoriquement, si le dossier n’est pas visible par l’utilisateur, cela peut sous-entendre qu’un programme n’existe pas.

Comment masquer un répertoire au yeux de l’utilisateur, mais le laisser accessible au kernel pour ne pas toucher à son fonctionnement ?

Identifier le syscall

Le syscall permettant de lister un répertoire s’appelle getdents64 (son prédécesseur getdents n’étant plus utilisé). Il est possible de voir qu’il s’agit d’un syscall utilisé par ps en utilisant l’outil strace.

strace ps permet d’afficher l’appel système getdents64, mais attention, l’affichage sera très long et difficile à lire

Pour pouvoir s’accrocher à ce syscall, il nous faut identifier le point d’attache (hook) eBPF. Il est possible de faire cela en utilisant un tracepoint (c’est-à-dire un événement prédéfini directement dans le noyau), ou un kprobe (une fonction précise du noyau).

Le répertoire /sys contient un grand nombre de documentations sur les différentes méthodes et types des fonctions sur lesquelles il est possible de s’attacher (de hook).
Concernant les tracepoints, ils peuvent avoir un statut activé ou désactivé et il est important de s’assurer que ce soit le cas avant de s’accrocher dessus.
La commande suivante permet de savoir si le tracepoint lié à getdents64 est bien actif (c’est le cas par défaut).

cat /sys/kernel/debug/tracing/available_events | grep getdents64

Grâce à cette commande, nous pouvons constater que nous avons donc 2 hooks possibles :

  • sys_enter_getdents64 qui est exécuté avant le syscall et la lecture du dossier
  • sys_exit_getdents64, qui exécuté après le syscall et qui contient la donnée complète relative au dossier

Un hook de type sys_enter est exécuté avant un appel système, tandis que sys_exit est exécuté après.

Un programme eBPF est défini grâce à la macro suivante. Et seulement ensuite, le programme eBPF peut être défini.

SEC("tp/syscalls/sys_enter_getdents64")

Un programme eBPF n’est autre qu’une fonction qui a comme en-tête la macro SEC.

Dans le cas d’un tracepoint, la fonction va prendre des paramètres de contexte (ctx dans le code suivant) qui contient un certain nombre d’informations relatives au hook, comme les arguments passés au syscall d’origine, par exemple.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("tp/syscalls/sys_enter_getdents64")
int acceis_ebpf_prog(void *ctx) {
 /* 
    Do something here ...
 */
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

Le type void * est utilisé ici de manière générique mais la donnée a bien un type précis qui dépend du tracepoint en question.

Avant de chercher à identifier ce que le ctx a à nous révéler, il est nécessaire de comprendre le fonctionnement du syscall pour savoir quoi chercher.

Comprendre le syscall

La documentation pour ce syscall se trouve directement dans la machine grâce au manuel.

man getdents64

En parcourant ce manuel, il y a 2 choses importantes que nous apprenons à soulever :

  • Il est détaillé la structure d’un linux_dirent ("dirent" pour "directory entries")
# L'appel système getdents() lit plusieurs structures linux_dirent
# depuis le répertoire référencé par le _file descriptor_ (fd) dans le buffer (dirp).

struct linux_dirent {
 ...
 unsigned short d_reclen;  /* Longueur du linux_dirent */
 char           d_name[];  /* Nom du fichier */
 ...
}

linux_dirent possède ici 2 paramètres intérressants. d_name qui est le nom du fichier réprésenté par une chaîne de caractère et permettrait d’identifier le PID à masquer en comparant leurs valeurs. d_reclen représente la taille de la structure dans le buffer.

  • La documentation comprend également un morceau de code qui montre l’utilisation du syscall.
char buf[BUF_SIZE]; // Buffer contenant toutes les entrées linux_dirent
long nread; // Longueur de toutes les entrées (linux_dirent) dans le buffer
struct linux_dirent  *d; // l'entrée courante (contient le type d'entrée, l'inode ...)

nread = syscall(SYS_getdents, fd, buf, BUF_SIZE);

// bpos peut être traduit par buffer position
for (size_t bpos = 0; bpos < nread;) {
 // l'entrée courante est définie par le buffer + la taille totale des entrées précédentes
 d = (struct linux_dirent *) (buf + bpos);

 /*
    ... Ici, nous pouvons print les informations concernant l'entrée courante…
 */

 // Addition de la taille de toutes les entrées déjà parcourues
 bpos += d->d_reclen;
}

L’utilisateur définit un buffer de taille BUF_SIZE. En passant ce buffer au syscall, il est complété d’autant de linux_dirent que la taille du buffer le permet. Le syscall retourne alors nread qui est la taille totale de toutes les entrées linux_dirent dans le buffer.
Pour obtenir une entrée précise dans ce buffer, il faut connaître la position de l’entrée précédente. La boucle for itère sur toutes les entrées en partant de 0 et est incrémentée par la longueur de l’entrée (d_reclen) pour atteindre l’élément suivant.

Les paramètres de linux_dirent, d_name et d_reclen sont très utiles pour notre cas d’usage. Soit identifié l’entrée correspondante au PID à masquer, soit pour incrémenter chaque entrée dans le buffer.
Il reste encore à récupérer ce buffer contenant toutes les entrées depuis le tracepoint sys_enter_getdents64.

Connaître le type du contexte (ctx) d’un tracepoint

Il existe 2 façons de connaître le type du paramètre d’un tracepoint :

  • La première méthode consiste à récupérer le type depuis le répertoire /sys/kernel/debug/tracing/events/syscalls/<NOTRE_TRACEPOINT>
  • La seconde méthode utilise un type décrit dans le fichier de header du noyau linux, vmlinux.h

Récupérer le contexte grâce au répertoire /sys

Le type peut être récupéré directement depuis le répertoire /sys grâce à la commande suivante :

cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_getdents64/format

# name: sys_enter_getdents64
# ID: 818
# format:

#   ...
#   field:unsigned int fd;  offset:16;  size:8; signed:0;
#   field:struct linux_dirent64 * dirent;   offset:24;  size:8; signed:0;
#   field:unsigned int count;   offset:32;  size:8; signed:0;

fd, linux_dirent64 et count sont les arguments passé par l’utilisateur au syscall.
Le buffer contenant toutes les entrées linux_dirent64 est ici visible comme étant le 2nd paramètre du syscall. C’est celui-ci qui nous sera utile pour la suite.

Pour utiliser les valeurs décrite dans ce fichier comme paramètre de contexte, il faut définir une struct contenant chacun de ces types (la plupart sont ici masqués par les ...).

Récupérer le contexte grâce au vmlinux.h

Une autre méthode consiste à utiliser le fichier header du noyau linux à savoir vmlinux.h. Il s’agit d’un fichier généré dynamiquement et qui regroupe l’ensemble des types utilisés dans le noyau linux.

Vous pouvez générer le vmlinux.h grâce à l’outil bpftool, un utilitaire indispensable pour travailler avec l’eBPF.

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

Notre hook est de type sys_enter, donc en faisant un petit ctrl+f et en cherchant sys_enter dans le fichier de header, nous trouvons rapidement le type lié à ce tracepoint.

struct trace_event_raw_sys_enter {
  struct trace_entry ent;
  long int id;
  long unsigned int args[6];
  char __data[0];
};

Il reste ensuite à extraire la donnée qui nous intéresse. Pour cela, les paramètres que l’utilisateur transmet au syscall sont stockés dans args. De ce que nous avons vu dans le fichier /sys/kernel/debug/tracing/events/syscalls/sys_enter_getdents64/format, nous pouvons donc en extraire 3 paramètres, fd, dirent et count. Ils seront placés dans args de sorte que args[0] = fd, args[1] = dirent, args[2] = count. Il suffira de caster les paramètres pour avoir la donnée typée.

Dans la partie 2, c’est cette méthode qui est utilisée.

Récupération du type pour sys_exit_getdents64

Pour connaître le type du paramètre du hook sys_exit_getdents64, il nous suffit de faire la même chose que précédemment.

struct trace_event_raw_sys_exit {
  struct trace_entry ent;
  long int id;
  long int ret;
  char __data[0];
};

Cette fois-ci, nous avons directement le paramètre ret qui contient la donnée relative au retour du syscall. Pour en savoir plus, regardons le manuel du syscall getdents64.

man getdents64

# RETURN VALUE
#       On success, the number of bytes read is returned.  On end of directory, 0 is returned.  On error, -1 is returned, and errno is set to indicate the error.

Donc notre paramètre ret nous servira d’index de référence pour connaître l’avancement dans la lecture du dossier.

Ici ret correspond à nread sur le code indiqué plus haut.

Élaboration du plan, dissimulation d’un PID

Dans le contexte du tracepoint sys_enter_getdents64, les paramètres définis par l’utilisateur (, donc dans l’espace utilisateur, ) sont accessibles. Il faut donc extraire le buffer contenant toutes les entrées dirent pour pouvoir l’utiliser après le syscall, c’est-à-dire une fois le buffer complété.
Pour transmettre de la donnée d’un programme eBPF à un autre, il faudra utiliser les maps, qui sont des structures de données ayant pour rôle de stocker des informations et les partager entre les programmes eBPF ou l’espace utilisateur. Vous pouvez vous référer à l’article introduction à l’eBPF pour plus de détails.

Dans le contexte du tracepoint sys_exit_getdents64, il n’y a que le paramètre long int ret qui est retourné. Cela est donc utile pour connaître le nombre maximum d’éléments lisible dans le buffer.

Nous pouvons schématiser la résolution avec du pseudo-code :

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

/*
 - Définition de la map capable de stocker les informations relatives au syscall
*/

SEC("tp/syscalls/sys_enter_getdents64")
int acceis_sys_enter_getents64(void *ctx) {
 /* 
   - récupération du buffer contenant les entrées linux_dirent64
   - Sauvegarde des informations dans la map
 */
}

SEC("tp/syscalls/sys_exit_getdents64")
int acceis_sys_exit_getents64(void *ctx) {
 /* 
   - récupération du buffer
   - récupération du PID à masquer
   - Identification de l'entrée correspondante au PID (grâce à l'utilisation du d_name)
   - Suppression de l'entrée dans le buffer
 */
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

Une façon de procéder à la suppression de l’entrée dans le buffer serait de modifier la valeur de la longueur de l’entrée (définie par d_reclen) précédent notre dirent à patch comme étant sa longueur plus la longueur de ce dirent.

Conclusion

Dans cet article, nous avons vu comment identifier et obtenir des informations sur un tracepoint eBPF. Nous avons également vu la méthodologie d’approche pour comprendre un syscall et pouvoir l’exploiter.

Pour dissimuler un PID, il faut supprimer l’entrée correspondante dans le buffer du syscall getdents64. Pour cela il faut récupérer ce buffer défini dans l’espace utilisateur avant l’appel au syscall et le sauvegarder dans une map pour une utilisation dans le sys_exit. Une fois cela fait, il fait faire une boucle sur toutes les entrées (linux_dirent64) du buffer pour identifier notre PID en comparant sa valeur avec d_name, puis si l’entrée est effectivement correspondante, surcharger la valeur d_reclen du dirent précédent par sa propre taille plus celle du dirent à masquer.

Dans quelques semaines, vous pourrez lire la partie 2, qui traitera de l’aspect purement pratique de la création d’un programme capable de dissimuler un PID.

Quelques ressources utiles

À propos de l’auteur

Article écrit par Tristan d’Audibert alias Sathi, apprenti ingénieur en cybersecurité chez ACCEIS.