PF_PACKET
Функция socket() открывает сокет (или, как сказано в 'man 2
socket' моего линукса, "оконечную точку коммуникации") и возвращает её
дескриптор. Синтаксис функции таков:
int socket(int domain, int type, int protocol);
Первый параметр, int domain, задаёт семейство (или, более
привычно, стек) протоколов, для которого создаётся сокет. Стеку IPv4
(Internet Protocol version 4), например, соответствует константа
PF_INET или её эквивалент, AF_INET. Все доступные (но не обязательно
поддерживаемые ядром) константы перечислены в <bits/socket.h>.
Так вот, в Linux'е поддерживается особый стек протоколов (который на
самом деле стеком не является), PF_PACKET.
Апеллируя к нему, можно открывать сокеты с доступом к самому низкому,
сетевому уровню (Local Networking Level по модели OSI), а это именно
то, что нам с тобой нужно для создания сниффера.
Второй параметр, int type, определяет тип протокола, требуемый
внутри выбранного семейства. Таких типов протоколов немного:
SOCK_STREAM (двустороннее потоковое соединение), SOCK_DGRAM
(односторонняя дейтаграммная передача без установления соединения),
SOCK_RAW (доступ к получению/формированию заголовков протоколов
выбранного семейства) и нужный нам SOCK_PACKET (доступ к
получению/формированию кадров данных). Существуют и другие типы
протоколов специального назначения, но они в настоящее время либо не
используются, либо используются крайне редко.
Третий параметр либо содержит нуль, указывая на то, что необходимо
использовать дефолтный протокол, подразумеваемый вторым параметром,
либо явно уточняет конкретный протокол, который необходимо использовать
для открываемого сокета. Например, вызов
sock = socket(PF_INET,SOCK_STREAM,0);
откроет нам IP/TCP'шный сокет, а вызов
sock = socket(PF_INET,SOCK_RAW,IPPROTO_ICMP);
откроет нам IP/ICMP'шный сокет.
В нашем же случае, третий параметр указывает на протокол
(Internetworking Level по модели OSI) сетевого уровня, с которым
необходимо проассоциировать сокет. Все доступные значения для сокета
PF_PACKET/SOCK_PACKET перечислены в <linux/if_ether.h>. В своём
примере я буду использовать параметр ETH_P_ALL, что означает приём всех
доступных ядру системы сообщений. Необходимо кастовать эту двухбайтную
константу к обратному порядку следования байт, макросом htons(). Итак,
sock = socket(PF_PACKET,SOCK_PACKET,htons(ETH_P_ALL));
откроет нужный нашему снифферу сокет.
Обрати внимание, дружище, ты не сможешь открыть такой сокет, не
обладая
привилегиями рута. Из соображений безопасности, ядро не позволит
открывать сокеты с SOCK_PACKET (и даже с SOCK_RAW) юзеру с UID'ом, не
равном нулю.
ПРИВЯЗКА СОКЕТА К УСТРОЙСТВУ
Необходимо привязать сокет к физическому устройству, которое мы
будем прослушивать. Это можно (и нужно) делать для любого устройства,
кроме псевдоустройств (типа loopback). Привязка осуществляется
системным вызовом setsockopt():
int setsockopt(int s, int level, int optname, const void *optval, socklen_t optlen);
int s -- сокетный дескриптор;
int level -- уровень, на котором необходимо произвести операцию;
int optname -- непосредственная операция, зависящая от второго параметра;
const void *optval -- указатель на начало аргумента к операции (либо NULL, если аргументов не требуется);
socklen_t optlen -- длина аргумента.
Вообще говоря, setsockopt() -- функция, весьма богатая своими
возможностями, но сейчас нас интересует только возможность привязки
сокета устройству, имя которого мы сообщим в четвёртом параметре,
например:
rc = setsockopt(sock,SOL_SOCKET, SO_BINDTODEVICE,"eth0\x00",strlen("eth0\x00")+1);
привяжет сокет sock к устройству eth0. Обрати внимание, необходимо
завершать имя устройство нулевым символом. Если setsockopt() совершит
привязку успешно, она вернёт нуль. В противном случае необходимо
проверять глобальную переменную errno для детальной диагностики ошибки.
PROMISCIOUS MODE
В нормальном режиме работы, устройство будет принимать (и,
соответственно, доставлять ядру) лишь те кадры данных, которые содержат
в поле Destination MAC Address проассоциированный с этим устройством
MAC-адрес. Иными словами, при нормальном режиме работы устройства, ты
сможешь ловить из сети только те сообщения, которые направлены к тебе.
Для того, чтобы получить возможность вылавливать из сети все кадры
данных без исключения, необходимо перевести устройство в promiscious
mode ("промискуитетный", т.е. неразборчивый режим). Это осуществляется
с помощью вызова ioctl():
int ioctl(int d, int request, ...);
int d -- файловый (или, как частный случай файлового, сокетный) дескриптор;
int request -- системный вызов ядра Linux (перечислены в man 2 ioctl_list и разбросаны по хидерам в <linux/...>);
... -- переменное число аргументов ко второму параметру.
Для осуществления этой операции нам потребуется сгрузить текущие
параметры и настройки устройства (интерфейса) в структуру struct ifreq
(описан в <linux/if.h>) с помощью системного вызова SIOCGIFFLAGS:
struct ifreq interface;
...
ioctl(sock,SIOCGIFFLAGS,&interface);
И взвести в элементе ifr_flags структуры interface бит IFF_PROMISC:
interface.ifr_flags |= IFF_PROMISC;
А затем, с помощью системного вызова SIOCSIFFLAGS, загрузить изменённую структуру interface обратно:
ioctl(sock,SIOCSIFFLAGS,&interface);
попутно проверяя результаты ioctl на предмет наличия ошибок.
Обязательно учти тот момент, что при большой нагрузке на сеть
(более нескольких тысяч кадров данных в секунду), сниффер, переведённый
в promiscious mode, будет "задыхаться", не успевая обрабатывать поток
сообщений. Это может привести к непредсказуемым последствиям. Начиная с
того, что такой сниффер будет попросту пропускать некоторые сообщения
(ядро не будет успевать "сгребать" эти сообщения с устройства), и
заканчивая тем, что активность такого сниффера можно легко обнаружить
различными анти-снифферскими методами. Эта опасность не преувеличена:
даже пара десятков windows-машин в локальном сегменте создадут ощутимую
нагрузку на сеть, обмениваясь пустопорожним NETBIOS-траффиком друг с
другом.
ВЫХОД ИЗ PROMISCIOUS MODE
После завершения работы сниффера, НЕОБХОДИМО вернуть устройство в
первоначальное (нормальное) состояние, сняв флаг IFF_PROMISC. Для
этого, производим обратную описанной выше операцию:
interface.ifr_flags ~= IFF_PROMISC;
и
ioctl(sock,SIOCSIFFLAGS,&interface);
RECVFROM
Итак, после того, как мы создали сокет, привязали его к нужному
интерфейсу и перевели устройство в promiscious mode, мы можем получать
кадры данных (начиная с заголовка EtherNet, соответственно), следующим
вызовом:
rc = recvfrom(sock,buf,sizeof(buf),0,0,0);
где buf -- достаточно крупный, чтобы вместить максимально
возможный кадр данных, буфер. sizeof(buf) я рекомендую делать не менее
65536 байт (хотя, на самом деле, во многих локальных сетях MTU не
превышает 1.5-4 тысячи байт). Последние три параметра, за
ненадобностью, опускаются.
Обрати внимание: сокет остаётся в блокирующем режиме. Это не очень
эффективно, но зато очень просто :)) В самый раз для демонстрационного
примера :) Итак, recvfrom() будет отдавать управление всякий раз, как
только в буфере сокета sock появится хоть что-нибудь для чтения и это
"что-нибудь" будет прочитано.
LOOPBACK
Для желающих сниффить loopback device (а такая необходимость
нередко возникает, особенно у разработчиков сетевого ПО, тестирующих
свой софт на локалхосте), следует не привязывать сокет к устройству и,
соответственно, не вводить его в promiscious mode, а просто
отфильтровывать (на программном уровне) сообщения с нулевыми Source и
Destination MAC-адресами. Этот подход проиллюстрирован в примере.
ПАРСИНГ
Я сделал небольшой примитивный парсинг вывода в демо-сниффере.
Метод, конечно же, оставляет желать лучшего :))) Но, всё же, это
эффективнее, чем вызывать по тысяче раз функцию printf()
непосредственно на консоль при парсинге одного-единственного сообщения
:) Вообще говоря, парсинг в сниффере -- это самая муторная и
ресурсоёмкая задача. Но, уверен, тебе не составит особого труда её
решить в том виде, в котором ты захочешь.
ЭФФЕКТИВНОСТЬ
Демо-сниффер адски неэффективен: он случает все без исключения
сообщения, проходящие мимо устройства. Можно, конечно же, реализовать
фильтр программного уровня, который будет отдавать на парсинг лишь те
сообщения, которые интересны пользователю, но ядро всё равно будет
испытывать всю тяжесть сетевого
трафика.
Выход, тем не менее, есть. Он был предложен аж в начале 80-х годов
и давно реализован в виде библиотеки libpcap для UNIX, используемой
популярным сниффером tcpdump. Это решение, именуемое Berkeley Packet
Filtering (bpf), заключается в установке фильтра на устройстве таким
образом, что устройство генерирует прерывание и отдаёт ядру лишь те
сообщения, которые этот фильтр пропускает. Фильтр можно настраивать на
MAC-адреса, на IP-адреса, на протоколы транспортного уровня (ICMP, TCP,
UDP, etc), на определённые порты, на количество байт, которое
необходимо сгребать с каждого кадра данных и на многое другое.
BPF можно программировать с помощью команд SO_ATTACH_FILTER и SO_DETACH_FILTER уровня SOL_SOCKET вызова setsockopt():
rc = setsockopt(sock,SOL_SOCKET,SO_ATTACH_FILTER,&opcode,sizeof(int));
особым BPF-ным псевдокодом (а это весьма муторное занятие), либо с
помощью вызова pcap_compile() библиотеки libpcap, компилируя псевдокод
из текстовой строчки, как это делается в tcpdump'е (вроде "host
192.168.0.1 proto 6 port 23\x00").
Но обо всём этом я напишу в следующей статье...
P.S.
Мне очень хотелось бы услышать твоё мнение относительно того,
хочешь ли ты увидеть продолжение этой статьи? Интересна ли эта тема?
Жду откликов во "мнениях"...
С уважением, [Privacy]
Исходники
|