Scapy, le couteau suisse Python pour le réseau

Spécialité(s)


Résumé

Découvrez comment utiliser Scapy afin d'écrire vos propres outils réseau en Python.


Body

Tout développeur réseau a déjà rêvé d'avoir à sa disposition une bibliothèque de manipulation de paquets, qui pourrait également être utilisée de façon interactive. C'est exactement ce que propose Scapy, que nous allons présenter dans cet article. Nous verrons tout d'abord son fonctionnement général, puis construirons des outils plus évolués. Il est impossible de détailler toutes les possibilités offertes par Scapy, mais à la fin de cet article, le lecteur devrait avoir toutes les bases nécessaires pour implémenter une application qui répondra à ses besoins.

1. Concepts de base

Voyons dans un premier temps comment prendre en main scapy en utilisant l'interpréteur. Nous utilisons ici la commande scapy3 fournie par le paquet Debian python3-scapy. Notons qu'il faut lancer la commande en tant que root.

# scapy3

WARNING: No route found for IPv6 destination :: (no default route?). This

affects only IPv6

INFO: Please, report issues to https://github.com/phaethon/scapy

WARNING: IPython not available. Using standard Python shell instead.

Welcome to Scapy (3.0.0)

>>>

Quelques avertissements peuvent apparaître selon la configuration de votre machine. Pensez à les relire si les exemples présentés dans la suite de l'article ne fonctionnent pas correctement.

1.1 Construire des paquets

Construisons et analysons un premier paquet :

>>> packet = IP()

>>> packet.show()

###[ IP ]###

  version= 4

  ihl= None

  tos= 0x0

  len= None

  id= 1

  flags=

  frag= 0

  ttl= 64

  proto= ip

  chksum= None

  src= 127.0.0.1

  dst= 127.0.0.1

  \options\

En affichant un paquet IP tout simple, on reconnaît les champs de l'entête IP, tels que décrits dans la RFC 791. Nous reproduisons en figure 1 le datagramme présenté en section 3.1 de cette RFC afin de rafraîchir la mémoire de nos lecteurs :

scapy_figure_01

 

Fig.1 : Structure de notre paquet.

On peut donner une valeur particulière à chacun des champs soit en passant des paramètres au constructeur de la classe IP(), soit en modifiant après coup les attributs :

>>> packet = IP(dst='192.168.0.254')

>>> packet.ttl = 42

>>> packet.show()

###[ IP ]###

  version= 4

  ihl= None

  tos= 0x0

  len= None

  id= 1

  flags=

  frag= 0

  ttl= 42

  proto= ip

  chksum= None

  src= 192.168.0.10

  dst= 192.168.0.254

  \options\

On peut ajouter des couches à notre paquet grâce à l'opérateur / :

>>> packet = IP(dst='192.168.0.254')/ICMP()

>>> packet.show()

###[ IP ]###

  version= 4

  ihl= None

  tos= 0x0

  len= None

  id= 1

  flags=

  frag= 0

  ttl= 64

  proto= icmp

  chksum= None

  src= 192.168.0.10

  dst= 192.168.0.254

  \options\

###[ ICMP ]###

     type= echo-request

     code= 0

     chksum= None

     id= 0x0

     seq= 0x0

On peut remarquer que les sommes de contrôle ne sont pas calculées. Elles le seront automatiquement lors de la construction du paquet, avant son envoi. Il est possible de les afficher grâce à la méthode show2 :

>>> packet.show2()

###[ IP ]###

  version= 4

  ihl= 5

  tos= 0x0

  len= 28

  id= 1

  flags=

  frag= 0

  ttl= 64

  proto= icmp

  chksum= 0xf887

  src= 192.168.0.10

  dst= 192.168.0.254

  \options\

###[ ICMP ]###

     type= echo-request

     code= 0

     chksum= 0xf7ff

     id= 0x0

     seq= 0x0

Nous avons donc construit un paquet similaire à ceux envoyés par l'utilitaire bien connu ping. Ce dernier ajoute par défaut une charge utile de 56 octets (qui peut contenir des informations d'horodatage, notamment sous GNU/Linux). Nous pouvons faire quelque chose de similaire :

