Aller au contenu principal

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

FichierRôle
src/core/symbols.cRetrouve les adresses des symboles kernel avec kprobe et kallsyms_lookup_name.
src/hooks/ftrace_hooks.cInstalle et retire les hooks ftrace.
src/hooks/dirent_hooks.cIntercepte getdents et getdents64.
src/hooks/dirent_filter.cRetire des records linux_dirent dans un buffer de listing.
src/hooks/read_filter.cFiltre read, pread64, readv et refuse certains mmap.
src/hooks/access_hooks.cBloque les accès directs par chemin: openat, statx, access, etc.
src/hooks/hidden_names.cCentralise les règles de noms, chemins et lignes sensibles.
include/wlkom_hooks.hDéclare les hooks et les pointeurs vers les syscalls originaux.

Ce qui est hooké

FamilleSyscallsPourquoi
Listing de dossiersgetdents64, getdentsRetirer des noms avant que ls, find ou un explorateur ne les voient.
Lecture de fichiers texteread, pread64, readvRenvoyer une vue filtrée des fichiers de persistance.
Mapping mémoiremmapEmpêcher une lecture directe qui éviterait les hooks read.
Accès par cheminopenat, openat2, newfstatat, statx, access, faccessat, readlinkat, unlinkatBloquer 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:

  1. Résoudre l'adresse du symbole.
  2. Sauvegarder cette adresse dans le pointeur wlkom_original_*.
  3. Préparer ftrace_ops.
  4. Limiter ftrace à cette adresse avec ftrace_set_filter_ip.
  5. Activer le hook avec register_ftrace_function.

Les flags importants:

FlagRôle
FTRACE_OPS_FL_SAVE_REGSDemande à ftrace de fournir les registres CPU.
FTRACE_OPS_FL_IPMODIFYAutorise le hook à modifier registers->ip.
FTRACE_OPS_FL_RECURSION_SAFEIndique 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:

RegistreExemple dans le projet
diPremier argument: fd, dirfd ou pathname selon le syscall.
siDeuxième argument: buffer ou pathname.
dxTroisième argument: taille, mode ou pointeur.
r10Quatrième argument sur certains syscalls.
r8Cinquième argument, utilisé ici pour le fd de mmap.
ipAdresse 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ègleExemple
Nom contenant wlkomwlkom.ko, wlkom.conf.
Nom égal à wlkom_dataDossier runtime.
Nom commençant par .wlkomFichiers 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:

SyscallDifférence
readLit depuis file->f_pos, puis avance f_pos.
pread64Lit depuis un offset donné par l'appelant, sans modifier f_pos.
readvLit dans plusieurs buffers userland décrits par des iovec.

Le module garde le comportement attendu:

  • read et readv avancent la position visible après lecture.
  • pread64 ne change pas la position courante.
  • readv copie 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:

  1. Le chemin brut copié avec strncpy_from_user.
  2. Le dirfd, si le chemin est relatif à un dossier déjà ouvert.
  3. Le chemin résolu avec user_path_at() puis d_path().

Schéma:

Cela évite plusieurs contournements simples:

  • chemin relatif depuis un dirfd déjà ouvert
  • symlink vers une zone protégée
  • test d'existence avec statx ou access au lieu d'un open.

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_IPMODIFY doit ê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 read est 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