From Kernel with love

From Kernel with love

Après plusieurs années à manipuler des langages plutôt haut niveau (principalement le Python), je suis revenu dernièrement à un langage un peu plus strict dans son approche, sans pour autant être moins fun : le C, plus précieusement le C du kernel Linux.
Qui ne s’est jamais amusé à hooker (ceci est un verbe) un petit appel système à travers un module noyau home-made, ne serait-ce que pour avoir la joie de se prendre un joli kernel panic dans les dents ? La logique était sensiblement la même ; à cela près qu’on ajoutait l’exigence d’aboutir à un résultat plus stable, et plus utile qu’un printk à chaque sys_open. Il fallait aussi se frotter à d’autres mécanismes du noyau, notamment manipuler :

  • un point d’entrée dans /proc pour contrôler (très sommairement, type ON/OFF) la payload du module,
  • un char device dans /dev comme point « de sortie » afin de pouvoir récupérer des informations diverses,
  • de loin, et surtout pour les voir, les locks, mutex, etc. proposés dans le noyau.

L’idée derrière tout ça était d’implémenter un petit keylogger derrière les pseudo-terminaux (ceux qu’on trouve dans /dev/pts/ typiquement).

Je ne vais pas parler de la partie client (récupérer le flux dans le /dev/XXX, trier les datas, etc.), qui n’a pas vraiment d’intérêt. Ce qui me parait utile de présenter, c’est d’un côté l’environnement de développement d’un module noyau, de l’autre la démarche globale qui a amené ce module noyau d’un code très naïf et dangereux pour la stabilité de l’OS à un code toujours un peu naïf certes, mais au moins relativement indolore pour l’hôte et globalement fonctionnel. En bref, ce qui m’a marqué durant ce petit travail (non terminé, mais la base est là).
Mascotte Linux

I. Environnement de développement

La toute première chose à faire, la priorité numéro un lorsqu’on commence benoitement à envisager de développer un module qui va venir se greffer sur le noyau comme une verrue, c’est de faire ses tests dans une machine virtuelle. C’est tout simplement nécessaire : immanquablement le développement va attirer un nombre impressionnant de kernel Oops, très souvent accompagnés de leurs potes moins subtils, les kernel panics aux noms évocateurs, par exemple le très fidèle null pointer dereference.
Ces encombrants invités demandent des reboots fréquents de la machine victime ; faire de cette dernière une machine virtuelle avec un petit montage NFS pour monter les sources en local, permet ainsi de garder tranquillement tout nos emacs, notre doc, notre navigateur ouverts ; en plus le redémarrage d’une machine virtuelle est en général beaucoup plus rapide que celui d’une machine physique.

Deuxième point : oubliez, pauvres hères, le bon vieux débogage au printf (au printk en l’occurrence). Son intérêt, déjà un peu discutable dans des programmes du userland, devient maintenant assez ridicule. La raison principale étant que lorsque ça plante, tous vos terminaux (et donc notre tailf /var/log/kern.log) vont se remplir d’une foule d’informations gentiment fournies par le noyau, mais nos pauvres printk, les malheureux, seront déjà bien loin, et évidemment pas possible de scroller (l’ordi vient de planter, dumbass !) … donc printk inutiles (d’accord, on pourrait les relire au reboot, mais bon…).
Là, si vous avez le bon goût d’utiliser QEMU-KVM comme hyperviseur, le travail des développeurs de ce projet étale sa classe car on peut, de façon triviale, intercaler un gdbserver sous l’exécution de notre machine virtuelle, ce qui permet un débogage distant, de notre module noyau (et de l’ensemble du noyau d’ailleurs, tant qu’on y est !) comme un vulgaire programme !
Dans l’idée, si on y regarde rapidement et sans attention, le concept ne parait pas forcément délirant (une machine virtuelle c’est un process, tac tac ça se débogue easy), mais dans la pratique, et pour avoir déjà essuyé une explication du fonctionnement d’un hyperviseur, je vous jure que les mécanismes pour arriver à des instructions sur le(s) cpu(s) physique(s) ou encore pour accéder à la mémoire sont vraiment très loin d’être triviaux… Alors quand on voit que les quelques étapes suivantes :

  1. dans la machine virtuelle, compiler le noyau avec les options de débogage,
  2. sur l’hôte, relancer notre machine virtuelle avec QEMU (ou KVM) auquel on ajoute un bête argument -s (on peut aller plus finement, mais dans le cas présent c’était suffisant) pour lancer le gdbserver sous la machine virtuelle,
  3. d’où on veut (à part de la machine virtuelle bien sûr), lancer gdb accompagné du fichier vmlinux (et pas vmlinuz hein) qu’on aura préalablement récupéré de notre machine virtuelle après avoir compilé nos sources, afin d’indiquer à gdb le mappage du programme (le noyau donc) qu’on s’apprête à déboguer (cette étape est optionnelle, mais fort utile si vous ne voulez pas vous taper toutes vos stacks en hexa, ce qui est tout de même un peu rude quand on ne connait pas par cœur les adresses des fonctions kernel),
  4. dans gdb enfin, se connecter au flux mis à disposition par gdbserver avec une commande du type target remote <host>:<port>,

