Технология eBPF — интересная штука. С её помощью можно без труда внедрять в ядро Linux фрагменты кода, которые затем компилируются в коды операций (опкоды), которые гарантированно не обрушат работу ядра. Набор допустимых инструкций ограничен, переходы назад не допускаются (поэтому не будет никаких неопределённых циклов). При этом вы не можете разыменовывать указатели, но вместо этого можете выполнять проверяемые операции считывания через указатели, которые потенциально могут оказаться неудачными, но при этом не спровоцируют паник на всю систему. eBPF в ядре Linux можно закреплять в тысячах хуков (точек перехвата), в качестве которых могут выступать u-пробы, k-пробы, точки трассировки и даже такие штуки как отказы страниц. У eBPF есть целый спектр захватывающих возможностей, которые при этом очень активно разрабатываются. Фичи, поддерживаемые в каждой конкретной версии ядра, перечислены в виде списка по этому адресу.

Также они отлично инструментированы — для некоторых элементарных практических случаев вам просто не придётся писать никакого кода. Например, если вам захочется просмотреть все включения системного вызова mkdir, то для этого достаточно всего одной строки:

Либо другой случай: вы видите процесс, устанавливающий TLS-соединения, и вам интересно, а что он отправляет? Это тоже просто. Нужно всего лишь перехватывать в OpenSSL соответствующие функции, шифрующие данные. Такой перехват делается при помощи sslsniff.

Складывается впечатление, будто страницы Github используют HTTP/2. :) Брендан Грегг прославился многочисленными статьями об этом наборе инструментов.

Вот вам несколько статей о том, как eBPF постепенно берут на себя все фичи, которые будут так или иначе играть роль брандмауэра в будущих версиях Linux. Они заменят серверный интерфейс командыiptables, поскольку работают не только гораздо гибче, но и быстрее. Теперь можно не связываться с сериями правил, которым пакет может соответствовать, а может и не соответствовать (по меркам iptables), а написать код, который будет определять, как поступить с пакетом — принять, отбросить или отредактировать! Некоторые из этих хуков начинают срабатывать сразу после того, как входящий пакет попадает на сетевую карту и до того, как начнётся любая дальнейшая обработка. Так экономятся драгоценные такты процессора, и этот механизм работает даже на специализированном железе.

Даже в рассматриваемом здесь практическом случае есть несколько точек перехвата, к которым можно подключить вашу программу, а затем решить, что делать с пакетом. Можно задействовать современный механизм перехвата XDP (eXpress Data Path), срабатывающий сразу по прибытии пакета и ещё даже до того, как ядро выделит память, чтобы скопировать его с сетевого интерфейса. На момент подготовки оригинала этой статьи данный этот механизм работает только с входящим трафиком, и мне этого для работы недостаточно. Другой вариант — использовать точки перехвата, предусмотренные в рамках подсистемы контроля трафика (tc) Linux. Она плохо исследована, но поддерживает как входящий, так и исходящий трафик, а также позволяет сделать с пакетом много всего другого: отбросить, перенаправить на другой интерфейс, отредактировать или допустить. Это отличный набор, но на момент подготовки статьи tc не поддерживалась у меня на CentOS. Поэтому я остановился на третьем варианте: перехватах на уровне cgroup.

В настоящее время cgroups (контрольные группы) обычно используются для того, чтобы ограничивать, в какой мере набор процессов может пользоваться некоторым ресурсом, будь то такты процессора или оперативная память. Таким образом, вы можете эксплуатировать много контейнеров Docker и не волноваться, что один из них захватит все ресурсы системы. Кроме того, редактировать эти пределы можно прямо на лету. Но контрольные группы также обеспечивают удобную точку перехвата входящего и исходящего трафика, позволяя решать, допускать конкретный пакет или нет. Прикрепляем к ним функцию и возвращается1 для allow и 0 для drop. Просто! Теперь давайте начнём писать реальный код.

Прежде всего, необходимо озаботиться возможностью компилировать наши eBPF при помощи Clang. Чтобы установить требования, можно выполнить на CentOS 8 следующую команду: yum install -y clang llvm go или следующую на Ubuntu: apt install -y clang llvm golang. Также должна быть смонтирована файловая система cgroup2, которая по умолчанию имеет адрес /sys/fs/cgroup/unified. В противном случае её можно смонтировать при помощи sudo mkdir /mnt/cgroup2 && sudo mount -t cgroup2 none /mnt/cgroup2. Далее перейдём, собственно, к обсуждению кода.

Есть полезный заголовочный файл bpf_helpers.h, который можно взять из дерева исходников Linux. В нём определены многие макросы для вызова функций eBPF. Эти функции, в частности, что-то копируют из памяти ядра в память BPF или обращаются к перехваченным аргументам методов — нам это очень пригодится.

Вот как выглядит минимальный работоспособный код, позволяющий вам заблокировать все пакеты и быть уверенным, что вам не повредят нехорошие люди из Интернета:

#include <stdbool.h>
#include <linux/bpf.h>
#include <netinet/ip.h>
#include "bpf_helpers.h"

#define __section(NAME)                  \
	__attribute__((section(NAME), used))

/* Ingress hook - handle incoming packets */
__section("cgroup_skb/ingress")
int ingress(struct __sk_buff *skb) {
    return false;
}

/* Egress hook - handle outgoing packets */
__section("cgroup_skb/egress")
int egress(struct __sk_buff *skb) {
    return false;
}

char __license[] __section("license") = "GPL";

Это можно скомпилировать при помощи clang -O2 -emit-llvm -c bpf.c -o - | llc -march=bpf -filetype=obj -o bpf.o. Так мы получим файл в формате ELF для целевой архитектуры BPF.

Посмотрим, что содержится в этом файле ELF.

$ readelf -S bpf.o
There are 12 section headers, starting at offset 0x610:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .strtab           STRTAB           0000000000000000  00000568
       00000000000000a7  0000000000000000           0     0     1
  [ 2] .text             PROGBITS         0000000000000000  00000040
       0000000000000000  0000000000000000  AX       0     0     4
  [ 3] cgroup_skb/ingres PROGBITS         0000000000000000  00000040
       0000000000000158  0000000000000000  AX       0     0     8
  [ 4] .relcgroup_skb/in REL              0000000000000000  000004e8
       0000000000000030  0000000000000010          11     3     8
  [ 5] cgroup_skb/egress PROGBITS         0000000000000000  00000198
       0000000000000158  0000000000000000  AX       0     0     8
  [ 6] .relcgroup_skb/eg REL              0000000000000000  00000518
       0000000000000030  0000000000000010          11     5     8
  [ 7] maps              PROGBITS         0000000000000000  000002f0
       0000000000000038  0000000000000000  WA       0     0     4
  [ 8] license           PROGBITS         0000000000000000  00000328
       0000000000000004  0000000000000000  WA       0     0     1
  [ 9] .eh_frame         PROGBITS         0000000000000000  00000330
       0000000000000050  0000000000000000   A       0     0     8
  [10] .rel.eh_frame     REL              0000000000000000  00000548
       0000000000000020  0000000000000010          11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000380
       0000000000000168  0000000000000018           1    10     8
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  p (processor specific)

$ readelf -s bpf.o

