Aller au contenu principal

Backend attaquant

Le backend est une application Flask située dans:

attacking_program/backend/

Il a deux rôles:

  1. servir le dashboard et les payloads HTTP
  2. 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:

  1. arrête les anciens processus
  2. prend un lock local
  3. reconstruit wlkom.ko
  4. entre dans ~/wlkom-attacker/backend
  5. vérifie le venv Python
  6. exporte WLKOM_COMMAND_PORT=4444
  7. 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

RouteAccèsFonction
/attaquant/localdashboard
/api/rootkit/statusattaquant/localétat session
/api/rootkit/authenticateattaquant/localauthentification
/api/rootkit/commandattaquant/localcommande shell
/api/rootkit/cdattaquant/localchangement de cwd logique
/api/rootkit/listattaquant/locallisting texte
/api/rootkit/exploreattaquant/localexploration structurée
/api/rootkit/uploadattaquant/localupload simple
/api/rootkit/upload-progressattaquant/localupload streamé NDJSON
/api/rootkit/download-fileattaquant/localdownload fichier
/api/rootkit/download-file-progressattaquant/localdownload streamé NDJSON
/api/rootkit/preview-fileattaquant/localaperçu fichier
/api/rootkit/download-directoryattaquant/localarchive dossier
/api/rootkit/screenshotattaquant/localcapture écran
/freecloudepublic réseau privépage victime
/payload/freecloude.pypublic réseau privéloader victime et élévation de privilèges
/payload/freecloude.shpublic réseau privéinstallateur lancé par le loader
/payload/rootkit-demo/wlkom.kopublic 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:

FichierRôle
controller.pyétat global, status, authentification, promotion de socket
transport.pylistener TCP, socket pending/active, send/recv chiffrés
crypto.pyFNV-1a et XOR
commands.pycommandes shell, cd, cwd logique
explorer.pyfind -printf et parsing
files.pyupload/download chunkés, preview, archives
screenshots.pyextraction et sauvegarde des PNG
events.pyhistorique d'actions
parsers.py[exit:N], find, blocs base64
config.pylimites de protocole

État principal:

ChampSens
_serversocket d'écoute
_pending_sockconnexion reçue mais non authentifiée
_socksession active
_cryptoclé et logique XOR
_tx_offsetoctets envoyés backend -> module
_rx_offsetoctets reçus module -> backend
_rx_bufferoctets déjà déchiffrés après un marqueur
cwddossier logique distant
_eventshistorique 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 port 4444 n'est pas disponible
  • detected=true, needs_password=true: une socket attend le mot de passe
  • connected=true: la session est authentifiée
  • last_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:

  • cd ou cd <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:

ConstanteValeurRôle
KERNEL_RX_SIZE160 KiBlimite ligne entrante kernel
UPLOAD_BASE64_CHUNK_SIZE120 KiBplafond base64 par append
USERMODE_SHELL_COMMAND_MAX128 KiBplafond de l'argument /bin/sh -c
DOWNLOAD_CHUNK_BYTES256 KiBtaille demandée par chunk
PREVIEW_MAX_BYTES512 KiBlimite 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.