IFT585 -- Travail pratique #2

Date de remise: 10 juin 2014, 23:59:59

Robustesse de connexion et de transmission

Nous poursuivons le développement de l’application d’exécution de programmes à distance amorcée durant le TP1. Le but de ce TP est de développer, au niveau de l’application, des procédés d’échange de données entre le client et le serveur qui soit robuste aux pertes de connexion et aux erreurs de transmission.

Le premier de ces problèmes est inhérent au fait que l’internet est un milieu d’échange fluide, où le service et la connectivité ne sont pas garantis. Lorsqu’on se connecte à un serveur par le protocole TCP, les transmissions entre les pairs connectés sont garanties, à l’exception d’une panne de service. Ainsi, la perte de la connexion est une condition limite qui doit être gérée tant par le serveur que par le client. Cependant, pour certaines applications, on aimerait pouvoir bâtir sur une connexion TCP l’abstraction d’une connexion robuste. Une telle connexion, articulée par un protocole construit sur TCP, fait en sorte qu’en cas de perte de connexion, le client et le serveur font le nécessaire pour reprendre contact et poursuivre leurs échanges, sans que l’application ne s’en rende compte. De son côté, l’application ne subirait qu’un temps de transfert plus long que d’ordinaire dans ses échanges.

Le second de ces problèmes est typiquement réglé par le protocole TCP. En revanche, une connexion TCP fait absolument confiance à son pair. On peut donc concevoir qu’une telle connexion peut être la proie d’une attaque par interposition (man-in-the-middle). Imaginons que la véritable adresse du serveur n’ai pas été transmise au client, mais qu’il s’agisse plutôt de l’adresse d’un attaquant. Cet attaquant, lui, connaît l’adresse du serveur. Lorsque le client se connecte aux coordonnées qu’il connaît, il se connecte en fait à l’attaquant, qui lui se connecte au serveur. À partir de ce moment, l’attaquant peut voir toutes les données échangées entre le serveur et le client. Il peut même manipuler ces échanges de données.

En principe, la bonne manière de se protéger des attaques par interposition consiste en un usage judicieux d’authentification et de cryptographie forte, de manière à ce que les données échangées entre le client et le serveur ne signifient rien à l’attaquant, et qu’il ne puisse pas manipuler ces échanges sans se révéler au client et au serveur. Dans le cadre de ce TP, nous répondrons plutôt à l’attaque par une approche naïve de détection des “erreurs” et de fiabilité des échanges.

Contexte du travail

J’ai préparé une version modifiée des programmes resh, reshd, ltee et lcat que vous devrez télécharger ici. Le code des programmes se trouve dans le sous-répertoire code, alors que le sous-répertoire test contient un script permettant de vérifier la fonctionnalité exigée pour le TP1. Vous savez déjà utiliser les programmes du TP1; quant au script de test, vous l’appelez depuis un terminal, où vous vous placez dans le sous-répertoire test avant d’invoquer:

python tp1.py ../code

Comparez alors la réponse attendue à celle obtenue pour déterminer à l’oeil si chaque test est réussi ou non. Au désarchivage, tous les tests devraient être réussis.

La principale modification apportée aux programmes en regard de la solution du TP1 déjà publiée est que les bases de l’abstraction d’une connexion robuste et persistente ont déjà été mises en place. Le protocole bâti par-dessus TCP est développé à travers les classes du module connection.py. Tout le travail a réaliser pour le TP consiste à modifier et augmenter le code de connection.py. Cela implique que le TP doit être réalisé en Python. Cela implique aussi que vous ne devez pas modifier les autres modules du code distribué dans le sous-répertoire code. Lors de la correction, la version originale des autres modules sera utilisée, ainsi que votre version du module connection.py.

Aperçu de l’architecture de connection.py

Les classes du module connection.py observent la hiérarchie suivante:

Connection
    Peer
        Client
        Accepted
    Server

La classe Connection offre le service de base, qui consiste en un socket local utilisé pour échanger les données avec la connexion robuste. Celle-ci les échange à son tour avec une connexion TCP de diverses manières.

D’une part, les connection de type pair (classes dérivées de Peer) sont des canaux de communication d’octets. D’une part, une instance de Client est utilisée comme un socket auquel on applique l’opération connect(); réciproquement, une instance d’Accepted équivaut à un socket obtenu d’un appel à accept(). D’autre part, les instances de Server offrent des méthodes pour réaliser le travail d’un socket de base d’un serveur, qui peuvent accepter des connexions à de multiples clients. Alors qu’on manipule les instances de Client et Accepted avec les méthodes send(chaine) et recv(nb_octets), on manipule Server avec accept(); toutes ces classes ont finalement une méthode close().

Sous le capot, le travail de transfert des données entre l’interface (paire de sockets locaux articulant un tunnel bidirectionnel de données) et la véritable connexion TCP se fait dans un fil d’exécution (thread) parallèle. Les connexions de type Peer reçoivent des chaînes d’octets sur l’interface de service (côté peer du tunnel) et les propagent sur la connexion TCP; simultanément, elles reçoivent des chaînes sur la connexion TCP et les propagent sur l’interface de service. Quant aux connexions de type Server, elles acceptent des connexions de client via un socket TCP dûment mis en place et renvoient ces connexions TCP emballées dans une instance de Accepted. Avant d’entreprendre la résolution des problèmes du TP, il est sage de prendre le temps de bien regarder le code de connection.py pour bien le comprendre. On peut même l’explorer un peu en y ajoutant des énoncés print pour bien comprendre l’ordre des opérations convenant à émuler le comportement des sockets.

Au moment de débuter le TP, évidemment, ces abstractions ne sont qu’une coquille enveloppant des sockets TCP tout simples: les données échangées sont vulnérables aux erreurs imputables à une attaque par interposition et les connexions ne sont pas persistentes aux pannes.

Procédé d’attaque

Le sous-répertoire mitm du code distribué pour le TP contient le module/script mitm.py, qui met en place une attaque d’interposition de base (affichage des messages échangés entre client et serveur). Cette attaque peut aussi être utilisée pour simuler une perte de connexion entre client et serveur. Vous pourrez vous servir de ce module pour tester la robustesse et la persistence de vos propres connexions, en créant des programmes à partir de mitm.py (soit en dérivant la classe ManInTheMiddle, soit en copiant et modifiant directement le fichier), implantant les méthodes appropriées pour transformer les données échangées.

Invariants

À travers la résolution des problèmes qui suivent, vos solutions devraient vérifier les invariants suivants.

  1. Les tests du TP1 passent tous sans problème.
  2. Le serveur fonctionne contre de multiples clients concurrents.
  3. On peut faire de multiples transferts concurrents par les commandes ltee ou lcat.

Notez comment tous ces invariants concernent la “couche application”: les changements qu’on réalise au-dessous de cette couche ne doivent pas affecter la couche du dessus, si ce n’est que d’une raisonnable pénalité de performance.

Problème 1 – Ordre des messages

Le premier problèmes dont on se préoccupe est de livrer les octets envoyés sur la connexion dans l’ordre où ils ont été envoyés. La meilleure façon de garantir l’ordre de livraison est de diviser la chaîne d’octets en cadres et d’implanter l’algorithme de la fenêtre coulissante pour transférer ces cadres de manière fiable. Utilisez une taille de fenêtre de 4 tant pour l’envoi que pour la réception.

Problème 2 – Consistance des messages

Le second problème est de détecter que le cargo des cadres est bien livré sans erreur. Si on détecte une erreur dans le cargo, on peut alors utiliser la fenêtre coulissante implantée au problème 1 pour assurer son retransfert. Notez qu’on peut avoir tant des erreurs de caractères, où des octets transférés sont remplacés, que des erreurs de cadrage, où des sous-séquences entières de la chaîne d’octets transférée peuvent être tronquées. Ces erreurs de caractère et de cadrage peuvent aussi être combinées.

Pour l’évaluation, notez que le correcteur ne tentera pas de renverser votre procédé de détection d’erreur de manière à produire une information redondante cohérente avec un cargo modifié. Le cargo et l’information de détection pourront être modifiées par l’attaque par interposition, mais pas avec assez d’intelligence pour que l’inconsistance du paquet ne soit pas détectable par un procédé vu en classe.

Instructions pour la remise

  • Le travail est exécuté par équipes de deux.
  • Le travail doit être remis par courriel à l’une de ces deux adresses: benoit@benoithamelin.com, benoit.hamelin@usherbrooke.ca.
    • Le sujet du courriel: IFT585 TP2
    • Le corps du courriel doit contenir le nom des deux coéquipiers.
    • Un fichier nommé connection.py doit être inclus en pièce jointe.

Évaluation

  • Le travail est évalué sur 20 points:
    • Conformité aux instructions de remise: 2 points
    • Propreté de la sortie des programmes: 1 point
    • Satisfaction des invariants et consignes de développement: 2 points
    • Les mêmes tests fonctionnent pour les deux directions de transfert: 3 points
    • Problème 1: 5 points
    • Problème 2: 7 points
  • La conformité du code aux exigences sera testée à l’aide de programmes externes basés sur l’attaque d’interposition dont le code de base est donné. Mettre en oeuvre des tests adéquats en regard de la spécification fait partie de votre travail.
  • Le code ne sera pas examiné. Ses qualités intrinsèques ne sont pas évaluées. On s’attend à ce que la difficulté du problème cause des problèmes de maintenance intractables (ou, à tout le moins, douloureux) pour des programmes conçus avec négligence ou sans égard à la lisibilité.

Questions et réponses

  1. Je suis coincé sur un bug dans l’exécution de ltee: le programme tente de lire 1079 caractères sur le socket alors que celui-ci en contient 6, puis 0. Toutes les commandes en dehors de ltee et lcat fonctionnent (ps, ps aux, ls, ls -l, vmstat, python -i, cat…).

    Premièrement, il faut marquer une distinction entre ltee et lcat, ainsi que toutes les autres commandes: les premières utilisent le système reshd/resh d’une manière particulière qui demande une bonne attention. Alors que je n’ai pas eu de bug particulier avec lcat, ltee m’a donné du fil à retordre à cause de la nature concurrente du protocole de transfert robuste développé dans connection.py. En d’autres termes, le problème vient d’une condition de compétition entre le fil d’exécution principal et le fil d’exécution Peer.run, où se réalisent les transferts.

    Lorsque ltee envoie son dernier message via msgs.send(...) (ligne 55 de ltee.py), il a terminé son travail. La dernière chose que l’interpréteur Python fait avant de quitter est d’invoquer conn.close(), dont l’exécution a été commandée sur toute sortie via atexit.register(). C’est alors la méthode Connection.close() qui est alors exécutée. Cette dernière ferme l’interface de service de l’instance de Connection, puis attend la terminaison du fil d’exécution Peer.run.

    De son côté, ce fil est à l’écoute de l’interface de service et se rend compte de l’intention de fermer la connexion lorsqu’il ne reçoit plus de données sur ce socket. Que fait-il alors? Il termine le plus vite possible son exécution. Le problème auquel j’ai fait face survient si les transferts cachés dans la fenêtre d’envois n’ont pas été tous complétés.

    Il faut donc réviser les conditions de sortie de la boucle infinie définie dans Peer.run. Lorsqu’on détecte l’intention de quitter via la réception de 0 octets sur self.peer, il ne faut pas interrompre la boucle sur-le-champ. Il faut plutôt lever un drapeau du genre conn_closing et n’accepter de sortir de la boucle que si conn_closing est vrai et que tous les messages cachés pour envoi ont été transmis et confirmés.