Представим: сервер может отправлять легитимные запросы, но 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. Но кроме него есть ещё несколько очень важных ссылок:

Комментарии (6)


  1. iig
    01.09.2022 10:49
    +1

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

    Неправильная постановка задачи, кмк. Учиться отличать своих от злоумышленников нужно до передачи информации.


    1. MAH69IK Автор
      01.09.2022 11:03
      +1

      Тогда представим что это тестовый сервер на котором как раз и тестируется ПО (-:


  1. amarao
    01.09.2022 11:43
    +2

    Если бы мне такую задачу поставили, то я бы настроил мирроринг DNS-трафика (например, через ovs, который бы делал mirror на порт по каким-то flow-правилам - ip/порт и т.д.). По сути это было бы близко к обычному pcap'у, но обеспечивало бы большую видимость и управляемость.

    Имея уже отфильтрованный трафик для анализа его можно хоть писать целиком, хоть парсить.

    У ovs'а (с момента появления megaflow) довольно высокая производительность (20Гбит на современных машинах вполне переваривает).

    eBPF хорош с точки зрения производительности (и возможности повесить на сокет приложения), но очень плох с точки зрения observability. Что-то где-то там работает, что именно не понятно, почему - тоже не понятно.


    1. Ostapus
      01.09.2022 22:57

      я так понял - идея не только парсить трафик, но еще и знать какой процесс его генерировал.


      1. MAH69IK Автор
        02.09.2022 01:54
        +1

        Да :) Ну и сама возможность что-то новое и очень интересное изучить тоже большую роль сыграла.


      1. amarao
        02.09.2022 12:44

        Да, OVS эту информацию, кажется, не предоставляет. Я сейчас поискал - нет, там слишком сильная абстракция. Пакет ушёл в интерфейс, всё, есть только пакет.