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.
En bref
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.
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.
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.
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
- 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.
- 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. - L'attaquerUn rootkit qui intercepte
getdents64par 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. - 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.
| Incident | Cause | Correction |
|---|---|---|
| La VM se fige au chargement du rootkit | GCC transforme l'appel à la fonction d'origine en saut (tail call) : le hook se rappelle lui-même | barrier() et notrace, appel vérifié au désassemblage |
ls boucle jusqu'à l'OOM killer | Position du répertoire virtuel et du répertoire de stockage désynchronisées | Positions recopiées avant et après le parcours |
umount bloqué à 100 % du processeur | Mauvais destructeur de superbloc pour des inodes créés à la volée | kill_anon_super |
| Édition de liens statique impossible | Bibliothèque statique manquante sur l'hôte Debian | Compilation dans un conteneur Alpine (musl), binaires statiques |
| Programme BPF refusé par le verifier | Deux tampons de 256 octets sur une pile limitée à 512 | Un seul chemin résolu, tampon réutilisé |
L'incident
Le shell est emprisonné, mais cat /etc/hostname passe
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.
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
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.