Hooks et masquage
Cette page explique la partie la plus kernel du projet: comment wlkom.ko intercepte certains syscalls, comment il récupère leurs arguments, puis comment il cache ou bloque ce qui doit rester invisible dans la démonstration.
L'idée générale est simple:
Programme userland -> syscall Linux -> ftrace -> hook WLKOM -> syscall original si autorisé
Le détail moins simple, c'est que chaque syscall ne donne pas les mêmes informations. getdents donne un buffer de noms. read donne un fd et un buffer. openat donne un chemin. Donc le module utilise plusieurs familles de hooks, chacune adaptée à un problème précis.
Pour les définitions plus lentes, voir les sections dédiées: ftrace, pt_regs, uaccess, fdget/d_path, résolution de chemins et macros kernel utiles.
Fichiers concernés
| Fichier | Rôle |
|---|---|
src/core/symbols.c | Retrouve les adresses des symboles kernel avec kprobe et kallsyms_lookup_name. |
src/hooks/ftrace_hooks.c | Installe et retire les hooks ftrace. |
src/hooks/dirent_hooks.c | Intercepte getdents et getdents64. |
src/hooks/dirent_filter.c | Retire des records linux_dirent dans un buffer de listing. |
src/hooks/read_filter.c | Filtre read, pread64, readv et refuse certains mmap. |
src/hooks/access_hooks.c | Bloque les accès directs par chemin: openat, statx, access, etc. |
src/hooks/hidden_names.c | Centralise les règles de noms, chemins et lignes sensibles. |
include/wlkom_hooks.h | Déclare les hooks et les pointeurs vers les syscalls originaux. |
Ce qui est hooké
| Famille | Syscalls | Pourquoi |
|---|---|---|
| Listing de dossiers | getdents64, getdents | Retirer des noms avant que ls, find ou un explorateur ne les voient. |
| Lecture de fichiers texte | read, pread64, readv | Renvoyer une vue filtrée des fichiers de persistance. |
| Mapping mémoire | mmap | Empêcher une lecture directe qui éviterait les hooks read. |
| Accès par chemin | openat, openat2, newfstatat, statx, access, faccessat, readlinkat, unlinkat | Bloquer les chemins protégés même si l'utilisateur les tape directement. |
Le point à retenir: cacher dans un listing ne suffit pas. Si le nom n'apparaît pas dans ls, un utilisateur peut quand même tenter cat /opt/wlkom_data/.cmd.out. C'est pour cela qu'il y a aussi les hooks par chemin.
Résolution des symboles
Au chargement, le module doit trouver l'adresse réelle des fonctions __x64_sys_*.
Il ne peut pas écrire:
__x64_sys_openat = 0xffffffff81234567
car cette adresse change selon le noyau, KASLR et la configuration.
Le code fait donc:
Le kprobe ne sert pas à intercepter les syscalls. Il sert surtout de lampe torche pour retrouver les adresses. L'interception elle-même est faite par ftrace. La mécanique complète est détaillée dans Kprobes et symboles.
Détournement avec ftrace
Chaque entrée wlkom_ftrace_hook contient:
symbol_name nom kernel, par exemple "__x64_sys_openat"
hook_function fonction WLKOM appelée à la place
original_function endroit où stocker l'adresse du vrai syscall
symbol_address adresse réelle résolue au chargement
ops structure ftrace_ops utilisée par ftrace
Installation d'un hook:
- Résoudre l'adresse du symbole.
- Sauvegarder cette adresse dans le pointeur
wlkom_original_*. - Préparer
ftrace_ops. - Limiter
ftraceà cette adresse avecftrace_set_filter_ip. - Activer le hook avec
register_ftrace_function.
Les flags importants:
| Flag | Rôle |
|---|---|
FTRACE_OPS_FL_SAVE_REGS | Demande à ftrace de fournir les registres CPU. |
FTRACE_OPS_FL_IPMODIFY | Autorise le hook à modifier registers->ip. |
FTRACE_OPS_FL_RECURSION_SAFE | Indique que la récursion est gérée par le hook. |
Le geste décisif est ici:
registers->ip = adresse_de_notre_hook
ip signifie Instruction Pointer. C'est l'adresse de la prochaine instruction exécutée par le processeur. En changeant ip, on ne modifie pas le syscall sur disque, on change la direction prise au moment de l'appel.
Les flags FTRACE_OPS_FL_SAVE_REGS, FTRACE_OPS_FL_IPMODIFY, notrace, container_of et within_module sont expliqués dans ftrace et macros kernel utiles.
Éviter la récursion
Le thunk ftrace vérifie:
if (!within_module(parent_ip, THIS_MODULE))
registers->ip = hook_function
Pourquoi? Parce que le module appelle lui-même des fonctions qui peuvent finir par toucher des syscalls hookés. Si on redirige aussi les appels internes du module, on peut faire:
hook -> opération interne -> hook -> opération interne -> hook -> ...
et finir en boucle. within_module() évite ce piège en laissant passer les appels qui viennent déjà de wlkom.ko.
Arguments dans pt_regs
Les hooks ne reçoivent pas openat(dirfd, path, flags, mode) comme une fonction C normale. Ils reçoivent un struct pt_regs, c'est-à-dire l'état des registres CPU au moment de l'appel.
Sur x86_64, les arguments utiles sont notamment:
| Registre | Exemple dans le projet |
|---|---|
di | Premier argument: fd, dirfd ou pathname selon le syscall. |
si | Deuxième argument: buffer ou pathname. |
dx | Troisième argument: taille, mode ou pointeur. |
r10 | Quatrième argument sur certains syscalls. |
r8 | Cinquième argument, utilisé ici pour le fd de mmap. |
ip | Adresse d'instruction modifiée par ftrace. |
Exemple:
read(fd, buf, count)
fd = registers->di
buf = registers->si
count = registers->dx
C'est pour cela que ce code est lié à x86_64 et aux symboles __x64_sys_*.
Pour relire calmement le lien entre syscall, registres et asmlinkage, voir pt_regs.
Masquage des listings
getdents et getdents64 sont utilisés pour lire le contenu d'un dossier. Un programme comme ls ne reçoit pas un tableau propre de chaînes. Il reçoit un buffer contenant des records collés les uns aux autres.
Image simplifiée:
[ "." ][ ".." ][ "wlkom_data" ][ "test.txt" ]
Chaque record contient surtout:
d_reclen taille de ce record
d_name nom visible
Le hook fait:
Pourquoi copier dans un buffer kernel? Parce que le buffer donné au syscall appartient au processus userland. Le noyau ne doit pas le parcourir comme un pointeur normal. Il utilise donc copy_from_user, modifie une copie côté kernel, puis renvoie le résultat avec copy_to_user.
La règle à retenir est dans Mémoire userland et uaccess: un pointeur marqué __user ne se déréférence pas comme un pointeur C normal.
Supprimer un dirent
Le filtre parcourt le buffer en avançant de d_reclen.
Cas simple:
[ "." ][ "secret" ][ "test.txt" ]
Pour cacher "secret", on agrandit le record précédent:
".".d_reclen = taille(".") + taille("secret")
Userland avance donc de "." directement à "test.txt". Il ne visite jamais "secret".
Cas spécial:
[ "secret" ][ "." ][ ".." ]
Si l'entrée à cacher est la première, il n'y a pas de record précédent à agrandir. Le code utilise alors memmove() pour décaler le reste du buffer vers le début.
Noms cachés
wlkom_should_hide_name() cache:
| Règle | Exemple |
|---|---|
Nom contenant wlkom | wlkom.ko, wlkom.conf. |
Nom égal à wlkom_data | Dossier runtime. |
Nom commençant par .wlkom | Fichiers temporaires de labo. |
| Artefacts runtime | .cmd.out, .screen.png, .screen.txt. |
Cette logique est centralisée dans hidden_names.c. C'est important: sinon getdents, getdents64 et les tests de chemins pourraient cacher des choses différentes.
Filtrage de lecture
Le hook de lecture est plus ciblé. Il ne modifie pas toutes les lectures de tous les fichiers.
Il filtre seulement les fichiers liés à la persistance:
/etc/modules
/etc/modules-load.d/*
/etc/modprobe.d/*
*wlkom.conf
Les lignes retirées sont celles qui mentionnent:
wlkom
options wlkom ...
/opt/wlkom_data
/lib/modules/.../wlkom.ko
Flux simplifié:
Pourquoi reconstruire une vue filtrée complète? Parce que si on retire une ligne au milieu, les offsets vus par userland ne correspondent plus au fichier réel. Le code travaille donc avec un offset dans la vue filtrée, pas seulement dans le fichier brut.
Les fonctions filp_open, kernel_read, loff_t et les pointeurs d'erreur sont expliqués dans Lecture de fichiers depuis le noyau et Pointeurs d'erreur.
read, pread64 et readv
Les trois variantes lisent des octets, mais pas de la même façon:
| Syscall | Différence |
|---|---|
read | Lit depuis file->f_pos, puis avance f_pos. |
pread64 | Lit depuis un offset donné par l'appelant, sans modifier f_pos. |
readv | Lit dans plusieurs buffers userland décrits par des iovec. |
Le module garde le comportement attendu:
readetreadvavancent la position visible après lecture.pread64ne change pas la position courante.readvcopie la sortie filtrée dans plusieurs buffers, en respectant les copies partielles.
Pour comprendre le tableau de buffers utilisé par readv, voir readv et iovec.
Pourquoi bloquer mmap
mmap peut mapper un fichier directement en mémoire. Dans ce cas, un programme peut lire le contenu sans appeler read.
Donc, pour les fichiers de persistance:
mmap(fichier_persistence) -> -EACCES
Le hook ne bloque pas les mappings anonymes (MAP_ANONYMOUS) ni les mappings sans fd valide. Il cible seulement le contournement qui révélerait les lignes brutes.
Voir aussi mmap pour la différence entre mapping fichier et mapping anonyme.
Blocage des accès par chemin
Le listing et la lecture ne couvrent pas tout. Un utilisateur peut connaître un chemin et tenter de l'ouvrir directement.
access_hooks.c protège donc:
/opt/wlkom_data
/sys/module/wlkom
segment wlkom_data
/lib/modules/.../wlkom.ko
Le hook ne se contente pas du texte fourni par userland. Il vérifie trois vues:
- Le chemin brut copié avec
strncpy_from_user. - Le
dirfd, si le chemin est relatif à un dossier déjà ouvert. - Le chemin résolu avec
user_path_at()puisd_path().
Schéma:
Cela évite plusieurs contournements simples:
- chemin relatif depuis un
dirfddéjà ouvert - symlink vers une zone protégée
- test d'existence avec
statxouaccessau lieu d'unopen.
Les notions AT_FDCWD, LOOKUP_FOLLOW, user_path_at et path_put sont détaillées dans Résolution de chemins kernel.
Bypass interne
Le module doit parfois accéder à ses propres fichiers:
- lire
/opt/wlkom_data/.cmd.out - supprimer
.cmd.out - lire
.screen.txt - créer ou nettoyer des fichiers temporaires.
S'il passait par ses propres hooks d'accès, il pourrait se bloquer. Le compteur atomique wlkom_bypass_access sert donc de panneau "c'est nous, laisse passer" autour des commandes lancées par call_usermodehelper.
Le compteur est atomique parce que les hooks et le thread réseau peuvent être appelés dans des contextes concurrents. On évite ainsi un simple booléen global fragile.
La primitive atomic_t est replacée dans Bypass interne.
Masquage du module chargé
Le masquage du module lui-même ne passe pas par ftrace.
Après initialisation réussie:
list_del_init(&THIS_MODULE->list)
Effet: lsmod et /proc/modules ne voient plus l'entrée classique.
Limite: le module reste chargé. Cette opération ne supprime pas les logs, ne garantit pas l'absence de traces dans sysfs, et ne protège pas contre une inspection mémoire ou offline.
Le rôle de THIS_MODULE->list et list_del_init est expliqué dans Masquage du module.
Retrait propre
À l'arrêt ou en cas d'échec d'installation à mi-chemin, le code retire les hooks déjà posés:
unregister_ftrace_function
ftrace_set_filter_ip(... remove ...)
Les hooks sont retirés en ordre inverse. Ce n'est pas magique, mais c'est une bonne habitude quand plusieurs modifications ont été empilées.
Limites à retenir
- Les hooks dépendent des symboles
__x64_sys_*du noyau cible. FTRACE_OPS_FL_IPMODIFYdoit être accepté par la configuration du noyau.- Le code est écrit pour x86_64, pas pour toutes les architectures.
- Le masquage reste partiel: logs, sysfs, inspection offline et outils spécialisés peuvent révéler des traces.
- Le filtre
readest volontairement ciblé pour éviter de casser les lectures normales. - Le blocage par chemin protège les chemins connus du projet, pas tout le système.
Références utiles
- Références techniques pour les définitions kernel détaillées.
- Linux kernel - Using ftrace to hook to functions.
- Linux kernel - Kprobes.
- man7 - getdents(2).
- man7 - read(2).