interceptando ping

Depois de umas discussões sobre como interceptar pacotes ICMP que chegam no kernel, vim escrever esse artigo e registrar o código dos programas que fazem isso, da maneira mais simples possível.

Na verdade, o procedimento pra interceptar o ICMP echo request (ping), é o mesmo de qualquer sniffer de pacotes, e por isso precisa ser executado como root. Isso ocorre porque o programa que intercepta precisa criar um socket do tipo RAW, e essa função do kernel não pode ser usada por usuários comuns.

Embora o código seja relativamente simples, envolve bastante teoria sobre programação com sockets, e conhecimento (nesse caso) do protocolo ICMP e suas características. Aquele papo furado que você aprendeu na faculdade, com os cabeçalhos de pacotes na forma de desenho colorido não serve pra nada aqui – pois aqui, os cabeçalhos de pacotes são estruturas de linguagem C.

Quando um programa solicita a criação de um SOCK_RAW pro kernel, é possível especificar um determinado protocolo, pra que o próprio kernel já faça um filtro e não envie coisas que você não quer. Assim, posso especificar o protocolo identificado pelo número 1 (veja no /etc/protocols, é o ICMP). Há ainda uma função, tanto em C quanto em python, chamada getprotobyname, onde você especifica o nome do protocolo e recebe o número como resposta – mas isso é bem juvenil, embora seja um padrão.

Depois de criar o SOCK_RAW, já filtrando o conteúdo por ICMP, basta sair capturando os pacotes com recvfrom. Apesar dessa função retornar, entre outros, o endereço IP do remetente do pacote, o cabeçalho IP inteiro estará disponível nos dados do pacote. Ao ler de um RAW socket filtrado por ICMP, você terá o seguinte nos dados do pacote:

  1. cabeçalho IP
  2. cabeçalho ICMP
  3. dados

Basicamente, o kernel picota o ethernet frame / ARP e manda o resto pro programa. Abaixo, o programa que faz essa malandragem e sai mostrando na tela os pacotes ICMP recebidos na sua máquina:

/* icmpd.c 20080704 AF
 *
 * print incoming icmp echo-request
 * compile:
 *  cc -Wall icmpd.c -o icmpd
 * references:
 *  /usr/include/netinet/ip_icmp.h
 *  SOCK_RAW socket(2)
 */

#include <stdio.h>
#include <fcntl.h>
#include <netdb.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>

// raw packet
struct packet {
    struct ip ip;
    struct icmp icmp;
    char data[4096];
};

int main()
{
    struct packet pkt;
    int fd, len, srclen;
    struct sockaddr_in src;

    if((fd = socket(AF_INET, SOCK_RAW, 1)) == -1)
        perror("socket"), exit(1);

    for(;;) {
        srclen = sizeof(src);
        memset(&pkt, 0, sizeof(pkt));
        memset(&src, 0, sizeof(src));
        len = recvfrom(fd, &pkt, sizeof(pkt), 0, (struct sockaddr *)&src, (socklen_t *)&srclen);

        if(len >= 1 && !pkt.icmp.icmp_type)
            fprintf(stdout, "icmp echo-request sequence %d with %d bytes from %s\n",
                    ntohs(pkt.icmp.icmp_hun.ih_idseq.icd_seq), len-20, inet_ntoa(src.sin_addr));
    }

    return 0;
}

Tem vários truques escondidos ai nesse programa. Primeiro, o que é lido pelo recvfrom “encaixa” no tipo de dados chamado packet, que foi criado lá em cima contendo a struct ip, struct icmp e um campo de dados que só armazena até 4096 bytes, menos o tamanho dos outros dois cabeçalhos. Se o programa receber um pacote ICMP com mais dados que isso (tipo, ping -s 5000 localhost), ele irá imprimir o tamanho do pacote errado.

Segundo, que depois do recvfrom, só entra no if quando o ICMP é do tipo 0 (echo reply), e não tipo 8 (echo request). Aí, quando o ICMP é do tipo zero, aquela union chamada icmp_hun (vide /usr/include/netinet/ip_icmp.h), provê os dados da struct ih_idseq, ao invés de qualquer outro dentro dela – quem não sabe como funciona union já viaja nessa parte; mas não desanime, procure no google que é fácil.

Pra fechar, imprimir o número de sequência do pacote requer uma conversão, visto que esse número está armazenado em network byte order, e não host byte order! Por isso, é necessário usar aquele ntohs() malandro ali. Não por menos, o tamanho do pacote len-20 é pra subtrair os 20 bytes do cabeçalho ip, e mostrar o tamanho correto, igual ao ping.

Um outro detalhe é que esse programa está baseado na struct icmp do BSD, porque estou usando um mac osx aqui. Porém, ele é totalmente compatível com lunix sem necessidade de alterar o código – os kernel/libc developers do lunix são muito malandros e deixam os headers (ip.h, ip_icmp.h, etc) no esquema.

Se você se empolgou com isso, eu não podia deixar por menos e me sinto quase que obrigado a colocar uma versão desse programa em python. É tão simples… veja aí:

#!/usr/bin/env python
# icmpd.py 20080704 AF
#
# print incoming icmp echo-request
# references:
#  /usr/include/netinet/ip_icmp.h
#  http://docs.python.org/lib/module-struct.html

import struct, socket

def parse(pkt):
    raw_size = len(pkt)
    pkt_size = raw_size - 1

    # set format and unpack
    format = '!B' + (pkt_size and '%ds' % pkt_size or '')
    rawdata = struct.unpack(format, pkt)

    # check for echo-reply
    if rawdata[0] or not pkt_size: return None

    # extract reply sequence number
    sequence = struct.unpack('!hh', rawdata[1][3:7])
    return (raw_size, sequence[1])

if __name__ == '__main__':
    fd = socket.socket(socket.AF_INET, socket.SOCK_RAW, 1)

    while 1:
        chunk, src = fd.recvfrom(4096)
        obj = parse(chunk[20:])
        if obj:
            print 'icmp echo-request sequence %d with %d bytes from %s' % (obj[1], obj[0], src[0])

O princípio é o mesmo. A diferença é que no python não é possível acessar aquelas structs do kernel direto, e por isso é necessário usar o módulo struct (hehe). Esse também tem vários truques, veja…

Logo depois de ler os dados, já envio pra função parse(), o chunk[20:], que passa os dados à partir dos 20 primeiros bytes – ou seja, já picota o cabeçalho IP que estava ali. Dentro da função parse, crio um formato tipo ‘!B60s’, que significa:

  1. o exclamação determina que os dados estão em network byte order
  2. o primeiro B especifica um unsigned char, o primeiro campo da struct icmp – que contém o tipo!
  3. o número acompanhado de “s”, especifica um campo char com N bytes, que não será modificado

Esse unpack retorna uma lista com apenas dois índices – o primeiro com o tipo do pacote ICMP, e o segundo com o resto do cabeçalho.

Se eu quisesse, por exemplo, pegar os três primeiros campos da struct icmp, usaria algo assim: ‘!BBh’. Veja no /usr/include/netinet/ip_icmp.h que os campos são: unsigned char type, unsigned char code e unsigned short checksum. Se comparar com o manual do módulo struct do python, verá que ‘!BBh’ significa a mesma coisa, e o exclamação ali pra indicar o formato dos dados – network byte order.

Por fim, pra pegar o número de sequência do pacote tem outro truque. Como o rawdata[0] era o tipo do pacote icmp, e o rawdata[1] era o resto do cabeçalho ICMP, era necessário pegar apenas aquela struct ih_idseq lá dentro, que possui dois campos do tipo unsigned short. Considerando que o rawdata[1] era o cabeçalho, à partir do campo “code”, pulei três bytes – 1 do code, e 2 do checksum. À partir dali, mandei ler os outros 32, sendo 16 de cada unsigned short – icd_id e então icd_seq.

Por isso, aquele struct.unpack(‘!hh’, rawdata[1][3:7]) retorna exatamente os dois campos da struct icmp e permite obter o número de sequência do pacote.