>>> packet=IP(dst='192.168.0.254')/ICMP()/Raw(load=RandString(56))

1.2 Envoyer des paquets

Maintenant que nous avons construit notre paquet ICMP, il nous faut l'envoyer. Plusieurs fonctions sont disponibles :

  • send() pour envoyer un paquet de la couche 3 ;
  • sendp() pour envoyer un paquet de la couche 2 ;
  • sr1() pour envoyer un paquet de la couche 3 et retourner la première réponse ;
  • srp1() pour envoyer un paquet de la couche 2 et retourner la première réponse ;
  • sr() pour envoyer un paquet de la couche 3 et retourner toutes les réponses ;
  • srp() pour envoyer un paquet de la couche 2 et retourner toutes les réponses.

Nous envoyons ici un paquet ICMP de type echo-request, et nous attendons à recevoir un autre paquet ICMP de type echo-reply. Nous pouvons donc utiliser sr1 :

>>> answer=sr1(packet, timeout=1)

>>> answer.show()

###[ IP ]###

  version= 4

  ihl= 5

  tos= 0x0

  len= 84

  id= 29709

  flags=

  frag= 0

  ttl= 64

  proto= icmp

  chksum= 0x8443

  src= 192.168.0.254

  dst= 192.168.0.10

  \options\

###[ ICMP ]###

     type= echo-reply

     code= 0

     chksum= 0x1a01

     id= 0x0

     seq= 0x0

###[ Raw ]###

        load= 'lJOHuMFbAVvLEttOTYQaF0fYGayqKLzAI8yQwvhJR0r7DQxQLT9kIf26'

Nous avons bien reçu la réponse attendue de la part de notre routeur (192.168.0.254). Si nous avions envoyé le paquet à une IP ne correspondant à aucune machine du réseau (ou ne répondant tout simplement pas au ping), la réponse aurait été None :

>>> packet=IP(dst='192.168.0.253')/ICMP()/Raw(load=RandString(56))

>>> answer=sr1(packet,timeout=1)

WARNING: Mac address to reach destination not found. Using broadcast.

>>> answer is None

True

1.3 Écouter le réseau

Il nous est possible d'écouter le réseau, afin, par exemple, de récupérer les deux prochains paquets ICMP sur l'interface eth0 :

>>> pkts = sniff(filter='icmp', count=2, iface='eth0')

# La commande est bloquante. Elle retourne après avoir lancé en parallèle un

# 'ping -c 1 192.168.1.254'

>>> pkts.summary()

Ether / IP / ICMP 192.168.0.10 > 192.168.0.254 echo-request 0 / Raw

Ether / IP / ICMP 192.168.0.254 > 192.168.0.10 echo-reply 0 / Raw

On voit bien ici apparaître notre requête et la réponse du routeur.

2. Scan de ports

Il est assez facile, et très utile, d'écrire un programme effectuant un balayage de ports à l'aide de scapy : cela nous permet de trouver quels ports sont ouverts sur une machine distante. Il ne suffit pas d'envoyer n'importe quel paquet pour obtenir une réponse satisfaisante. Intéressons-nous à deux exemples détaillés.

2.1 Cas classique : scan TCP

Pour déterminer s'il est possible d'établir une connexion sur un port TCP donné, nous ne pouvons pas nous contenter d'envoyer un paquet forgé « au hasard » : nous risquerions de ne recevoir aucune réponse même si le port est ouvert. Essayons de contacter www.gnulinuxmag.com sans spécifier de drapeaux particuliers dans notre paquet TCP :

>>> answer = sr1(IP(dst='www.gnulinuxmag.com')/TCP(dport=80,flags=''),timeout=1)

>>> answer is None

True

Nous devons donc simuler une connexion TCP en trois étapes. Notons que la troisième étape n'est pas strictement nécessaire : recevoir un SYN-ACK nous indiquera que le port scanné est ouvert. Grâce à ce que nous avons appris dans la première partie de cet article, nous pouvons écrire la fonction suivante :

from scapy.all import sr1, IP, TCP, conf

