Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Ève

Tester la robustesse des serveurs DNS avec Scapy

Première rédaction de cet article le 4 mars 2010


Comme aime le dire Paul Vixie, l'Internet est, pour les serveurs réseau, plutôt l'équivalent de New York que celui du petit village tranquille où tout le monde se connait. Si on s'évanouit de peur à chaque truc bizarre qui passe, on ne va pas loin. Un serveur réseau connecté à l'Internet doit être robuste : il va voir des tas de paquets mal formés et ne devra pas planter (ou, pire, ouvrir une voie de pénétration) lorsqu'un de ces paquets arrive. Un des outils pour tester facilement la robustesse d'un serveur est Scapy. Voyons son application à des serveurs DNS.

Un serveur DNS reçoit des paquets UDP et TCP qui sont normalement conformes à la description qu'en fait le RFC 1035 et quelques RFC ultérieurs (notamment le RFC 2671). Suivre à la lettre ces RFC devrait mener à du code parfait. Ainsi, pour analyser un nom de domaine figurant dans la section Question d'un paquet, on lit la section 4.1.2 du RFC 1035, qui dit que le nom est représenté par une suite de composantes, chaque composante étant faite d'un octet (qui indique la longueur de la composante) et d'une suite d'octets pour le texte. On arrive à du code qui ressemble à (en Go) :

for {
        labelsize, error := buf.ReadByte()
        if labelsize == 0 {
                break
        }
        label := make([]byte, labelsize)
        n, error := buf.Read(label)
        nlabels += 1
        labels = labels[0:nlabels]
        labels[nlabels-1] = string(label)
}

ou bien (en C) :

for (sectionptr = qsection; !end_of_name;) {
  labelsize = (uint8_t) * sectionptr;
  if (labelsize == 0) {
      sectionptr++;
      end_of_name = true;
  } else {
      strncat(fqdn, ".", 1);
      strncat(fqdn, (char *) sectionptr + 1, labelsize);
      fqdn_length += (labelsize + 1);
      fqdn[fqdn_length] = '\0';
      sectionptr = sectionptr + labelsize + 1;
  }
}

Ces codes (simplifiés) marchent avec des paquets normaux. Mais l'expérience montre clairement que, dans le vrai Internet, il existe une minorité non négligeable de paquets anormaux. On les voit indirectement dans les alertes de sécurité de Wireshark. Pour le cas du DNS, l'usage d'un outil comme DNSmezzo sur un serveur de noms public montre rapidement des milliers de paquets qui violent le RFC. D'où viennent ces paquets ? Certains sont issus d'erreurs de programmation (il y a des tas d'amateurs incompétents qui programment), d'autres sont des essais par des étudiants en plein TP d'écriture d'un client DNS, d'autres enfin sont d'authentiques tentatives de piratage, par exemple en cherchant à provoquer un débordement de tampon chez le serveur.

Tout code réel (i.e. pas les deux exemples plus haut) va donc chercher à se protéger contre de tels paquets. On vérifie donc, on regarde si un pointeur de compression ne sort pas du paquet, on teste si on est arrivé au bout du paquet avant de continuer aveuglément, etc. C'est le B. A. BA de la programmation réseau.

Mais comment tester que ces protections sont efficaces ? Il faudrait pouvoir fabriquer facilement des paquets anormaux. Et la plupart des bibliothèques de programmation réseau sont au contraire conçues pour faciliter la production de paquets normaux. C'est là que Scapy devient utile.

Scapy est un outil d'analyse de paquets existants et de fabrication de paquets, offrant une très grande souplesse de manipulation. Scapy fait tout tout seul... mais laisse le programmeur intervenir où il veut. C'est l'outil rêvé des programmeurs qui veulent tester un logiciel (ou des pirates qui veulent l'attaquer). S'appuyant sur Python, il nécessite de connaitre ce langage. Lisez la documentation. Puis utilisons-le en interactif pour commencer :


# scapy
>>> p = IP(dst="203.0.113.162")/UDP(sport=RandShort(),dport=53)/\
...      DNS(rd=1,qd=DNSQR(qname="www.slashdot.org", qtype="AAAA"))

On a fabriqué une requête DNS (demande de l'adresse IPv6AAAA - de www.slashdot.org). Notez qu'un certain nombre de paramètre n'ont pas été fournis (par exemple l'adresse IP source), Scapy les fournira. Vérifions :


>>> p.show()
###[ IP ]###
  version= 4
...
  chksum= 0x0
  src= 203.0.113.69
  dst= 203.0.113.162
  options= ''
###[ UDP ]###
     sport= <RandShort>
     dport= domain
     len= None
     chksum= 0x0
###[ DNS ]###
        id= 0
        qr= 0
        opcode= QUERY
        aa= 0
        tc= 0
        rd= 1
        ra= 0
        z= 0
        rcode= ok
        qdcount= 1
        ancount= 0
        nscount= 0
        arcount= 0
        \qd\
         |###[ DNS Question Record ]###
         |  qname= 'www.slashdot.org'
         |  qtype= AAAA
         |  qclass= IN
        an= None
        ns= None
        ar= None

Le champ src (adresse IP source) a bien été rempli. Des champs comme la longueur du paquet UDP (len, actuellement None, la valeur indéfinie en Python) seront remplis automatiquement lors de l'émission du paquet. De même, l'appel à RandShort() ne sera évalué qu'au moment de l'envoi du paquet (et produira un nombre aléatoire).

D'ores et déjà, je peux interactivement modifier tous les champs, par exemple :


>>> p.rd = 0

et p.show() (ou bien tcpdump si on envoie le paquet) montrera bien que la requête n'est plus récursive.

Ah, justement, envoyons le paquet :


>>> sr1(p)
Begin emission:
.Finished to send 1 packets.
*
Received 2 packets, got 1 answers, remaining 0 packets
<IP  version=4L ihl=5L tos=0x0 len=62 id=0 flags=DF frag=0L ttl=63 proto=udp chksum=0xb1bb src=203.0.113.162 dst=203.0.113.69 options='' |<UDP  sport=domain dport=50474 len=42 chksum=0x1c97 |<DNS  id=0 qr=1L opcode=QUERY aa=0L tc=0L rd=1L ra=1L z=0L rcode=ok qdcount=1 ancount=0 nscount=0 arcount=0 qd=<DNSQR  qname='www.slashdot.org.' qtype=AAAA qclass=IN |> an=None ns=None ar=None |>>>

Le paquet a été transmis et la réponse (« il n'y a pas d'adresse IPv6 pour www.slashdot.org ») reçue et analysée.

Bien, tout cela, c'étaient des paquets DNS normaux. Maintenant, essayons de fabriquer des paquets anormaux, pour voir si le serveur DNS réagit bien. Je vais tester avec un serveur DNS très sommaire, qui a peu de protections, GRONG. Si je lance GRONG et que je lui envoie le paquet ci-dessus, le serveur affiche :

% ./server -debug=4
34 bytes packet from 203.0.113.69:24007
Query is true, Opcode is 0, Recursion is true, Rcode is 0
FQDN is www.slashdot.org, type is 28, class is 1
Replying with ID 0...

Place maintenant à un paquet DNS malformé. Changeons qdcount (dans la section DNS) qui indique la taille de la section Question (normalement, 1) :


>>> p[DNS].qdcount=0

et envoyons ce paquet incohérent (qdcount ne correspond plus à la taille de la section Question), le résultat ne se fait pas attendre, le serveur, programmé de manière insuffisamment défensive, plante :

34 bytes packet from 203.0.113.69:23287
throw: index out of range

panic PC=0x40078b90
throw+0x46 /usr/local/go/src/pkg/runtime/runtime.c:74
        throw(0x80bcd88, 0x0)
runtime.throwindex+0x24 /usr/local/go/src/pkg/runtime/runtime.c:47
        runtime.throwindex()
main.parse+0x407 /home/stephane/src/Go/dns/grong/server.go:137
        main.parse(0x40046c00, 0x0, 0x0)
main.generichandle+0x5d /home/stephane/src/Go/dns/grong/server.go:147
        main.generichandle(0x40046c00, 0x400475c0, 0x40042270, 0x0, 0x0, ...)
main.udphandle+0x136 /home/stephane/src/Go/dns/grong/server.go:191
        main.udphandle(0x400414c8, 0x400475c0, 0x40042270, 0x40046c00, 0x0, ...)
goexit /usr/local/go/src/pkg/runtime/proc.c:140
        goexit()
0x400414c8 unknown pc

Heureusement, Go nous fournit une belle pile d'appels, qui nous permettra d'améliorer le serveur (ce sera pour un autre article).

Changer uniquement un champ est amusant mais on peut aussi modifier plus drastiquement le paquet. Par exemple, si je veux le tronquer ? Il faut pour cela sérialiser le paquet en une chaîne de caractères (avec str()), le modifier puis le retransformer en paquet :


>>> s = str(p)
>>> p2 = IP(s[:-4])
>>> sr1(p2)

Ce code transforme p dans la chaîne s puis retransforme en paquet la chaîne privée de ses quatre derniers octets. Mais le paquet n'arrive pas au serveur de noms, qui semble ne rien recevoir. C'est parce que le gros problème de cette approche est que la sérialisation « gèle » les valeurs « flottantes » comme la longueur indiquée dans l'en-tête, qui est désormais invalide. Remettons tout cela à zéro, ainsi que les sommes de contrôle et Scapy le recalculera proprement :


>>> del p2[UDP].len
>>> del p2[IP].len
>>> p2[UDP].sport=RandShort()
>>> del p2[UDP].chksum
>>> del p2[IP].chksum
>>> sr1(p2)

Désormais, le paquet tronqué est bien reçu... et plante le serveur :

30 bytes packet from 203.0.113.69:12662
Error in Read of an int16: EOF (0 bytes read)

Et si je veux modifier une valeur quelconque, non accessible depuis un champ nommé, par exemple la longueur d'une des composantes du FQDN ? Là encore, on sérialise, on fabrique une nouvelle chaîne en changeant un des octets (ici, le n° 44) et on refait le paquet.


>>> s=str(p)
>>> s
'E\x00\x00>\x00\x01\x00\x00@\x11\xf0\xfb\xc0\x86\x04E\xc0\x86\x04au*\x1fu\x00*\xce\x18\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03www\x08slashdot\x03org\x00\x00\x1c\x00\x01'
>>> s[44]
'\x08'
>>> s2 = s[:44] + '\x030' + s[45:]
>>> s2
'E\x00\x00>\x00\x01\x00\x00@\x11\xf0\xfb\xc0\x86\x04E\xc0\x86\x04au*\x1fu\x00*\xce\x18\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03www\x030slashdot\x03org\x00\x00\x1c\x00\x01'
>>> p2 = IP(s2)             
>>> del p2[UDP].chksum
>>> del p2[IP].chksum
>>> sr1(p2)

À noter que cette erreur particulière ne pose pas de problème à GRONG qui ignore ce paquet, reconnu comme invalide.

Bien sûr, il y a des tas de tests que l'on souhaite ainsi faire. Scapy fournit d'ailleurs des moyens de faire varier certains paramètres (fuzzy testing). On a donc intérêt à mettre ces tests dans un programme Python normal, qui utilisera Scapy pour faire tourner tous les tests... et vérifier ainsi le serveur. Pour un exemple d'un tel programme, voir test-dns-scapy.py. Je n'ai pas de scrupule à livrer ce programme de test de robustesse car BIND et nsd, bien écrits, y survivent sans problème.

Ah, un petit gag avec Scapy, qui est dans la FAQ mais qui m'avait coûté quelque temps de recherche. Au début, on commence souvent par tester un serveur qui figure sur la même machine et on teste donc l'adresse IP locale, 127.0.0.1. Mais l'interface locale a quelques particularités et Scapy exige donc qu'on indique, dans ce cas :


>>> conf.L3socket=L3RawSocket

Cette instruction ne marche pas, à l'heure actuelle, depuis un programme Python, uniquement en interactif (bogue Scapy #193). Depuis un programme, on obtient un NameError: global name 'Ether' is not defined. La correction est d'ajouter from scapy.layers.l2 import Ether dans les importations de scapy/supersocket.py (merci à rmkml).

Autre piège où j'ai passé du temps en apprenant Scapy : = ne fait pas une copie des paquets, le nouveau paquet, si j'écris p2 = p est juste un pointeur. Pour copier réellement un paquet :


>>> p2 = p.copy()

Merci à Pierre-François Bonnefoi pour son aide. Il est par ailleurs l'auteur d'un excellent support de cours Scapy en français. L'exposé « Network packet forgery with Scapy », de Philippe Biondi, contient plein de techniques utiles.

Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)

Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)