Cycle ingénieurProjet d'écoleLecture : 5 min

Programmer le noyau Linux : du système bootable au miniEDR en BPF-LSM

Un Linux construit de zéro, modifié de l'intérieur, attaqué avec un rootkit puis défendu par un miniEDR en eBPF : quatre TP sur une seule image disque.

Contexte
Projet d'école · École 2600, cycle ingénieur, module de sécurité des systèmes (noyau Linux)
Période
Septembre – octobre 2026
Mon rôle
Réalisé en autonomie, quatre TP cumulatifs
Outils
Linux 6.12 · C · eBPF / BPF-LSM · ftrace · QEMU · GRUB

En bref

Le problème

Comprendre le noyau Linux de l'intérieur : le démarrer, y ajouter du code, l'attaquer puis le défendre, sur une image unique livrée en fin de module.

Ce que j'ai fait

J'ai recompilé un noyau 6.12, ajouté deux appels système, écrit un rootkit par hook ftrace et un système de fichiers chiffrant, puis un miniEDR en BPF-LSM.

Le résultat

Une image QEMU bootable, un miniEDR qui journalise et emprisonne des processus : 17 tests sur 17 sur l'image finale, cinq incidents noyau résolus.

17/17tests du miniEDR réussis sur l'image finale
2appels système ajoutés dans l'arbre du noyau
5incidents noyau diagnostiqués et corrigés

Contexte

Module de sécurité des systèmes de mon cycle ingénieur à l'École 2600 : programmer le noyau Linux. Quatre TP enchaînés, et un seul rendu : une image disque enrichie de TP en TP, livrée compressée en fin de module avec les sources et les scripts de test.

La méthode est imposée par l'environnement : tout le code s'écrit et se compile sur la machine hôte, puis s'injecte dans l'image montée en loopback. La machine virtuelle QEMU, un Alpine sans en-têtes noyau, ne sert qu'à charger et tester.

Ce que j'ai fait

  1. Construire un Linux qui démarreUn fichier traité comme un disque, une table MBR, une partition ext4, un système Alpine, un noyau 6.12.9 recompilé et GRUB dans le secteur d'amorçage : l'image démarre jusqu'au login sur la console série.
  2. Ajouter du code au noyauDeux appels système dans l'arbre des sources, déclarés dans la table x86-64 (l'un désactivable par une option de configuration), puis un module chargeable qui expose un périphérique /dev/version.
  3. L'attaquerUn rootkit qui intercepte getdents64 par un hook ftrace : le fichier visé disparaît de tout listage sans être touché sur le disque. Puis un système de fichiers qui chiffre à l'écriture et déchiffre à la lecture.
  4. Le défendreUn miniEDR en BPF-LSM : des programmes eBPF attachés aux points de décision de sécurité du noyau journalisent chaque ouverture et chaque exécution, et peuvent emprisonner un processus en lui refusant des chemins.
Voir les détails techniques

Rootkit. Trois contraintes imposées : passer par ftrace, ne jamais modifier la table des appels système, ne pas toucher au bit de protection en écriture du processeur (CR0). Le vrai point d'entrée est __x64_sys_getdents64, qui reçoit un unique struct pt_regs * ; et kallsyms_lookup_name n'est plus exportée depuis le noyau 5.7 : je la résous par une kprobe temporaire posée sur son propre nom. Le tampon renvoyé est copié en espace noyau, filtré, puis rendu à l'utilisateur.

VM · test du rootkit

=== avant chargement ===
rootkit
=== chargement ===
hidemod: hook pose sur __x64_sys_getdents64
--- /dev/rootkit existe en acces direct :
crw-rw-rw-    1 root     root       10, 124  /dev/rootkit
--- mais absent des listages :
  /dev/      : (masque)
  /home/user : (masque)

Système de fichiers chiffrant. Il se monte par-dessus un répertoire de stockage et décale chaque lettre d'un nombre passé en option : foobar lu en clair est sbbone sur le disque avec un décalage de 13. Il s'appuie sur l'enregistrement d'un système de fichiers, la résolution de chemins et les lectures et écritures noyau.

Kconfig · ce qu'il a fallu activer pour BPF-LSM

CONFIG_BPF_SYSCALL=y       # l'appel bpf() lui-même
CONFIG_BPF_JIT=y           # requis par BPF_LSM (trampolines)
CONFIG_BPF_LSM=y           # attacher des programmes aux hooks LSM
CONFIG_SECURITYFS=y        # expose /sys/kernel/security/lsm
CONFIG_DEBUG_INFO_BTF=y    # types du noyau embarqués : socle du CO-RE

miniEDR. Hooks en mode sleepable, exigé par bpf_d_path pour résoudre le chemin complet ; événements écrits sans copie dans un ring buffer de 16 Mo ; PPID lu via CO-RE, l'offset du champ étant relocalisé au chargement grâce au BTF. La politique d'emprisonnement vit dans une table épinglée dans bpffs, partagée entre le daemon et l'outil jailer, deux processus distincts.

VM · journal du daemon

