Realizzare un semplice packet sniffer in C su Windows XP
Cos'è un packet sniffer e perché utilizzarlo
Un packet sniffer (letteralmente: "annusatore di pacchetti") è un software, generalmente ad uso diagnostico, che consente di visualizzare e analizzare tutto il traffico di rete fisicamente ricevibile da una interfaccia di rete. Di solito un'interfaccia di rete scarta tutti i frame il cui indirizzo MAC non coincide con il proprio (i frame sono l'equivalente dei pacchetti al livello 2 nello stack ISO/OSI). Quello che fa un packet sniffer è impostare la scheda di rete in promiscuous mode, una modalità di funzionamento che permette al sistema operativo di accedere anche ai frame normalmente scartati dal NIC.
Chiedersi a cosa possa servire un packet sniffer è più o meno come chiedersi a cosa serva un coltellino svizzero: dipende dall'occasione. Giusto per elencarene qualche possibile utilizzo:
- Effettuare una diagnostica delle connessioni di rete
- Sniffare password o altri dati sensibili
- Studiare il funzionamento dei protocolli di rete
In questo testo vedremo come programmare un packet sniffer non degno di essere nemmeno lontanamente paragonato con Ethereal (a.k.a. Wireshark), e probabilmente neanche con tcpdump, ma spero che sarà un esercizio interessante per chi vuole avvicinarsi alla programmazione delle socket a basso livello.
Prerequisiti
Assolutamente indispensabile una conoscenza non eccessivamente superficiale del C (ma non serve che siate Kerningham & Ritchie). Anche se spiegherò quasi tutte le istruzioni utilizzate, un'infarinatura sulla programmazione delle socket sotto Windows è molto consigliata, un buon riferimeto può essere la non aggiornatissima ma sempre valida Winsock programmer's FAQ. È anche bene che conosciate abbastanza bene lo stack TCP/IP, quindi una lettura alle RFC 791 (protocollo IP), RFC 792 (protocollo ICMP), RFC 768 (protocollo UDP) e RFC 793 (protocollo TCP) non può che far bene (anche se come diceva qualcuno leggere le RFC è uno sport fuori moda).
Insomma: qui spiego come scrivere un packet sniffer, non riprendo tutto ab ovo.
Questo packet sniffer si basa sull'utilizzo delle raw socket. La disponibilità delle raw socket sono state al centro di un dibattito piuttosto acceso (il troll di turno era Steve Gibson, che sarà pure un esperto di sicurezza, ma a volte è illuminato da lampi di imbecillità), quindi su alcune piattaforme sono disponibili, su altre no, su altre boh. AFAIK le raw socket sono disponibili su Windows 2000, Windows XP Home, Windows XP Home SP1 e Windows XP Professional agli utenti con privilegi di amministratore, ma non ho indagato più di tanto, quindi siete invitati a sperimentare.
L'idea di base
In realtà la tecnica di base per creare il packet sniffer non è molto complessa: quello che si fa è creare una socket e effettuarne il binding su un certo indirizzo di rete (non è possibile effettuare il binding con INADDR_ANY) e tramite una chiamata alla funzione WSAIoctl comunicare a Windows che si desidera che l'interfaccia di rete su cui è in ascolto la socket sia impostata in modalità promiscua per poter ricevere tutti i dati fisicamente ricevibili. Fatto questo basta un semplice loop su recv per ricevere i dati dalla socket, che restituirà i dati compresi gli header dei protocolli: non resta che stampare a video o su file i dati interessanti. Tutto il resto sono fronzoli per determinare gli indirizzi IP su cui è possibile mettersi in ascolto, elaborare i dati del pacchetto e così via.
Implementazione
Strutture necessarie
Per analizzare comodamente i pacchetti ricevuti è bene creare le strutture adatte per accedere facilmente ai dati contenuti. Dopo una rapida lettura alle RFC prima citate, non è difficile scrivere le seguenti strutture:
// header pacchetto IP
typedef struct ipheader
{
// versione IP e lunghezza dell'header in
// word da 32 bit
unsigned char ver_ihl;
// Type Of Service
unsigned char tos;
// lunghezza totale del pacchetto header compreso
unsigned short tot_len;
//identificativo univoco del pacchetto
unsigned short id;
// flag del pacchetto e offset del frammento
unsigned short flag_off;
#define IP_RF 0x8000 /* reserved fragment flag */
#define IP_DF 0x4000 /* dont fragment flag */
#define IP_MF 0x2000 /* more fragments flag */
#define IP_OFFMASK 0x1fff /* mask for fragmenting bits */
// Time To Live
unsigned char ttl;
// identificativo del protocollo
unsigned char protocol;
// checksum del pacchetto
unsigned short checksum;
// indirizzo del mittente
unsigned long saddr;
// indirizzo del destinatario
unsigned long daddr;
} iphdr;
// header pacchetto ICMP
typedef struct icmpheader
{
// tipo di messaggio
unsigned char type;
// codice del messaggio
unsigned char code;
// checksum del pacchetto
unsigned short checksum;
// union contenente gli altri dati
// a seconda del tipo di messaggio
union
{
// struttura per messaggi tipo
// echo e echo reply
struct
{
unsigned short id;
unsigned short sequence;
} echo;
// indirizzo del gateway per
// messaggi tipo redirect
unsigned long gateway;
} un;
} icmphdr;
// header pacchetto TCP
typedef struct tcpheader
{
// porta sorgente
unsigned short sport;
// porta di destinazione
unsigned short dport;
// numero di sequenza del pacchetto
unsigned long seq;
// numero di ack nello stream
unsigned long ack;
unsigned short off_reserved_flags;
#define TH_FIN 0x01
#define TH_SYN 0x02
#define TH_RST 0x04
#define TH_PUSH 0x08
#define TH_ACK 0x10
#define TH_URG 0x20
// finestra di ottetti disponibile per
// la ricezione
unsigned short window;
// checksum
unsigned short checksum;
// puntatore ai dati urgenti
unsigned short urp;
} tcphdr;
// header pacchetto UDP
typedef struct udpheader
{
// porta sorgente
unsigned short sport;
// porta di destinazione
unsigned short dport;
// lunghezza dell'header udp
// e dei dati
unsigned short len;
// checksum del pacchetto
unsigned short checksum;
} udphdr;
(Per chi se lo stesse chiedendo: sì ho copiato parte di questo codice dal sorgente del kernel Linux, evviva l'open source).
Inizializzazione
Il primo passo è inizializzare i winsock, con la solita riga di codice:
int main(int argc, char *argv[])
{
WSADATA wsaData;
/* ... */
WSAStartup(MAKEWORD(2, 1), &wsaData);
Nel codice che è possibile scaricare potrete anche vedere come indicare i possibili codici di errore in modo significativo.
Per impostare l'interfaccia di rete in modalità promiscua è necessario utilizzare la funzione WSAIoctl, a cui viene passato come parametro il flag SIO_RCVALL. A proposito di questo flag la documentazione di Windows riporta:
SIO_RCVALL: enables a socket to receive all IP packets on the network. The socket handle passed to the WSAIoctl function must be of AF_INET address family, SOCK_RAW socket type, and IPPROTO_IP protocol. The socket also must be bound to an explicit local interface, which means that you cannot bind to INADDR_ANY.
Once the socket is bound and the ioctl set, calls to the WSARecv or recv functions return IP datagrams passing through the given interface. Note that you must supply a sufficiently large buffer. Setting this ioctl requires Administrator privilege on the local computer. SIO_RCVALL is available in Windows 2000 and later versions of Windows.
Seguiamo le indicazioni dell'SDK e creiamo un socket con le impostazioni corrette:
sock = WSASocket(AF_INET, SOCK_RAW, IPPROTO_IP, NULL, 0, 0);
Anche qui è necessario altro codice per la gestione dei codici di errore.
Fatto questo mostriamo gli indirizzi IP disponibili per effettuare il binding della socket. Per ottenere questo risultato ci si affida ancora una volta a WSAIoctl, a cui si passa il flag SIO_GET_INTERFACE_LIST, che restituisce un array di strutture INTERFACE_INFO, che permette di recuperare l'indirizzo di rete di ogni interfaccia presente sul sistema. Il codice necessario è il seguente:
void listAddresses() {
//variabile per contenere il l'hostname del sistema
char hostname[256];
int i;
DWORD nBytesReturned;
SOCKET sd;
// array di INTERFACE_INFO per ricevere i dati da WSAIoctl
INTERFACE_INFO interfaceList[20];
// recuperiamo il nome host del sistema
if (gethostname(hostname, sizeof(hostname))==SOCKET_ERROR) {
// in caso di errore si potrbbe rendere l'output più verboso
fprintf(stderr, "Errore in gethostname(): %d\n", WSAGetLastError());
WSACleanup();
exit(-1);
}
// WSAIoctl richiede come parametro un socket, ne creiamo uno
sd = WSASocket(AF_INET, SOCK_DGRAM, 0, 0, 0, 0);
if (sd == SOCKET_ERROR) {
fprintf(stderr, "Errore in WSASocket(): %d\n", WSAGetLastError());
WSACleanup();
exit(-1);
}
// chiediamo la lista delle interfacce disponibili a WSAIoctl
if (WSAIoctl(sd, SIO_GET_INTERFACE_LIST, 0, 0, &interfaceList, sizeof(interfaceList), &nBytesReturned, 0, 0)==SOCKET_ERROR) {
fprintf(stderr, "Errore in WSAIoctl(): %d\n", WSAGetLastError());
closesocket(sd);
WSACleanup();
exit(-1);
}
// stampiamo i risultati su schermo
for (i=0; i<(strlen("Indirizzi IP disponibili per ")+strlen(hostname)+3); i++)
printf("=");
printf("\n");
printf("Indirizzi IP disponibili per %s:\n", hostname);
for (i=0; i<(nBytesReturned/sizeof(INTERFACE_INFO)); i++) {
printf("* %s\n", inet_ntoa(interfaceList[i].iiAddress.AddressIn.sin_addr));
}
closesocket(sd);
}
Si chiede dunque all'utente su quale indirizzo IP voglia effettuare il binding, a tale scopo si utilizza la solita struttura sockadrr_in (usatissima nella programmazione dei winsock) e la funzione bind:
struct sockaddr_in sockaddr;
char ipaddr[16];
int retcode;
/* ... */
// azzeriamo il contenuto di sockaddr
memset(&sockaddr, 0, sizeof(sockaddr));
printf("Indirizzo ip (es.: 192.168.0.2)? ");
fflush(stdout);
fgets(ipaddr, sizeof(ipaddr), stdin);
printf("Tento di effettuare il binding su %s\n", ipaddr);
// inseriamo l'indirizzo ip in sockaddr, opportunamente convertito
// in come intero di 32 bit con i byte in "network order"
sockaddr.sin_addr.s_addr = inet_addr(ipaddr);
sockaddr.sin_family = AF_INET;
sockaddr.sin_port = htons(0);
// cerchiamo di effettuare il bind
if (bind(sock, (struct sockaddr *)&sockaddr, sizeof(sockaddr))==SOCKET_ERROR) {
retcode=WSAGetLastError();
WSACleanup();
fprintf(stderr, "Errore in bind(): - %d\n", retcode);
exit(-1);
}
Veniamo ora al vero fulcro che permette al sistema di funzionare: WSAIoctl. Tale funzione permette di controllare in modo molto accurato il funzionamento delle socket. La signature del metodo è:
int WSAIoctl(SOCKET s, DWORD dwIoControlCode, LPVOID lpvInBuffer, DWORD cbInBuffer, LPVOID lpvOutBuffer, DWORD cbOutBuffer, LPDWORD lpcbBytesReturned, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine)
Quello che ci interessa è richiamare WSAIoctl sulla socket sock passando come argomento dwIoControlCode la costante SIO_RCVALL. Per motivi che mi sfuggono tale costante non è definita negli header, affinchè tutto funzioni è necessario aggiungere la riga:
#define SIO_RCVALL _WSAIOW(IOC_VENDOR,1)
Possiamo quindi richiamare la funzione. Poiché molti parametri non ci interessano, utilizziamo NULL o 0 ove possibile:
if (WSAIoctl(sock, SIO_RCVALL, &retcode, sizeof(retcode), NULL, 0, &bytesReturned, NULL, NULL)==SOCKET_ERROR) {
retcode=WSAGetLastError();
closesocket(sock);
WSACleanup();
fprintf(stderr, "Errore in WSAIoctl(): - %d\n", retcode);
exit(-1);
}
Possiamo ora eseguire il loop principale, che non fa altro che ciclare su recvfrom e analizzare i pacchetti ricevuti (si noti come la socket si comporti come se fosse di tipo SOCK_DGRAM).
Analisi dei pacchetti
Considerando le strutture dati presentate, se si ha accesso diretto alle struct correttamente formate è relativamente semplice presentarne le informazioni in modo strutturato. Per mostrare le informazioni dell'header IP è sufficiente il seguente codice:
void printIPheaderinfo(iphdr iph) {
struct in_addr temp;
printf("Header IP:\n");
printf("Ver: %d\nLunghezza header: %d byte", iph.ver_ihl >> 4, iph.ver_ihl & 0xF);
printf("ToS: %d\nLunghezza totale: %d byte", iph.tos, ntohs(iph.tot_len));
// la funzione ntohs() converte un intero "short" (16 bit) dal "network order" (big endian) all'"host order"
// (little endian su microprocessori x86)
printf("ID: %d\n", ntohs(iph.id));
printf("Flags: RF = %d, DF = %d, MF = %d", iph.flag_off & IP_RF != 0 ? 1:0, iph.flag_off & IP_DF != 0 ? 1:0, iph.flag_off & IP_MF != 0 ? 1:0);
printf("Fragment offset: %d\n", ntohs(iph.flag_off & IP_OFFMASK));
printf("TTL: %d\n", iph.ttl);
printf("Protocol: %d\n", iph.protocol);
printf("Checksum: 0x%.08x\n", ntohs(iph.checksum));
temp.s_addr = iph.saddr;
printf("Source address: %s\n", inet_ntoa(temp));
temp.s_addr = iph.daddr;
printf("Destination address: %s\n", inet_ntoa(temp));
}
In modo simile per il protocollo UDP e TCP:
void printTCPheaderinfo(tcphdr tcph) {
printf("Header TCP:\n");
printf("Porta sorgente: %d\nPorta destinazione: %d\n", ntohs(tcph.sport), ntohs(tcph.dport));
printf("Sequence number: %d\nAck number: %d\n", ntohl(tcph.seq), ntohl(tcph.ack));
printf("TCP header size: %d byte\n", (tcph.off_reserved_flags & 0xF0)>> 7); // shift a destra di 12 e poi a sinistra di 5 (*=32)
printf("Flags: FIN = %d, SYN = %d, RST = %d, PUSH = %d, ACK = %d, URG = %d\n", tcph.off_reserved_flags & TH_FIN != 0 ? 1:0, tcph.off_reserved_flags & TH_SYN != 0 ? 1:0, tcph.off_reserved_flags & TH_RST != 0 ? 1:0, tcph.off_reserved_flags & TH_PUSH != 0 ? 1:0, tcph.off_reserved_flags & TH_ACK != 0 ? 1:0, tcph.off_reserved_flags & TH_URG != 0 ? 1:0);
printf("Window: %d\n", ntohs(tcph.window));
printf("Checksum: 0x.08x\nUrgent pointer: %d\n", ntohs(tcph.checksum), ntohs(tcph.urp));
}
void printUDPheaderinfo(udphdr udph) {
printf("Header UDP:\n");
printf("Porta sorgente: %d\nPorta destinazione: %d\n", ntohs(udph.sport), ntohs(udph.dport));
printf("Lunghezza totale %d\n", ntohs(udph.len));
printf("Checksum: 0x%.08x\n", ntohs(udph.checksum));
}
Il codice per l'header ICMP è leggermente più complesso a causa delle specifiche del protocollo:
void printICMPheaderinfo(icmphdr icmph) {
printf("Header ICMP:\n");
printf("Type: %d\nCode:%d\n", icmph.type, icmph.code);
switch (icmph.type) {
case ICMP_ECHOREPLY:
printf("ICMP_ECHOREPLY\n");
printf("id: %d\nsequence number: %d\n", ntohs(icmph.un.echo.id), ntohs(icmph.un.echo.sequence));
break;
case ICMP_DEST_UNREACH:
printf("ICMP_DEST_UNREACH\n");
break;
case ICMP_SOURCE_QUENCH:
printf("ICMP_SOURCE_QUENCH\n");
break;
case ICMP_REDIRECT:
printf("ICMP_REDIRECT\n");
break;
case ICMP_ECHO:
printf("ICMP_ECHO\n");
printf("id: %d\nsequence number: %d\n", ntohs(icmph.un.echo.id), ntohs(icmph.un.echo.sequence));
break;
case ICMP_TIME_EXCEEDED:
printf("ICMP_TIME_EXCEEDED - ");
if (icmph.code == 0)
printf("ICMP_EXC_TTL - TTL count exceeded\n");
else if (icmph.code == 1)
printf("ICMP_EXC_FRAGTIME - Fragment Reassembly time exceeded\n");
else
printf("codice sconosciuto\n");
break;
case ICMP_PARAMETERPROB:
printf("ICMP_PARAMETERPROB\n");
break;
case ICMP_TIMESTAMP:
printf("ICMP_TIMESTAMP\n");
break;
case ICMP_TIMESTAMPREPLY:
printf("ICMP_TIMESTAMPREPLY\n");
break;
case ICMP_INFO_REQUEST:
printf("ICMP_INFO_REQUEST\n");
break;
case ICMP_INFO_REPLY:
printf("ICMP_INFO_REPLY\n");
break;
case ICMP_ADDRESS:
printf("ICMP_ADDRESS\n");
break;
case ICMP_ADDRESSREPLY:
printf("ICMP_ADDRESSREPLY\n");
break;
default:
printf("Tipo di messaggio sconosciuto\n");
}
}
Il ciclo principale che si occupa di ricevere i pacchetti è molto simile a quello che ci si potrebbe aspettare da un'applicazione server che utilizzi l'UDP: è sostanzialmente un ciclo infinito che riceve i datagrammi e li elabora:
void sniff(SOCKET s) {
char *buff = (char *)malloc(MAX_PACKET_SIZE), *testo;
long recvdbytes;
struct sockaddr_in from;
int sockaddr_size = sizeof(struct sockaddr_in);
if (buff==NULL) {
fprintf(stderr, "Errore in malloc()\n");
closesocket(s);
WSACleanup();
exit(-1);
}
do {
memset(buff, 0, MAX_PACKET_SIZE);
memset(&from, 0, sizeof(struct sockaddr_in));
recvdbytes = recvfrom(s, buff, MAX_PACKET_SIZE, 0, (struct sockaddr *)&from, &sockaddr_size);
if (recvdbytes==SOCKET_ERROR) {
fprintf(stderr, "Errore in recvfrom(): %d\n", WSAGetLastError());
free(buff);
closesocket(s);
WSACleanup();
exit(-1);
}
analyzePacket(buff, recvdbytes);
} while ((recvdbytes > 0) && (getch()==-1));
free(buff);
}
La funzione analyzePacket() stampa i dati dell'header IP e, se possibile, anche quelli del protocollo incapsulato al suo interno tramite le funzioni precedentemente mostrate:
int analyzePacket(char* buff, int size) {
iphdr *ipheader;
tcphdr *tcpheader;
udphdr *udpheader;
icmphdr *icmpheader;
int i;
if (size<sizeof(iphdr))
return -1;
printf("======= Pacchetto catturato =======\n");
ipheader = (iphdr *)buff;
printIPheaderinfo(*ipheader);
switch (ipheader->protocol) {
case IPPROTO_TCP:
tcpheader = (tcphdr*)(buff+20);
printTCPheaderinfo(*tcpheader);
break;
case IPPROTO_UDP:
udpheader = (udphdr*)(buff+20);
printUDPheaderinfo(*udpheader);
break;
case IPPROTO_ICMP:
icmpheader = (icmphdr*)(buff+20);
printICMPheaderinfo(*icmpheader);
break;
default:
printf("Protocollo di livello 4 non supportato\n");
break;
}
Infine viene stampato il dump in esadecimale del contenuto del pacchetto e l'equivalente ASCII, mostrando solo i caratteri stampabili:
for (i=0; i<size; i++)
printf("%.02X ", buff[i]);
printf("\n");
for (i=0; i<size; i++)
if (buff[i]>31 && buff[i]<127)
printf("%c");
else
printf(".");
Conclusioni
Il software presentato è di una semplicità disarmante: l'unica difficoltà è sapere quali sono i parametri corretti con cui richiamare le API di Windows in modo da aver accesso a una socket che legga tutto il traffico in entrata e in uscita, per il resto si tratta di operazioni quasi ovvie.
Le possibili espansioni sono innumerevoli:
- Supporto per le opzioni previste dal protocollo IP e TCP
- Supporto per i codici contenuti nei pacchetti ICMP
- Supporto per altri protocolli
- Possibilità di filtrare i pacchetti
- Aggiungere un meccanismo di buffering per rendere più efficiente il programma
e molto altro ancora. Si tenga presente che Windows fornisce nativamente il supporto per i socket di questo tipo solo dallo strato di rete 3, vale a dire che possono essere visualizzati solo i pacchetti del protocollo IP; i pacchetti ARP, ad esempio, sono totalmente ignorati, e non è possibile neanche accedere alle informazioni dello strato 2 (vale a dire gli indirizzi MAC); per fare questo è necessario utilizzare librerie esterne, come ad esempio le librerie winpcap, sviluppate dal Politecnico di Torino.
