| Предыдущая статья | Оглавление | Следующая статья |
Исследование протоколов - довольно интересное занятие. По-прежнему многие исследователи занимаются их оценкой и верификацией в различных ситуациях. Лично я столкнулся с необходимостью исследования протокола в первый год работы в ИММ УрО РАН [1]. Как бы это ни звучало банально, но это был TCP протокол. Причина, по которой я стал этим заниматься, следующая - необходимо было разобраться с сетью в одном из вычислительных кластеров. При передаче данных по протоколу TCP через 2-е 100 Мбит. сетевые карты одновременно (т.н. связывание каналов или по англ., bonding) наблюдался прирост скорости в два раза, и в тоже время на гигабитных карточках скорость не только не возрастала, но даже падала до 600 Мбит. в сек. Снифер ничего особого не показал. Да и вообще говоря, снифер не всегда может дать исчерпывающую информацию о происходящем в сети. С его помощью можно узнать о том, что произошло в сети и на основе этого попытаться понять, что происходило с протоколом. Но для этого как минимум необходимо точно себе представлять реализацию данного протокола в этой ОС. Согласитесь, это не легко. Да и надо ли это вообще, когда сама ОС и все протоколы прямо перед нами? К тому же, когда это open-source ОС, где нет ничего, что было бы скрыто от наших глаз.
Данная статья описывает один из двух (на данный момент мне известных!) способов встраивания в TCP/IP стек. Я это делал с целью узнать значение некоторых параметров и состояние протокольной машины TCP для конкретного соединения. Возможно, у вас будут на это другие причины :). Первый способ - это внести изменения в ядро ОС Linux, пересобрать его, перезагрузить машину и затем собирать необходимую информацию. И если пойти дальше, то можно даже включить это опцией при сборке ядра. Но этот способ примитивный, скучный, а значит, не доставляет никакого удовольствия. Второй - написать модуль к ядру, который бы подключался к TCP протоколу и выводил всю необходимую информацию. Он немного сложнее первого, но значительно интереснее, поверьте! Вот его мы и разберем.
В связи с тем, что изменения в ядро ОС Linux вносятся довольно часто, трудно написать пример кода, который бы работал на многих ядрах сразу. По этой причине, в этой статье описан пример встраивания в TCP стек в ядро ОС Linux версии 2.6.12. Также, в приложении можно найти код для версии ядра 2.6.18 (спасибо VenROCK’у, заставил!).
Начнем, пожалуй, с того, как в ОС Linux представлен сетевой стек. Для работы с сетью ОС Linux в основном используется 2-е структуры. Первая - это sk_buff, описывает пакет с содержимым и хранит служебную информацию, например, для какого сокета адресован данный пакет, когда он был создан, указатели на заголовки (Ethernet, IP, TCP..), размер пакета, с какого интерфейса пакет был получен или через какой его необходимо отправить и т.д. Некоторые из этих полей задаются в момент создания пакета, другие же - в процессе. Например, при отправке пакета структура sk_buff изначально содержит ссылку на сокет, а при получении пакета из сети - NULL, и это поле заполняется в процессе. Правда, некоторые поля могут оставаться пустыми на протяжении всего процесса существования, в зависимости от протокола.
Как только у ОС появляется необходимость работать с сетевым пакетом - это sk_buff. Она создается в тот момент, когда система получила пакет из сети, или когда ей необходимо сформировать новый для отправки. По этой причине в сетевом стеке практически каждая функция получает эту структуру в качестве параметра. Основное её назначение - дать простой и эффективный способ работы с пакетом на всех сетевых уровнях сетевого стека (см. рис. 1). Структура sk_buff представляет собой управляющую структуру с присоединенным блоком памяти, в котором находится пакет [2]. Таким образом, изменяя переменные в структуре, мы изменяем содержимое пакета или служебную информацию о нем.

Ниже представлена основная часть этой структуры.
struct sk_buff {
/* Первые две записи должны быть первыми. */
struct sk_buff *next;
struct sk_buff *prev;
struct sk_buff_head *list;
struct sock *sk;
struct timeval stamp;
struct net_device *dev;
struct net_device *input_dev;
struct net_device *real_dev;
/*
* Секция транспортного уровня, указывает на
* соответствующий протокол, такой как TCP, UDP, ICMP и т.д.
*/
union {
struct tcphdr *th;
struct udphdr *uh;
struct icmphdr *icmph;
struct igmphdr *igmph;
struct iphdr *ipiph;
struct ipv6hdr *ipv6h;
unsigned char *raw;
} h;
/* Секция сетевого уровня */
union {
struct iphdr *iph;
struct ipv6hdr *ipv6h;
struct arphdr *arph;
unsigned char *raw;
} nh;
/* Указатель на заголовок канального уровня, например, Ethernet. */
union {
unsigned char *raw;
} mac;
/*
* Остальная часть структуры содержит сопутствующую информацию о пакете.
* Длину, тип пакета, контрольную сумму и т.д.
*/
unsigned int len,
data_len,
mac_len,
csum;
unsigned short protocol,
security;
…
Более подробное её описание можно найти в исходных текстах ядра Linux: include/linux/skbuff.h.
Вторая структура, с которой необходимо познакомится - это sock, содержит информацию о состоянии сокета (connected, unconnected…), его тип (SOCK_STREAM, SOCK_DGRAM, SOCK_RAW), используемый протокол (TCP, UDP, IPPROTO_RAW …), размер буфера для приема и отправки пакетов, указатели на область памяти, где расположены буферы для приема и отправки пакетов, и т.д. Она создается и заполняется в момент, когда пользователь делает системный вызов socket. Ниже представлен кусок этой структуры с некоторыми комментариями.
struct sock {
/*
* Now struct tcp_tw_bucket also uses sock_common, so please just
* don't add nothing before this first member (__sk_common) --acme
*/
struct sock_common __sk_common;
#define sk_family __sk_common.skc_family /* Семейство протокола */
#define sk_state __sk_common.skc_state /* Состояние */
#define sk_reuse __sk_common.skc_reuse
#define sk_bound_dev_if __sk_common.skc_bound_dev_if
#define sk_node __sk_common.skc_node
#define sk_bind_node __sk_common.skc_bind_node
#define sk_refcnt __sk_common.skc_refcnt
unsigned char sk_shutdown : 2,
sk_no_check : 2,
sk_userlocks : 4;
unsigned char sk_protocol; /* Протокол */
unsigned short sk_type; /* Тип протокола */
int sk_rcvbuf; /* Размер буфера для приема */
socket_lock_t sk_lock;
wait_queue_head_t *sk_sleep; /* Очередь для ожидания */
struct dst_entry *sk_dst_cache;
struct xfrm_policy *sk_policy[2];
rwlock_t sk_dst_lock;
atomic_t sk_rmem_alloc;
atomic_t sk_wmem_alloc;
atomic_t sk_omem_alloc;
struct sk_buff_head sk_receive_queue; /* Очередь для входящих пакетов */
struct sk_buff_head sk_write_queue; /* Очередь для исходящих пакетов */
int sk_wmem_queued;
int sk_forward_alloc;
unsigned int sk_allocation;
int sk_sndbuf; /* Размер буфера для отправки */
int sk_route_caps;
int sk_hashent;
unsigned long sk_flags; /* Флаги :) */
unsigned long sk_lingertime;
…
Её полное описание можно найти в include/net/sock.h.
Имея в своем распоряжении эти две структуры (struct sk_buff и struct sock), можно принимать и отправлять пакеты, доставлять информацию от сетевого интерфейса к пользовательскому приложению и наоборот. Но структура sock описывает только общие вещи. Например, она не содержит ожидаемый номер последовательности TCP сегмента и многих других полезных вещей. Для этих целей служат другие структуры.
Каждый протокол имеет свою структуру для хранения необходимой ему информации. Например, для TCP эта структура называется tcp_sock, для UDP - udp_sock, продолжите сами :). Все они наследуют структуру sock, правда, не всегда напрямую, как это в случае с tcp_sock (см. рис 2). Все самое интересное о работе протокола можно найти в этих структурах - его состояние, таймауты, номер последовательности ожидаемого пакета, и много другой специфической информации. Таким образом, самый лучший способ узнать, что происходит с протокольной машиной TCP - это считывать данные из структуры tcp_sock.

Перед тем как начать поиск, необходимо выяснить, что именно мы хотим обнаружить? Как было описано выше, tcp_sock наследует структуру sock, вот почему сначала нам необходимо найти нужный сокет, а по нему мы сможем получить доступ к tcp_sock. В ОС Linux для быстрого поиска TCP и UDP сокета их хранят в специальной хеш таблице. Место их расположения определяется путем вычисления некоторого хеша на основе 4-х параметров: адрес отправителя, порт отправителя, адрес получателя и порт получателя. Ну что же, логично! После определения его места расположения сокет можно извлечь. В самом TCP протоколе операция по обнаружению и извлечению сокета описана в функции __tcp_v4_lookup_established, которая находится в файле net/ipv4/tcp_ipv4.c. Вот её исходный код:
486 static inline struct sock *__tcp_v4_lookup_established(u32 saddr, u16 sport,
487 u32 daddr, u16 hnum,
488 int dif)
489 {
490 struct tcp_ehash_bucket *head;
491 TCP_V4_ADDR_COOKIE(acookie, saddr, daddr)
492 __u32 ports = TCP_COMBINED_PORTS(sport, hnum);
493 struct sock *sk;
494 struct hlist_node *node;
495 /* Optimize here for direct hit, only listening connections can
496 * have wildcards anyways.
497 */
498 int hash = tcp_hashfn(daddr, hnum, saddr, sport);
499 head = &tcp_ehash[hash];
500 read_lock(&head->lock);
501 sk_for_each(sk, node, &head->chain) {
502 if (TCP_IPV4_MATCH(sk, acookie, saddr, daddr, ports, dif))
503 goto hit; /* You sunk my battleship! */
504 }
505
506 /* Must check for a TIME_WAIT'er before going to listener hash. */
507 sk_for_each(sk, node, &(head + tcp_ehash_size)->chain) {
508 if (TCP_IPV4_TW_MATCH(sk, acookie, saddr, daddr, ports, dif))
509 goto hit;
510 }
511 sk = NULL;
512 out:
513 read_unlock(&head->lock);
514 return sk;
515 hit:
516 sock_hold(sk);
517 goto out;
518 }
Высчитывание хеша производится в 498 строке путем вызова функции tcp_hashfn с 4-мя параметрами: локальный адрес, локальный порт, удаленный адрес, удаленный порт.
498 int hash = tcp_hashfn(daddr, hnum, saddr, sport);
А затем извлекается сам сокет:
499 head = &tcp_ehash[hash];
500 read_lock(&head->lock);
501 sk_for_each(sk, node, &head->chain) {
502 if (TCP_IPV4_MATCH(sk, acookie, saddr, daddr, ports, dif))
503 goto hit; /* You sunk my battleship! */
504 }
Функция, которая производит расчет хеша, называется tcp_hashfn, начиная с версии ядра 2.6.18 она стала называться inet_ehashfn() и её перенесли в заголовочный файл, так что теперь нет нужды описывать её в своем коде. Но мы пишем код под 2.6.12, так что придется нам посмотреть, где и как описывается эта функция. Исходный код её так же можно подсмотреть в net/ipv4/tcp_ipv4.c.
107 static __inline__ int tcp_hashfn(__u32 laddr, __u16 lport,
108 __u32 faddr, __u16 fport)
109 {
110 int h = (laddr ^ lport) ^ (faddr ^ fport);
111 h ^= h >> 16;
112 h ^= h >> 8;
113 return h & (tcp_ehash_size - 1);
114 }
Тут стоит сделать одно важное замечание - локальный порт передается в обычном виде (LittleEndian), а порт назначения в сетевом порядке байт (BigEndian), т.е. необходимо выполнить htons(dest). После извлечения сокета доступ к структуре tcp_sock производится очень просто:
struct tcp_sock *tp; tp=(struct tcp_sock *)sk;
Ну а дальше, что вашей душе угодно.
В приложении можно найти исходный код для ядра 2.6.12 и 2.6.18. Используя NetFilter[4], модуль перехватывает все входящие пакеты и анализирует их. Если этот пакет относится к искомому соединению, тогда мы вычисляем хеш, получаем указатель на сокет, извлекаем его, и через него получаем доступ к tcp_sock. Если не понятно, читайте все сначала :).
Помимо различий в самой ветке 2.6., существует несколько отличий при подключении к TCP стеку в 2.4. Но все отличие заключается в другом названии структур и немного отличный способ извлечения сокета. Принцип остается тем же.
Автор выражает благодарность за помощь при подготовке данной статьи Игумнову А.С. и Шарфу С.В. из ИММ УрО РАН.
| Предыдущая статья | Оглавление | Следующая статья |