Представим: сервер может отправлять легитимные запросы, но 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().
Комментарии (6)
amarao
01.09.2022 11:43+2Если бы мне такую задачу поставили, то я бы настроил мирроринг DNS-трафика (например, через ovs, который бы делал mirror на порт по каким-то flow-правилам - ip/порт и т.д.). По сути это было бы близко к обычному pcap'у, но обеспечивало бы большую видимость и управляемость.
Имея уже отфильтрованный трафик для анализа его можно хоть писать целиком, хоть парсить.
У ovs'а (с момента появления megaflow) довольно высокая производительность (20Гбит на современных машинах вполне переваривает).
eBPF хорош с точки зрения производительности (и возможности повесить на сокет приложения), но очень плох с точки зрения observability. Что-то где-то там работает, что именно не понятно, почему - тоже не понятно.
Ostapus
01.09.2022 22:57я так понял - идея не только парсить трафик, но еще и знать какой процесс его генерировал.
MAH69IK Автор
02.09.2022 01:54+1Да :) Ну и сама возможность что-то новое и очень интересное изучить тоже большую роль сыграла.
amarao
02.09.2022 12:44Да, OVS эту информацию, кажется, не предоставляет. Я сейчас поискал - нет, там слишком сильная абстракция. Пакет ушёл в интерфейс, всё, есть только пакет.
iig
Неправильная постановка задачи, кмк. Учиться отличать своих от злоумышленников нужно до передачи информации.
MAH69IK Автор
Тогда представим что это тестовый сервер на котором как раз и тестируется ПО (-: