Представим: сервер может отправлять легитимные запросы, но 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://github.com/iovisor/bcc/blob/master/docs/reference_guide.md - краткий справочник по eBPF, включая модуль Python'а. Каждая функция имеет ссылку на поиск для просмотра примеров в которых она используется.
https://blogs.oracle.com/linux/post/bpf-in-depth-bpf-helper-functions - функции eBPF. Удобство в том, что здесь они сгруппированы по типам программ.
https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md - множество коротких примеров на Python с пояснениями, но раздел про сеть пока отсутствует.
https://github.com/xdp-project/xdp-tutorial - уроки по XDP, разбитые по уровням сложности. Для тех кто хочет погрузиться глубже.
https://dev.to/satrobit/absolute-beginner-s-guide-to-bcc-xdp-and-ebpf-47oi - XDP для самых маленьких. Не так масштабно как предыдущая ссылка, всего один пример, но, субъективно, легче для вхождения.
"Linux Network Architecture" - если хотите разобраться в сетевых функциях ядра по книге, а не по чтению исходного кода. Хорошо описывает что происходит в udp_sendmsg() и tcp_sendmsg().
