La programmation des sockets bruts sous Windows

Jessee Edouard

Ce tutoriel va vous apprendre la programmation des sockets bruts (SOCK_RAW) sous Windows en langage C au sein d'un environnement TCP/IP.

Commentez cet article : Commentez

- Titre : La programmation des sockets bruts sous Windows

- Auteur : Jessee Edouard

- Parution : 22 juillet 2008

- Mise à jour : 4 avril 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2008 Melem. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Compléments sur les structures et les champs de bits

- II-A. Introduction

- II-B. Alignement

- II-C. Les champs de bits

- III. IP : Le protocole de l'Internet

- IV. Les sockets bruts

- IV-A. Généralités

- IV-B. Le protocole UDP

- IV-C. Le protocole ICMP

- IV-D. Le protocole TCP

- IV-E. Programmation d'un sniffer

I - Introduction

La dernière fois nous avons vu comment programmer les sockets en mode connecté (SOCK_STREAM) et en mode non connecté (SOCK_DGRAM). Cette fois-ci nous allons nous intéresser aux sockets bruts (SOCK_RAW) qui permettent de travailler à plus bas niveau avec les protocoles d'une famille donnée, dans notre cas : la suite de protocoles TCP/IP (PF_INET), ou même d'inventer de nouveaux protocoles, mais là n'est pas notre objectif. Nous travaillerons principalement sous Windows, mais il ne sera pas du tout difficile d'adapter nos programmes pour qu'ils puissent compiler pour d'autres plateformes. Entrons maintenant dans le vif du sujet.

II - Compléments sur les structures et les champs de bits

II-A - Introduction

La manipulation des en-têtes TCP, IP, etc. nécessite une bonne compréhension des propriétés des structures et des champs de bits. La difficulté vient du fait que beaucoup de ces propriétés ne sont pas définies par la norme. Afin de mieux nous concentrer sur notre véritable problème plutôt que sur des détails d'implémentation du C, nous utiliserons, dans ce tutoriel, le compilateur Visual C++ (version 6 ou supérieure) étant donné que nous avons déjà fait le choix de travailler sous Windows. Tous les exemples de cette page ont été compilés puis testés avec Visual C++ 2005, Borland C++ Compiler 5.5 et gcc (GCC) 3.4.5 sous Windows XP Professionnel Service Pack 2.

II-B - Alignement

La norme du langage C stipule que les membres d'une structure doivent apparaitre en mémoire dans le même ordre qu'ils ont été déclarés. Cependant, c'est une erreur de croire que les membres d'une structure soient toujours collés (serrés) entre eux autrement dit que la taille d'une structure soit toujours égale à la somme des tailles de chacun de ses membres. Cela vient du fait que les objets ne sont pas stockés en mémoire de façon aléatoire. En effet, pour des raisons de performance (cela est même requis par certains processeurs), chaque objet doit être alloué en mémoire à une adresse p telle que p soit un multiple d'un entier n appelé align-requirement (alignment requirement) de l'objet en question. Cette contrainte est connue dans la littérature française sous le nom de contrainte d'alignement.

Pour les types de base (char, short, int, double, etc.), ce nombre est égal à la taille de ce type. Par exemple, la taille d'un int étant de 32 bits (4 octets) sur un processeur Intel 32 bits, n est donc de 4, ce qui signifie qu'une variable de type int doit commencer à une adresse qui est multiple de 4. Pour une structure (ou un tableau ou une union), la contrainte d'alignement est par défaut celle du membre dont l'alignement requis est le plus grand. De plus, chaque membre de la structure commence à une adresse qui est soit multiple de sa taille, soit multiple du nombre imposé par la contrainte d'alignement de la structure, en prenant le minimum. Cela permet de garantir que chaque membre de la structure soit correctement aligné pour le processeur. Pour illustrer ce principe, considérons la structure suivante :

struct s {
char  c; /* 1 octet  */
int   n; /* 4 octets */
};

struct s x;

Avec les réglages par défaut du compilateur, on a sizeof(x) == 8 et non 5. En effet, la contrainte d'alignement de la structure est de 4, car sizeof(n) == 4, n étant le membre dont l'alignement requis ici est le plus grand (4 contre 1 pour c) donc : x doit commencer à une adresse multiple de 4 (mais en fait ce détail ne nous intéresse pas ici), c doit commencer à une adresse multiple de 1 ou de 4 donc 1 (autrement dit peut commencer à n'importe quelle adresse), car 1 est le minimum des deux et n doit commencer à une adresse multiple de 4. La taille de la structure est donc de 8.

Considérons maintenant la structure suivante :

struct s {
char  c; /* 1 octet  */
short u; /* 2 octets */
int   n; /* 4 octets */
};

struct s x;

La taille de x ici est toujours de 8 octets, car c doit commencer à une adresse multiple de 1, u à une adresse multiple de 2 et n à une adresse multiple de 4, avec comme tailles respectives 1, 2 et 4. Si on considère cependant l'exemple suivant :

struct s {
char  c; /* 1 octet  */
char  d; /* 1 octet  */
short u; /* 2 octets */
int   n; /* 4 octets */
};

struct t {
char  c; /* 1 octet  */
short u; /* 2 octets */
char  e; /* 1 octet  */
int   n; /* 4 octets */
};

struct s x;
struct t y;

La taille de x sera de 8 tandis que celle de y sera de 12 ! Vous en savez, je crois, assez pour expliquer ce phénomène.

II-C - Les champs de bits

Les champs de bits sont des bits consécutifs d'un objet de type entier. L'objet en question doit cependant avoir été encapsulé dans une structure. Par exemple :

struct t {
unsigned a;
unsigned m : 23;
unsigned e : 8;
unsigned s : 1;
};

struct t x;

En faisant abstraction de la structure, y est une structure composée de deux entiers non signés - a et un autre décomposé en 3 champs de bits à savoir : m (bits 0 à 22), e (bits 23 à 30) et s (bit 31). La taille de x est de 8 octets.

La norme du langage C stipule qu'un champ de bits doit être créé à l'intérieur d'un int ou unsigned int, mais autorise une implémentation particulière à supporter d'autres types. Ces derniers doivent cependant être de type entier. La norme laisse également au soin de chaque implémentation de définir l'ordre dans lequel ces champs sont rangés à l'intérieur de leur support. Dans l'implémentation du C de Microsoft, un champ de bits peut être créé au sein de n'importe quel type entier, signé ou non et ils sont rangés allant du bit du poids le plus faible au bit de poids le plus fort de leur support.

III - IP : Le protocole de l'Internet

Comme nous les savons déjà le données qui circulent sur Internet se présentent sous la forme de paquets IP, constitué d'un en-tête (contenant entre autres l'adresse du destinataire et celle de l'émetteur) immédiatement suivi des données à transporter. Ces données proviennent de la couche juste au-dessus de IP c'est-à-dire du protocole TCP ou UDP ou même d'un protocole de même niveau comme ICMP par exemple. Ces données se présentent également sous forme de paquet (on parle donc de paquet TCP, UDP ou ICMP…) constitué d'un en-tête suivi également des données originales. Les sockets bruts permettent entre autres de composer soi-même un paquet IP et de le soumettre au réseau c'est pourquoi il est important de connaître le format d'un tel paquet. Vous avez donc deviné, cela permet par exemple de composer un paquet avec une adresse source (adresse de l'émetteur) falsifiée ! Le format d'un paquet IP, dans la version 4 (IPv4), est le suivant :

Avant d'expliquer le rôle de chaque champ, passons tout d'abord par la définition la structure C représentant l'en-tête d'un paquet IP :

Fichier : ip.h

#ifndef H_IP_H

#define H_IP_H

typedef struct ipheader {
UCHAR  hlen : 4, version : 4;
UCHAR  tos;
USHORT tot_len;
USHORT id;
USHORT offset : 13, flags : 3;
UCHAR  ttl;
UCHAR  protocole;
USHORT somme;
ULONG  ip_source;
ULONG  ip_dest;
} IPHEADER;

#endif

Voici donc à présent la signification de chacun de ces champs :

- Version : indique la version du protocole utilisé. Il faut mettre 4, car on utilise la version 4 de ce protocole ;

- Longueur : contient le nombre de mots de 32 bits constituant l'en-tête. Ce champ contient donc généralement 5 mais en fait, l'en-tête peut également comporter des « options » et dans ce cas, sa taille devient supérieure à 5 (en fonction de la taille des options) ;

- Type de service : ce champ permet d'indiquer le type de service exigé par le client (l'utilisateur). Le service doit être supporté par le routeur. On peut tout simplement mettre zéro si aucun service particulier n'est exigé ;

- Longueur totale : la longueur (mesurée en octets) du paquet IP. Un paquet IP peut donc faire tout au plus 65 536 octets (64 Ko). En fait, chaque type de support de transmission est caractérisé par la taille du plus gros paquet que celui-ci peut transporter. Si jamais la taille d'un paquet dépasse le maximum autorisé, IP le découpe en de plus petits paquets pouvant chacun être accepté par le support. Ce processus est appelé la fragmentation des paquets. À l'arrivée, les fragments seront recollés pour reconstituer le paquet initial ;

- Numéro d'identification : il s'agit d'un numéro attribué à chaque fragment afin de permettre leur réassemblage. Autrement dit les fragments d'un paquet donné doivent porter le même numéro ;

- Flags (0, D et M) : ce champ est constitué de 3 bits à savoir 0 : inutilisé, D (Do not fragment) : si mis à 1, indique que le paquet ne doit pas être fragmenté (si jamais la taille du paquet dépasse le maximum autorisé, le paquet ne sera pas envoyé) et M (More fragments) : si mis à 1, indique qu'il y a encore d'autres fragments (ce qui signifie que le paquet lui-même est également un fragment). Si ce bit vaut 0, cela signifie que le paquet est le dernier des fragments du paquet à reconstituer ou tout simplement que le paquet n'a pas subi de fragmentation ;

- Offset : indique la position (offset) à partir de laquelle les données actuelles se trouvaient dans le paquet initial. Cette position n'est pas cependant exprimée en octets, car la taille d'un paquet IP est codée sur 16 bits alors que ce champ n'utilise que 13 bits. Les données doivent donc commencer à une position multiple de 8 octets (0 pour le premier fragment), nombre alors choisi comme unité de ce champ ;

- Durée de vie (TTL) : indique d'une certaine manière le nombre maximal de routeurs que le paquet peut traverser, bref : sa « durée de vie ». Ce champ est décrémenté chaque fois que le paquet tombe sur un routeur. Si après décrémentation la valeur de ce champ est 0, le routeur détruira le paquet. Comme nous pouvons le constater, cela permet de tuer un paquet qui met trop de temps à atteindre la destination ;

- Protocole : permet de savoir quel type de paquet (TCP ? UDP ? ICMP ?…) est encapsulé dans le paquet IP. Ce champ vaut 6 (IPPROTO_TCP) si le paquet encapsulé provient de TCP, 17 (IPPROTO_UDP) s'il provient d'UDP et 1 (IPPROTO_ICMP) s'il provient d'ICMP ;

- Somme de contrôle (Checksum) : valeur permettant de vérifier l'intégrité de l'en-tête. Elle doit être égale au complément à 1 d'une somme spéciale de tous les mots de 16 bits de l'en-tête, calculée en mettant ce champ à 0. Pour calculer cette somme : on fait la somme de tous les mots de 16 bits de l'en-tête, on sauve le résultat (32 bits). Si la taille de l'en-tête n'est pas un multiple de 16 bits, prendre l'octet restant puis l'ajouter au résultat précédent. Tant que le résultat ne tient pas sur 16 bits, calculer la somme de tous les mots de 16 bits de cette somme. Voir plus bas pour un exemple de fonction calculant le checksum d'un message contenu dans un buffer dont l'adresse de base et la taille sont passées en arguments ;

- Adresse source : adresse IP de la source ;

- Adresse de destination : adresse IP de la destination.

La fonction suivante permet le calcul du checksum tel que défini dans la spécification du protocole IP :

Fonction : checksum

USHORT checksum(void * lpData, size_t size)
{
USHORT * t = lpData;
DWORD somme = 0;
size_t i, n = size / sizeof(USHORT);

for(i = 0; i < n; i++)
somme += t[i];

if (size % 2)
{
UCHAR * u = lpData;
somme += u[size - 1];
}

while (HIWORD(somme))
somme = HIWORD(somme) + LOWORD(somme);

return LOWORD(~somme);
}

IV - Les sockets bruts

IV-A - Généralités

Les sockets bruts sont tout simplement des sockets de type SOCK_RAW. Dans la suite, nous verrons comment utiliser ces sockets avec TCP/IP pour pouvoir travailler à bas niveau avec les protocoles de cette suite. Avec ce type de socket, on a plus de choix concernant la valeur qu'on peut spécifier dans le paramètre protocol de la fonction socket. Pour un socket TCP/IP, ceci peut être par exemple IPPROTO_TCP pour un socket TCP, IPPROTO_UDP pour un socket UDP, IPPROTO_ICMP pour un socket ICMP, IPPROTO_IP pour un socket supportant tous les protocoles de la suite ou encore IPPROTO_RAW pour pouvoir utiliser un protocole personnalisé. Voici par exemple comment créer un socket capable d'envoyer et de recevoir des paquets IP transportant un message UDP et de tels paquets uniquement :

SOCKET s;

s = socket(PF_INET, SOCK_RAW, IPPROTO_UDP);
if (s == INVALID_SOCKET)
/* La fonction socket a echoue */ ;
else
{
/* On a reussi */

closesocket(s);
}

Vous devez normalement être administrateur pour pouvoir créer un socket de type SOCK_RAW. En outre, il est important de savoir que pour pouvoir composer un paquet IP soi-même, il faut activer l'option IP_HDRINCL (déclaré dans ws2tcpip.h) du niveau IPPROTO_IP, avec setsockopt bien sûr.

DWORD sockopt = TRUE;

if (setsockopt(s, IPPROTO_IP, IP_HDRINCL, (char *)&sockopt, sizeof(sockopt)) == SOCKET_ERROR)
/* La fonction setsockopt a echoue */ ;
else
{
/* On continue */
}

Si l'option IP_HDRINCL n'est pas activée alors nous ne pouvons nous contenter que de pouvoir composer le paquet à soumettre à IP, sans le pouvoir de composer le paquet IP lui-même. À noter cependant qu'en réception (recv…), c'est toujours un paquet IP qui est copié dans le buffer que cette option ait été activée ou non. Il faut donc s'assurer que le buffer soit assez grand pour pouvoir contenir n'importe quel paquet IP si on ne veut pas se faire des ennuis. En outre, sachez que l'utilisation des protocoles de niveau message, c'est-à-dire TCP ou UDP, nécessite toujours l'activation de l'option IP_HDRINCL. Seuls les protocoles de même niveau que IP, comme ICMP ou IGMP par exemple, peuvent être utilisés avec ou sans cette option.

Pour ce qui est des opérations de lecture/écriture, ce sont les fonctions sendto et recvfrom qu'il faut utiliser, car send et recv ne sont adaptées que pour les sockets connectés. Dans sendto, il faut que l'adresse spécifiée dans la structure pointée par to corresponde à l'adresse de destination spécifiée dans dans l'en-tête du paquet IP sinon la fonction échouera. Dans recvfrom, l'adresse source spécifiée dans l'en-tête IP sera copiée dans la structure pointée par from.

Et enfin, sachez que certaines versions de Windows n'acceptent pas l'envoi de paquets TCP via raw sockets (Windows XP SP2/SP3, Windows Vista, Windows 7). La raison de cette limitation est que la plupart des attaques utilisées sur Internet sont basées sur l'exploitation de ce protocole… D'autre part, toujours dans ces versions, l'adresse source d'un paquet encapsulant UDP doit être une adresse existante sur le réseau, cela afin de limiter la possibilité de falsification d'adresse. Si vous voulez donc programmer les sockets bruts sans aucune limitation, vous devriez utiliser une version antérieure à Windows XP SP2 ou une édition « Serveur » (sauf Windows Server 2008 R2). Par exemple : Windows XP sans service pack, Windows Server 2003, Windows Server 2008.

Dans ce tutoriel, nous faisons l'hypothèse que nous sommes sous Windows XP SP 2 et qu'il faut donc tenir compte de ces limitations.

IV-B - Le protocole UDP

Nous avons déjà eu l'occasion de parler de ce protocole plusieurs fois, en particulier dans notre introduction aux sockets. UDP est un des protocoles les plus simples, son en-tête ne comporte que 4 champs (contre 16 pour le protocole TCP !) immédiatement suivis des données : le numéro de port source (16 bits), le numéro de port de destination (16 bits), la taille (mesurée en octets) du paquet et la somme de contrôle d'un « pseudo en-tête » UDP. Dans IPv4, cette somme est cependant facultative, UDP étant un protocole non fiable, et on peut tout simplement le laisser à 0 (si une valeur différente de 0 est spécifiée, le réseau considèrera que ce champ doit être pris en compte). Pour plus de détails concernant cette structure, référez-vous à la spécification de ce protocole.

Passons maintenant à la définition de la structure C correspondante :

Fichier : udp.h

#ifndef H_UDP_H

#define H_UDP_H

typedef struct udpheader {
USHORT port_source;
USHORT port_dest;
USHORT tot_len;
USHORT somme;
} UDPHEADER;

#endif

L'exemple suivant montre comment composer un paquet IP transportant un paquet UDP en utilisant les sockets bruts. Le message sera « Bonjour. » et sera envoyé sur le port 5050 de la machine locale. Il est donc supposé qu'on a de l'autre côté un serveur UDP en attente du message.

Fichier : udp_send.c

#include <stdio.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include "ip.h"
#include "udp.h"
#include <stdlib.h>

#define MAX_MESSAGE 100
#define IP_SOURCE "127.0.0.1"
#define IP_DEST "127.0.0.1"
#define PORT_SOURCE 5050
#define PORT_DEST 5050

USHORT checksum(void * lpData, size_t size);

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a echoue.\n");
else
{
SOCKET s;

s = socket(PF_INET, SOCK_RAW, IPPROTO_UDP);
if (s == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a echoue.\n");
else
{
DWORD sockopt = TRUE;

if (setsockopt(s, IPPROTO_IP, IP_HDRINCL, (char *)&sockopt, sizeof(sockopt)) == SOCKET_ERROR)
fprintf(stderr, "La fonction setsockopt a echoue.\n");
else
{
char message[MAX_MESSAGE] = "Bonjour.", * buf;
USHORT message_len, udp_len, ip_len;

message_len  = (USHORT)(strlen(message) + 1); /* Longueur du message */
udp_len      = (USHORT)(sizeof(UDPHEADER) + message_len); /* Longueur du paquet UDP */
ip_len       = (USHORT)(sizeof(IPHEADER) + udp_len); /* Longueur du paquet IP */

buf = malloc(ip_len);
if (buf == NULL)
fprintf(stderr, "La fonction malloc a echoue.\n");
else
{
SOCKADDR_IN dest;
IPHEADER ip;
UDPHEADER udp;

/* Remplissage de la structure ip (en-tete du paquet) */

/* Rappel : Attention a l'ordre des octets (pour les mots de plus de 1 octet).         */
/*          Utilisez htons et htonl pour mettre les shorts et les longs au bon format. */
/*          Cela ne concerne pas les chars, car la taille d'un char est de 1 octet.     */

ip.version    = 4;
ip.hlen       = 5; /* sizeof(ip) / 4 */
ip.tos        = 0;
ip.tot_len    = htons(ip_len);
ip.id         = htons(1);
ip.flags      = 0;
ip.offset     = 0;
ip.ttl        = 100;
ip.protocole  = IPPROTO_UDP;
ip.somme      = 0;
ip.ip_source  = inet_addr(IP_SOURCE);
ip.ip_dest    = inet_addr(IP_DEST);

ip.somme      = checksum(&ip, sizeof(ip));

udp.port_source  = htons(PORT_SOURCE);
udp.port_dest    = htons(PORT_DEST);
udp.tot_len      = htons(udp_len);
udp.somme        = 0;

memcpy(buf, &ip, sizeof(ip));
memcpy(buf + sizeof(ip), &udp, sizeof(udp));
memcpy(buf + sizeof(ip) + sizeof(udp), message, message_len);

ZeroMemory(&dest, sizeof(dest));
dest.sin_family       = AF_INET;
dest.sin_addr.s_addr  = inet_addr(IP_DEST);
dest.sin_port         = htons(PORT_DEST);

if (sendto(s, buf, ip_len, 0, (SOCKADDR *)&dest, sizeof(dest)) == SOCKET_ERROR)
fprintf(stderr, "La fonction sendto a echoue.\n");
else
printf("Message envoye !\n");

free(buf);
}
}

closesocket(s);
}

WSACleanup();
}

return 0;
}

USHORT checksum(void * lpData, size_t size)
{
USHORT * t = lpData;
DWORD somme = 0;
size_t i, n = size / sizeof(USHORT);

for(i = 0; i < n; i++)
somme += t[i];

if (size % 2)
{
UCHAR * u = lpData;
somme += u[size - 1];
}

while (HIWORD(somme))
somme = HIWORD(somme) + LOWORD(somme);

return LOWORD(~somme);
}

IV-C - Le protocole ICMP

ICMP (Internet Control Message Protocol) est un protocole faisant partie de la suite TCP/IP, permettant d'obtenir des informations sur la structure du réseau et de gérer les erreurs de transmission. Par exemple, lorsqu'un paquet IP a expiré (le TTL a atteint la valeur critique 0), le routeur qui a causé cette mort du paquet envoie à l'émetteur un message (un paquet ICMP) indiquant que le paquet a été détruit, car sa durée de vie a atteint la limite. Les codes d'erreurs de ce protocole sont hélas trop nombreux pour pouvoir être détaillés ici. Avant de continuer avec les possibilités offertes par ICMP, voyons tout d'abord à quoi ressemble un message ICMP.

Un message ICMP est composé d'un champ indiquant le type du message (8 bits), d'un champ indiquant le code (qui peut être par exemple un code d'erreur dans le cas d'un message d'erreur) du message (8 bits), de la somme de contrôle du message (16 bits) et des données (de longueur variable et dont la signification dépend du type du message). On peut donc dire en quelque sorte que tout est inclus dans l'en-tête et que la structure d'un en-tête ICMP varie selon le type du message.

Prenons l'exemple d'une requête ping. Une requête ping est un message ICMP qui sollicite le destinataire de ce message à renvoyer ce message. Il est important de comprendre que les réponses ICMP, de même que les messages d'erreur, sont générées « automatiquement », c'est-à-dire sans l'intervention d'une application (de niveau application) prévue à cet effet. En d'autres termes, ces types de messages sont générés par le pilote de l'interface de la machine (l'ordinateur ou le routeur) elle-même. En effet, ICMP est un protocole de niveau réseau comme IP et non un protocole de la couche transport ou application.

Revenons au ping. Un ping est un message ICMP de type 8 (Echo request) transportant des données permettant par exemple de le reconnaitre lorsqu'il sera renvoyé par l'hôte distant. Si l'hôte a reçu le message, donc les données, il va les retourner à l'envoyeur à l'intérieur d'un message de type 0 (Echo reply). Voici donc la structure de l'en-tête commun à tous les messages ICMP :

Fichier : icmp.h

#ifndef H_ICMP_H

#define H_ICMP_H

typedef struct icmpheader {
UCHAR  type;
UCHAR  code;
USHORT somme;
} ICMPHEADER;

#endif

Et voici le format (personnalisé) des messages échangés lors d'un ping :

Fichier : ping.h

#ifndef H_PING_H

#define H_PING_H

typedef struct icmppingmessage {
/* 'En-tete' ICMP */
UCHAR  type;
UCHAR  code;
USHORT somme;

/* 'Donnees' (definis par le programmeur) */
DWORD  id; /* Ce champ nous permettra de reconnaitre notre message */
DWORD  timestamp; /* Celui-ci nous permettra de savoir quand ce message a ete envoye */
} ICMPPINGMESSAGE;

#define ICMP_ECHO_REQ 8
#define ICMP_ECHO_REPLY 0

#endif

Nous aurons également besoin de fonctions permettant de connaître l'adresse d'un hôte dont le nom est donné afin que notre programme soit plus simple d'emploi. N'oubliez pas non plus qu'on ne peut pas mettre 127.0.0.1 dans le champ adresse source de l'en-tête IP sauf dans le cadre d'un test local sinon le message ne nous reviendra pas (il va être « avalé » par le destinataire !). Voici les deux fonctions qui nous seront utiles :

Fonctions : resolve_addr, resolve_computer_addr

/* Cette fonction permet de determiner l'adresse IP d'un hote dont le nom est fourni en argument */

HOSTENT * resolve_addr(char * name, ULONG * p_addr)
{
HOSTENT * h = gethostbyname(name);

if (h != NULL)
memcpy(p_addr, h->h_addr_list[0], sizeof(ULONG));

return h;
}

/* Cette fonction permet de determiner l'adresse IP de l'ordinateur */

HOSTENT * resolve_computer_addr(ULONG * p_addr)
{
HOSTENT * h = NULL;
char computer_name[100];

if (gethostname(computer_name, sizeof(computer_name)) == 0)
h = resolve_addr(computer_name, p_addr);

return h;
}

Enfin, le programme ne va pas attendre indéfiniment le retour du message. Si au bout d'un certain temps fixé celui-ci ne revient toujours pas, on abandonne l'attente et affiche un message indiquant que le message a mis trop de temps pour revenir.

Voici donc un petit programme qui envoie une requête ping à la destination spécifiée et reste en attente de la réponse pendant un intervalle de temps donné. Pour réduire la taille de notre fichier source, les définitions des fonctions resolve_addr, resolve_computer_addr et checksum n'ont pas été incluses dans ce fichier.

Fichier : echo_req.c

#include <stdio.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include "ip.h"
#include "ping.h"
#include <stdlib.h>

#define REMOTE "localhost"
#define ICMP_PING_TIMEOUT 2000

HOSTENT * resolve_addr(char * name, ULONG * p_addr);
HOSTENT * resolve_computer_addr(ULONG * p_addr);
USHORT checksum(void * lpData, size_t size);

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a echoue.\n");
else
{
SOCKET s;

s = socket(PF_INET, SOCK_RAW, IPPROTO_ICMP);
if (s == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a echoue.\n");
else
{
DWORD sockopt;

sockopt = TRUE;

if (setsockopt(s, IPPROTO_IP, IP_HDRINCL, (char *)&sockopt, sizeof(sockopt)) == SOCKET_ERROR)
fprintf(stderr, "La fonction setsockopt a echoue (IP_HDRINCL).\n");
else
{
sockopt = ICMP_PING_TIMEOUT;

if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char *)&sockopt, sizeof(sockopt)) == SOCKET_ERROR)
fprintf(stderr, "La fonction setsockopt a echoue (SO_RCVTIMEO).\n");
else
{
char * buf;
USHORT ip_len = (USHORT)(sizeof(IPHEADER) + sizeof(ICMPPINGMESSAGE));

buf = malloc(ip_len);
if (buf == NULL)
fprintf(stderr, "La fonction malloc a echoue.\n");
else
{
ULONG ip_source;

if (resolve_computer_addr(&ip_source) == NULL)
fprintf(stderr, "La fonction resolve_computer_addr a echoue.\n");
else
{
ULONG ip_dest;

if (resolve_addr(REMOTE, &ip_dest) == NULL)
fprintf(stderr, "Impossible de trouver l'hote '%s'.\n", REMOTE);
else
{
SOCKADDR_IN remote;
int remote_len = (int)sizeof(remote);
IPHEADER ip;
ICMPPINGMESSAGE icmp;

ip.version    = 4;
ip.hlen       = 5;
ip.tos        = 0;
ip.tot_len    = htons(ip_len);
ip.id         = htons(1);
ip.flags      = 0;
ip.offset     = 0;
ip.ttl        = 100;
ip.protocole  = IPPROTO_ICMP;
ip.somme      = 0;
ip.ip_source  = ip_source;
ip.ip_dest    = ip_dest;

ip.somme      = checksum(&ip, sizeof(ip));

icmp.type       = ICMP_ECHO_REQ;
icmp.code       = 0;
icmp.somme      = 0;
icmp.id         = GetCurrentProcessId();
icmp.timestamp  = GetTickCount();

icmp.somme      = checksum(&icmp, sizeof(icmp));

memcpy(buf, &ip, sizeof(ip));
memcpy(buf + sizeof(ip), &icmp, sizeof(icmp));

ZeroMemory(&remote, sizeof(remote));
remote.sin_family       = AF_INET;
remote.sin_addr.s_addr  = ip_dest;

printf("Envoi d'une requete ping sur %s avec %hu octets de donnees.\n",
inet_ntoa(remote.sin_addr), ip_len);

if (sendto(s, buf, ip_len, 0, (SOCKADDR *)&remote, remote_len)
== SOCKET_ERROR)
fprintf(stderr, "La fonction sendto a echoue.\n");
else
{
printf("Attente de la reponse.\n");

if (recvfrom(s, buf, ip_len, 0, (SOCKADDR *)&remote, &remote_len)
== SOCKET_ERROR)
{
if (WSAGetLastError() == WSAETIMEDOUT)
printf("Delai d'attente depasse.\n");
else
fprintf(stderr, "La fonction recv a echoue.\n");
}
else
{
DWORD delta_t = GetTickCount() - icmp.timestamp;

ZeroMemory(&ip, sizeof(ip));
ZeroMemory(&icmp, sizeof(icmp));

memcpy(&ip, buf, sizeof(ip));
memcpy(&icmp, buf + (ip->hlen * 4), sizeof(icmp));

if (icmp.id != GetCurrentProcessId())
printf("Recu : paquet etranger.\n");
else
{
switch(icmp.type)
{
case ICMP_ECHO_REPLY:
printf("Temps de reponse = %lu ms\n", delta_t);
break;

default:
printf("Recu : paquet inattendu.\n");
break;
}
}
}
}
}
}

free(buf);
}
}
}

closesocket(s);
}

WSACleanup();
}

return 0;
}

Bien entendu, un programme bien abouti ne se contentera pas d'envoyer une seule requête à l'hôte distant et d'afficher le temps de réponse, mais maintenant que vous avez compris le principe, vous pouvez développer des applications plus complexes.

IV-D - Le protocole TCP

L'architecture TCP/IP définit deux protocoles de niveau transport à savoir TCP, qui est orienté connexion et UDP, non orienté connexion. TCP fournit aux applications qui l'utilisent un service de transport fiable contrairement à UDP qui ne permet que d'envoyer un message sans garantie de livraison à moins d'implémenter son propre système d'acquittement au niveau de la couche application. Avant d'étudier le fonctionnement de ce protocole, nous allons tout d'abord voir le format détaillé d'un paquet TCP.

TCP intègre un système d'acquittement (accusé de réception) permettant à l'émetteur de s'assurer que son message a bien été reçu par le récepteur. De plus, contrairement à ce qui se passe avec UDP, il est garanti que les données transmises en utilisant le protocole TCP arrivent au récepteur dans le même ordre qu'avec lequel elles ont été envoyées.

Toute communication basée sur TCP commence par une demande de connexion et se termine par une déconnexion. La demande de connexion se fait en envoyant un paquet avec le flag SYN (S sur la figure) activé (c'est-à-dire mis à 1). En recevant un tel paquet, le serveur reconnait que le client demande l'établissement d'une connexion (ou synchronisation). Si celui-ci accepte, il doit réponde par un paquet avec les bits SYN et ACK (A sur la figure) activés. Le bit ACK est appelé bit d'acquittement (acknowledgement). Lorsque ce bit est activé, cela signifie que le paquet est un accusé de réception. Après que le client a acquitté la réponse du serveur, on dit que la connexion est établie.

Une fois la connexion établie, le transfert de données peut commencer. TCP transporte les données comme des flots d'octets. Chaque octet à transporter possède un numéro permettant de le situer par rapport aux autres octets du flot de données. Lors d'une transmission, le champ numéro de séquence contient le numéro du premier octet du flot en cours. Quand le récepteur aura reçu les données, il devra ensuite répondre par un paquet ACK avec le numéro du prochain octet attendu dans le champ numéro d'acquittement et, d'une certaine manière, le nombre d'octets qu'il peut recevoir dans le champ fenêtre. Le numéro d'acquittement permet donc également de connaître le nombre d'octets de données reçus par le récepteur et de détecter une éventuelle erreur de transmission. Les champs numéro de séquence et numéro d'acquittement utilisent une arithmétique modulo 2^32.

Attention ! Le numéro de séquence du premier flot de données envoyé n'est pas nécessairement égal à 0 (mais correspond d'une certaine manière à 0…). En fait, pendant la phase de synchronisation (établissement de connexion), le client indique dans le champ associé son numéro de séquence initial qui peut être choisi arbitrairement. Le serveur répond ensuite par un paquet avec les bits SYN et ACK activés, le numéro de séquence initial du client + 1 dans le champ numéro d'acquittement et son numéro de séquence initial (également choisi arbitrairement) dans le champ prévu à cet effet. Ensuite le client répond avec un accusé de réception comportant, dans le champ numéro d'acquittement, le numéro de séquence initial du serveur + 1.

Le champ DO (Data Offset) de l'en-tête TCP permet de localiser les données dans le paquet. Il s'agit en fait du nombre de mots de 32 bits constituant l'en-tête. Ce champ est codé sur 4 bits. Les autres flags ont également chacun leur importance. URG (U) par exemple indique si le paquet est urgent, PSH (PUSH, P sur la figure) indique que le paquet doit être immédiatement transmis (l'implémentation de TCP sur la machine peut attendre suffisamment de données pendant un intervalle de temps déterminé avant d'invoquer le protocole de la couche inférieure afin d'optimiser les performances…), à ne pas confondre avec un paquet urgent, RST (RESET, R sur la figure) indique que l'émetteur souhaite que la connexion soit redémarrée et FIN (F) indique que le paquet est une demande de fermeture de la connexion. Quant au pointeur des données urgentes, il n'est utilisé que lorsque le flag URG est activé. Dans ce cas, il joue en quelque sorte le rôle du champ numéro de séquence, mais seulement pour les données urgentes. Et enfin, les options ont une taille variable. Elles ne sont d'ailleurs pas toujours présentes.

Voici donc la structure permettant de manipuler l'en-tête des paquets TCP en langage C :

Fichier : tcp.h

#ifndef H_TCP_H

#define H_TCP_H

/* En-tete */

typedef struct tcpheader {
USHORT port_source;
USHORT port_dest;
UINT   i_seq;
UINT   i_ack;
USHORT flags : 6, reserved : 6, offset : 4;
USHORT fenetre;
USHORT somme;
USHORT urg_ptr;
} TCPHEADER;

/* Flags */

#define TCP_FIN 0x01
#define TCP_SYN 0x02
#define TCP_RST 0x04
#define TCP_PSH 0x08
#define TCP_ACK 0x10
#define TCP_URG 0x20

#endif

IV-E - Programmation d'un sniffer

Ici, c'est un peu un résumé ou plutôt une réunion de tout ce qu'on a vu jusqu'à présent. Un sniffer, si vous ne le savez pas encore, est tout simplement un programme qui intercepte tous les paquets passant à travers une interface réseau et les traduit dans un format lisible par un humain. S'ils semblent faire partie de prime abord de la catégorie des logiciels malhonnêtes, sachez que les sniffers sont très utilisés par les administrateurs de réseau pour analyser un réseau, en particulier lorsque celui-ci ne tourne pas bien rond.

La programmation d'un sniffer n'est pas très différente de la programmation d'un serveur puisqu'il s'agit d'attacher le socket (qui va nous permettre de sniffer) à une interface réseau (celle qu'on va sniffer), de lire tous les paquets qui passent à travers puis de les rendre lisibles à un humain. Pour pouvoir lire tous ces paquets, peu importent leur source et leur destination, il faut avoir appelé préalablement la fonction WSAIoctl avec le control code SIO_RCVALL (inclure mstcpip.h) et comme valeur du paramètre d'entrée 1 (TRUE). Le socket doit être un socket AF_INET de type SOCK_RAW et de protocole IPPROTO_IP et doit avoir été attaché à une interface locale bien déterminée (ce qui signifie entre autres qu'on ne peut pas utiliser les adresses spéciales comme INADRR_ANY, 127.0.0.1, etc.).

Le programme suivant intercepte tous les paquets passant à travers notre interface et affiche une partie de l'en-tête de chaque paquet. Le programme boucle jusqu'à 40 paquets lus. Les fonctions resolve_addr et resolve_computer_addr ont déjà été définies dans les sections précédentes.

Fichier : sniffer.c

#include <stdio.h>
#include <winsock2.h>
#include <mstcpip.h>
#include "ip.h"
#include "icmp.h"
#include "udp.h"
#include "tcp.h"

#define STR_IP(x) inet_ntoa(*((IN_ADDR *)&(x)))

HOSTENT * resolve_addr(char * name, ULONG * p_addr);
HOSTENT * resolve_computer_addr(ULONG * p_addr);

int enable_rcvall(SOCKET s);
void dump_ip(void * buffer);
void dump_icmp(void * buffer);
void dump_udp(void * buffer);
void dump_tcp(void * buffer);

int main()
{
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2, 0), &wsaData) != 0)
fprintf(stderr, "La fonction WSAStartup a echoue.\n");
else
{
SOCKET s;

s = socket(PF_INET, SOCK_RAW, IPPROTO_IP);
if (s == INVALID_SOCKET)
fprintf(stderr, "La fonction socket a echoue.\n");
else
{
ULONG u_addr; /* Notre adresse IP */

if (resolve_computer_addr(&u_addr) == NULL)
fprintf(stderr, "La fonction resolve_computer_addr a echoue.\n");
else
{
SOCKADDR_IN i; /* Notre interface */

ZeroMemory(&i, sizeof(i));
i.sin_family       = AF_INET;
i.sin_addr.s_addr  = u_addr;

if (bind(s, (SOCKADDR *)&i, sizeof(i)) == SOCKET_ERROR)
fprintf(stderr, "La fonction bind a echoue.\n");
else
{
if (enable_rcvall(s) == SOCKET_ERROR)
fprintf(stderr, "La fonction WSAIoctl a echoue.\n");
else
{
SOCKADDR_IN from;
int from_len;
char buffer[10000];
int i;

for(i = 0; i < 40; i++)
{
from_len = sizeof(from);
recvfrom(s, buffer, sizeof(buffer), 0, (SOCKADDR *)&from, &from_len);
dump_ip(buffer);
}
}
}
}

closesocket(s);
}

WSACleanup();
}

return 0;
}

int enable_rcvall(SOCKET s)
{
DWORD dwInBuffer = TRUE, dwBytesReturned /* Requis par WSAIoctl */;

return WSAIoctl(s, SIO_RCVALL, &dwInBuffer, sizeof(dwInBuffer), NULL, 0, &dwBytesReturned, NULL, NULL);
}

void dump_ip(void * buffer)
{
IPHEADER * ip = buffer;

printf("+----------------------------+\n");
printf("| En-tete IP (extrait)       |\n");
printf("+----------------------------+\n");
printf("| From     | %-15s |\n", STR_IP(ip->ip_source));
printf("| To       | %-15s |\n", STR_IP(ip->ip_dest));

buffer = ((char *)buffer) + ip->hlen * 4;

switch(ip->protocole)
{
case IPPROTO_ICMP:
dump_icmp(buffer);
break;

case IPPROTO_UDP:
dump_udp(buffer);
break;

case IPPROTO_TCP:
dump_tcp(buffer);
break;

default:
printf("+----------+-----------------+\n");
}

printf("\n");
}

void dump_icmp(void * buffer)
{
ICMPHEADER * icmp = buffer;

printf("+----------------------------+\n");
printf("| En-tete ICMP (extrait)     |\n");
printf("+----------------------------+\n");
printf("| Type     | %-5u           |\n", icmp->type);
printf("| Code     | %-5u           |\n", icmp->code);
printf("+----------+-----------------+\n");
}

void dump_udp(void * buffer)
{
UDPHEADER * udp = buffer;

printf("+----------------------------+\n");
printf("| En-tete UDP (extrait)      |\n");
printf("+----------------------------+\n");
printf("| Port Src | %-5u           |\n", ntohs(udp->port_source));
printf("| Port Dst | %-5u           |\n", ntohs(udp->port_dest));
printf("+----------+-----------------+\n");
}

void dump_tcp(void * buffer)
{
TCPHEADER * tcp = buffer;

printf("+----------------------------+\n");
printf("| En-tete TCP (extrait)      |\n");
printf("+----------------------------+\n");
printf("| Port Src | %-5u           |\n", ntohs(tcp->port_source));
printf("| Port Dst | %-5u           |\n", ntohs(tcp->port_dest));
printf("+----------+-----------------+\n");
}