… suffisent pour contrôler et déboguer totalement une machine virtuelle à distance… ‘fin je sais pas vous mais moi ça m’a tout de même pas mal bluffé.

II. Le module en lui-même

Le développement du module s’est vraiment fait en tâtonnant, puisque je découvrais tout juste le code kernel. Les idées de base, le superficiel, j’avais touché, mais rien de sérieux. J’appréhendais pas mal la lecture des sources… alors que, sur ce dernier point, on trouve finalement assez bien son chemin. Mais prenons dans l’ordre.

Déjà, au niveau des spécs : je développe pour une Debian 32 bits avec un noyau 2.6.32 (pas de toute première jeunesse donc).
Une chose à vérifier, au cas où, est de disposer pour votre module kernel du même compilateur que celui utilisé pour compiler votre noyau… Mais comme vous allez compiler vous-même ce dernier… pas de problème normalement. Au cas où, vous trouvez l’information dans /proc/version.

1ère version : hook brutal de sys_read

Autant partir quelque part, même si on sait que ça ne peut pas être la version finale. Mettre un hook sur sys_read, c’est un peu comme tirer un filet au travers du Rhône pour récupérer cinq poissons, et on a un ralentissement notable de la réactivité de la machine virtuelle… surtout que finalement, on ne sait pas très bien si le poisson qu’on récupère est le bon ou pas, et donc pour cela il fallait aussi filtrer sys_open, afin de :

  • trier sur les chemins à ouvrir intéressants,
  • si c’est bon, stocker quelque part le int file_descriptor retourné par la vraie sys_open,
  • s’en servir pour reconnaître, dans notre précédente hooked_sys_read, les lectures sur les fichiers qui nous intéressent.

Bref, une grosse usine foireuse pour pas grand chose… Mais cela permit de poser un cadre général et de commencer à fouiller tranquillement.

Ce fut aussi l’occasion de se remettre d’aplomb sur certains mécanismes C. Par exemple, pour modifier la sys_call_table, il faut la passer de son état initial, a priori lecture seule, en lecture-écriture. On doit donc modifier le bit correspondant dans son entrée (appelons celle-ci pte au hasard), et on change les droits avec des combinaisons d’opérateurs pas nécessairement beaucoup utilisés, du genre pte  |= _PAGE_RW pour passer l’adresse en écriture, et surtout pte = pte &~_PAGE_RW pour passer l’adresse en lecture, qui se comprennent bien avec un peu de temps, mais surprennent quand on a trop pris le pli des langages de haut niveau qui ne manipulent pas trop les bits comme ça.

2e version : modification d’un driver interne au kernel

De trop grossier d’abord, je suis manifestement passé à trop fin ensuite : la modification à chaud d’un driver du noyau (pour voir) ; plus précisément, réussir à caler une fonction maison dans une struct tty_operations.

Là, clairement je restais devant un mur. Détourner un appel système est assez simple, toutes leurs adresses sont stockées dans la sys_call_table, et on trouve l’adresse de cette dernière dans /boot/System.map. Sans doute à cause d’une incompréhension sur le rôle du tableau chrdevs[] (lui aussi présent dans /boot/System.map, mais avec un petit flag b comme dans uninitialized), je pensais que les drivers des char devices étaient rangés sensiblement dans la même idée…
Bien sûr, l’adresse indiquée par /boot/System.map ne pointait sur rien de vivant, et j’avais beau décaler mon pointeur à la recherche d’un pantonyme se fondant dans une char_device_struct, je ne tombais sur rien d’exploitable. J’ai ensuite passé beaucoup de temps à lire les headers du noyau, à tenter de trouver une fonction, une structure qui me donnerait un accès à ce Graal, sans succès.
Au bout de quelques jours, j’abandonnais piteusement pour revenir sur des choses plus abordables.