[FILE_OPEN] pid=423 ppid=381 uid=0 comm=sh   path=/bin/busybox
[EXEC]      pid=423 ppid=381 uid=0 comm=sh
[FILE_OPEN] pid=423 ppid=381 uid=0 comm=cat  path=/etc/hostname

Le filtre par nom de processus ne s'applique qu'à l'émission des événements, jamais à la prison : sinon, il suffirait de porter le nom filtré pour s'évader.

IncidentCauseCorrection
La VM se fige au chargement du rootkitGCC transforme l'appel à la fonction d'origine en saut (tail call) : le hook se rappelle lui-mêmebarrier() et notrace, appel vérifié au désassemblage
ls boucle jusqu'à l'OOM killerPosition du répertoire virtuel et du répertoire de stockage désynchroniséesPositions recopiées avant et après le parcours
umount bloqué à 100 % du processeurMauvais destructeur de superbloc pour des inodes créés à la voléekill_anon_super
Édition de liens statique impossibleBibliothèque statique manquante sur l'hôte DebianCompilation dans un conteneur Alpine (musl), binaires statiques
Programme BPF refusé par le verifierDeux tampons de 256 octets sur une pile limitée à 512Un seul chemin résolu, tampon réutilisé

L'incident

Le shell est emprisonné, mais cat /etc/hostname passe

Problème

Le PID du shell était bien dans la table de prison, et pourtant cat lisait un chemin interdit. Le shell lance chaque commande par fork() puis exec() : cat a son propre PID, absent de la table.

Résolution

Héritage de la politique : si le PID n'est pas dans la table, le programme cherche celui du parent. C'est cet héritage qui rend l'emprisonnement effectif.

Résultats

Architecture du miniEDR : les appels système des processus passent par les hooks LSM, où un programme eBPF vérifié journalise dans un ring buffer lu par le daemon et applique la politique de la table épinglée, écrite par l'outil jailer. Espace utilisateur Processus open(), exec() Daemon miniedr journal des événements Outil jailer emprisonne un PID Noyau 6.12 · BPF-LSM + BTF Hooks LSM ouverture, exécution Programme eBPF vérifié au chargement décide : 0 ou −EPERM Table épinglée PID → chemins interdits appel système ring buffer 16 Mo écrit lue
Fig. 1Le programme eBPF, vérifié par le noyau avant d'être chargé, journalise chaque ouverture et chaque exécution et applique la politique écrite par l'outil jailer.

L'image finale démarre, contient le noyau recompilé, les deux appels système, trois modules (le périphérique, le rootkit et le système de fichiers chiffrant) et le miniEDR. Un script rejoue tout sur l'image livrée, sans rien supposer : du noyau BPF-LSM réellement actif jusqu'à la prison, qui doit refuser /etc et /proc tout en laissant passer le reste. Les tests se lisent comme une chaîne : si un prérequis tombe, ce qui suit n'a plus de sens.

VM QEMU · test-miniedr.sh sur l'image finale (extrait de la sortie)

=== 0. Prerequis ===
  [OK] BPF-LSM actif : capability,selinux,bpf
  [OK] BTF present (6297855 octets)
  [OK] bpffs monte
=== 1. Chargement du programme BPF et epinglage de la map ===
  [OK] miniedr tourne (pid=436)
  [OK] jail_map epinglee dans bpffs
=== 2. Journalisation des evenements ===
  [OK] evenements FILE_OPEN journalises
  [OK] bpf_d_path resout les chemins
  [OK] evenements EXEC journalises
  [OK] ppid resolu via CO-RE
=== 3. Emprisonnement du shell courant ===
  [OK] /etc/ refuse (EPERM)
  [OK] /proc/ refuse (EPERM)
  [OK] /home/user autorise
=== 4. Filtrage cote BPF ===
  [OK] 'cat' journalise
  [OK] bruit 'sh' filtre
  [OK] prison independante du filtre
=== Resultat ===
  17 test(s) OK, 0 test(s) KO

La série se lit de part et d'autre : au TP3, un hook ftrace mal compilé figeait la machine ; au TP4, le verifier BPF refuse simplement de charger un programme incorrect. C'est le basculement qu'ont opéré les outils de détection modernes, comme Falco ou Tetragon, et je l'ai vécu côté attaque comme côté défense.

Ce que j'améliorerais

  • Attacher la politique à un cgroup v2 ou au stockage par tâche plutôt qu'au PID : l'héritage ne remonte qu'au parent direct, et deux fork() suffisent à en sortir.
  • Épingler aussi le lien BPF : aujourd'hui, arrêter le daemon libère la prison, et la table épinglée reste orpheline.
  • Garder la règle tirée du rootkit pour tout hook noyau : un passe-plat d'abord, la logique ensuite, et l'appel vérifié au désassemblage avant tout chargement.

Compétences

  • Programmation noyau Linux (C)
  • Appels système et modules
  • Rootkits et dissimulation
  • eBPF, BPF-LSM et CO-RE
  • Débogage noyau
  • Construction d'images Linux