Petite devinette : à part leurs implications dans la sécurité des systèmes d’information, quel point commun existe-t-il entre la recherche de vulnérabilités, les systèmes de détection d’intrusions, la supervision de réseau, le DPI, les pots de miel et la détection de fuites de données ?Trivial me diriez-vous :) Toutes reposent sur la connaissance préalable des protocoles de communication. Mais alors, ces mécanismes de défense sont-ils efficaces en présence de protocoles propriétaires, ou du moins, non documentés ? Vous savez, ce flux USB inconnu, ces paquets UDP bizarres ou ces appels IPC réguliers incompréhensibles…Dans cet article, nous présentons une méthodologie pour disséquer sur le vif, un protocole de communication. Promis, pas de copie d’écrans d’IDA ni d’OllyDbg et à l’inverse, pas de formule mathématique. Pour être précis, ici on dissèque des protocoles inconnus en Python avec « son Netzob et son couteau ».
Fig. 0 : Zoby, la mascotte officielle du projet
1. Rappels sur le reverse
1.1 Les approches actuelles
Le monde du reverse de protocoles se décompose (globalement) en deux grandes familles. La première inspecte le binaire d’un protocole pour découvrir ses spécifications. Cette approche repose sur l’analyse statique du binaire (sous IDA Pro par exemple) et/ou sur des techniques de débogage et d’instrumentation. Des heuristiques sont utilisées pour déduire du traitement des entrées/sorties le format des messages échangés et donc, de reverser le protocole [Prospex],[Tupni], [Dispatcher], [HowIMetYourPointer].
Ce n’est pas l’approche retenue dans cet article, ni dans Netzob. En effet, elle requiert forcément la maîtrise d’une implémentation du protocole, ce qui n'est pas le cas lorsque le client ne fournit que des exemples du protocole (PCAP, traces fichiers, logs serveurs, …). De plus, des protections spécifiques peuvent être implémentées dans le binaire rendant le coût de son analyse, de son débogage, ou même de son instrumentation prohibitif. En outre, celui-ci peut également être déployé sur une appliance ou un équipement auquel nous n'avons pas accès (protections physiques, droits d’auteur, hors-périmètres, ...).
A contrario, l’autre famille déduit les spécifications du protocole à partir d’exemples de communications. L’outil « The Protocol Informatic Project » [Beddoe] est parmi les premiers dans cette famille à automatiser l’apprentissage d’un protocole sur base d’exemples. Cependant, aucun des travaux publiés à la suite ([Discoverer],[ScriptGen],[PRISMA]) n’a donné lieu à la publication d’outils.
C’est pour cette raison que les reversers de protocoles finissent encore trop souvent par porter des lunettes. Eh oui, ça déglingue les yeux de fixer des octets pendant des heures [RobSavoye, DrewFisher]. Pour illustrer la rétroconception de protocoles sur base d'exemples de communications, nous utilisons Netzob, un outil qui vise à casser la glace entre les académiques et les industriels. Fini les yeux rouges et les lunettes (ou alors super fines) :)
1.2 Netzob, un framework pour le reverse de protocoles
Netzob [netzob.org] est un projet initialement dédié à la rétroconception de protocoles de communication et qui possède désormais des fonctionnalités de génération de trafic et de fuzzing. Il propose par ailleurs différents modules d’import et de capture de données (réseau, fichier, IPC et API) et d’export de modèles de protocoles vers des outils d’analyse tiers.
Il s’agit d’un outil libre, distribué sous licence GPLv3, écrit en Python et C. Netzob est utilisable à travers une interface graphique facilitant la manipulation des données. Il est également possible d’écrire des scripts manipulant directement l’API, ce que nous allons faire dans cet article.
1.3 Application sur le protocole propriétaire Ventrilo
Ventrilo [ventrilo.com] est une application de VoIP développée par Flagship Industries et utilisée par les joueurs de jeux en réseau pour communiquer. Pour cet article, nous nous sommes intéressés au protocole de communication utilisé par la dernière version du client Ventrilo sous Windows (3.0.8 en 64bits), téléchargeable gratuitement sur le site de l’auteur.
Son protocole a déjà été reversé, notamment pour créer une alternative libre appelée « mangler » [mangler]. Cependant, nous n’utilisons pas les résultats de précédents travaux de manière à présenter une analyse réaliste du reverse d’un protocole.
Retrouvez l’ensemble des ressources nécessaires à la reproductibilité de cette expérience sur le site web du projet Netzob [ressourcesTutoVentrilo]. Vous y trouverez les captures utilisées (pcaps), les scripts développés au cours de cette analyse, ainsi que les fichiers de configuration utilisés.
2. Les caractéristiques typiques d'un protocole
Les protocoles de communication possèdent des caractéristiques communes et des variations qui leur sont propres. On trouve d’une part le format des messages, rassemblant les informations suivantes :
- Des types de message ou un ensemble de commandes autorisées ;
- Un découpage en champs, avec des tailles de champs fixes ou variables ;
- Un domaine de définition, qui traduit l’ensemble des valeurs possibles pour le champ. Ces valeurs peuvent être statiques (« bonjour », 0x05de45, …) ou calculées. Dans ce cas, on distingue trois types de relations qui participent à la définition de la valeur : i) les relations vis-à-vis d’autres champs du même message (ex : le champ Data Offset de TCP, qui précise la taille de l’entête) ; ii) les relations vis-à-vis de champs observés dans de précédents messages (ex : le numéro d’acquittement TCP) ; iii) les dépendances vis-à-vis de paramètres environnementaux (ex : l’adresse IP source) ou applicatifs.
- Une sémantique, qui précise la signification des types de message et des champs ;
- Le format d’interprétation des données protocolaires (format d’encodage, signe et boutisme).
D’autre part, un protocole peut posséder une machine à états, spécifiant les séquences autorisées de messages (états et transitions entre les états).
De toutes ces caractéristiques, il en découle des étapes plus ou moins linéaires de rétroconception ; étapes que nous présentons dans la suite de l’article.
3. La capture de trafic
Pour commencer, nous devons obtenir des échantillons de communications qui utilisent le protocole. Nous mettons en place une architecture réseau destinée à générer du trafic, que nous pourrons ensuite analyser (voir Fig. 1).Celle-ci se compose de 3 postes : un serveur et deux clients. Le proxy publié par L. Auriemma [proxyVentrilo] est utilisé pour capturer les communications déchiffrées entre le client 1 et le serveur.
Fig. 1 : Architecture mise en place pour capturer des données
Nous réalisons 3 captures en modifiant les paramètres de l’environnement à chaque fois. Chaque capture est donc associée à une configuration réseau, matérielle et logicielle, différente. Les variations utilisées sont consignées dans un cahier d’expériences pour assurer la reproductibilité de nos expériences. Nous utilisons ces variations pour identifier l’influence de chaque paramètre sur le trafic généré.
Ces variations portent sur la valeur et sur la taille des paramètres. Par exemple, à chaque capture, le message du client 1 est modifié (« bonjour », puis « lu all » et « mais ou êtes vous » respectivement pour la première, deuxième et troisième capture).
Finalement, nous sélectionnons une série d’actions que nous réalisons dans le même ordre lors des 3 captures. Arbitrairement, nous avons sélectionné un sous-ensemble des fonctionnalités proposées par le protocole, qui comprend des actions de connexion, déconnexion, de paramétrage du compte et d’envoi de messages. Au final, nous obtenons 3 PCAP de 433 paquets comme base de travail.
4. Regroupement des messages par similarité
À l’issue de l’étape précédente, nous sommes fin prêts pour le reverse des 433 messages. Nous commençons par regrouper les messages que nous qualifions/identifions comme similaires. Une des approches souvent retenue dans la littérature consiste à appliquer différents facteurs de similarité les uns après les autres.
Premier critère, nous séparons les messages reçus des messages émis à l’aide de Wireshark, ce qui permet d’obtenir rapidement 6 PCAP (2 par capture). À l’origine d’un tel choix, nous considérons que le serveur dispose d’un jeu de commandes différent de celui de l’acteur.
Nous nous concentrons donc sur les messages reçus de manière à comprendre leurs formats. Puis, comme les messages en double ne nous apportent pas d’informations, nous les supprimons. Ce filtrage permet de réduire notre échantillon à 180 messages uniques. En outre, comme le montre la console Python ci-dessous (Console 1),nous créons une session applicative pour chaque PCAP. Puis, à chaque session sont attachées les données applicatives sauvegardées dans notre cahier d’expérience.
>>> importer = PCAPImporter(None)
>>> importer.setSourceFiles(pcapFile)
>>> importer.setImportLayer(4)
>>> importer.readMessages()
>>> session = Session(uuid.uuid4(), name, project, None)
>>> session.addMessages(importer.messages)
>>> for app in ApplicativeData.loadFromCSV(csvFile):
>>> session.addApplicativeData(app)
Console. 1 : Exemple de l'import dans Netzob des payloads des protocoles de transport TCP/UDP présents dans 1 pcap, puis référencement des messages importés dans une session à laquelle on attache les données applicatives.
Pour la deuxième classification des messages similaires, nous utilisons les données applicatives. En effet, à défaut de disposer de la sémantique associée à chaque paquet, nous connaissons la sémantique des paramètres applicatifs et environnementaux de la capture. Par exemple, « Server 1 » représente le nom du serveur dans la première session et « PasswordForUsers » le mot de passe de connexion à notre serveur.
Nous appliquons donc une heuristique simple : les messages qui transportent la même information sont similaires. Pour réaliser ce traitement, Netzob annote chaque paquet avec le type des informations environnementales et applicatives qu’il contient, en se servant du moteur de recherche. Ces annotations deviennent des signatures. Elles sont ensuite utilisées comme facteur de classification ; deux messages avec la même signature sont considérés comme similaires. Cette fonctionnalité est implémentée dans Netzob sous le terme d’ApplicativeDataClustering et son utilisation est illustrée par la console 2.
>>> appClustering = ApplicativeDataClustering()
>>> newGroups = appClustering.execute([defaultGroup])
Console. 2 : Identification des messages similaires en fonction des données applicatives qu'ils transportent.
Les résultats sont très intéressants, voici un sous-ensemble des signatures obtenues avec le nombre de messages associés :
Signatures |
Nombre de messages |
SERVER_Name + SERVER_Phonetic + CLIENT1_User name + CLIENT1_Phonetic |
2 |
CLIENT1_Message1 |
6 |
CLIENT1_CommentMessage |
1 |
CLIENT1_CommentMessage + CLIENT1_CommentURL |
2 |
SERVER_Name |
16 |
Cette approche est efficace pour l’ensemble des messages transportant des informations applicatives. Cependant, près de 120 messages n’ont pu être classifiés avec cette solution, car ne transportant pas d’informations applicatives.
En observant la répartition des valeurs de chaque octet des messages (voir Fig. 2), on remarque que les premiers octets des messages (voir flèche rouge sur la figure) semblent caractériser le type des messages en raison de leurs faibles domaines de variation.
Fig. 2 : Affichage dans l'interface graphique de Netzob de la distribution des valeurs pour chaque octet sur les 3 sessions. La flèche rouge désigne une zone de données à faible entropie.
On applique donc un partitionnement simple sur l’ensemble des messages de ce groupe (voir Console 3), de manière à identifier une structure commune d'enchaînement de champs statiques et dynamiques. On utilise ensuite les valeurs du premier champ comme signatures discriminantes. Ainsi, tous les messages commençant par la même série d’octets seront classifiés dans le même groupe.
>>> field = noAppGroup.getField()
>>> field.simplePartitioning(UnitSize.BITS16)
>>> field.createSymbolsWithFixedField(field.getLocalFields()[0])
Console. 3 : Identification des messages similaires selon la valeur de leur premier champ
Ce troisième niveau de classification permet de regrouper les 120 messages inconnus en 8 groupes de messages similaires. Au final, nous disposons de 23 groupes de messages similaires. Ce découpage pourra être amélioré par la suite en fonction de la compréhension de chaque groupe de messages.
5. Découpage des messages en champs
À ce moment de l’analyse, nous ne savons pas comment les champs sont découpés. Il peut y avoir des champs de taille fixe, des champs dynamiques dont la taille est renseignée dans un autre champ, ou encore des champs découpés par un délimiteur.
Avec Netzob, nous pouvons tester différents découpages. Nous essayons dans un premier temps de réaliser un partitionnement dit « simple » sur ce qui semble correspondre à l’entête des messages. Ce partitionnement met en évidence les variations pour une position donnée de la valeur des messages et fusionne les champs dynamiques adjacents. Nous choisissons une granularité d’un octet sur les messages commençant par l’octet 0x5d et transportant les données applicatives relatives au nom de l’utilisateur (voir Console 4).
>>> layer.simplePartitioning(UnitSize.BITS8)
>>> for message in layer.getMessages():
>>> print message.applyAlignment()
['5d000000', '04', '00', '02', '00', '00', '000000', '0200']
['5d000000', '01', '00', '01', '00', '02', '000000', '0004']
['5d000000', '02', '00', '01', '00', '01', '000000', '0001']
['5d000000', '02', '00', '01', '00', '01', '000000', '0001']
Console. 4 : Découpage des champs par partitionnement simple
Les messages se découpent et s’alignent a priori plutôt bien. On voit apparaître des champs statiques et quelques champs avec une faible variation. Nous avons donc visiblement affaire à un protocole binaire avec un entête à champs de taille fixe.
Dans un second temps, nous cherchons à identifier le découpage sur la seconde portion des messages, celle qui comporte notamment les données applicatives. Nous testons ici le partitionnement par alignement de séquences, qui a pour objectif d’identifier les plus grands segments variables entre deux segments statiques.
>>> alignmentSolution = NeedlemanAndWunsch(unitSize=8, ...)
>>> alignmentSolution.alignFields([layer])
>>> for message in layer.getMessages():
>>> print message.applyAlignment()
['000000', '744696d697472690', '00', '744697472696368', '000000000000']
['000000', '34775790', '00', '7537570656c6563', '000000000000']
['000000', '84672e964e97269630', '00', '54672656479', '000000000000']
Console. 5 : Découpage des champs par alignement de séquences
On découvre un alignement intéressant des octets statiques 0x00 et des suites d’octets dynamiques de taille variable. En général, le partitionnement par alignement de séquences donne de bons résultats sur les protocoles à champs de taille variable.
Nous appliquons la même démarche d’identification du découpage en champs sur l’ensemble des groupes de messages.
6. Analyse du domaine de définition des champs
Comme présenté dans le chapitre 2, les valeurs d’un champ sont influencées par plusieurs éléments (valeurs d’autres champs du même message ou d’autres messages, paramètres environnementaux, données applicatives, etc.). L’analyse du domaine de définition peut donc être découpée en fonction de toutes ces sources potentielles de valeurs.
6.1 Identification des dépendances applicatives et environnementales
La valeur d’un champ peut dépendre de données extérieures établies par le contexte d’utilisation du protocole (tels que le nom de l’utilisateur, les messages envoyés ou le mot de passe du serveur) et des paramètres environnementaux (tels que des adresses IP ou des timestamps). L’identification de ces dépendances permet d’affiner la définition des champs qui composent les messages échangés.
Netzob dispose d’un moteur de recherche à variations qui permet de trouver une donnée même si elle a subi des modifications (encodages, légères variations de valeurs, hashage, …). Ce moteur est utilisé pour la recherche des informations applicatives, manuellement collectées par l'expert sous la forme d'un fichier CSV, et environnementales, automatiquement enregistrées par Netzob lors de l'import des messages.
La console 6 illustre la recherche des données applicatives présentes dans chaque message du groupe CLIENT1_Message1 du protocole de Ventrilo.
>>> searcher = Searcher(project)
>>> for message in grpClient1Message1.getMessages():
>>> print searcher.searchApplicativeDataInMessage(message)
['42000000', '0100', '020000000000', '00066c7520616c6c', '']
['42000000', '0100', '020000000000', '0007626f6e6a6f7572', '']
['42000000', '0500', '020000000000', '00136d616973206f7520ea74657320766...
Console. 6 : Recherche des dépendances applicatives appliquée au groupe « CLIENT1_Message1 »
Cette recherche a permis d'identifier une relation applicative à partir du troisième octet (cinquième demi-octet) du quatrième champ de tous les messages du groupe CLIENT1_Message1(cf. zones mises en valeur dans la console 6). On en déduit que ce champ peut être divisé en deux (comme illustré par la console 7), avec une partie gauche codée sur 2 octets et une partie droite, de taille dynamique, qui représente la donnée applicative.
>>> fieldToSplit = grpClient1Message1.getExtendedFields()[3]
>>> fieldToSplit.splitField(4, "left")
>>> grpClient1Message1.getExtendedFields()[4].setFormat(Format.STRING)
['42000000', '0100', '020000000000', '0006', 'lu all', '']
['42000000', '0100', '020000000000', '0007', 'bonjour', '']
['42000000', '0500', '020000000000', '0013', 'mais ou êtes vous ?']
Console. 7 : Séparation du 4ème champ du groupe « CLIENT1_Message1 » après le 4ème demi-octet en partant de la gauche du champ. On fixe le format d'interprétation en STRING du champ applicatif ( 5ème champ après la séparation) pour révéler les chaînes de caractères.
6.2 Identification des relations intra-messages
La recherche de relations intra-messages s’effectue sur les cellules de chaque champ. L’idée est de trouver des relations qui s’appliquent sur l’ensemble des messages découpés. Les relations actuellement gérées par Netzob sont les relations d’équivalence (le champ F1 est équivalent au champ F3, ou à une sous-partie de celui-ci), de taille (le champ F2 correspond à la taille du champ F5) et de répétition (le champ F4 représente le nombre de répétitions d’un champ).
Il faut par ailleurs prendre en compte l’encodage et le type de valeur lors de la recherche des relations. Par exemple, un champ « taille » peut représenter une valeur en nombre d’octets ou en nombre de mots de quatre octets (cas du champ DataOffset dans l’entête TCP).
La console 8 illustre la recherche de relations dans le groupe CLIENT1_Message1.
>>> finder = RelationFinder(project)
>>> finder.execute(grpClient1Message1)
Relations found:
[+] Group: grpClient1Message1
[+] size(F[3][0:4]) == value(F4[:])
Console. 8 : Recherche de relations intra-messages
Nous détectons un champ « taille » qui précise que les deux premiers octets du champ F3 correspondent à la taille du champ F4, autrement dit à la taille de la chaîne de caractères correspondant au message « Message1 ».
6.3 Identification des données propres à la session
Afin d’identifier les champs dont la valeur est spécifique à la session courante, nous utilisons un mécanisme de diff de sessions. L’idée est de faire apparaître les caractères ou octets qui varient entre deux sessions identiques et situés à une position similaire, en prenant en compte les successions de messages émis et reçus. Dans l’exemple suivant (Fig. 3), nous comparons deux sessions capturées avec des environnements et configurations de clients Ventrilo identiques et en jouant les mêmes messages applicatifs, à savoir « test » et « ok ».
Fig. 3 : Exemple du rendu d'un diff de sessions sous Netzob
Si l’on regarde spécifiquement le format des messages applicatifs (ceux commençant par 0x42), on voit apparaître une seule différence : le deuxième champ, qui semble indiquer un identifiant de session ou une direction.
6.4 Identification des relations inter-messages
Nous réalisons les mêmes recherches de relation, mais cette fois-ci en comparant les sessions. Par exemple, pour confirmer l'existence d’une relation entre M[1]F[3] (Message 1, Champ 3) et M[2]F[5] (Message 2, Champ 5), celle-ci doit s’appliquer à toutes les sessions.
Dans l’exemple suivant (Console 9), nous recherchons des relations au sein de deux sessions.
>>> finder = RelationFinder(project)
>>> finder.execute(sessions[:2])
Relations found:
[+] Value(M[79]F[1]) == Value(M[80]F[1]) : c5c60f0000
[+] Value(M[82]F[1]) == Value(M[83]F[1]) : 2d59100000
[+] Value(M[86]F[1]) == Value(M[87]F[1]) : db16100000
(...)
Console. 9 : Recherche de relations inter-messages
Nous identifions une simple répétition pour deux messages du même type échangés par les clients (le premier champ du message N envoyé correspond au premier champ du message N+1 reçu). Ce comportement ressemble typiquement à un ping permettant de s’assurer de l’activité du correspondant.
6.5 Les fonctions de transformation pour le support du chiffrement
Comment gérer les protocoles chiffrés, les champs compressés et autres transformations ? La cryptanalyse d’un protocole sort très largement du cadre de cet article, nous n’en parlerons donc pas. Cependant, une fois que l’on dispose de la routine de traitement de la donnée (chiffrement, compression, …), celle-ci doit être applicable aisément aux données manipulées.
Nous proposons pour cela des fonctions de transformation destinées à modifier la valeur d’un ou de plusieurs champ(s) selon un algorithme connu ou directement proposé par l’expert. Ces fonctions sont par ailleurs automatiquement appliquées lors de la génération de trafic par Netzob, soit pour décoder les données reçues, soit pour correctement encoder les messages à transmettre.
Ventrilo utilise deux fonctions de chiffrement basique. La première fonction est utilisée sur le premier couple de messages échangés, la seconde est utilisée pour chiffrer/déchiffrer le reste de la conversation. La console 10 illustre la création d’une fonction de transformation personnalisée pour déchiffrer le premier message reçu (message de type 0x06). Dans le script, la transformation s’opère sur la variable message qui est initialisée avec la valeur brute de la donnée reçue.
>>> sourceCode = """
key = "\xAA\x55\x22\xCC\x69\x7C\x38\x91\x88\xF5\xE1"
msg_decrypt = []
for i in range(2, len(payload_crypt)):
tmp = ord(message[i]) - (ord(key[i%11]) + (i%27))
msg_decrypt += chr(0xff & tmp)
message = "".join(msg_decrypt)
"""
>>> myFunction = CustomFunction("Decrypt", sourceCode, sourceCode)
>>> symbol00.getField().addTransformationFunction(myFunction)
Console. 10 : Code pour la création d’une fonction de transformation qui déchiffre les messages de type 0x06.
L'application de la fonction de transformation sur un message de type 0x06, permet d'obtenir sa version déchiffrée (voir l'exemple illustré par le console 11).
>>>> symbol06.getMessages()[0].getStringData()
'00a2b05624cf6d813e9894feebb5c98a...'
>>> symbol06.getField().addTransformationFunction(myFunction)
>>>symbol06.getMessages()[0].getStringData()
'060000000000000004000000685b656a...'
Console. 11 : Code appliquant le déchiffrement d’un message
6.6 L’abstraction des messages en symboles
Tout au long de ce chapitre, nous avons présenté comment identifier, puis regrouper des messages similaires, les découper en champs, puis finalement rechercher leurs sémantiques. Dans cette partie, nous décrivons les principes de l’abstraction de messages similaires en un unique symbole et à l’inverse, l’instanciation d’un symbole en messages.
Comme illustré en 6.1, un message est très souvent contextualisé, c’est-à-dire qu’il transporte des données qui varient en fonction de son contexte d’exécution, de son environnement. Nous utilisons le terme de « symbole » pour décrire la forme abstraite ou décontextualisée d’un ensemble de messages similaires. Par exemple, les messages « bonjour Windows » et « bonjour Linux » peuvent s’abstraire avec le même symbole « bonjour $OS ». Cette dernière représentation est plus compacte et permet de regrouper les messages équivalents, tout en conservant suffisamment de détails pour générer (instancier) des messages concrets valides. Par ailleurs, l’ensemble des symboles d’un protocole forme son vocabulaire. Ce dernier est utilisé comme donnée d’entrée pour découvrir la machine à l’état, objet du prochain chapitre.
La figure 4 présente le format inféré pour les symboles de ping (de type 0x37) et ceux contenant les messages applicatifs (de type 0x42). Nous ne présentons ici que les symboles les plus intéressants, c'est-à-dire ceux contenant des relations inter- et intra-messages et des données identifiées comme applicatives. Le symbole 0x42 couvre par ailleurs plusieurs types d'actions utilisateur : la connexion à un channel et l'envoi d'un message aux autres participants.
Fig. 4 : Format inféré des messages 0x42 et 0x37
7. Inférence automatique de l’automate à états
Sujet très largement présent dans la littérature scientifique et ce, depuis de nombreuses années, l’inférence grammaticale appliquée aux protocoles de communication, qu’elle soit active [Bohlin] [Cho] ou passive [Antunes] [Wang] [Shoham] [Prospex], a déjà prouvé son efficacité (théorique). Pour rappel, la grammaire d’un protocole spécifie les enchaînements valides de messages sous la forme de règles. Il existe plusieurs modèles pour représenter ces règles. En particulier, si la grammaire est dite « régulière », il est courant de la modéliser à l’aide d’un automate à états finis (aussi appelé FSM pour Finite State Machine en anglais).
Afin de supporter un plus grand nombre de protocoles, nous avons étendu ce modèle et représenté la grammaire d’un protocole sous la forme d’une (on prend une grande inspiration) Machine de Mealy Stochastique à Transitions Déterministes, ou MMSTD pour les intimes. Concrètement, l’utilisation de ce modèle offre deux nouvelles fonctionnalités par rapport à un automate à entrées/sorties : i) elle permet d’apprendre « le temps de réaction » entre les échanges de messages et ii) elle autorise plusieurs messages de sorties pour la même transition. Ce dernier point est important. Il assure qu’étant donné un triplet <état courant, message d’entrée et état de sortie>, plusieurs messages de sortie sont possibles.
Nous avons un modèle, voyons maintenant comment l’apprendre. Le schéma de la figure 5 illustre les différentes étapes de notre « recette », qui s’articule autour de l’algorithme d’Angluin, le L* (prononcé à l’anglaise « L star »).
Fig. 5 : Schéma illustrant les différentes étapes d’Angluin L*a dans Netzob
Dans notre scénario d’apprentissage, nous jouons le rôle de l’élève et disposons du vocabulaire et d’une implémentation du protocole. Pour commencer, l’élève construit une hypothèse sur la grammaire (ce qui se matérialise sous la forme d’un automate) en stimulant l’implémentation, appelée Professeur, avec des requêtes d’appartenances (les spécialistes que nous ne sommes pas ne manqueront pas de corriger en « requêtes de sorties »).
Dans notre cas, formuler une telle requête consiste à demander au professeur la réponse à la question : « Quel message de sortie génère l’implémentation si je lui envoie la séquence suivante ? ». Entre chaque requête, nous réinitialisons l’implémentation et au fur et à mesure, améliorons notre hypothèse sur l’automate grâce aux résultats obtenus.
Dès que cet automate hypothétique vérifie certaines contraintes portant notamment sur sa « complétude » et sa « consistance », nous changeons de stratégie et soumettons des requêtes d’équivalences.
La définition d’une requête d’équivalence est très théorique. Elle consiste à demander à un oracle si notre automate hypothétique est équivalent à celui de l’implémentation. Cette définition n’est pas applicable dans de nombreux cas, puisque très peu d’implémentations autorisent ce genre de requête. Nous remplaçons donc cette requête par la recherche d’un contre-exemple par échantillonnage. Pour plus de détails, n’hésitez pas à vous reporter à notre article publié à SSTIC [SSTIC-2012].
Ok, voyons ce que ça donne dans la vraie vie !
Bien évidemment, Netzob dispose d’un tel module d’inférence de grammaire. Mais avant de l’utiliser, nous vérifions sa capacité à stimuler le serveur de Ventrilo en générant du trafic réseau conforme à son vocabulaire. La console 12 illustre l’envoi d’un message membre du symbole de type 0x00 à un serveur Ventrilo en écoute sur le 127.0.0.1:3784 et la réception d'un message abstrait par un symbole de type 0x06.
>>> channel = NetworkClient(uuid.uuid4(), Memory(), "TCP", "127.0.0.1", 42672, "127.0.0.1", 3784)
>>> abstractionLayer = AbstractionLayer(channel, voca, Memory())
>>> abstractionLayer.connect()
NetworkClient has initiated a connection with 127.0.0.1:4242
>>> abstractionLayer.writeSymbol(symbol00)
(...)
Sending symbol 'connection over the communication channel’
>>> print abstractionLayer.receiveSymbol()
symbol 06
Console. 12 : Code pour la création d’un acteur réseau TCP et sa connexion au 127.0.0.1:3784. Une fois connecté, il émet par l’intermédiaire de la couche d’abstraction le symbole « symbol00 » et attend une réponse qui se manifeste par la réception du symbole « symbol 06 ».
À ce stade, nous sommes en mesure de réaliser plusieurs tests pour vérifier que l’implémentation supporte l’inférence active. En outre, si votre protocole est plutôt simple, pourquoi ne pas rédiger un petit script pour la parcourir automatiquement ?
Dès que nous sommes prêts, nous « lâchons les chiens » et à l’aide du code [CODE_APPRENTISSAGE_GRAMMAIRE] démarrons l’inférence automatique de la grammaire du protocole de communication utilisé entre un serveur et un client Ventrilo.
script = "./restart_ventrilo.sh" # Pour redémarrer Ventrilo à chaque itération
maxNumberOfState = 15 # Taille maximale d'un parcours dans l'automate attendu
# Creation d’un canal de communication client
clientOracle = NetworkClient(str(uuid.uuid4()), Memory(), "TCP", "127.0.0.1", 10000, "127.0.0.1", 3784)
# Création d’un oracle d’équivalence (WMethode pour l'échantillonnage)
equivalenceOracle = WMethodNetworkEquivalenceOracle(clientOracle, maxNumberOfState, script)
inferer = GrammarInferer(project.getVocabulary(), inputSymbols, clientOracle, equivalenceOracle, scriptFilename, ...)
# On démarre l'inférence
inferer.infer()
computedAutomaton = inferer.getInferedAutomaton()
Console. 13 : Apprentissage de la grammaire d’un protocole UDP en utilisant Angluin L*a. Emploi d’un oracle d’équivalence reposant sur la WMethod pour la génération des cas de tests.
Le temps d’exécution d’une telle procédure peut être élevé (de l’ordre de quelques minutes pour un vocabulaire de 3-4 symboles d’entrée et plusieurs heures pour des vocabulaires d’une taille supérieure à 10 symboles d’entrée). Au final, Netzob génère un code DOT permettant de représenter l’automate de Ventrilo obtenu.
La figure 6 illustre l'automate automatiquement inféré sur base des 5 premiers symboles observés lors des captures. La première partie de l’automate représente l’opération d'enregistrement auprès du serveur. Cet enregistrement est réalisé en trois étapes : émission du symbole 0x00, réception du symbole 0x06 et envoi du symbole 0x48 contenant les données utilisateur. Les étapes d’après correspondent quant à elles au début d'une longue séquence de challenges/réponses, au bout de laquelle les messages applicatifs peuvent être échangés. On remarque par ailleurs un état final dans lequel le serveur ne répond plus aux sollicitations.
En comparant ce résultat avec les séquences de messages générées par un client et un serveur légitimes, on peut valider la cohérence de l’automate automatiquement découvert par Netzob, et également découvrir des transitions non observées dans des situations réelles.
Fig. 6 : Automate automatiquement inféré à partir des 5 premiers symboles capturés et mettant en évidence la phase d'authentification du protocole. La prise en compte de plus de symboles génère un schéma beaucoup trop complexe pour être imprimé dans le cadre de cet article. En outre, et toujours pour améliorer la lisibilité du schéma, les transitions ne donnant lieu à la réception d'aucun symbole sont retirées. Les états apparaissant comme orphelins de transition après ce nettoyage sont également supprimés.
À ce stade de l’analyse, nous avons une compréhension correcte des différents types de messages, ou symboles, proposés par le protocole Ventrilo et connaissons le format des messages « applicatifs » (ceux transportant les données utilisateur) et de certains messages de signalisation.
Nous avons également utilisé l’apprentissage actif de la grammaire du protocole pour extraire l’automate qui régit son fonctionnement. Bien que nous n’ayons pas une vue complète de l’automate, notre connaissance est suffisante pour pouvoir dialoguer avec une vraie implémentation.
8. Que faire une fois le modèle inféré ?
Une première utilisation consiste à générer des dissecteurs, afin de pouvoir étudier des protocoles propriétaires avec des outils d’analyse reconnus. Netzob supporte la génération automatique de dissecteurs pour Wireshark, sous la forme de scripts en langage LUA. Il est par ailleurs envisagé de générer automatiquement des dissecteurs pour Scapy et des parsers en langage BinPAC (utilisé dans l’IDS Bro).
D’autre part, il peut être utile de générer du trafic réaliste d’un protocole propriétaire si l’on souhaite tester la capacité de produits de sécurité de type NIDS ou pare-feu à détecter et/ou bloquer des protocoles exotiques. Dans cette optique, Netzob permet de simuler des acteurs et de générer un trafic réaliste entre ces acteurs.
Par ailleurs, l’analyse de robustesse par fuzzing d’une implémentation d’un protocole n’est généralement pertinente que si l’expert ou l’outil d’analyse a la connaissance du protocole. Netzob supporte actuellement la génération automatique de fichiers de configuration pour le fuzzer Peach. De cette manière, il est possible de tirer parti d’un fuzzer reconnu dans la communauté et de pouvoir l’appliquer sur un protocole propriétaire.
Conclusion
Le domaine du RE de protocoles peut apparaître comme le parent pauvre du monde de la rétroconception, tant le manque d’outils est flagrant. Avec le projet Netzob, nous essayons de combler ce vide, en proposant des techniques automatisant certaines tâches rébarbatives et complexes du reverse. Cet outil dépasse également le simple besoin de rétroconception : il propose la simulation de trafic suivant le protocole inféré et permet l’export des spécifications vers des outils (dissecteurs, parsers et fuzzers) reconnus dans la communauté.
L'équipe travaille activement sur de nouvelles fonctionnalités ou améliorations (intégration du fuzzing directement dans Netzob, amélioration des algorithmes d’inférence, support de nouveaux canaux de communication, etc.). Bien sûr, toute contribution de la communauté est la bienvenue.
Remerciements
Nous remercions chaleureusement tous les contributeurs de Netzob et notamment les équipes d’AMOSSYS et de Supélec CIDre pour leur soutien dans le projet. Merci également à Mathilde Rossignol, Julie Lemeteyer et Charles Meslay pour leurs précieuses relectures.
Références
[SSTIC-2012] Netzob : un outil pour la rétroconception de protocoles de communication par Georges Bossert, Frédéric Guihéry, Guillaume Hiet au SSTIC 2012.
[Prospex] Prospex: Protocol Specification Extraction par Paolo Milani Comparetti, Gilbert Wondracek, Christopher Kruegeland et Engin Kirda à SSP’09.
[Tupni] Tupni: automatic reverse engineering of input formats par Weidong Cui, Marcus Peinado, Karl Chen, Helen J. Wang. et Luis Irun-Briz à CCS’08.
[HowIMetYourPointer] « How I Met Your Pointer » par Carlos Garcia Prado au 29C3.
[Dispatcher] Dispatcher: enabling active botnet infiltration using automatic protocol reverse-engineeringpar Juan Caballero, Pongsin Poosankam, Christian Kreibich et Dawn Song à CCS’09.
[Beddoe] Network protocol analysis using bioinformatics algorithmsparM. A. Beddoe à Toorcon, 2004.
[Discoverer] Discoverer: Automatic protocol reverse engineering from network traces par Weidong Cui à USENIX’07.
[ScriptGen] ScriptGen: an automated script generation tool for honeydpar Corrado Leita, Ken Mermoud et Marc Dacier à ACSAC’05.
[PRISMA] Learning stateful models for network honeypots par Tammo Krueger, Hugo Gascon, Nicole Kramer et Konrad Rieck à AISec'12.
[RobSavoye] Reverse Engineering of Proprietary Protocols, Tools and Techniques par Rob Savoye au FOSDEM 2009.
[DrewFisher] Reverse Engineering USB Devicespar Drew Fisher au 28C3.
[netzob.org] Site web de l’outil Netzob : http://www.netzob.org
[ventrilo.com] Site web de l’application Ventrilo : http://www.ventrilo.com
[ref:Antunes] Reverse engineering of protocols from network traces par J. Antunes, N. Neves et P. Verissimo à WCRE’11
[ref:Wang] Inferring protocol state machine from network traces : a probabilistic approach par Y. Wang, Z. Zhang, D. D. Yao, B. Qu, et L. Guo. à ACNS’11
[ref:Shoham] Static specification mining using automata-based abstractions par S. Shoham, E. Yahav, S. Fink, et M. Pistoia à ISSTA’07
[ref:Bohlin] Regular inference for communication protocol entities par T. Bohlin et B. Jonsson dans Technical Report 2008-024, Uppsala University, Computer Systems.
[ref:Cho] Inference and analysis of formal models of botnet command and control protocols par C. Y. Cho, D. Babi ́, E. C. R. Shin et D. Song à CCS’10
[mangler] Site web de l’application Mangler : http://www.mangler.org
[ressourcesTutoVentrilo] Retrouvez toutes les ressources liées à cet article à l’adresse http://www.netzob.org/resources/tutorial_ventrilo
[proxyVentrilo] Site Internet de l’auteur du proxy Ventrilo : http://aluigi.altervista.org