3e version : filtre sur sys_open, puis modification de la struct file_operations

Dans cette dernière version, je changeais de cible pour me concentrer cette fois sur la struct file. Tous les fichiers (et, dans un système UNIX, tout est fichier) possèdent une structure file, qui contient notamment une struct file_operations qui elle pointe sur les fonctions utilisées pour manipuler le fichier.
Le module noyau fait donc d’abord un détournement de l’appel système sys_open pour, dans le cas où le fichier ouvert se trouve dans /dev/pts/, remplacer la structure file_operations (on ne peut pas la modifier vu qu’elle est déclarée const), par une faite maison avec nos propres fonctions custom_read et custom_write.
Ces fonctions custom_read et custom_write viennent ensuite nourrir un buffer circulaire commun, après avoir ajouté des métadatas pour identifier le pseudo-terminal, ou savoir si on lit ou si on écrit… Bien sûr, on renvoie le résultat des véritables fonctions real_read et real_write histoire de ne pas paniquer le kernel.
Ça fonctionne, c’est beau, c’est pur.

Sortir les données du kernel : le char device

Pendant quelques jours, j’ai envisagé d’organiser / trier un minimum les données à l’intérieur même du module noyau, afin de pouvoir les faire sortir de façon facilement lisible par le client (qui aurait pu/dû être distant). Mais les premières implémentations, tout comme les conseils de personnes beaucoup plus au fait de la programmation noyau que moi m’ont convaincu de bannir toute intelligence du code noyau pour le déporter sur le client. L’extraction des données se fait donc assez brutalement sur un char device, via un buffer circulaire interne au module.

Créer un char device et ses fonctions associées (dans mon cas, seules open, release, read et write ont été implémentées) n’est pas très difficile; par contre il faut savoir qu’il n’y a pas de façon propre de le créer si le numéro majeur a été automatiquement alloué par le kernel.
Explication : en gros, les drivers des devices (de type char et block, visible sur le premier bit des droits lorsqu’on fait ls -l dans /dev/, c ou b – ou d si dossier) sont rangés selon deux entiers : major et minor (encore une fois visible avec ls -l dans /dev/, il s’agit des deux numéros après les champs OWNER GROUP).
Par exemple, les devices qui gèrent/manipulent la mémoire ont le major 1 (/dev/mem a le minor 1, /dev/zero le 5, /dev/urandom le 9, etc.). Les pseudo-terminaux esclaves, dans /dev/pts/, ont les major 136 à 143 (c’est sur ces nombres que je m’appuyais pour fouiller la mémoire et cet hypothétique chrdevs[]). Le détail de tout ce beau monde se trouve dans ce fichier, dont la lecture est très intéressante.
La déclaration d’un nouveau device doit donc s’accompagner d’un major et d’un minor (ou plutôt une plage de minors). Afin d’éviter d’écraser un driver déjà existant, on peut demander au kernel de nous attribuer automatiquement un major (avec la fonction alloc_chrdev_region). Mais dans ce cas le point d’entrée dans /dev/ n’est pas créé, il faut le faire soit en ligne de commande avec mknod, soit récupérer le major précédemment attribué et refaire une création du char device en imposant le major, avec la fonction register_chrdev_region. Bref, pas très propre…

Bon au final, le char device est créé, et marche sans souci… à un détail près.
Fonctionnellement, ce char device sert à rendre toutes les informations récoltées depuis les pseudo-terminaux, et stockées dans un buffer circulaire, accessibles à un programme client en userland. Celui-ci n’a qu’à lire le /dev/mon_point_de_sortie pour récupérer les données.
Le problème est que les informations lues en sortie sont de temps en temps différentes de celles présentes dans le buffer; concrètement, des caractères sont perdus dans le voyage. Ces pertes sont rares, donc non critiques lorsqu’elles apparaissent dans les datas (du point de vue du traitement), par contre si elles affectent les métadatas que j’ajoute pour m’y retrouver (quel terminal ? lecture ? écriture ?), ça fait totalement foirer le parser du client. Et vu le niveau d’overhead (plus de 90%), il y a de forte chance que ça affecte les métadata.
Pourquoi tant d’overhead ? D’une part, parce que la chaîne de caractère que j’intercale pour signifier la différence entre data et métadata est assez longue (pour éviter au maximum que cette chaîne n’apparaisse dans la data et tue le parser). Ensuite parce que le traitement par le kernel de chaque caractère tapé dans un pseudo-terminal appelle successivement les fonctions read et write. Donc en tapant « F » par exemple, mon module noyau extrait une soupe du genre : <début méta><lecture><fin méta>F<début méta><écriture><fin méta>F. Et comme l’intelligence – le tri – ne se fait pas dans le module noyau, c’est toute cette bonne grosse bouillie que le client se mange en sortie. D’où l’overhead.
Bon pour ces histoire de pertes, le premier réflexe est de vérifier que le buffer est correct. En l’occurrence, ce n’était pas le cas, la faute certainement à des ajouts (lorsqu’on écrit dans un pseudo-terminal) / retraits (lorsqu’on lit dans /dev/mon_point_de_sortie) lancés en concurrence et qui viennent décaler le pointeur sur la tête du buffer avant qu’on ait récupéré / enlevé la donnée. Ce premier souci s’est très bien réglé avec l’ajout de spinlock pour protéger les sections sensibles.
Par contre à la sortie on constate toujours des erreurs, très rares mais suffisantes pour toujours faire mal au parser. Là, j’avoue que je n’ai pas encore de solution sous la main, c’est en court d’analyse. Ce pourrait être un problème de « vitesse » du client (quoique peu probable, vu que j’ai le même problème, avec la même fréquence, en C comme en Python), ou une faille dans l’implémentation de la fonction read du driver du char device, bien que je l’ai vérifiée de nombreuses fois…

III. La doc

Inutile de partir de zéro pour faire un module noyau, internet est rempli d’exemples qui sont autant de portes d’entrées pour le développement kernel. Par contre les articles ont souvent des défauts qui peuvent conduire à des incompréhensions, notamment l’absence d’indication sur la version du noyau pour lequel le module est censé fonctionner. Par exemple, l’architecture du noyau a pas mal bougé entre, il me semble, la 2.4 et la 2.6, ce qui fait que les exemples emploient des fonctions obsolètes ou renseignent des champs de structures désuets.
Il n’en reste pas moins que ça éclaire tout de même la philosophie générale et permet d’aller fouiller des concepts dont la maîtrise est de toute façon nécessaire.

Parmi les sources de documentation sympas on a :

  • pour les exemples, le site du Phrack, avec du code intéressant pour rentrer dans le bain (j’avoue sans honte que les portions de mon module qui détournent les appels systèmes sont quasiment des copies identiques d’exemples de Phrack), ou encore le site de The Linux Documentation Project,
  • pour la doc, les bouquins de O’Reilly sur les device drivers, ou plus généralement le noyau linux, efficaces (même si quelques détails sont un peu éludés parfois),
  • toujours pour la doc, les sources, les sources, les sources ! Les headers sont indispensables pour compiler le module, mais les sources du noyau sont indispensables pour comprendre ce qu’on fait. Comme on dit, la meilleure documentation c’est le code. En plus c’est beaucoup plus clair qu’on croit, donc faut en bouffer !
  • pour les points de détails, le forum de stack overflow, ou les listes de mail comme lkml, où on tombe pas souvent (voir jamais) exactement sur notre problème mais ça donne des pistes, ça soulève des ambiguïtés, donc c’est utile.
  • pour tout le reste, connaître une personne expérimentée et qui maîtrise les concepts, c’est vraiment un plus. Pas forcément pour qu’il fasse tout le travail à votre place (aucun intérêt, et puis en général s’il est vraiment bon il a clairement autre chose à faire), mais plutôt pour pouvoir avoir, à un moment, une saine discussion avec lui qui permettra de prendre du recul, de bien consolider et structurer toute la connaissance que vous avez avalé (et aussi revenir en pleurant avec tout une liste de concepts/mécanismes à étudier).

IV. Du code ? Naan…

Je ne sais pas bien s’il est bien pertinent de mettre du code ici, surtout dans son état actuel (pas du tout portable, pas optimisé, pas fini quoi). Rien de très difficile dans tout ce que j’ai codé, le seul point un peu tricky (algorithmiquement parlant) était au niveau du buffer circulaire, mais ça n’a rien a voir avec le kernel linux.
L’intérêt pour ma part se trouvait plutôt dans les différentes approche sur la construction du module : qu’elles aient abouti ou non, toutes les implémentations ont permis de fouiller et de comprendre différents mécanismes du noyaux, et c’est l’essentiel. Au final, ce petit travail me fait presque envisager de mettre mon nez dans le driver de ma carte WiFi rlt8188ce qui équipe mon portable et a une réception très foireuse depuis que j’ai upgradé ma distribution…

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.