conf.verb = 0 # Quiet mode

SYN = 0x02

ACK = 0x10

SYNACK = SYN | ACK

def tcp_scan(host, port):

    syn_pkt = IP(dst=host)/TCP(dport=port, flags='S') # 'S' for 'SYN'

    synack_pkt = sr1(syn_pkt, timeout=1)

    if synack_pkt is None:

        print('Cannot reach host "%s" on port %d' % (host, port))

    elif synack_pkt['TCP'].flags == SYNACK:

        print("%5d OPEN" % port)

    else:

        print("%5d CLOSED" % port)

Ce code est relativement simple : on forge un paquet TCP comportant le drapeau SYN, on l'envoie à l'hôte sur le port que l'on souhaite tester, et si l'on reçoit une réponse comportant les drapeaux SYN et ACK, on déclare le port ouvert.

Il est alors possible d'utiliser cette fonction dans l'interpréteur Python, et d'avoir sous la main un outil similaire à nmap, bien que beaucoup moins avancé :

>>> from tcp_port_scanner import tcp_scan

>>> tcp_scan('192.168.0.12', 22)

   22 OPEN

>>> tcp_scan('192.168.0.12', 80)

   80 CLOSED

Cette machine du réseau local fait sans doute tourner un serveur SSH, mais pas de serveur web (ou peut-être sur un port autre que le 80).

2.2 Plus compliqué : scan OpenVPN

Dans la partie précédente, nous avons pris l'exemple bien connu de la connexion TCP en 3 étapes. Comment aurait-il fallu procéder pour tester la présence d'un autre service, comme un serveur OpenVPN ?

L'association Aquilenet, fournisseur d'accès à Internet associatif en Gironde, met à disposition de ses adhérents un VPN (vpn.aquilenet.fr) qui écoute sur le port 1194 (le port classique pour OpenVPN). En environnement hostile, il peut pourtant être impossible de se connecter sur ce port, et il pourrait être utile de pouvoir tester rapidement si la connexion est possible sur l'un des autres ports sur lesquels écoute OpenVPN.

On ne peut malheureusement pas se contenter d'envoyer un paquet UDP quelconque, auquel le VPN ne répondrait pas :

>>> pkt = IP(dst='vpn.aquilenet.fr')/UDP(dport=1194)

>>> answer = sr1(pkt, timeout=2)

Begin emission:

.Finished to send 1 packets.

.......

Received 8 packets, got 0 answers, remaining 1 packets

>>> answer is None

True

Comme précédemment, nous devons donc forger un paquet valide et vérifier que le serveur OpenVPN y répond. Lançons donc la connexion dans un terminal, et regardons les paquets intéressants dans Wireshark (figure 2), grâce au filtre openvpn :

# openvpn /etc/openvpn/aqn.ovpn

scapy_figure_02

Fig. 2 : Premier paquet envoyé par notre client OpenVPN au serveur.

Nous pouvons alors écrire le code suivant :

#!/usr/bin/env python3

from scapy.all import *

conf.verb = 0

def openvpn_udp_scan(host, port):

    print('[+] Scanning port %d... ' % port, end='')

    # La charge utile que l'on peut voir dans la figure 2

    msg = '\x38\x78\xdd\x5d\x8a\xa7\xd1\xc9\x38\x00\x00\x00\x00\x00'

    pkt = IP(dst=host)/UDP(sport=45290, dport=port)/Raw(load=msg)

    answer = sr1(pkt, timeout=3)

    if answer is None:

        print('KO')

    elif 'ICMP' in answer:

        print('KO')

    else:

        print('OK')

openvpn_udp_scan('vpn.aquilenet.fr', 1194)

openvpn_udp_scan('vpn.aquilenet.fr', 1195)

La seule subtilité est la gestion du cas où, le port étant injoignable, le serveur nous renverrait un paquet ICMP dont le type serait destination unreachable. On peut noter que nous ne vérifions pas ici le type ; la seule présence de la couche ICMP nous indique que nous n'arrivons pas à joindre le port.

Certes, ce petit programme fonctionne, mais il n'est pas très élégant de recopier la suite d'octets comme nous l'avons fait. Nous aurions préféré écrire quelque chose comme :

