Programmation d'un sniffer sous Unix

Sniffer consiste à espionner les communications circulant le réseau auquel on est connecté. Après de rapides révisions sur le fonctionnement en réseau, cet article présente trois méthodes pour programmer un sniffer : le device /dev/bpf sous BSD, les sockets systèmes sous Linux, la bibliothèque multi-plateforme libcap.

Comme cet article fait référence à beaucoup de code, nous n'avons mis que les extraits utiles. L'intégralité des source est disponible sur www.cgsecurity.org ou http://www.security-labs.org

Comment ca marche ?

Les différentes machines sont sur le même réseau Ethernet. Chaque interface réseau y est identifiée par son adresse MAC (Media Access Control), une adresse de 48 bits dont les 24 premiers ,Organizationally Unique Identifier, désignent le constructeur de la carte. A l'aide de la commande ifconfig, on obtient l'adresse MAC.

$ ifconfig eth0
eth0      Link encap:Ethernet  HWaddr 00:00:86:35:C9:3F
          inet addr:10.0.0.227  Bcast:10.0.0.255  Mask:255.255.255.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:7570319 errors:0 dropped:0 overruns:0 frame:0
          TX packets:7395141 errors:0 dropped:0 overruns:0 carrier:112
          collisions:0 txqueuelen:100
          Interrupt:7 Base address:0x300
 

Il s'agit ici de l'adresse 00:00:86:35:C9:3F.

Hub/Switch

Un hub (ou répéteur) renvoie sur chaque port la trame ethernet qu'il reçoit. Un switch dresse une table de correspondance port/adresse MAC (table statique et/ou dynamique) et dirige la trame uniquement vers le port du destinataire. Ainsi, les communications sont isolées et permettent de hauts débits en minimisant les collisions.

Sniffer passif

Il suffit de passer l'interface réseau en mode promiscuous pour que la carte réseau laisse voir toutes les trames ethernet, y compris celles dont le destinataire est différent de l'adresse MAC de l'interface.

On observe ainsi le trafic réseau de son segment mais l'utilisation de switch rend inefficace cette méthode. D'autres moyens, actifs, sont alors nécessaires pour contourner cette difficulté.

Sniffer actif

De façon à recevoir les trames qui ne nous sont pas adressés, il faut détourner le trafic vers notre machine. Différentes techniques ont été présentées dans un numéro précédent autour du protocole ARP, mais il en existe encore d'autres, par exemple avec le protocole ICMP.

Programmation d'un sniffer

Dans la suite de cet article, nous présentons trois techniques différentes. Néanmoins, il existe un schéma général, identique dans tous les cas :

  1. lier le processus à une (ou plusieurs) interface(s) réseau et paramétrer (mode promiscuous par exemple). Nous verrons deux approches, l'une utilisant des mécanismes directement gérés par le noyau (les socket PF_PACKET sous Linux), l'autre reposant sur un device spécifique (appelé Network Tap) qui permet de superviser le trafic associé à une interface (nous présenterons /dev/bpf sous BSD, mais /dev/nit sous HP-UX ou SunOS, entre autre, fonctionnent sur un modèle similaire ).
  2. mise en place d'un filtre, pour ne remonter que les paquets désirés (par exemple, si on veut surveiller le trafic ICMP, il n'est nul besoin de remonter les paquets TCP ou UDP). Cette étape est optionnelle dans le sens où le programme peut effectuer ceci lui-même après la capture des paquets (étape suivante). Cependant, ces mécanismes de capture disposent souvent de leur propre filtrage, plutôt efficace.
  3. lecture des paquets (en fait, il est également possible d'en injecter sur le réseau par ce même moyen) et traitement. Nous ne nous intéresserons pas trop à cette partie, car il s'agit essentiellement de transformer une suite d'octets en un structure type paquet (IP, ICMP, etc...).

Tout d'abord, nous montrons comment utiliser le Berkeley Packet Filter (/dev/bpf) disponible sous les BSD. Dans une deuxième partie, nous nous intéressons à Linux et aux sockets de type PF_PACKET. Enfin, nous terminons par une bibliothèque, la libpcap, qui fournit un niveau d'abstraction supplémentaire.

Quelques mots sur la fonction ioctl()

Sniffer nécessite de se rapprocher du système et de ses composants, comme la carte réseau par exemple. C'est alors qu'intervient la fonction ioctl() :

#include <sys/ioctl.h>

int ioctl(int fd, int request, ...)

Un extrait de page man valant mieux qu'un long discours : la fonction ioctl modifie le comportement des périphériques sous-jacents des fichiers spéciaux. Examinons les arguments de cette fonction :

si cette fonction est bien définie en elle-même, ses argument en revanche changent d'un système à l'autre (enfin, surtout la requête).

Sous BSD, du réseau aux devices

Les systèmes BSD dispose de devices spéciaux destinés à manipuler des paquets (par manipulation, on entend ici indistinctement lecture ou écriture, c'est-à-dire sniffer ou injecter des paquets).

Ces devices, appelés /dev/bpf* (BPF = Berkeley Packet Filter), fournissent une interface vers la couche liaison du modèle OSI (comme les sockets PF_PACKET sous Linux). Signalons que même les paquets non destinées à la machine sont accessible au travers de ce device, pour peu que le mode promiscuous soit activé.

Dans un premier temps, nous donnons les principales étapes pour parvenir à capturer le trafic. Puis, dans un second temps, nous mettrons en place le filtrage des paquets.

Utilisation du Berkeley Packet Filter

Nous reprenons ici pas à pas les étapes mentionnées lors de l'introduction.

Accéder à l'interface

L'accès au device /dev/bpf est très simple : puisque (presque) tout est fichier sous Unix, on y accède comme avec un fichier classique :

  fd =  open("/dev/bpf0", O_RDONLY);

Cependant, ce device n'est pas l'interface. Il est donc nécessaire de lier ce fichier à l'interface sur laquelle on souhaite travailler. Mais avant de faire cela, nous devons récupérer un paramètre lié au device /dev/bpf : la taille des paquets qui seront transmis. Prenez bien soin de la faire avant de lier le descripteur et l'interface, sans quoi votre programme ne marchera pas (j'en ai fait l'expérience pour vous ... cela est précisé dans le man bpf) :

  if (ioctl(fd, BIOCGBLEN, &bsize) < 0) {
    perror("BIOCGBLEN()");
    exit(-1);
  }

Cet appel à ioctl() place la taille des paquets dans la variable &bsize. Nous pouvons maintenant lier le descripteur de fichier et l'interface (la première interface Ethernet dans notre exemple) :

  struct ifreq ifr;

  strncpy(ifr.ifr_name, "ed0", sizeof(ifr.ifr_name)-1);
  ifr.ifr_name[sizeof(ifr.ifr_name)-1] = 0;
  if (ioctl(fd, BIOCSETIF, (caddr_t)&ifr) == -1) {
    perror("BIOCSETIF()");
    exit(-1);
  }

Maintenant, il est temps de paramétrer l'interface :

De nombreux autres paramètres sont disponibles. Je vous invite à les consulter dans la page man bpf.

Lire les paquets

Pour notre exemple, nous cherchons à capturer le trafic ICMP. Pour lire les paquets, on fait comme d'habitude lorsqu'on manipule un descripteur de fichier ::

  n_read = read(fd, buf, bsize);

Lors de la lecture, une structure bpf_hdr est ajoutée avant le début du paquet :

  struct bpf_hdr {
    struct timeval bh_tstamp;     /* time stamp */
    u_long bh_caplen;             /* length of captured portion */
    u_long bh_datalen;            /* original length of packet */
    u_short bh_hdrlen;            /* length of bpf header (this
                                  /* struct  plus alignment padding */
     };

Par conséquent, il est nécessaire de faire un peu d'arihmétique pour récupérer les morceaux du paquet que nous voulons :

Ici, il suffit de savoir lire les RFCs pour trouver les positions des champs intéressants dans le buffer, ou comme nous l'avons fait ici, de transformer le buffer dans le structure appropriée. Bref, tout ça pour dire que nous ne nous étendrons pas plus sur la partie de décodage des données en elles-mêmes

En revanche, la partie filtrage est bien plus critique. Les performances d'un sniffer se mesurent relativement à sa capacité à ne pas perdre de paquets, et donc à en traiter un maximum au fur et à mesure de leur arrivée. Ici, le filtre est relativement simple puisque nous contrôlons uniquement le protocole dans l'entête IP. Cependant, on peut le rendre bien plus efficace en le plaçant non plus dans notre programme, mais directement au niveau du device, qui se charge lui-même de remonter les paquets désirés. Ainsi, notre sniffer n'a plus à se préoccuper de trier les paquets, et se concentre uniquement sur leur traitement. La partie suivante présente la mise en oeuvre d'un filtre simple.

Mise en place du filtre

Dans l'exemple ci-dessus, on récupère tout le trafic ICMP. Maintenant, nous allons nous limiter aux échanges induits par la commande ping, c'est-à-dire aux messages echo-request et echo-reply.

Avec le Berkeley Packet Filter vient un petit langage descriptif pour l'élaboration de filtres. Le gain de performances évoqué précédemment est obtenu au détriment de la simplicité. En effet, le pseudo-filtre qui nous avions était très simple à écrire à l'aide d'un simple if. Il n'en est plus de même maintenant.

La page man de bpf décrit en détail toutes les possibilités offertes par ce langage. Nous nous contentons ici d'en présenter le principe général et un exemple commenté d'utilisation.

Le filtrage fonctionne avec une suite d'instructions qui décrivent donc un petit programme. Toutefois, le langage étant relativement bas niveau, il n'est pas très agréable à utiliser (enfin, c'est l'avis de l'auteur ;-). Une instruction est construite de la manière suivante :

struct bpf_insn {
  u_short code;  /* code de l'instruction à exécuter */
  u_char  jt;    /* pour un branchement conditionnel */
                 /* offset vers la prochaine instruction à exécuter */
                 /* si la condition est VRAIE */
  u_char  jf;    /* idem si la condition est FAUSSE */
  u_long k;      /* variable en fonction des instructions */
};

En plus des instructions, le programme dispose d'un accumulateur et d'un index pour stocker ses informations. Ils n'ont pas de nom spécifique et sont accessibles depuis les instructions qui travaillent directement dessus.

Enfin, il existe 2 macros qui servent tout le temps :

Revenons aux instructions. Il existe 8 classes d'instructions. Chaque classe voit son comportement légèrement modifié en fonction des données manipulées. Un petit exemple valant souvent mieux qu'un log discours, place au code :

struct bpf_insn insns[] = {
  /* Copie (BPF_LD) le demi-mot (BPF_H) situé en position */
  /*   absolue (BPF_ABS) dasn l'accumulateur */
  /* Récupère le prototype du protocole de niveau 3 */
  BPF_STMT(BPF_LD+BPF_H+BPF_ABS, 12),

  /* Compare la valeur contenue dans l'accumulateur à ETHERTYPE_IP */
  /* Si c'est bien de l'IP, on saute à l'instruction suivante */
  /* Sinon, on saute à la fin où on revoie 0 pour signifier qu'on */
  /*   ne veut pas garder le paquet */
  BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ETHERTYPE_IP, 0, 6),

  /* Copie l'octet (BPF_B) situé en position absolue 23 */
  /*   dans l'accumulateur */
  /* Récupère le protocole de l'entête IP */
  BPF_STMT(BPF_LD+BPF_B+BPF_ABS, 23),

  /* Compare la valeur contenue dans l'accumulateur à  IPPROTO_ICMP */
  /* blablabla ... */
  BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, IPPROTO_ICMP, 0, 4),

  /* Copie l'octet situé en position absolue 34 dans l'accumulateur */
  /* Récupère le type du message ICMP */
  BPF_STMT(BPF_LD+BPF_B+BPF_ABS, 34),

  /* Compare la valeur contenue dans l'accumulateur à  ICMP_ECHOREPLY */
  /* Si c'est un ICMP_ECHOREPLY, on saute une instruction, i.e. */
  /*   on renvoie une valeur non nulle pour signifier que le */
  /*   filtre accepte le paquet */
  /* Sinon, on regarde si le message est de type ICMP_ECHO */
  /*   Si c'est la cas, on l'accepte */
  /*   Sinon, on le rejette */
  BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ICMP_ECHOREPLY, 1, 0),
  BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ICMP_ECHO, 0, 1),

  /* accept packet => return !0 */
  BPF_STMT(BPF_RET+BPF_K, (u_int)-1),

  /* reject packet => return 0 */
  BPF_STMT(BPF_RET+BPF_K, 0),
};

Une fois les instructions définies, il faut encore en faire un programme, puis les attacher au descripteur de fichier :

  /* 9 instructions sont définies */
  struct bpf_program filter = {9, insns};

  ioctl(fd, BIOCSETF, &filter);

Ce petit exemple très rudimentaire monte néanmoins la syntaxe relativement lourde associé au filtrage. Signalons de suite que la libpcap que nous présentons ci-après fournit une fonction, pcap_compile_nopcap(), qui génère le programme à notre place.

Sous Linux, du réseau aux sockets

La solution porposée par Linux est complètement différente dans le sens où il s'agit de manipuler un type particulier de socket. Ainsi, la gestion du matériel est complètement transparente. Les étapes nécessaires à la création d'un sniffer sont donc :

  1. création et paramétrisation de la socket : la difficulté consiste à choisir le type de sockets approprié ;
  2. mise en place d'un filtre : les sockets utilisées supportent les programmes BPF vus précédemment, sous réserve que votre noyau soit compilé avec l'option CONFIG_FILTER : l'appel setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &filtre, sizeof(filtre)); plaçant le filtre ;
  3. traitement des données.

Dans la suite de cette partie, nous expliquons la programmation d'un sniffer sous Linux, mais détaillons également la gestion du mode promiscuous. En effet, nous avons constaté il y a quelques temps déjà que la commande ifconfig ne montrait pas toujours que l'interface était en mode promiscuous. Cet article nous a permis de comprendre pourquoi :-) Enfin, nous indiquons quelques directions pour détecter la présence du mode promiscuous sur un système.

Les sockets

L'entête de la fonction qui construit une socket (littéralement «douille«, mais communément traduit par «chaussette») est le suivant :
int socket(int domaine, int type, int protocol);

Les domaines les plus courants sont :

Néanmoins, depuis les noyaux 2.2, un nouveau type de socket est disponible : PF_PACKET, qui donne accès à la couche liaison (data link layer, niveau 2 du modèle OSI). Elles fournissent une interface (au sens de programmation, pas de carte réseau) pour les paquets qui transitent sur la carte réseau. Elles sont accessibles à root et aux utilisateurs avec la capability CAP_NET_RAW.

Signalons d'emblée que ce domaine n'existe que sur Linux. Pour le supporter, il faut que le noyau soit compilé avec l'option CONFIG_PACKET. Mentionnons aussi la possibilité de placer des filtres sur les paquets capturés. Là encore, Linux développe sa propre solution avec l'option CONFIG_FILTER. Des renseignements sont disponibles dans le fichier linux/Documentation/networking/filter.txt.

La difficulté du matin : quelle type de chaussettes choisir ?

Il existe deux types de socket :

En mode SOCK_RAW, on obtient les informations situées à tous les niveaux en le demandant via l'argument protocole lors de la création de la socket. Ainsi, pour récupérer complètement un paquet, on utilise la socket suivante :

sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
Le dernier argument indique qu'on souhaite avoir tous les protocoles.

Cependant, comment connaître le protocole associé aux paquets reçus ? Lors de la lecture des paquets, la fonction recvfrom() renseigne une structure de type sockaddr_ll, structure associée au link layer, d'où le "ll" à la fin (dans le prototype de recvfrom(), on trouve un type sockaddr : il s'agit en fait du nom de la structure générique). Dans cette structure, le champ sll_hatype donne le protocole au format ARP. Par exemple, si on a ARPHRD_ETHER, il s'agit alors du protocole Ethernet.

int  recvfrom(int  s,  void  *buf,  size_t len, int flags,
              struct sockaddr *from, socklen_t *fromlen);


struct sockaddr {
  sa_family_t     sa_family;      /* address family, AF_xxx       */
  char            sa_data[14];    /* 14 bytes of protocol address */
};


struct sockaddr_ll
{
  unsigned short  sll_family;    /* Always AF_PACKET */
  unsigned short  sll_protocol;  /* Physical layer protocol */
  int             sll_ifindex;   /* Interface number */
  unsigned short  sll_hatype;    /* Header type */
  unsigned char   sll_pkttype;   /* Packet type */
  unsigned char   sll_halen;     /* Length of address */
  unsigned char   sll_addr[8];   /* Physical layer address */
};

Par ailleurs, le type du paquet est indiqué dans sll_pkttype, ce qui permet de filtrer au besoin les paquets en diffusion PACKET_BROADCAST ou en multicast PACKET_MULTICAST (les adresses MAC multicast commencent par 01:00:5e). En mode promiscuous, le type PACKET_OTHERHOST désigne les paquets destinés à d'autres machines.

Toutefois, si le sniffer ne traite que le niveau IP, ce n'est pas la peine de s'embêter. En effet, le noyau peut filtrer les informations des entêtes pour nous à l'aide des sockets de type SOCK_DGRAM :

sock = socket(PF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL));

Avec un ping vers Google, on voit bien passer le echo-request, puis la réponse echo-reply :

=====> Packet intercepted, 84 bytes sniffed <===== Outgoing
sll_hatype=1, sll_protocol=8
IP 172.18.100.49 -> 216.239.51.101
ICMP type=8, code=0 Echo request
=====> Packet intercepted, 84 bytes sniffed <===== Host
sll_hatype=1, sll_protocol=8
IP 216.239.51.101 -> 172.18.100.49
ICMP type=0, code=0 Echo reply

Maintenant, en changeant le paramètre protocol lors de la création de la socket, on constate un comportement différent. On ne sélectionne que le protocole IP 

sock = socket(PF_PACKET, SOCK_DGRAM, htons(ETH_P_IP));
=====> Packet intercepted, 84 bytes sniffed <===== Host
IP 216.239.39.101 -> 172.18.100.49
ICMP type=0, code=0 Echo reply

On ne capture plus que les paquets entrants ! En fait, cela provient d'un bogue connu du noyau Linux (merci à M. Arboi pour ses indications : voir http://lists.insecure.org/linux-kernel/2001/Nov/0265.html). Pour résumer simplement, c'est juste que le paquet n'est pas intercepté au même endroit selon qu'il entre ou sorte du système.

Mode promiscuous

Par défaut, une carte réseau Ethernet s'occupe uniquement des paquets portant son adresse MAC en destination, c'est-à-dire seulement aux paquets qui lui sont destinés. En passant la carte en mode promiscuous, ce filtrage disparaît, et tous les paquets sont alors remontés (c'est le type de paquets PACKET_OTHERHOST vu ci-dessus).

Au temps de la préhistoire ...

Avec les noyaux 2.0, il suffisait de changer l'état de l'interface ::

  strncpy(ifr.ifr_name, "eth0", IFNAMSIZ);
  ioctl(sock, SIOCGIFFLAGS, &ifr);
  ifr.ifr_flags |= IFF_PROMISC;
  ioctl(sock, SIOCSIFFLAGS, &ifr);

Mais cela n'était pas sans inconvénient. Si un programme restaurait l'interface dans l'état initial, il désactivait ce mode pour tous les autres programmes !

L'ère contemporaine

Depuis les noyaux 2.2, la socket doit activer des options particulières via la fonction setsockopt() pour passer une interface en mode promiscuous. Cette fonction nécessite manipule alors une structure packet_mreq qui contient entre autre les champs suivants :

L'option PACKET_ADD_MEMBERSHIP, demandée par setsockopt(), permet alors d'ajouter le programme parmi la liste de ceux qui utilisent l'interface réseau en mode promiscuous. En fait, il ne s'agit pas réellement d'une liste, mais d'un compteur. A chaque fois qu'un processus demande à une interface de passer en mode promiscuous, ce compteur est incrémenté. Lorsque le processus rebascule l'interface en mode normal (avec l'option PACKET_DROP_MEMBERSHIP), le compteur est décrémenté, mais l'interface ne repasse en mode normal que si le compteur vaut 0, c'est-à-dire que plus aucun processus ne se sert de l'interface en mode promiscuous.
int go_promiscuous(int sock,const char *device)
{
  struct ifreq ifr;
  struct packet_mreq mr;
  struct sockaddr_ll sll;
  ifr.ifr_ifindex = 0;
  strcpy(ifr.ifr_name, device);
  if(ioctl(sock,SIOGIFINDEX,&ifr) < 0)
  {
    perror("ioctl error ");
    return 1;
  }

  memset(&mr,0,sizeof(mr));
  mr.mr_ifindex = ifr.ifr_ifindex;
  mr.mr_type =  PACKET_MR_PROMISC;
  if(setsockopt(sock, SOL_PACKET, PACKET_ADD_MEMBERSHIP, (char *)&mr, sizeof(mr)) < 0) {
    perror("setsockopt error ");
    return 1;
  }
}

Le noyau est ainsi capable de savoir en permanence le nombre de demandes pour le mode promiscuous, demande qu'il signale au système de log :

Apr 18 14:36:17 vectra kernel: device eth0 entered promiscuous mode
Apr 18 14:36:22 vectra kernel: device eth0 left promiscuous mode

Détecter le mode promiscuous sur le système

Seul le mode promiscuous demandé par l'ancienne méthode SIOCSIFFLAGS apparaît avec la fonction SIOCGIFFLAGS. Lorsque le mode promiscuous est activé par PACKET_MR_PROMISC, il faut utiliser d'autres moyens.

Il n'est pas difficile de lister les sockets AF_PACKET ouvertes sur le système

cat /proc/net/packet 
sk       RefCnt Type Proto  Iface R Rmem   User   Inode
c2cbbae0 3      3    0003   2     1 0      0      115788

et d'en trouver le propriétaire à partir du numéro d'inode, nombre sans signification particulière dans ce contexte.

ls -l /proc/`pidof tcpdump`/fd
total 0
lrwx------    1 root     root           64 avr 18 16:57 0 -> /dev/pts/4
lrwx------    1 root     root           64 avr 18 16:57 1 -> /dev/pts/4
lrwx------    1 root     root           64 avr 18 16:57 2 -> /dev/pts/4
lrwx------    1 root     root           64 avr 18 16:57 3 -> socket:[115788]

La commande lsof donne un résultat équivalent :

lsof -p `pidof tcpdump`
COMMAND   PID USER   FD   TYPE DEVICE    SIZE   NODE NAME
tcpdump 31037 root  cwd    DIR    3,9    5120      2 /root
tcpdump 31037 root  rtd    DIR    3,7    2048      2 /
tcpdump 31037 root  txt    REG   3,10  380044  16814 /usr/sbin/tcpdump
tcpdump 31037 root  mem    REG    3,7  494286  42183 /lib/ld-2.2.4.so
tcpdump 31037 root  mem    REG    3,7  918752  42397 /lib/libcrypto.so.0.9.6b
tcpdump 31037 root  mem    REG    3,7  436384  42338 /lib/libnsl-2.2.4.so
tcpdump 31037 root  mem    REG    3,7 5780406  22127 /lib/i686/libc-2.2.4.so
tcpdump 31037 root  mem    REG    3,7   65997  42336 /lib/libdl-2.2.4.so
tcpdump 31037 root  mem    REG    3,7  262272  42345 /lib/libnss_files-2.2.4.so
tcpdump 31037 root    0u   CHR  136,4              6 /dev/pts/4
tcpdump 31037 root    1u   CHR  136,4              6 /dev/pts/4
tcpdump 31037 root    2u   CHR  136,4              6 /dev/pts/4
tcpdump 31037 root    3u  sock    0,0         115788 can't identify protocol

Décoder les paquets

Les paquets sont capturés dans un buffer par la fonction recvfrom() :

 n_read = recvfrom(sock, buffer, 2048, 0,
                   (struct sockaddr*)&from, &fromlen);
La valeur de retour donne le nombre d'octets lus et placés dans buffer. Il reste alors à « découper » ce buffer selon les spécifications des RFCs pour le protocole considéré afin d'obtenir les différentes informations qu'il contient.
  while (1) {
    int n_read;
    char buffer[BUFFER_MAX];
    struct sockaddr_ll      from;
    socklen_t               fromlen = sizeof(from);
    n_read = recvfrom(sock, buffer, 2048, 0, (struct sockaddr*)&from, &fromlen);

    if((from.sll_pkttype==PACKET_BROADCAST)||(from.sll_pkttype==PACKET_MULTICAST))

      continue;
    printf("=====> Packet intercepted, %d bytes sniffed <===== ", n_read);
    switch (from.sll_pkttype) {
      case PACKET_HOST:      printf("Host"); break;
      case PACKET_BROADCAST: printf("Broadcast"); break;
      case PACKET_MULTICAST: printf("Multicast"); break;
      case PACKET_OTHERHOST: printf("Other host"); break;
      case PACKET_OUTGOING:  printf("Outgoing"); break;
    }
    printf("\n");
    switch(level)
    {
      case LEVEL_RAW:
	if(from.sll_hatype==ARPHRD_ETHER)
	  decode(LEVEL_ETH,buffer,n_read);
	break;
      case LEVEL_DGRAM:
	printf("sll_hatype=%x, sll_protocol=%x, sll_ifindex=%u\n",from.sll_hatype,from.sll_protocol,from.sll_ifindex);
      case LEVEL_IP:
	decode(LEVEL_IP,buffer,n_read);
	break;
    }
  }

La bibliothèque Pcap

La bibliothèque Pcap tcpdump && libpcap fournit une interface de programmation pour capturer des paquets réseaux.

Un programme développé avec cette bibliothèque n'a que peu (voire pas) de problème de portabilité. Elle fonctionne entre autre sur les systèmes gérant les BSD Packet Filter ainsi que sous Linux. Elle fournit aussi un filtre simple, celui connu et utilisé par tcpdump, pour sélectionner les paquets.

Le sniffer

Dans cette partie, nous montrons comment utiliser la libpcap pour programmer un petit sniffer.

Sélectionner l'interface

Il existe deux solutions pour sélectionner une interface :

  1. soit on sait sur quelle(s) interface(s) on veut sniffer, et dans ce cas, il suffit de mettre de nom de l'interface dans une chaîne de caractères ("ppp0", "eth0", ou "any" pour sniffer sur toutes les interfaces à la fois) ;
  2. soit on ne connaît pas les interfaces disponibles, auquel cas on laisse la bibliothèque détecter les interfaces :
          dev = pcap_lookupdev(errbuf);
          

Les renseignements sur le réseau accessibles par cette interface (adresse, masque réseau) sont fournies avec la fonction pcap_lookupnet((). Ces informations serviront par la suite.

Ouverture de l'interface

Il s'agit maintenant de récupérer un descripteur qui permettra la capture des paquets. Pour cela, la fonction pcap_open_live() renvoie un pointeur sur un objet de type pcap_t. Les arguments de cette fonction sont le nom de l'interface précédemment établi, la taille maximale des paquets (snaplen), un indicateur pour dire dans quel état doit fonctionner l'interface réseau (mode promiscuous ou non), et enfin un délai maximal pour la lecture.

Mise en place du filtre

Il est possible de filtrer les paquets à récupérer. Tout d'abord, pcap_compile() vérifie puis transforme une expression donnée sous forme de texte en format BPF (simplement "icmp" dans notre exemple). Ensuite, ce filtre est appliqué avec la fonction pcap_setfilter().

  pcap_compile(handle, &filter, "icmp", 0, mask);
  pcap_setfilter(handle, &filter);

La capture

  packet = pcap_next(handle, &header);

La fonction pcap_next renvoie le paquet lu packet et remplis la structure header struct pcap_pkthdr header; avec les informations sur ce paquet, notament header.caplen donne la taille en octets de la capture. Il n'y a plus qu'à décoder les données comme avec le sniffer à base de socket. Pour buffériser/temporiser la collecte et le traitement des paquets, en particulier pour traiter des fichiers de capture, il est recommander d'utiliser les fonctions pcap_dispatch() et pcap_loop().

Traitement

Les fonctions pcap_dispatch() et pcap_loop() appellent une fonction de callback à la réception des paquets pour les traiter. Dans l'exemple suivant, la fonction de callback, appelée callback(), transmet le paquet à la fonction decode() qui s'occupe d'en extraire les différents champs. Les arguments de la fonction de callback sont :

Ce qui nous donne la fonction suivante :
void callback(unsigned char *user, const struct pcap_pkthdr *header,
                 const unsigned char *packet)
{
  struct s_user *my_data=(struct s_user*)user;
  printf("=====> Packet intercepted, %d bytes sniffed >=====\n", header->len);
  switch(my_data->datalink)
  {
    case DLT_EN10MB:
      decode(LEVEL_ETH,packet,header->caplen);
      break;
    case DLT_LINUX_SLL:
      switch(packet[14]>>8|packet[15])
      {
	case ETHERTYPE_ARP:
	  decode(LEVEL_ARP,packet+my_data->offset,header->caplen-my_data->offset);
	  break;
	case ETHERTYPE_IP:
	  decode(LEVEL_IP,packet+my_data->offset,header->caplen-my_data->offset);
	  break;
      }
      break;
  }
}

Ainsi, cette fonction est mise en place par l'appel suivant :

while(pcap_loop(handle,-1,(pcap_handler)callback,(u_char *)&user_data));

Pour conclure brièvement, disons juste que la programmation d'un sniffer avec pcap est à la fois triviale et puissante.

Conclusion

Tout d'abord, nous avons pris ici comme prétexte de programmer un sniffer. Cependant, vous aurez bien compris que ce que nous avons réalisé pour extraire des paquets du réseau l'est aussi pour en injecter.

Nous avons montré ici deux méthodes dépendant fortement du système, avant de présenter une bibliothèque qui rend transparent toute interaction directe avec le système, offrant ainsi une meilleure portabilité. Nous avons vu combien la programmation d'un sniffer est simple avec cette bibliothèque. Cependant, elle souffre de quelques manques, qui sont heureusement comblés par la bibliothèque Nids.

Celle-ci permet de suivre un échange de datagramme UDP, de réassembler une communication TCP à la manière de noyau Linux 2.0.36., ou encore de défragmenter les paquets IP. L'utilisation de libnids est capitale si l'information à traiter se trouve au niveau applicatif. Un application concrète est l'espionnage des mots de passe dans les sessions ftp ou pop par exemple. Elle utilise libpcap pour sniffer ainsi que libnet pour injecter des paquets. Tout comme libpcap, elle s'appuie sur des fonctions de callback pour traiter les paquets.

A propos de libnet, nous vous recommandons la lecture des sources si vous souhaitez en savoir plus sur comment manipuler les interfaces réseau sur de nombreux systèmes, autres que ceux que nous avons traité ici.


Christophe Grenier - grenier@cgsecurity.org Consultant Sécurité - Global Secure
Frédéric Raynal - pappy@miscmag.com ou pappy@security-labs.org