Symbol table '.symtab' contains 15 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS bpf.c
     2: 00000000000000a0     0 NOTYPE  LOCAL  DEFAULT    3 LBB0_3
     3: 0000000000000100     0 NOTYPE  LOCAL  DEFAULT    3 LBB0_6
     4: 0000000000000138     0 NOTYPE  LOCAL  DEFAULT    3 LBB0_7
     5: 00000000000000a0     0 NOTYPE  LOCAL  DEFAULT    5 LBB1_3
     6: 00000000000000e0     0 NOTYPE  LOCAL  DEFAULT    5 LBB1_6
     7: 0000000000000138     0 NOTYPE  LOCAL  DEFAULT    5 LBB1_7
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
    10: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    8 __license
    11: 000000000000001c    28 OBJECT  GLOBAL DEFAULT    7 blocked_map
    12: 0000000000000000   344 FUNC    GLOBAL DEFAULT    5 egress
    13: 0000000000000000    28 OBJECT  GLOBAL DEFAULT    7 flows_map
    14: 0000000000000000   344 FUNC    GLOBAL DEFAULT    3 ingress

Как видите, макрос __section справился со своей задачей. Все функции, определённые нами в коде на C, пошли каждая в свой раздел, и для каждой из них есть символ. Важно, как называются разделы: cgroup_skb/{e,in}gress — это соглашение, указывающее, в какой именно точке ядра будет перехватываться эта программа eBPF. Символьное имя также очень важно — чтобы впоследствии ссылаться по нему на наши программы. «SKB» — это «буфер сокета ядра» (также именуемый sk_buff), и именно в нём хранится пакет, попавший в ядро. Как видно в коде C, здесь записывается также тип аргумента, который будут получать наши программы при выполнении. В буфере сокета содержится вся информация, чтобы определить дальнейшую судьбу пакета; правда, в данный момент мы совершенно ею не пользуемся и просто отбрасываем все пакеты без разбора.

Разумеется, этот код нельзя просто взять и запустить, так как в нём нет точки входа. Его требуется загрузить! Обычно это происходит при системном вызове bpf, обрабатывающем все детали, определяющие порядок работы ядра Linux с eBPF. Например, речь может идти о загрузке программы или о создании словаря для коммуникации с пользовательским пространством, чем мы займемся позже. Библиотека Cilium eBPF для Go с готовностью выполняет за нас все низкоуровневые операции. Следующая программа на Go принимает наш двоичный файл BPF и загружает его в ядро.

Сначала — необходимые импорты:

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/cilium/ebpf"
	"github.com/cilium/ebpf/link"
	"golang.org/x/sys/unix"
)

В самом начале программы определяем все константы, чтобы они были чистыми. В частности, здесь указывается путь для cgroup2 и файловой системы BPF, путь ELF и программные имена (на основе символьных имён, рассмотренных нами ранее), которые мы хотим загрузить.

const (
	rootCgroup	  = "/sys/fs/cgroup/unified"
	ebpfFS		  = "/sys/fs/bpf"
	bpfCodePath	 = "bpf.o"
	egressProgName  = "egress"
	ingressProgName = "ingress"
)

Попробуем что-нибудь запустить. Сначала задаём в качестве значения rlimit бесконечность. Дело в том, что словари eBPF используют блокируемую память, которой по умолчанию выделяется мало. Пока мы словарями ещё не пользуемся, но обязательно будем.

func main() {
	unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{
		Cur: unix.RLIM_INFINITY,
		Max: unix.RLIM_INFINITY,
	})

Затем загружаем двоичный файл. Первая строка избавляет нас от ощутимого куска работы, поскольку библиотека сама определит, где находятся секции и загрузит отдельно не только каждую из них, но и инструкции, соответствующие каждой, после чего обернёт их в красивую структуру, которой мы сможем воспользоваться позже.

Также определяем пути, на которых мы будем закреплять разные программы. Таким образом, их можно будет выполнять даже после того, как программа на Go завершит работу. Затем можно будет также написать процедуру «отмены загрузки», которая загружает программы с закреплённых путей именно с перспективой их последующей выгрузки — в противном случае мы были бы лишены всякой возможности с ними взаимодействовать. Именно для этого предназначена файловая система BPF (по умолчанию монтируемая по адресу /sys/fs/bpf) — к ней и будем всё прикреплять.

Также получаем файловый дескриптор для контрольной группы root, при помощи которой мы будем управлять пакетами в масштабах всей системы.

collec, err := ebpf.LoadCollection(bpfCodePath)
	if err != nil {
		fmt.Println(err)
		return
	}

	var ingressProg, egressProg *ebpf.Program
	ingressPinPath := filepath.Join(ebpfFS, ingressProgName)
	egressPinPath := filepath.Join(ebpfFS, egressProgName)
	cgroup, err := os.Open(rootCgroup)
	if err != nil {
		return
	}
	defer cgroup.Close()

Наконец, находим наши программы в двоичном файле. Они называются «ingress» и «egress», по их символьным именам. Прикрепляем их к обозначенным выше путям и относим к загруженной нами контрольной группе. Под капотом здесь опять выполняется системный вызов bpf — отдельно для каждой программы. Это делается с помощью команды BPF_PROG_ATTACH и типов BPF_ATTACH_TYPE_CGROUP_INET_{E,IN}GRESS. Кроме того, здесь мы передаём файловые дескрипторы для каждой программы BPF, которую мы уже успели загрузить, и для той контрольной группы, к которой мы их прикрепляем.

ingressProg = collec.Programs[ingressProgName]
	ingressProg.Pin(ingressPinPath)

	egressProg = collec.Programs[egressProgName]
	egressProg.Pin(egressPinPath)

	_, err = link.AttachCgroup(link.CgroupOptions{
		Path:	cgroup.Name(),
		Attach:  ebpf.AttachCGroupInetIngress,
		Program: collec.Programs[ingressProgName],
	})
	if err != nil {
		fmt.Println(err)
		return
	}

	_, err = link.AttachCgroup(link.CgroupOptions{
		Path:	cgroup.Name(),
		Attach:  ebpf.AttachCGroupInetEgress,
		Program: collec.Programs[egressProgName],
	})
	if err != nil {
		fmt.Println(err)
		return
	}
}

Вот и конец кода! Вышеприведённую программу можно скомпилировать при помощи go build ./ebpf-fw.go. Выполняя её, BPF прикрепят её к контрольной группе, в результате чего все сетевые подключения будут разорваны. Если вы хотите восстановить соединение с Интернетом — читайте дальше, как это сделать. :)

К счастью, откреплять программы гораздо проще. Просто потребуется загрузить прикреплённые программы и открыть контрольную группу:

func main() {
	var ingressProg, egressProg *ebpf.Program
	ingressPinPath := filepath.Join(ebpfFS, ingressProgName)
	egressPinPath := filepath.Join(ebpfFS, egressProgName)

	ingressProg, err := ebpf.LoadPinnedProgram(ingressPinPath)
	if err != nil {
		fmt.Println(err)
		return
	}
	egressProg, err = ebpf.LoadPinnedProgram(egressPinPath)
	if err != nil {
		fmt.Println(err)
		return
	}

	cgroup, err := os.Open(rootCgroup)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer cgroup.Close()

После чего их можно будет открепить от контрольной группы и убрать закрепы:

ingressProg.Detach(int(cgroup.Fd()), ebpf.AttachCGroupInetIngress, 0)
	egressProg.Detach(int(cgroup.Fd()), ebpf.AttachCGroupInetEgress, 0)

	os.Remove(ingressPinPath)
	os.Remove(egressPinPath)
}

Просто, согласитесь! Конечно, можно добавить ещё много других фич. Хотелось бы не просто отбрасывать все пакеты, а контролировать, какие именно IP-адреса будут блокироваться. Именно здесь нам и пригодятся словари. В них мы сможем не только хранить эти IP-адреса, но и менять их на лету прямо из пользовательского пространства. Нам не потребуется каждый раз заново загружать и выгружать программу.

К счастью, пользоваться словарями также относительно просто. Сначала потребуется определить новый словарь в ELF-файле (в отведённой под этот словарь секции), чтобы его можно было загрузить. Поскольку мы собираемся хранить просто адреса IPv4, размер каждого из которых составляет всего 4 байта, для этого вполне подойдёт тип int. Чтобы определить словарь в коде на C, воспользуемся структурой bpf_map_def.

/* Словарь для блокирования IP-адресов из пользовательского пространства */
struct bpf_map_def __section("maps") blocked_map = {
	.type = BPF_MAP_TYPE_HASH,
	.key_size = sizeof(__u32),
	.value_size = sizeof(__u32),
	.max_entries = 10000,

Разумеется, нам также потребуется изменить код, чтобы можно было проверять, присутствует ли в словаре либо IP-адрес отправителя, либо IP-адрес получателя. Так мы сможем определить, блокировать ли его. Для этого нам понадобится загрузить заголовок пакета из памяти ядра в память BPF, поскольку напрямую обращаться к памяти ядра невозможно. Далее дело за малым — найти интересующий нас адрес в словаре и проверить, был ли он заблокирован.

/* Обрабатываем пакет: возвращаем информацию о том, следует ли его допустить либо отбросить */
inline bool handle_pkt(struct __sk_buff *skb) {
    struct iphdr iph;
    /* Загружаем заголовок пакета */
    bpf_skb_load_bytes(skb, 0, &iph, sizeof(struct iphdr));
    /* Проверяем наличие IP-адресов в словаре «на блокировку» */
    bool blocked = bpf_map_lookup_elem(&blocked_map, &iph.saddr) || bpf_map_lookup_elem(&blocked_map, &iph.daddr);
    /* Возвращаем информацию о том, что следует сделать с пакетом — допустить или отбросить */
    return !blocked;
}

/* Перехват входящего трафика – обрабатываем входящие пакеты */
__section("cgroup_skb/ingress")
int ingress(struct __sk_buff *skb) {
    return (int)handle_pkt(skb);
}

/* Перехват исходящего трафика – обрабатываем исходящие пакеты */
__section("cgroup_skb/egress")
int egress(struct __sk_buff *skb) {
    return (int)handle_pkt(skb);

Теперь нам нужно просто обратиться к словарю из программы на Go, чтобы вставлять в него записи / удалять записи. Сначала давайте объявим на Go его имя:

const blockedMapName  = "blocked_map"

Затем нам понадобится поставить закреп, чтобы мы могли загружать его при последующих прогонах программы из пользовательского пространства.

	blockedPinPath := filepath.Join(ebpfFS, blockedMapName)

После того, как будет звгружен файл ELF с библиотекой Cilium, окажется, что она любезно разместит словарь в своём собственном словаре Maps!

		blockedMap, _ = collec.Maps[blockedMapName]
		blockedMap.Pin(blockedPinPath)

Впоследствии мы сможем снова его загрузить при помощи следующего кода:

		blockedMap, err = ebpf.LoadPinnedMap(blockedPinPath)

Наконец, чтобы вставить в него IP-адрес, нам сначала потребуется превратить этот адрес из строки в целое число. Для этого преобразуем 4 октета в форму «от младшего к старшему», чтобы они фигурировали в том же порядке, в котором идут в обычном формате IP-адресации. В Go это можно сделать при помощи библиотек net и binary. Выполнив такое преобразование, сможем вставить результат в словарь BPF, а программа BPF должна его подхватить и заблокировать!

	ip_bytes := net.ParseIP(ip_addr).To4()
	ip_int := binary.LittleEndian.Uint32(ip_bytes)
	if err = blockedMap.Put(&ip_int, &ip_int); err != nil {
		fmt.Println(err)
	}

Аналогичным образом при помощи словарей можно добавлять и другие возможности, чтобы взаимодействовать с программой через пользовательское пространство. Например, я добавил отслеживание того, какие IP-адреса видны. Теперь я могу в режиме реального времени в виде списка просматривать, к чему именно подключается мой компьютер. Всю реализацию можно посмотреть тут.

Вот как выглядит блокировка IP-адреса через командную строку, когда программа eBPF загружена:


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале 

Перейти ↩