pkt = IP(dst=host)/UDP(sport=45290, dport=port)

pkt/= OpenVPN(opcode='P_CONTROL_HARD_RESET_CLIENT_V2',

              keyid=0, ...)

Malheureusement, scapy ne définit pas de classe permettant de manipuler des paquets OpenVPN. Pourquoi ne pas l'écrire nous-mêmes dans la partie suivante ?

3. Définissez votre propre type de paquets

Scapy permet d'implémenter un type de paquet en écrivant une classe dérivée de la classe Packet. Voyons cela avec l'exemple du protocole OpenVPN, défini à l'adresse suivante : https://openvpn.net/index.php/open-source/documentation/security-overview.html.

3.1 Une liste de champs

Un paquet est juste une liste de champs. Il suffit donc de les lister, grâce à une syntaxe particulièrement claire :

class OpenVPN(Packet):

    name = 'OpenVPN'

    fields_desc = [

        BitEnumField('opcode', 1, 5, {

            1: 'P_CONTROL_HARD_RESET_CLIENT_V1',

            2: 'P_CONTROL_HARD_RESET_SERVER_V1',

            3: 'P_CONTROL_SOFT_RESET_V1',

            4: 'P_CONTROL_V1',

            5: 'P_ACK_V1',

            6: 'P_DATA_V1',

            7: 'P_CONTROL_HARD_RESET_CLIENT_V2',

            8: 'P_CONTROL_HARD_RESET_SERVER_V2',

            9: 'P_DATA_V2',

        }),

        BitField('keyid', 0, 3),

    ]

Nous définissons ici deux champs, que nous avons vu dans la figure 2 :

  • le champ opcode, long de 5 bits, dont la valeur par défaut est 1 ;
  • le champ keyid, long de 3 bits, dont la valeur par défaut est 0.

Nous remarquons que le champ opcode est une énumération de toutes les valeurs possibles pour le champ. Il est d'ailleurs possible d'utiliser les deux syntaxes suivantes, strictement équivalentes, lors de la création d'un paquet :

pkt = OpenVPN(opcode=7)

pkt = OpenVPN(opcode='P_CONTROL_HARD_RESET_CLIENT_V2')

Essayons maintenant de créer un paquet OpenVPN complet et de l'afficher :

pkt = IP(dst='vpn.aquilenet.fr')/UDP(sport=1337,dport=1194)

pkt /= OpenVPN(opcode='P_CONTROL_HARD_RESET_CLIENT_V2')

# La charge utile est presque la même que dans la première partie : seul le

# premier octet est manquant, puisqu'il correspond aux champs opcode et keyid

# que nous définissons désormais de façon plus facile à lire.

pkt /= Raw(load='\x78\xdd\x5d\x8a\xa7\xd1\xc9\x38\x00\x00\x00\x00\x00')

pkt.show2()

En exécutant ce code, on peut voir 3 couches :

###[ IP ]###

...

###[ UDP ]###

...

###[ Raw ]###

...

Nous aurions préféré voir une couche OpenVPN ainsi qu'une description lisible des champs définis dans notre classe. Il nous faut ici aider scapy en lui expliquant qu'il convient de décoder la couche qui vient après de l'UDP comme un paquet OpenVPN si le port utilisé est 1194 (le port par défaut d'OpenVPN) :

bind_layers(UDP, OpenVPN, sport=1194)

bind_layers(UDP, OpenVPN, dport=1194)

Si nous relançons le code précédent, nous obtenons désormais un résultat bien plus facile à lire :

###[ IP ]###

...

###[ UDP ]###

###[ OpenVPN ]###

        opcode = P_CONTROL_HARD_RESET_CLIENT_V2

        keyid = 0

###[ Raw ]###

...

On peut donc maintenant envoyer notre paquet et vérifier que nous obtenons bien une réponse, comme précédemment :

answer = sr1(pkt)

answer['OpenVPN'].show2()

# La sortie :

###[ OpenVPN ]###

  opcode = P_CONTROL_HARD_RESET_SERVER_V2

  keyid = 0

###[ Raw ]###

     load = ...

Nous n'avons pas implémenté ici tous les champs visibles dans la figure 2 (Session ID, Message Packet-ID, etc.), ce qui nous force à spécifier une bonne partie du paquet en y ajoutant une charge utile. C'est parce que ces champs dépendent en effet de l'opcode. Implémenter complètement le type de paquet OpenVPN est un exercice un peu fastidieux dont la réalisation complète ne présenterait qu'un intérêt limité dans le cadre de cet article. Montrons toutefois comment utiliser des champs « optionnels » dans nos paquets.

3.2 Champs optionnels

Lorsque le paquet OpenVPN est envoyé en utilisant TCP, le premier champ de la couche OpenVPN doit être la taille du paquet, encodée sur 16 bits. Ce champ ne doit pas être présent lorsqu'on utilise UDP. Scapy permet heureusement de déclarer des champs optionnels :

class OpenVPN(Packet):

    name = 'OpenVPN'

    fields_desc = [

        ConditionalField(ShortField("length", None),

                         lambda pkt: isinstance(pkt.underlayer, TCP)),

    ...

Nous indiquons ici que nous voulons insérer un ShortField uniquement si la fonction passée comme deuxième argument au constructeur de la classe ConditionalField retourne True pour ce paquet. On comprend aisément, en lisant le bout de code ci-dessus, que cette fonction lambda teste si la couche « du dessous » est TCP.  Il nous faut maintenant donner une valeur correcte à ce champ lors de la construction du paquet : la méthode post_build prend en paramètre le paquet et sa charge utile, et nous permet de modifier notre paquet :

class OpenVPN(Packet):

    ...

    def post_build(self, pkt, pay):

        if isinstance(self.underlayer, TCP) and self.length is None:

            pkt = struct.pack("!H", len(pkt) - 2) + pkt[2:]

        return pkt + pay

Un petit tour dans la documentation du module struct de Python nous apprend que !H est la notation permettant d'obtenir un unsigned short en big endian, ce qui est exactement ce que nous voulons. Nous récupérons la longueur du paquet (moins la taille du champ length lui-même) et l'écrivons dans les deux premiers octets du paquet final.

Il ne nous resterait plus, pour complètement implémenter le format de paquet propre à OpenVPN, qu'à ajouter tous les champs qui dépendent de l'opcode, et à gérer leurs valeurs. Cela suggère bien évidemment de comprendre parfaitement le protocole OpenVPN. L'exercice est laissé au lecteur...

3.3 Sommes de contrôles

Donnons un autre exemple d'opération devant être effectuée dans la méthode post_build : le calcul des sommes de contrôles. Nous avons vu dans la première partie de cet article qu'il nous fallait utiliser la méthode show2, qui affiche un paquet après sa construction, pour pouvoir lire la valeur des sommes de contrôles de nos paquets. Voyons par exemple comment scapy gère le protocole IP :

# Dans scapy/layers/inet.py

class IP(Packet, IPTools)

    ...

    def post_build(self, p, pay):

        ...

        if self.chksum is None:

            ck = checksum(p)

            p = p[:10]+chr(ck>>8)+chr(ck&0xff)+p[12:]

        ...

On voit ici comment, très simplement, scapy calcule la somme de contrôle d'un paquet IP et l'insère au bon endroit. Il est souvent utile d'aller fouiller dans les sources de scapy afin de trouver ce genre de code qui peut être réutilisé dans l'implémentation d'autres types de paquets.

Conclusion

Cette présentation de scapy s'achève, et bien qu'elle ne montre pas toutes les possibilités offertes par l'outil, le lecteur devrait désormais avoir les notions de base lui permettant de commencer à l'utiliser, et devrait pouvoir approfondir par lui-même ses connaissances afin d'écrire le code réseau de ses rêves.

Il est de toute façon particulièrement intéressant de parcourir le code de scapy (comme nous l'avons fait à la fin de la dernière partie de cet article), ou de s'intéresser aux programmes écrits grâce à scapy, afin de découvrir tout le potentiel de ce cadriciel.



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous