Как не быть программистом, раскурить eBPF за сутки и начать мониторить DNS

от автора

Представим: сервер может отправлять легитимные запросы, но IP, на которые он будет их слать, неизвестны. В журнале сетевого фильтра видно что запросы таки да, идут. Но не ясно — это как раз легитимные или информация уже утекает к злоумышленникам? Было бы проще если бы был известен домен на который сервер посылает данные. Увы, но PTR не в моде, а securitytrails показывает или ничего, или слишком много по этому IP.

Можно запустить tcpdump. Но кто захочет постоянно смотреть в монитор? А если сервер не один? Есть packetbeat. Это чудовище, которое выжрало процессор на всех серверах. Брр… Не хочу о нём вспоминать. Osquery — неплохой инструмент который многое знает о сетевых подключениях и ничего — о DNS-запросах. Соответствующее предложение было просто закрыто. Zeek — о нём я узнал когда начал искать как отслеживать DNS-запросы. Похоже он неплох, но меня смутило два момента: он следит не только за DNS, а значит ресурсы будут тратиться на работу результат которой мне не нужен (хотя, возможно, в настройках можно выбрать протоколы); а ещё он ничего не знает о том какой процесс послал запрос.

Неужели это всё? Я вроде бы что-то слышал про eBPF…

Я не буду рассказывать здесь про то что такое eBPF. Теории по этому вопросу достаточно. Например вот отличный цикл статей (спасибо, @aspsk, твои статьи полезные и интересные!). Чего, на мой взгляд, хотелось бы больше (особенно на русском языке) — так это практики. Поэтому в этой статье я опишу процесс создания реального приложения с самого начала, постепенно обогащая функционал и сопровождая всё это пояснениями, комментариями и ссылками на исходники. А иногда — немного отходя в сторону, потому что хочется дать чуть больше примеров, а не просто решение конкретной задачи. Как итог — надеюсь желающие познакомиться с eBPF затратят меньше времени на поиск полезных материалов и быстрее приступят к программированию.

Что не так с packetbeat?

Я не смог удержаться и не поделиться наболевшим 🙂 В документации сказано: чем больше размер кольцевого буфера, тем меньше системных вызовов должно быть, а следовательно — меньше ресурса ЦП. Ну увеличил я ему память. Посмотрел на графики — да, памяти больше стало потребляться. Процессора… тоже больше.

А вот так стали выглядеть сервера когда я задолбался и снёс packetbeat:

Знакомство

Писать будем на Python и начнём с простейшего — поймём как вообще происходит взаимодействие Python и eBPF. В этом нам поможет пример взятый здесь (жаль что автор так и не написал следующую статью, должно было быть интересно). Вначале поставим эти пакеты:

python3-bpfcc bpfcc-tools libbpfcc linux-headers-$(uname -r)

Это для Debian. Но если вы собрались лезть в ядро, то найти нужные пакеты под ваш дистрибутив не должно быть для вас проблемой =). А теперь приступим:

#!/usr/bin/env python3  from bcc import BPF  BPF_PROGRAM = r""" int hello(void *ctx) {   bpf_trace_printk("Hello world! clone() is calling\n");   return 0; } """   bpf = BPF(text=BPF_PROGRAM) bpf.attach_kprobe(event=bpf.get_syscall_fnname("clone"), fn_name="hello")  while True:     try:         (_, _, _, _, _, msg_b) = bpf.trace_fields()         msg = msg_b.decode('utf8')         if 'Hello world' in msg:             print(msg)     except ValueError:         continue     except KeyboardInterrupt:         break

Как и положено всем приветмирным примерам, он не делает ничего полезного, но знакомит нас с базой. eBPF-программы могут вызываться на разные события происходящие в ядре. attach_kprobe() означает срабатывание когда вызывается определённая функция ядра. Но нам привычнее иметь дело с системными вызовами, кто знает имена соответствующих функций? Поэтому, для преобразования имени системного вызова в функцию ядра, используется вспомогательная функция get_syscall_fnname().

Самый простой вариант вывода в eBPF — это функция bpf_trace_printk(). Но это — вывод для отладки. Всё что вы передадите в эту функцию — будет доступно через файл /sys/kernel/debug/tracing/trace_pipe. И чтобы не читать в соседней консоли этот файл — мы используем функцию trace_fields(), которая сама читает этот файл и делает его содержимое доступным нам в программе.

Остальная часть должна быть понятна — в бесконечном цикле, который прерывается по нажатию Ctrl-C, мы читаем отладочный вывод и если в строке встречается «Hello world» — выводим её целиком.

NB: bpf_trace_printk() умеет форматировать текст, подобно printf(), но с важными ограничениями — не более 3-х аргументов и среди них всего один %s.

Теперь, поняв как вообще происходит работа с eBPF, давайте начнём строить настоящее приложение. Оно будет мониторить все запросы и ответы DNS и писать в журнал кто что спрашивал и какой ответ получил.

Первый шаг

Начнём с eBPF. Самый простой способ работать с пакетами — прицепится к сетевому сокету. В таком случае наша программа будет срабатывать на каждый пакет. Как именно это делается — покажу позже, а пока — нам нужно среди всех пакетов поймать UDP с портом 53. И для этого нам придётся самим разобрать структуру пакета и разделить все вложенные протоколы. На C. Начиная с Ethernet. В этом нам поможет макрос cursor_advance, который перемещает курсор (указатель) по пакету, возвращая его текущее положение и сдвигая на указанную величину (неплохо про этот макрос написано на StackOverflow):

#include <linux/if_ether.h> #include <linux/in.h> #include <bcc/proto.h>  int dns_matching(struct __sk_buff *skb) {     u8 *cursor = 0;      // Проверяем протокол IP:     struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));      if (ethernet->type == ETH_P_IP) {         …

Структура ethernet_t описана в файле proto.h:

struct ethernet_t {   unsigned long long  dst:48;   unsigned long long  src:48;   unsigned int        type:16; }

Сам формат Ethernet-кадра довольно простой – это 6 байт (48 бит) назначения, столько же источника, а затем два байта (16 бит) типа содержимого.

Тип содержимого и кодируется константой ETH_P_IP, которая равна 0x0800 и определена в файле if_ether.h — она позволяет убедиться что протоколом следующего уровня является IP (этот код, а так же другие возможные значения, описан у IEEE).

Идём дальше. Теперь проверим что в IP вложен UDP с портом 53:

        // Проверяем протокол UDP:         struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));          if (ip->nextp == IPPROTO_UDP) {             // Проверяем порт 53:             struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));              if (udp->dport == 53) {                 // Запрос                 return -1;             }              if (udp->sport == 53) {                 // Ответ                 return -1;             }         }

ip_t и udp_t определены всё в том же proto.h. А вот IPPROTO_UDP уже из файла in.h. Вообще этот пример не совсем корректный. Структура IP уже чуть сложнее — в  ней есть необязательное поле из-за чего длина заголовка может варьироваться. Было бы правильным вначале из заголовка получить значение его длины, а уже потом выполнять смещение, но мы же только приступили — не будем сходу усложнять.

DNS-пакет мы нашли и это оказалось не сложно. Теперь нужно разобрать его структуру. Чтобы сделать это легче — передадим пакет в пространство пользователя (за это отвечает return -1 — код возврата 0 означал бы что пакет копировать не надо).

Вернёмся к Python. Во-первых всё же прицепим нашу программу на сокет:

#!/usr/bin/env python3  import dnslib import sys  from bcc import BPF  bpf = BPF(text=BPF_PROGRAM) function_dns_matching = bpf.load_func("dns_matching", BPF.SOCKET_FILTER) BPF.attach_raw_socket(function_dns_matching, '')

Такое отличие от прошлого примера и связано тем, что теперь наша программа будет вызываться не при вызове какой-либо функции, а на каждый пакет. Пустой аргумент в attach_raw_socket означает «все сетевые интерфейсы». Если бы нам нужен был какой-то конкретный — там должно быть его имя.

Переведём сокет в блокирующий режим:

import fcntl import os  socket_fd = function_dns_matching.sock fl = fcntl.fcntl(socket_fd, fcntl.F_GETFL) fcntl.fcntl(socket_fd, fcntl.F_SETFL, fl & ~os.O_NONBLOCK)

Остальная часть незамысловата — используем аналогичный бесконечный цикл, в нём читаем данные из сокета, отсекаем все заголовки, добираясь непосредственно до DNS-пакета и декодируем его.

Полный текст второго примера
#!/usr/bin/env python3  import dnslib import fcntl import os import sys  from bcc import BPF   BPF_PROGRAM = r''' #include <linux/if_ether.h> #include <linux/in.h> #include <bcc/proto.h>  int dns_matching(struct __sk_buff *skb) {     u8 *cursor = 0;      // Проверяем протокол IP:     struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));      if (ethernet->type == ETH_P_IP) {         // Проверяем протокол UDP:         struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));          if (ip->nextp == IPPROTO_UDP) {             // Проверяем порт 53:             struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));              if (udp->dport == 53 || udp->sport == 53) {                 return -1;             }         }     }     return 0; } '''   bpf = BPF(text=BPF_PROGRAM) function_dns_matching = bpf.load_func("dns_matching", BPF.SOCKET_FILTER) BPF.attach_raw_socket(function_dns_matching, '')  socket_fd = function_dns_matching.sock fl = fcntl.fcntl(socket_fd, fcntl.F_GETFL) fcntl.fcntl(socket_fd, fcntl.F_SETFL, fl & ~os.O_NONBLOCK)  while True:     try:         packet_str = os.read(socket_fd, 2048)     except KeyboardInterrupt:         sys.exit(0)      packet_bytearray = bytearray(packet_str)      ETH_HLEN = 14     UDP_HLEN = 8      # Длина заголовка IP     ip_header_length = packet_bytearray[ETH_HLEN]     ip_header_length = ip_header_length & 0x0F     ip_header_length = ip_header_length << 2      # Начало DNS-пакета     payload_offset = ETH_HLEN + ip_header_length + UDP_HLEN      payload = packet_bytearray[payload_offset:]      dnsrec = dnslib.DNSRecord.parse(payload)      # Если это ответ:     if dnsrec.rr:         print(f'ОТВЕТ: {dnsrec.rr[0].rname} {dnslib.QTYPE.get(dnsrec.rr[0].rtype)} {", ".join([repr(dnsrec.rr[i].rdata) for i in range(0, len(dnsrec.rr))])}')     # Если запрос:     else:         print(f'ЗАПРОС: {dnsrec.questions[0].qname} {dnslib.QTYPE.get(dnsrec.questions[0].qtype)}')

Этот пример (его оригинал находится тут) покажет какие DNS-запросы/ответы проходят через ваш сетевой интерфейс, но мы таким образом не узнаем какой процесс с ними работает. Т. е. как раз та информация, из-за отсутствия которой я не выбрал Zeek.

Всё-таки теория

Для получения информации о процессе в eBPF используются следующие функции — bpf_get_current_pid_tgid(), bpf_get_current_uid_gid(), bpf_get_current_comm(char *buf, int size_of_buf). Они доступны когда мы привязываем нашу программу к вызову какой-то функции ядра (как в первом примере). С UID/GID должно быть понятно. А вот первая требует пояснения для тех кто раньше не сталкивался с такими подробностями работы ядра. Дело в том, что то, что в ядре видится как PID — в пространстве пользователя отображается как идентификатор нити процесса. А то что ядро считает thread group ID — в пространстве пользователя является PID’ом. Схожим образом и с bpf_get_current_comm() — она возвращает не привычное имя процесса, которое можно увидеть через ps, а имя нити.

Хорошо, данные о процессе мы получим. Как нам их передать в пространство пользователя? Для этого используются таблицы. Создаются они как BPF_PERF_OUTPUT(event), передаются методом event.perf_submit(ctx, data, data_size), а принимаются путём опроса через b.perf_buffer_poll(). После чего, как только данные будут доступны, вызовется функция callback(), таким образом: b["event"].open_perf_buffer(callback).

Всё это детально я распишу ниже, а пока давайте продолжим теорию и поразмышляем вот над чем. Передать сам пакет мы сможем так же как и данные. Но для этого нам нужно выделить в структуре с передаваемыми данными переменную определённой длины. Какой? Быстрый и неправильный ответ — 512 байт. Но он не учитывает EDNS, а ещё хотелось бы отслеживать (корректно!) DNS-пакеты ходящие через TCP. Так что нам пришлось бы выделять большой объём «про запас», отбрасывать пакеты которые всё же окажутся крупнее и большую часть времени у нас будет выделено памяти больше чем фактически надо. Мне такой подход не нравится. К счастью есть ещё один метод — perf_submit_skb(). Помимо данных — он передаёт так же указанное количество байт пакета из буфера. Но есть нюанс — метод доступен только для сетевых программ eBPF — сокет, XDP. Т. е. тех, где мы не можем получить информацию о процессе =)

К счастью мы можем использовать несколько eBPF-программ и даже обмениваться между ними данными! И это тоже происходит через таблицы. Объявляются они так:

BPF_TABLE_PUBLIC("hash", key, val, name, max_elements);

Это чтобы сделать её доступной для других eBPF-программ. А чтобы к ней обратиться, в другой программе пишем так:

BPF_TABLE("extern", key, val, name, max_elements);

Чтобы нам не потерять наш пакет среди остальных — достаточно 5 уникальных параметров: протокол, адрес источника, порт источника, адрес получателя, порт получателя. Поэтому ключом будет следующая структура:

struct port_key {     u8 proto;     u32 saddr;     u32 daddr;     u16 sport;     u16 dport; };

А значением — всё то, что мы хотим узнать о процессе:

struct port_val {     u32 ifindex;     u32 pid;     u32 tgid;     u32 uid;     u32 gid;     char comm[64]; };

ifindex — это сетевое устройство. Заполнять это значение мы будем в другой программе, работающей на сокете. А здесь используем чтобы в дальнейшем целиком передать всю эту структуру в пространство пользователя.

Итого: при вызове функции ядра для отправки пакета — мы сохраняем информацию о том какой процесс в этом замешан. А когда на сетевом интерфейсе появляется какой-либо пакет (причём не важно — исходящий или входящий) мы проверяем — есть ли у нас какая-то информация для пакетов ходящих между этими адресатами по такому-то протоколу. Если она есть — вместе с самим пакетом мы передаём её в Python, где уже и делаем оставшуюся работу.

Что ж, основную логику будущей программы проговорили — давайте уже программировать!

А кто это сделал?!

Начнём с того что получим информацию о процессе. Для отправки пакетов используются функции udp_sendmsg() и tcp_sendmsg(). Обе в качестве первого аргумента принимают структуру sock которая нам нужна. В eBPF получить доступ к аргументам исследуемой функции можно двумя способами: указать их как параметры нашей функции либо воспользоваться макросом PT_REGS_PARMx, где x — номер аргумента. Ниже я покажу оба этих варианта. И вот наша первая программа, BPF_KPROBE_TEXT:

// Структура которая будет использоваться в качестве ключа для // eBPF-таблицы 'proc_ports': struct port_key {     u8 proto;     u32 saddr;     u32 daddr;     u16 sport;     u16 dport; };  // Структура которая будет храниться в eBPF-таблице 'proc_ports', // содержит информацию о процессе: struct port_val {     u32 ifindex;     u32 pid;     u32 tgid;     u32 uid;     u32 gid;     char comm[64]; };  // Публичная (доступная из других eBPF-программ) eBPF-таблица // в которую записывается информация о процессе. // Читается при появлении пакета на сокете: BPF_TABLE_PUBLIC("hash", struct port_key, struct port_val, proc_ports, 20480);   // Это два способа получения доступа к аргументам функции: //int trace_udp_sendmsg(struct pt_regs *ctx) { //    struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx); int trace_udp_sendmsg(struct pt_regs *ctx, struct sock *sk) {     u16 sport = sk->sk_num;     u16 dport = sk->sk_dport;        // Обрабатываем только пакеты на порту 53.     // 13568 = ntohs(53);     if (sport == 13568 || dport == 13568) {         // Подготавливаем данные:         u32 saddr = sk->sk_rcv_saddr;         u32 daddr = sk->sk_daddr;         u64 pid_tgid = bpf_get_current_pid_tgid();         u64 uid_gid  = bpf_get_current_uid_gid();          // Формируем структуру-ключ.         // Эти странные преобразования будут объяснены ниже.         struct port_key key = {.proto = 17};         key.saddr = htonl(saddr);         key.daddr = htonl(daddr);         key.sport = sport;         key.dport = htons(dport);          // Формируем структуру со свойствами процесса:         struct port_val val = {};         val.pid = pid_tgid >> 32;         val.tgid = (u32)pid_tgid;         val.uid = (u32)uid_gid;         val.gid = uid_gid >> 32;         bpf_get_current_comm(val.comm, 64);          // Записываем значение в таблицу eBPF:         proc_ports.update(&key, &val);     }     return 0; }

Работа с tcp_sendmsg будет абсолютной такой же. Единственное отличие — в структуре port_key поле proto будет равно 6. Два этих значения (17 и 6) являются кодами протоколов UDP и TCP соответственно. Эти значения можно посмотреть в файле /etc/protocols.

Обе функции bpf_get_current_* возвращают 64 бита, поэтому чтобы извлечь данные — мы берём из них отдельно нижние и верхние 32 бита. Причём для PID/TGID мы сразу берём их в привычном для нас виде (т. е. в поле pid записываем верхние 32 бита, которые содержат то, что ядро считает TGID).

Теперь на счёт преобразований при формировании структуры-ключа. Аналогичную структуру мы будем формировать в программе в следующем разделе. Вот только будем брать данные не из ядерной структуры sock, а из eBPF’ной __sk_buff, а в ней данные хранятся именно в таком виде:

__u32 remote_ip4;/* Stored in network byte order */ __u32 local_ip4;/* Stored in network byte order */ __u32 remote_port;/* Stored in network byte order */ __u32 local_port;/* stored in host byte order */

Ловим пакеты

Вторая наша программа, BPF_SOCK_TEXT, которая будет «висеть» на сокете, для каждого пакета будет проверять наличие информации о соответствующем процессе и передавать её, вместе с самим пакетом, в пользовательское пространство:

// Структура которая будет использоваться в качестве ключа для // eBPF-таблицы 'proc_ports': struct port_key {     u8 proto;     u32 saddr;     u32 daddr;     u16 sport;     u16 dport; };  // Структура которая будет храниться в eBPF-таблице 'proc_ports', // содержит информацию о процессе: struct port_val {     u32 ifindex;     u32 pid;     u32 tgid;     u32 uid;     u32 gid;     char comm[64]; };  // eBPF-таблица из которой извлекается информация о процессе. // Наполняется при вызове функций ядра udp_sendmsg()/tcp_sendmsg(): BPF_TABLE("extern", struct port_key, struct port_val, proc_ports, 20480);  // Таблица для передачи данных в пользовательское пространство: BPF_PERF_OUTPUT(dns_events);   // Среди проходящих через сокет данных ищем DNS-пакеты // и проверяем наличие информации о процессе: int dns_matching(struct __sk_buff *skb) {     u8 *cursor = 0;      // Проверяем протокол IP:     struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));      if (ethernet->type == ETH_P_IP) {         struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));          u8 proto;         u16 sport;         u16 dport;          // Проверяем протокол транспортного уровня:         if (ip->nextp == IPPROTO_UDP) {             struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));              proto = 17;              // Получаем данные о портах:             sport = udp->sport;             dport = udp->dport;         } else if (ip->nextp == IPPROTO_TCP) {             struct tcp_t *tcp = cursor_advance(cursor, sizeof(*tcp));              // Нам не нужны пакеты где не передаются данные:             if (!tcp->flag_psh) {                 return 0;             }              proto = 6;              // Получаем данные о портах:             sport = tcp->src_port;             dport = tcp->dst_port;         } else {             return 0;         }          // Если это DNS-запрос:         if (dport == 53 || sport == 53) {             // Формируем структуру-ключ:             struct port_key key = {};             key.proto = proto;             if (skb->ingress_ifindex == 0) {                 key.saddr = ip->src;                 key.daddr = ip->dst;                 key.sport = sport;                 key.dport = dport;             } else {                 key.saddr = ip->dst;                 key.daddr = ip->src;                 key.sport = dport;                 key.dport = sport;             }              // По ключу ищем значение в таблице eBPF:             struct port_val *p_val;             p_val = proc_ports.lookup(&key);              // Если значение не найдено - значит у нас нет информации о             // процессе и дальше продлжать нет смысла:             if (!p_val) {                 return 0;             }              // Индекс сетевого устройства:             p_val->ifindex = skb->ifindex;              // Передаём структуру с информацией о процессе вместе с             // skb->len байтами отправленными в сокет:             dns_events.perf_submit_skb(skb, skb->len, p_val,                                        sizeof(struct port_val));             return 0;         } //dport == 53 || sport == 53     } //ethernet->type == ETH_P_IP      return 0; }

Начинается программа так же, как и один из первых рассмотренных примеров. Мы смещаемся по пакету и собираем информацию с протоколов разных уровней. По прежнему в силе замечание о том что такой подход не учитывает фактическую длину заголовка IP. Но добавилось и кое-что новое — для TCP-пакетов мы проверяем флаг — нам не нужны пакеты которые не несут в себе данные (SYN, ACK и т. п.).

А вот дальше нам нужно восстановить ключ чтобы получить данные из таблицы proc_ports. При этом мы должны различать направление трафика — ведь когда мы заносили в таблицу данные, мы подразумевали что источником являемся мы. Но для входящих пакетов источником будет удалённый сервер. Чтобы понять направление движения пакетов — я использовал поле ingress_ifindex, которое для исходящего трафика равно 0.

Обрабатываем данные

От Python’а нам нужно три вещи: загрузить в ядро наши программы, получить от них данные и обработать их.

Первые две задачи простенькие. Тем более оба метода работы с eBPF мы уже рассмотрели в первых примерах:

# Инициализация BPF: bpf_kprobe = BPF(text=BPF_KPROBE_TEXT) bpf_sock = BPF(text=BPF_SOCK_TEXT)  # Отправка UDP: bpf_kprobe.attach_kprobe(event="udp_sendmsg", fn_name="trace_udp_sendmsg")  # Отправка TCP: bpf_kprobe.attach_kprobe(event="tcp_sendmsg", fn_name="trace_tcp_sendmsg")  # Сокет: function_dns_matching = bpf_sock.load_func("dns_matching", BPF.SOCKET_FILTER) BPF.attach_raw_socket(function_dns_matching, '')

Получение данных ещё короче:

bpf_sock["dns_events"].open_perf_buffer(print_dns)  while True:     try:         bpf_sock.perf_buffer_poll()     except KeyboardInterrupt:         exit()

А вот обработка данных будет более громоздкой. Не смотря на наличие готовых модулей — я решил сам разобрать заголовки протоколов. Во-первых я хотел сам разобраться как это происходит (и наконец-то правильно обработать длину заголовка IP пакета, хотя в данном случае это и бессмысленно, ведь пакеты с дополнительными опциями в заголовке отбросятся ещё в eBPF), а во-вторых — уменьшить зависимость от модулей. Правда для разбора непосредственно DNS я всё же (пока что) использую модуль — структура DNS чуть сложнее, чем у IP/TCP. Ещё один модуль (ctypes) нужен для работы с C-шными типами данных:

def print_dns(cpu, data, size):     import ctypes as ct     class SkbEvent(ct.Structure):         _fields_ =  [             ("ifindex", ct.c_uint32),             ("pid", ct.c_uint32),             ("tgid", ct.c_uint32),             ("uid", ct.c_uint32),             ("gid", ct.c_uint32),             ("comm", ct.c_char * 64),             ("raw", ct.c_ubyte * (size - ct.sizeof(ct.c_uint32 * 5) - ct.sizeof(ct.c_char * 64)))         ]     # Получам нашу структуру 'port_val', а так же сам пакет в поле 'raw':     sk = ct.cast(data, ct.POINTER(SkbEvent)).contents      # Протоколы:     NET_PROTO = {6: "TCP", 17: "UDP"}      # eBPF оперирует именами нитей.     # Иногда они совпадают с именами процессов, но зачастую - нет.     # Поэтому попробуем получить имя процесса по его PID'у:     try:         with open(f'/proc/{sk.pid}/comm', 'r') as proc_comm:             proc_name = proc_comm.read().rstrip()     except:         proc_name = sk.comm.decode()      # Получаем имя сетевого интерфейса по индексу:     ifname = if_indextoname(sk.ifindex)      # Длина заголовка Ethernet-кадра - 14 байт:     ip_packet = bytes(sk.raw[14:])      # Длина заголовка IP-пакета не фиксированна из-за произвольного     # количества параметров.     # Из всего возможного заголовка IP нас интересуют только 20 байт:     (length, _, _, _, _, proto, _, saddr, daddr) = unpack('!BBHLBBHLL', ip_packet[:20])     # Непосредственно длина записана во второй половины первого байта (0b00001111 = 15):     len_iph = length & 15     # Длина записывается в 32-битных словах, переводим их в байты:     len_iph = len_iph * 4     # Преобразовываем адреса из чисел в IP, собирая его по октетам:     saddr = ".".join(map(str, [saddr >> 24 & 0xff, saddr >> 16 & 0xff, saddr >> 8 & 0xff, saddr & 0xff]))     daddr = ".".join(map(str, [daddr >> 24 & 0xff, daddr >> 16 & 0xff, daddr >> 8 & 0xff, daddr & 0xff]))      # Если протокол транспортного уровня - UDP:     if proto == 17:         udp_packet = ip_packet[len_iph:]         (sport, dport) = unpack('!HH', udp_packet[:4])         # Длина заголовка UDP-датаграммы - 8 байт:         dns_packet = udp_packet[8:]     # Если протокол транспортного уровня - TCP:     elif proto == 6:         tcp_packet = ip_packet[len_iph:]         # Длина заголовка TCP-пакета тоже не фиксирована из-за необязательности         # опций. Из всего заголовка TCP нас интересуют только данные по 13-й         # байт (длину заголовка):         (sport, dport, _, length) = unpack('!HHQB', tcp_packet[:13])         # Непосредственно длина записана в первой половине (4-х битах):         len_tcph = length >> 4         # Длина записывается в 32-битных словах, переводим в байты:         len_tcph = len_tcph * 4         # Самая загадочная часть.         # Непонятно где я ошибся и зачем нужно смещение на 2 байта,         # но оно необходимо, т. к. DNS-пакет начинается только после него:         dns_packet = tcp_packet[len_tcph + 2:]     # Прочие протоколы не обрабатываем:     else:         return      # Декодирование DNS-данных:     dns_data = dnslib.DNSRecord.parse(dns_packet)      # Типы ресурсных записей:     DNS_QTYPE = {1: "A", 28: "AAAA"}      # Запрос:     if dns_data.header.qr == 0:         # Нас интересуют только A (1) и AAAA (28) записи:         for q in dns_data.questions:             if q.qtype == 1 or q.qtype == 28:                 print(f'COMM={proc_name} PID={sk.pid} TGID={sk.tgid} DEV={ifname} PROTO={NET_PROTO[proto]} SRC={saddr} DST={daddr} SPT={sport} DPT={dport} UID={sk.uid} GID={sk.gid} DNS_QR=0 DNS_NAME={q.qname} DNS_TYPE={DNS_QTYPE[q.qtype]}')     # Ответ:     elif dns_data.header.qr == 1:         # Нас интересуют только A (1) и AAAA (28) записи:         for rr in dns_data.rr:             if rr.rtype == 1 or rr.rtype == 28:                 print(f'COMM={proc_name} PID={sk.pid} TGID={sk.tgid} DEV={ifname} PROTO={NET_PROTO[proto]} SRC={saddr} DST={daddr} SPT={sport} DPT={dport} UID={sk.uid} GID={sk.gid} DNS_QR=1 DNS_NAME={rr.rname} DNS_TYPE={DNS_QTYPE[rr.rtype]} DNS_DATA={rr.rdata}')     else:         print('Неверный тип запроса DNS.')

Финал

Запустим приложение и выполним в соседней консоли запрос:

$ dig @dns3.p08.nsone.net. api.github.com +tcp

Вывод программы:

$ sudo python ~/eBPF-habr.py Программа запущена. Нажмите Ctrl-C для прерывания. COMM=dig PID=1030124 TGID=1030124 DEV=tap0 PROTO=UDP SRC=10.x.x.x DST=10.y.y.y SPT=52383 DPT=53 UID=1000 GID=1000 DNS_QR=0 DNS_NAME=dns3.p08.nsone.net. DNS_TYPE=A COMM=dig PID=1030124 TGID=1030124 DEV=tap0 PROTO=UDP SRC=10.x.x.x DST=10.y.y.y SPT=52383 DPT=53 UID=1000 GID=1000 DNS_QR=0 DNS_NAME=dns3.p08.nsone.net. DNS_TYPE=AAAA COMM=dig PID=1030124 TGID=1030124 DEV=tap0 PROTO=UDP SRC=10.y.y.y DST=10.x.x.x SPT=53 DPT=52383 UID=1000 GID=1000 DNS_QR=1 DNS_NAME=dns3.p08.nsone.net. DNS_TYPE=A DNS_DATA=198.51.44.72 COMM=dig PID=1030124 TGID=1030124 DEV=tap0 PROTO=UDP SRC=10.y.y.y DST=10.x.x.x SPT=53 DPT=52383 UID=1000 GID=1000 DNS_QR=1 DNS_NAME=dns3.p08.nsone.net. DNS_TYPE=AAAA DNS_DATA=2620:4d:4000:6259:7:8:0:3 COMM=dig PID=1030124 TGID=1030125 DEV=wlp1s0 PROTO=TCP SRC=192.x.x.x DST=198.51.44.72 SPT=38085 DPT=53 UID=1000 GID=1000 DNS_QR=0 DNS_NAME=api.github.com. DNS_TYPE=A COMM=dig PID=1030124 TGID=1030125 DEV=wlp1s0 PROTO=TCP SRC=198.51.44.72 DST=192.x.x.x SPT=53 DPT=38085 UID=1000 GID=1000 DNS_QR=1 DNS_NAME=api.github.com. DNS_TYPE=A DNS_DATA=140.82.121.6

Вот мы и создали полезное приложение которое показывает все DNS-запросы в нашей системе. Надеюсь мои объяснения были достаточно подробными и, если вы заинтересовались написанием eBPF-программ, вам будет проще начать. Лично мне это приложение уже помогло лучше понять что происходит на серверах. Ниже я размещаю его полный код.

Можно ли сделать ещё лучше? Конечно! Во-первых стоит добавить поддержку IPv6. Во-вторых — наконец перестать полагаться на фиксированную длину заголовка IP и нормально его разобрать. Я не зря отказался от использования библиотеки в Python’е для работы с пакетами — в C всё равно придётся делать это вручную. В-третьих — хорошо бы полностью переписать код на C, отказавшись от Python’а. Это приведёт к четвёртому пункту — ручной анализ DNS-пакета. И наконец самый заманчивый пункт — перестать смотреть на порты, а попробовать анализировать каждый пакет и искать среди них те, которые подходят под формат DNS. Это позволит нам засекать пакеты даже на нестандартных портах.

Итоговый код:
#!/usr/bin/env python3  from bcc import BPF from socket import if_indextoname from struct import unpack   BPF_KPROBE_TEXT = """ #include <net/sock.h>  // Структура которая будет использоваться в качестве ключа для // eBPF-таблицы 'proc_ports': struct port_key {     u8 proto;     u32 saddr;     u32 daddr;     u16 sport;     u16 dport; };  // Структура которая будет храниться в eBPF-таблице 'proc_ports', // содержит информацию о процессе: struct port_val {     u32 ifindex;     u32 pid;     u32 tgid;     u32 uid;     u32 gid;     char comm[64]; };  // Публичная (доступная из других eBPF-программ) eBPF-таблица // в которую записывается информация о процессе. // Читается при появлении пакета на сокете: BPF_TABLE_PUBLIC("hash", struct port_key, struct port_val, proc_ports, 20480);   int trace_udp_sendmsg(struct pt_regs *ctx) {     struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);      u16 sport = sk->sk_num;     u16 dport = sk->sk_dport;        // Обрабатываем только пакеты на порту 53.     // 13568 = ntohs(53);     if (sport == 13568 || dport == 13568) {         // Подготавливаем данные:         u32 saddr = sk->sk_rcv_saddr;         u32 daddr = sk->sk_daddr;         u64 pid_tgid = bpf_get_current_pid_tgid();         u64 uid_gid  = bpf_get_current_uid_gid();          // Формируем структуру-ключ.         struct port_key key = {.proto = 17};         key.saddr = htonl(saddr);         key.daddr = htonl(daddr);         key.sport = sport;         key.dport = htons(dport);          // Формируем структуру со свойствами сокета:         struct port_val val = {};         val.pid = pid_tgid >> 32;         val.tgid = (u32)pid_tgid;         val.uid = (u32)uid_gid;         val.gid = uid_gid >> 32;         bpf_get_current_comm(val.comm, 64);          // Записываем значение в таблицу eBPF:         proc_ports.update(&key, &val);     }     return 0; }  int trace_tcp_sendmsg(struct pt_regs *ctx, struct sock *sk) {     u16 sport = sk->sk_num;     u16 dport = sk->sk_dport;        // Обрабатываем только пакеты на порту 53.     // 13568 = ntohs(53);     if (sport == 13568 || dport == 13568) {         // Подготавливаем данные:         u32 saddr = sk->sk_rcv_saddr;         u32 daddr = sk->sk_daddr;         u64 pid_tgid = bpf_get_current_pid_tgid();         u64 uid_gid  = bpf_get_current_uid_gid();          // Формируем структуру-ключ.         struct port_key key = {.proto = 6};         key.saddr = htonl(saddr);         key.daddr = htonl(daddr);         key.sport = sport;         key.dport = htons(dport);          // Формируем структуру со свойствами сокета:         struct port_val val = {};         val.pid = pid_tgid >> 32;         val.tgid = (u32)pid_tgid;         val.uid = (u32)uid_gid;         val.gid = uid_gid >> 32;         bpf_get_current_comm(val.comm, 64);          // Записываем значение в таблицу eBPF:         proc_ports.update(&key, &val);     }     return 0; } """   BPF_SOCK_TEXT = r''' #include <net/sock.h> #include <bcc/proto.h>  // Структура которая будет использоваться в качестве ключа для // eBPF-таблицы 'proc_ports': struct port_key {     u8 proto;     u32 saddr;     u32 daddr;     u16 sport;     u16 dport; };  // Структура которая будет храниться в eBPF-таблице 'proc_ports', // содержит информацию о процессе: struct port_val {     u32 ifindex;     u32 pid;     u32 tgid;     u32 uid;     u32 gid;     char comm[64]; };  // eBPF-таблица из которой извлекается информация о процессе. // Наполняется при вызове функций ядра udp_sendmsg()/tcp_sendmsg(): BPF_TABLE("extern", struct port_key, struct port_val, proc_ports, 20480);  // Таблица для передачи данных в пользовательское пространство: BPF_PERF_OUTPUT(dns_events);   // Среди проходящих через сокет данных ищем DNS-пакеты // и проверяем наличие информации о процессе: int dns_matching(struct __sk_buff *skb) {     u8 *cursor = 0;      // Проверяем протокол IP:     struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));      if (ethernet->type == ETH_P_IP) {         struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));          u8 proto;         u16 sport;         u16 dport;          // Проверяем протокол транспортного уровня:         if (ip->nextp == IPPROTO_UDP) {             struct udp_t *udp = cursor_advance(cursor, sizeof(*udp));              proto = 17;              // Получаем данные о портах:             sport = udp->sport;             dport = udp->dport;         } else if (ip->nextp == IPPROTO_TCP) {             struct tcp_t *tcp = cursor_advance(cursor, sizeof(*tcp));              // Нам не нужны пакеты где не передаются данные:             if (!tcp->flag_psh) {                 return 0;             }              proto = 6;              // Получаем данные о портах:             sport = tcp->src_port;             dport = tcp->dst_port;         } else {             return 0;         }          // Если это DNS-запрос:         if (dport == 53 || sport == 53) {             // Формируем структуру-ключ:             struct port_key key = {};             key.proto = proto;             if (skb->ingress_ifindex == 0) {                 key.saddr = ip->src;                 key.daddr = ip->dst;                 key.sport = sport;                 key.dport = dport;             } else {                 key.saddr = ip->dst;                 key.daddr = ip->src;                 key.sport = dport;                 key.dport = sport;             }              // По ключу ищем значение в таблице eBPF:             struct port_val *p_val;             p_val = proc_ports.lookup(&key);              // Если значение не найдено - значит у нас нет информации о             // процессе и дальше продлжать нет смысла:             if (!p_val) {                 return 0;             }              // Индекс сетевого устройства:             p_val->ifindex = skb->ifindex;              // Передаём структуру с информацией процессе вместе с             // skb->len байтами отправленными в сокет:             dns_events.perf_submit_skb(skb, skb->len, p_val,                                        sizeof(struct port_val));             return 0;         } //dport == 53 || sport == 53     } //ethernet->type == ETH_P_IP      return 0; } '''   try:     import dnslib except ImportError:     print("Ошибка: требуется модуль Python dnslib.")     print("Установите его при помощи команды:")     print("\t$ pip3 install dnslib")     print("   или")     print("\t$ sudo apt-get install python3-dnslib"           "(на Ubuntu 18.04+)")     exit(1)   def print_dns(cpu, data, size):     import ctypes as ct     class SkbEvent(ct.Structure):         _fields_ =  [             ("ifindex", ct.c_uint32),             ("pid", ct.c_uint32),             ("tgid", ct.c_uint32),             ("uid", ct.c_uint32),             ("gid", ct.c_uint32),             ("comm", ct.c_char * 64),             ("raw", ct.c_ubyte * (size - ct.sizeof(ct.c_uint32 * 5) - ct.sizeof(ct.c_char * 64)))         ]     # Получам нашу структуру 'port_val', а так же сам пакет в поле 'raw':     sk = ct.cast(data, ct.POINTER(SkbEvent)).contents      # Протоколы:     NET_PROTO = {6: "TCP", 17: "UDP"}      # eBPF оперирует именами нитей.     # Иногда они совпадают с именами процессов, но зачастую - нет.     # Поэтому попробуем получить имя процесса по его PID'у:     try:         with open(f'/proc/{sk.pid}/comm', 'r') as proc_comm:             proc_name = proc_comm.read().rstrip()     except:         proc_name = sk.comm.decode()      # Получаем имя сетевого интерфейса по индексу:     ifname = if_indextoname(sk.ifindex)      # Длина заголовка Ethernet-кадра - 14 байт:     ip_packet = bytes(sk.raw[14:])      # Длина заголовка IP-пакета не фиксированна из-за произвольного     # количества параметров.     # Из всего возможного заголовка IP нас интересуют только 20 байт:     (length, _, _, _, _, proto, _, saddr, daddr) = unpack('!BBHLBBHLL', ip_packet[:20])     # Непосредственно длина записана во второй половины первого байта (0b00001111 = 15):     len_iph = length & 15     # Длина записывается в 32-битных словах, переводим в байты:     len_iph = len_iph * 4     # Преобразовываем адреса из чисел в IP:     saddr = ".".join(map(str, [saddr >> 24 & 0xff, saddr >> 16 & 0xff, saddr >> 8 & 0xff, saddr & 0xff]))     daddr = ".".join(map(str, [daddr >> 24 & 0xff, daddr >> 16 & 0xff, daddr >> 8 & 0xff, daddr & 0xff]))      # Если протокол транспортного уровня - UDP:     if proto == 17:         udp_packet = ip_packet[len_iph:]         (sport, dport) = unpack('!HH', udp_packet[:4])         # Длина заголовка UDP-датаграммы - 8 байт:         dns_packet = udp_packet[8:]     # Если протокол транспортного уровня - TCP:     elif proto == 6:         tcp_packet = ip_packet[len_iph:]         # Длина заголовка TCP-пакета тоже не фиксирована из-за необязательности опций.         # Из всего заголовка TCP нас интересуют только данные по 13-й байт         # (длину заголовка):         (sport, dport, _, length) = unpack('!HHQB', tcp_packet[:13])         # Непосредственно длина записана в первой половине (4-х битах):         len_tcph = length >> 4         # Длина записывается в 32-битных словах, переводим в байты:         len_tcph = len_tcph * 4         # Самая загадочная часть.         # Непонятно где я ошибся и зачем нужно смещение на 2 байта,         # но оно необходимо, т. к. DNS-пакет начинается только после него:         dns_packet = tcp_packet[len_tcph + 2:]     # Прочие протоколы не обрабатываем:     else:         return      # Декодирование DNS-данных:     dns_data = dnslib.DNSRecord.parse(dns_packet)      # Типы ресурсных записей:     DNS_QTYPE = {1: "A", 28: "AAAA"}      # Запрос:     if dns_data.header.qr == 0:         # Нас интересуют только A (1) и AAAA (28) записи:         for q in dns_data.questions:             if q.qtype == 1 or q.qtype == 28:                 print(f'COMM={proc_name} PID={sk.pid} TGID={sk.tgid} DEV={ifname} PROTO={NET_PROTO[proto]} SRC={saddr} DST={daddr} SPT={sport} DPT={dport} UID={sk.uid} GID={sk.gid} DNS_QR=0 DNS_NAME={q.qname} DNS_TYPE={DNS_QTYPE[q.qtype]}')     # Ответ:     elif dns_data.header.qr == 1:         # Нас интересуют только A (1) и AAAA (28) записи:         for rr in dns_data.rr:             if rr.rtype == 1 or rr.rtype == 28:                 print(f'COMM={proc_name} PID={sk.pid} TGID={sk.tgid} DEV={ifname} PROTO={NET_PROTO[proto]} SRC={saddr} DST={daddr} SPT={sport} DPT={dport} UID={sk.uid} GID={sk.gid} DNS_QR=1 DNS_NAME={rr.rname} DNS_TYPE={DNS_QTYPE[rr.rtype]} DNS_DATA={rr.rdata}')     else:         print('Неверный тип запроса DNS.')  # Инициализация BPF: bpf_kprobe = BPF(text=BPF_KPROBE_TEXT) bpf_sock = BPF(text=BPF_SOCK_TEXT)  # Отправка UDP: bpf_kprobe.attach_kprobe(event="udp_sendmsg", fn_name="trace_udp_sendmsg")  # Отправка TCP: bpf_kprobe.attach_kprobe(event="tcp_sendmsg", fn_name="trace_tcp_sendmsg")  # Сокет: function_dns_matching = bpf_sock.load_func("dns_matching", BPF.SOCKET_FILTER) BPF.attach_raw_socket(function_dns_matching, '')  print('Программа запущена. Нажмите Ctrl-C для прерывания.')  bpf_sock["dns_events"].open_perf_buffer(print_dns)  while True:     try:         bpf_sock.perf_buffer_poll()     except KeyboardInterrupt:         exit()

Полезные ссылки

Я уже упоминал в самом начале статьи цикл от @aspsk. Но кроме него есть ещё несколько очень важных ссылок:


ссылка на оригинал статьи https://habr.com/ru/post/683566/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *