
Привет, ��абр! Сейчас только ленивый не пишет про eBPF. Получается — теперь пишет.
Я Саша Лысенко, техлид DevSecOps в К2 Кибербезопасность. Как ИБ-специалиста, меня просто приводит в восторг идея, предлагаемая технологией eBPF. Произвольная программа в контексте ядра операционной системы — и все это без паники, ну сказка.
На самом деле eBPF уже активно применяется в индустрии разработки. Например, в Cilium для организации сети, в Tetragon для runtime безопасности контейнеров, в Falco для мониторинга событий на хостах и в контейнерах, в Katran для балансировки нагрузки, в Android для профилирования использования памяти, сети и энергии. Список этот огромный и продолжать можно долго.
Пройти мимо такого просто не возможно, и я тоже решил попробовать написать небольшой материал о том, как подступиться к eBPF. Для развлечений я обычно использую Rust, и этот случай не будет исключением.
Пролог. Расширенный пакетный фильтр Беркли
Прежде чем переходить к коду, необходимо разобраться, что такое eBPF и какие возможности он для нас открывает.
eBPF — технология, позволяющая безопасно и эффективно выполнять пользовательские программы внутри ядра без изменения его исходного кода или загрузки модулей ядра.
Немного истории. В 1992 году в Университете Беркли рождается BPF — технология для фильтрации сетевых пакетов. Она приобретает большую популярность (tcpdump, например) и становится де-факто стандартом в Unix-системах. BPF перехватывала вызовы сетевого стека и позволяла анализировать сетевые фреймы. В 2013-14 годах технология получает заветное extended, JIT — компилятор и возможность перехватывать события ядра не только сетевого стека.
Как же это работает:
Пользователь пишет программу на eBPF.
Компилятор (llvm) преобразует код в байт-код eBPF.
Байт-код загружается в ядро через системный вызов.
Верификатор анализирует байт-код.
JIT-компилятор преобразует байт-код в машинный код.
Программа прикрепляется к событиям ядра (системные вызовы, сетевые пакеты, точки трассировки) и выполняется при их срабатывании.
Данные между eBPF-программами (в том числе между собой) и userspace передаются через специальные map (хэш-таблицы или массивы). Чем-то напоминает работу с каналами.

Главное преимущество eBPF — работа в изолированном пространстве / виртуальной машине. Паника в eBPF программе не приводит к панике ядра. Сбрасывается обработка события в eBPF-программе, при этом обработка события ядром продолжается. Это исключает возможность сломать нагруженную систему и позволяет обновлять инструменты, использующие eBPF «на лету».
Несмотря на мощь eBPF, технология имеет ряд принципиальных ограничений, связанных с безопасностью, производительностью и архитектурой ядра Linux:
Нет поддержки произвольной арифметики указателей. Например, запрещено прибавлять переменную (не константу) к указателю.
Ограниченная сложность программ — 1 млн. инструкций на программу (64 тыс. для XDP).
Нет произвольного доступа к памяти ядра (оно и к лучшему на самом деле). Прочитать данные можно только через вспомогательные функции.
Конкурентный доступ к map может приводить к блокировкам.
Зависимость от версий ядра. Ядро, во-первых, должно поддерживать eBPF, во-вторых — быть скомпилировано с включенной поддержкой eBPF.
Сложность отладки из-за контекста исполнения и отсутствия привычного инструментария.
Уязвимости верификатора и атаки через доступ к map.
Низкая производительность при большом количестве eBPF-программ, вызванная работой JIT-компилятора.
Ограничение стека и отсутствие аллокатора. Единственная доступная динамическая память — map. Поэтому бывает сложно уместить все свои хотелки в eBPF-программу, а обильное использование map сильно грузит систему.
Глава 1. Дилеммы
Первая дилемма, с которой я столкнулся — язык программирования. Приложения на eBPF обычно состоят из двух частей: программы в userspace и eBPF-кода, выполняемого в ядре. И хочется использовать для этого один язык программирования. Таким образом, нам потребуется компилируемый язык с LLVM в бекенде. Rust отлично подходит на эту роль.
Вторая дилемма — фреймворк/библиотека. Идем в поисковик и видим первой ссылкой Aya-rs — 4к звезд на github. Отлично, тест «тысячи звезд» пройден. Актуальная версия — 0.13.1. Несмелые нынче программисты боятся назвать версию стабильной. Что ж, мы к такому привыкли. Последний релиз был больше полугода назад. Настораживает, но есть коммиты на прошлой неделе — сойдет, пройдено.
Также стоит упомянуть библиотеку libbpf-rs — rust-обвязку вокруг libbpf. Но она позволит написать только часть userspace.
Третья дилемма — что писать. eBPF позволяет нам «подписываться» на события ядра, но писать очередную «эффективную» обработку сетевых пакетов не хочется. Тем более пример есть в документации Aya. Еще мы можем посмотреть на все системные вызовы происходящие в системе. Вот на них и посмотрим, ограничивая себя, например, каким-нибудь контейнером. Итак пишем приложение, которое будет выводить все системные вызовы совершенные в контейнере.
Глава 2. Начало. Базовый проект
Aya предоставляет широкий тулинг, позволяющий упростить разработку userspace-приложения и eBPF-программы. Ставим его и дополнительно устанавливаем nightly-сборку rust, bpf-linker и cargo-generate. Последний нужен для создания проекта из шаблонов.
rustup toolchain install nightly --component rust-src
cargo install bpf-linker
cargo install --git https://github.com/aya-rs/aya -- aya-tool
cargo install cargo-generate
После создаем базовый проект. Для этого есть несколько заготовленных типов: xdp, tracepoint, kprobe и т.д. Полный список можно посмотреть тут (поле Choices). Нас интересует tracepoint, так как мы планируем «читать» все системные вызовы.
cargo generate --name cp -d program_type=tracepoint https://github.com/aya-rs/aya-template
На запрос имени точки трассировки выбираем sys_enter — наша eBPF-программа будет выполняться перед обработкой системного вызова. На запрос категории выбираем raw_syscalls, чтобы перехватить все вызовы. Имя для проекта мы выбрали cp, но можно выбрать любое. В итоге получаем следующий проект:

Основные директории:
cp ({{ Project Name }}) — для userspace-программы;
cp-ebpf ({{ Project Name }}-ebpf) — для ebpf-программы;
cp-common ({{ Project Name }}-common) — для библиотечных модулей (например, для структур данных, которыми общаются ebpf-программа и userspace-приложение).
Запустить приложение можно используя команду:
cargo run --release --config 'target."cfg(all())".runner="sudo -E"'
Глава 3. Ловим системные вызовы
Для начала попробуем вывести event id (идентификатор системного вызова) и pid вызвавшего его процесса.
Для этого в первую очередь понадобится создать map для передачи данных с eBPF в userspace. Для этого есть удобный макрос #[map], который со стороны eBPF-программы создает map и дает объект для взаимодействия с ним.
#[map]
static mut CP_EVENTS: PerfEventArray<CallEvent> = PerfEventArray::new(0);
Для передачи будем использовать perf event buffer — удобно для стриминга. В качестве данных будем передавать объект CallEvent — структуру определяем самостоятельно и удобно это сделать в cp-common, чтобы и в eBPF и в userspace использовать один и тот же тип.
Далее нам нужно собрать информацию. Захваченный контекст содержится в объекте TracePointContext. Посмотрим в документации доступные нам функции (картинка ниже).

Не густо, но есть функция read_at, которая позволит нам считать произвольные данные по заданному сдвигу. Сдвиг можно посмотреть в файле формата трассировки системного вызова: /sys/kernel/debug/tracing/events/[category]/[name]/format
. В нашем случае это /sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/format
.
PID можем достать используя функцию pid из trait EbpfContext.
И для записи данных в PerfEventArray используем функцию output.
Итого получаем следующую eBPF-программу:
fn try_cp(ctx: TracePointContext) -> Result<u32, u32> {
let syscall_id = unsafe {
match ctx.read_at::<u64>(8) {
Ok(id) => id,
Err(_) => return Ok(0),
}
};
let pid = ctx.pid();
let event = CallEvent { syscall_id, pid };
#[allow(static_mut_refs)]
unsafe {
CP_EVENTS.output(&ctx, &event, 0);
}
Ok(0)
}
Поздравляю — наша eBPF-программа готова. Перейдем к userspace. Нам нужно:
1. Загрузить собранную eBPF программу и прикрепить ее к точке трассировки:
let mut ebpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
env!("OUT_DIR"),
"/cp"
)))?;
let program: &mut TracePoint = ebpf.program_mut("cp").unwrap().try_into()?;
program.load()?;
program.attach("raw_syscalls", "sys_enter")?;
2. Получить доступ к map:
let mut perf_array = AsyncPerfEventArray::try_from(ebpf.take_map("CP_EVENTS").unwrap())?;
3. Для каждого процессора запустить асинхронную задачу по чтению данных из map и выводить их в stdout:
for cpu_id in online_cpus().unwrap() {
let mut buf = perf_array.open(cpu_id, None)?;
task::spawn(async move {
let mut buffers = (0..10)
.map(|_| BytesMut::with_capacity(1024))
.collect::<Vec<_>>();
loop {
let events = match buf.read_events(&mut buffers).await {
Ok(events) => events,
Err(e) => {
eprintln!("Error reading events: {}", e);
break;
}
};
for buf in buffers.iter().take(events.read) {
let ptr = buf.as_ptr() as *const CallEvent;
let data = unsafe { *ptr };
println!("Process: {}; Syscall ID: {}",
data.pid,
data.syscall_id,
);
}
}
});
}
Тут стоит пояснить, что у каждого CPU есть свой perf ring buffer, из-за чего в userspace необходимо открыть его для каждого CPU.
Запускаем собранную программу и видим в stdout огромный поток сообщений, а также сильно возросшую нагрузку на CPU. Это происходит из-за того, что мы логируем каждый системный вызов, используя perf ring buffer CPU. А таких вызовов много, очень много. Поэтому попробуем немного ограничить «захват» каким-нибудь namespace, например, docker-контейнером.
Глава 4. Ограничиваем «напор»
Начнем с eBPF-программы. Во-первых, нам надо внутри eBPF определить pid namespace. В этом нам поможет структура task_struct. Это C-структура, которая содержит всю необходимую для ядра информацию о процессе. Ключевая проблема, что это C-структура, а аналогичной Rust-структуры в ядре — нет (на момент написания статьи). Сделать Rust привязку к task_struct нам поможет утилита aya-tool, которую мы заблаговременно установили:
cd cp-ebpf/src
aya-tool generate task_struct > task_struct.rs
Теперь, используя task_struct, мы можем получить интересующую нас информацию о процессе. Но что именно нас интересует? Namespace в Linux представлены в виде специальных объектов, каждый из которых записан в /proc/<pid>/ns/* как символическая ссылка. Ссылка ведет на inode объекта namespace. Таким образом, идентифицировать pid namespace мы можем по inode объекта. В userspace это можно сделать следующим образом:

4026532455 — это ID inode pid namespace (inum).
В eBPF-программе, используя task_struct, это можно сделать следующим образом:
let task = unsafe { aya_ebpf::helpers::bpf_get_current_task_btf() };
#[allow(non_upper_case_globals)]
let task = unsafe { &*(task as *const task_struct::task_struct) };
let pid_ns = unsafe { (*(*task).nsproxy).pid_ns_for_children };
let pid_ns_inum = unsafe { (*pid_ns).ns.inum };
Теперь нам нужно понять в eBPF-программе, по какому именно inum фильтровать процессы. Для этого создадим еще один map и передадим это значение в eBPF-программу из userspace.
#[map]
static mut CP_CONFIG: HashMap<u32,u32> = HashMap::with_max_entries(1, 0);
#[allow(static_mut_refs)]
let target_pidns = unsafe {
match CP_CONFIG.get(&0) {
Some(ns) => *ns,
None => 0,
}
};
let task = unsafe { aya_ebpf::helpers::bpf_get_current_task_btf() };
#[allow(non_upper_case_globals)]
let task = unsafe { &*(task as *const task_struct::task_struct) };
let pid_ns = unsafe { (*(*task).nsproxy).pid_ns_for_children };
let pid_ns_inum = unsafe { (*pid_ns).ns.inum };
if target_pidns != 0 && pid_ns_inum != target_pidns {
return Ok(0);
}
В userspace читаем значение из аргументов к программе и передаем через map в eBPF-программу. Для работы с аргументами используем crate clap:
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long)]
pidns: Option<u32>,
}
let args = Args::parse();
let target_pidns = args.pidns.unwrap_or(0);
let mut config = HashMap::try_from(ebpf.take_map("CP_CONFIG").unwrap())?;
config.insert(0, target_pidns, 0)?;
Запускаем получившееся приложение и наслаждаемся результатом:

Полный код получившегося проекта можно посмотреть тут.
Эпилог. Выводы и впечатления
Aya-rs выглядит интересным проектом, позволяющим довольно легко начать свой путь в разработке систем с использованием eBPF. Уже сейчас есть обширный тулинг и большое количество удобных абстракций. Но проекту не хватает хорошей документации и более частых релизов, из-за чего затягивается выпуск стабильной версии. Использовать Aya здесь и сейчас в реальных проектах стоит очень аккуратно — между версиями много изменений, ломающих совместимость. Так же ряда «напрашивающихся» абстракций не хватает. Но думаю это временно.
Для специалистов по безопасности eBPF — это прорыв, а Rust с Aya-rs — отличный способ этим прорывом воспользоваться. Пример с трекингом системных вызов — лишь первая ступень. Следующими шагами могут быть парсинг аргументов вызовов, анализ сетевой активности или корреляция событий. Все ограничено только вашей фантазией и текущими версиями ядра. И как знать, возможно скоро мы увидим «Blazing Fast Tetragon».
Напоследок оставлю пару интересных ссылок, если тема вас «зацепила»:
хороший цикл статей, чуть шире описывающий путь к первой eBPF-программе;
Aya-book в лучших традициях Rust;