Привет, ��абр! Сейчас только ленивый не пишет про eBPF. Получается — теперь пишет.

Я Саша Лысенко, техлид DevSecOps в К2 Кибербезопасность. Как ИБ-специалиста, меня просто приводит в восторг идея, предлагаемая технологией eBPF. Произвольная программа в контексте ядра операционной системы — и все это без паники, ну сказка.

На самом деле eBPF уже активно применяется в индустрии разработки. Например, в Cilium для организации сети, в Tetragon для runtime безопасности контейнеров, в Falco для мониторинга событий на хостах и в контейнерах, в Katran для балансировки нагрузки, в Android для профилирования использования памяти, сети и энергии. Список этот огромный и продолжать можно долго.

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

Пролог. Расширенный пакетный фильтр Беркли

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

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

Немного истории. В 1992 году в Университете Беркли рождается BPF — технология для фильтрации сетевых пакетов. Она приобретает большую популярность (tcpdump, например) и становится де-факто стандартом в Unix-системах. BPF перехватывала вызовы сетевого стека и позволяла анализировать сетевые фреймы. В 2013-14 годах технология получает заветное extended, JIT — компилятор и возможность перехватывать события ядра не только сетевого стека.

Как же это работает:

  1. Пользователь пишет программу на eBPF.

  2. Компилятор (llvm) преобразует код в байт-код eBPF.

  3. Байт-код загружается в ядро через системный вызов.

  4. Верификатор анализирует байт-код.

  5. JIT-компилятор преобразует байт-код в машинный код.

  6. Программа прикрепляется к событиям ядра (системные вызовы, сетевые пакеты, точки трассировки) и выполняется при их срабатывании.

  7. Данные между eBPF-программами (в том числе между собой) и userspace передаются через специальные map (хэш-таблицы или массивы). Чем-то напоминает работу с каналами.

Из документации eBPF

Главное преимущество eBPF — работа в изолированном пространстве / виртуальной машине. Паника в eBPF программе не приводит к панике ядра. Сбрасывается обработка события в eBPF-программе, при этом обработка события ядром продолжается. Это исключает возможность сломать нагруженную систему и позволяет обновлять инструменты, использующие eBPF «на лету».

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

  1. Нет поддержки произвольной арифметики указателей. Например, запрещено прибавлять переменную (не константу) к указателю.

  2. Ограниченная сложность программ — 1 млн. инструкций на программу (64 тыс. для XDP).

  3. Нет произвольного доступа к памяти ядра (оно и к лучшему на самом деле). Прочитать данные можно только через вспомогательные функции.

  4. Конкурентный доступ к map может приводить к блокировкам.

  5. Зависимость от версий ядра. Ядро, во-первых, должно поддерживать eBPF, во-вторых — быть скомпилировано с включенной поддержкой eBPF.

  6. Сложность отладки из-за контекста исполнения и отсутствия привычного инструментария.

  7. Уязвимости верификатора и атаки через доступ к map.

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

  9. Ограничение стека и отсутствие аллокатора. Единственная доступная динамическая память — 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».

Напоследок оставлю пару интересных ссылок, если тема вас «зацепила»: