Backend attaquant
Le backend est une application Flask située dans:
attacking_program/backend/
Il a deux rôles:
- servir le dashboard et les payloads HTTP
- maintenir la session TCP chiffrée avec
wlkom.ko.
La partie critique est RootkitController: il reproduit le protocole du module, la dérivation FNV-1a, le XOR de flux et les offsets TX/RX.
À comprendre avant de lire le code: Flask ne parle pas directement à une commande distante. Flask appelle RootkitController, qui écrit une ligne chiffrée sur une socket TCP déjà authentifiée.
Lancement
Dans la VM attaquante:
~/start-attacker-panel.sh
Ce helper:
- arrête les anciens processus
- prend un lock local
- reconstruit
wlkom.ko - entre dans
~/wlkom-attacker/backend - vérifie le venv Python
- exporte
WLKOM_COMMAND_PORT=4444 - lance
app.py.
Flask écoute sur:
0.0.0.0:8080
Le listener C2 écoute sur:
0.0.0.0:4444
Routes et accès
| Route | Accès | Fonction |
|---|---|---|
/ | attaquant/local | dashboard |
/api/rootkit/status | attaquant/local | état session |
/api/rootkit/authenticate | attaquant/local | authentification |
/api/rootkit/command | attaquant/local | commande shell |
/api/rootkit/cd | attaquant/local | changement de cwd logique |
/api/rootkit/list | attaquant/local | listing texte |
/api/rootkit/explore | attaquant/local | exploration structurée |
/api/rootkit/upload | attaquant/local | upload simple |
/api/rootkit/upload-progress | attaquant/local | upload streamé NDJSON |
/api/rootkit/download-file | attaquant/local | download fichier |
/api/rootkit/download-file-progress | attaquant/local | download streamé NDJSON |
/api/rootkit/preview-file | attaquant/local | aperçu fichier |
/api/rootkit/download-directory | attaquant/local | archive dossier |
/api/rootkit/screenshot | attaquant/local | capture écran |
/freecloude | public réseau privé | page victime |
/payload/freecloude.py | public réseau privé | loader victime et élévation de privilèges |
/payload/freecloude.sh | public réseau privé | installateur lancé par le loader |
/payload/rootkit-demo/wlkom.ko | public réseau privé | module compilé |
La restriction est appliquée dans security.py: seuls /freecloude et /payload/* sont publics. Les autres routes acceptent 192.168.100.1, 127.0.0.1 et ::1.
RootkitController
Fichiers:
| Fichier | Rôle |
|---|---|
controller.py | état global, status, authentification, promotion de socket |
transport.py | listener TCP, socket pending/active, send/recv chiffrés |
crypto.py | FNV-1a et XOR |
commands.py | commandes shell, cd, cwd logique |
explorer.py | find -printf et parsing |
files.py | upload/download chunkés, preview, archives |
screenshots.py | extraction et sauvegarde des PNG |
events.py | historique d'actions |
parsers.py | [exit:N], find, blocs base64 |
config.py | limites de protocole |
État principal:
| Champ | Sens |
|---|---|
_server | socket d'écoute |
_pending_sock | connexion reçue mais non authentifiée |
_sock | session active |
_crypto | clé et logique XOR |
_tx_offset | octets envoyés backend -> module |
_rx_offset | octets reçus module -> backend |
_rx_buffer | octets déjà déchiffrés après un marqueur |
cwd | dossier logique distant |
_events | historique visible dans le dashboard |
_rx_buffer est un détail très important. Si un recv(4096) lit le marqueur de fin et aussi le début du prompt suivant, le backend ne jette pas ces octets. Il les garde pour la lecture suivante. C'est exactement le genre de détail nécessaire parce que TCP est un flux.
États affichés
GET /api/rootkit/status renvoie notamment:
{
"connected": false,
"authenticated": false,
"detected": true,
"needs_password": true,
"pending_peer": "192.168.100.2:54321",
"listener_bound": true,
"connection_attempts": 1,
"auth_failures": 0,
"crypto": {
"cipher": "xor-fnv1a64-stream",
"tx_offset": 0,
"rx_offset": 0
}
}
Interprétation:
listener_bound=false: le port4444n'est pas disponibledetected=true,needs_password=true: une socket attend le mot de passeconnected=true: la session est authentifiéelast_error: dernier problème de listener, auth ou transport.
Handshake
Pourquoi une socket pending? Le module se connecte avant que l'opérateur ait saisi le mot de passe. Le backend garde donc la socket sans l'utiliser pour les commandes. Elle ne devient active que si le protocole chiffré est cohérent.
Si l'authentification échoue, la socket pending est fermée. Le module retentera une nouvelle connexion après sa pause réseau.
Crypto Python
CryptoState reproduit rootkit/src/crypto/xor_stream.c:
offset_basis = 0xcbf29ce484222325
fnv_prime = 0x100000001b3
key[i] = (hash >> ((i % 8) * 8)) & 0xff
Chaque appel à _send_encrypted avance _tx_offset. Chaque _recv_until avance _rx_offset.
_rx_buffer est important: si un recv lit plus loin que le prompt attendu, les octets supplémentaires déjà déchiffrés sont conservés pour la lecture suivante.
Exemple simple: le module peut envoyer Authenticated.\n puis wlkom> séparément, mais TCP peut livrer les deux d'un coup. À l'inverse, un seul message peut arriver coupé en deux lectures. Le backend traite donc la socket comme un long ruban d'octets et garde l'offset XOR exact au lieu de recommencer la clé à zéro à chaque recv.
Commandes
Le frontend envoie:
POST /api/rootkit/command
run_shell_command traite:
cdoucd <path>: mise à jour du cwd logique- autre commande:
cd <cwd> && <commande>.
Le module renvoie:
stdout/stderr
[exit:N]
__KOZACI_RESPONSE_END_8F8F6A0E6D4A41C2__
Ensuite, le module envoie le prompt suivant wlkom> . Le backend attend d'abord le marqueur de fin de réponse, puis consomme le prompt. parse_exit cherche le dernier marqueur [exit: pour éviter de confondre une sortie utilisateur avec le code de retour ajouté par le module.
Exploration
explore_directory exécute:
find "$target" -maxdepth 1 -mindepth 1 -printf '%y\t%f\t%p\t%s\t%TY-%Tm-%Td %TH:%TM\n'
La première ligne PWD\t... donne le chemin résolu. Les lignes suivantes deviennent des objets JSON avec type, name, path, size et modified.
Pourquoi find -printf? La sortie tabulée est plus stable à parser que ls -la.
Transferts
Constantes principales dans files.py:
| Constante | Valeur | Rôle |
|---|---|---|
KERNEL_RX_SIZE | 160 KiB | limite ligne entrante kernel |
UPLOAD_BASE64_CHUNK_SIZE | 120 KiB | plafond base64 par append |
USERMODE_SHELL_COMMAND_MAX | 128 KiB | plafond de l'argument /bin/sh -c |
DOWNLOAD_CHUNK_BYTES | 256 KiB | taille demandée par chunk |
PREVIEW_MAX_BYTES | 512 KiB | limite d'aperçu |
Upload:
upload-begin <path>
upload-append <base64> <path>
Le plafond UPLOAD_BASE64_CHUNK_SIZE concerne le texte base64, pas les octets
bruts. Un chunk de 120 KiB base64 représente environ 90 KiB de fichier,
car base64 ajoute environ un tiers de volume.
Cette limite vient du protocole de démo: le module reçoit une ligne texte, puis lance une commande userland de la forme:
printf '%s' '<base64>' | base64 -d >> <path>
Le chunk base64 est inclus dans l'argument de /bin/sh -c. Linux limite la
taille d'un argument individuel à environ 128 KiB, et il faut compter aussi le
préfixe printf, la redirection, le chemin distant et le byte nul final. Viser
120 KiB base64 garde cette marge tout en réduisant fortement le nombre
d'allers-retours réseau.
Le backend calcule donc la taille brute à partir du plus petit de ces plafonds:
ligne kernel, chunk base64 autorisé, et commande userland complète. Pour un
chemin courant comme /home/user/100MB.bin, cela donne des morceaux de 90 KiB
bruts, encodés en 120 KiB base64. Si le chemin distant est très long, la taille
du morceau est réduite avant l'envoi. Si elle ne peut plus tenir, l'upload échoue
côté backend sans envoyer de commande invalide au module.
Pour aller plus haut, les alternatives seraient:
- un vrai mode streaming binaire, avec
upload-stream <path> <size>puis des frames chiffrées écrites directement côté kernel - un staging temporaire en plusieurs writes sans passer le contenu dans un argument shell
- un protocole applicatif avec longueur + payload, plutôt qu'une ligne texte.