Технология eBPF не нова. Её используют повсеместно, ведь она упрощает написание кода для ядра ОС. Классно и удобно, а главное безопасно! Но, как оказывается на практике, не все так гладко… Это не только удобное средство для написания кода, но и новые потенциальные векторы для атак. Поэтому давайте подробно разберём, как она работает, и как можно избежать потенциальных проблем. Для меня как безопасника интереснее всего использование eBPF сервисами и инструментами в продакшене. Именно там открываются возможные пути обхода для злоумышленников.

Меня зовут Лев Хакимов, я DevOps и Kubernetes Security Lead в MWS Cloud Platform, а ещё преподаю в ИТМО. Занимаюсь обеспечением и построением процессов безопасности платформ Kubernetes в облаке MWS, организую CTF-соревнования по всей стране для школьников, студентов и действующих специалистов. 

Как писать код в Kernel Space: модифицируем ядро

Общий подход к запуску кода в Linux достаточно простой. У нас есть:

User Space:

  • Для прикладного ПО, где проводится множество проверок, чтобы как можно реже «стрелять себе в колено».

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

  • Общение с ядром через syscall’ы.

Тёмная лошадка — Kernel Space:

  1. Здесь живёт ядро, его модули, драйверы.

  2. Нет защиты при работе с памятью.

  3. Есть прямой доступ ко всем устройствам памяти.

  4. Требует особого внимания, поэтому мало кто пишет программы под Kernel Space. Это определённое искусство, которым надо владеть.

Написать код в Kernel Space можно разными способами.

Патч в ядро Linux

Первое, что мы можем сделать — пропатчить ядро Linux. Иногда такое действительно практикуется в компаниях, у которых есть собственные ядра Linux. Но у этого способа есть проблемы:

  • Сложный код на C: далеко не все умеют писать патчи для Linux.

  • Плохая переносимость между версиями ядра. А ещё нужно не забыть своевременно всё перенести.

  • Перекомпиляция ядра для изменений.

Модули ядра

Можно делать это прямо в модуле ядра, но проблемы остаются: 

  • Всё ещё код на C.

  • Его всё ещё сложно писать.

  • В придачу, никакой защиты работы с памятью и проверок в принципе нет.

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

eBPF

eBPF — новый механизм, который появился относительно недавно и покорил сердца многих:

  • Вместо сложного С — его ограниченное подмножество. Да, сложный код на C всё ещё есть, но он уже не такой сложный, поскольку код на eBPF ограничен небольшим подмножеством. А с недавних версий можно писать ещё на Rust, хотя в основном все по-прежнему пишут на C.

  • Продуманный интерфейс общения с User Space: как отдавать результаты работы программы.

  • Ограничение песочницей ВМ и различными верификаторами. Это вызвало отдельную симпатию у многих, так как не даёт сломать ничего серьёзного.

Подробнее про eBPF

Теперь подробнее про eBPF. Новая технология резко снизила порог вхождения в написание кода для ядра и сделала его универсальным для разных дистрибутивов/версий ядра Linux.

Вот уже ставшая каноничной картинка, которую многие видели, когда читали документацию к Cilium:

eBPF (extended Berkeley Packet Filter) — это мощный инструмент для разработки, который позволяет запускать собственный код прямо внутри ядра операционной системы.

У eBPF есть три крупных зоны применения: сеть, безопасность и трейсинг. Различные проекты работают с использованием технологии eBPF, например, Cilium, Katran, множество самописных. Например, если вы разрабатываете облако и пишете сетевую подсистему, то, чтобы не обсчитывать сетевые пакеты в User Space, это можно проделать с помощью eBPF-программ. 

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

А ещё есть подсистема, предоставляемая самой Linux: верификаторы, JIT-компиляция, позволяющая собрать байт-код и затем компилировать его на конкретной системе JIT под конкретную архитектуру, интерфейс Maps, runtime для eBPF программ.

Основные фишки eBPF

  • Запускаем код, который должен работать быстро прямо в ядре.

  • Собранный код переносим между версиями ядер — с определёнными ограничениями, но сравнительно легко за счёт встроенных механизмов совместимости.

  • Есть встроенный верификатор, защищающий от «стрельбы очередью по коленям».

  • Каждая eBPF-программа выполняет строго определённую задачу — например, фильтрация пакетов или трассировка системных вызовов. Это повышает надёжность: нельзя сделать больше, чем разрешено. eBPF-программы позволяют нам делать что-то одно, но хорошо. А главное, нельзя сделать больше, чем задумано изначально.

  • Обмен данными между ядром и пользовательским пространством (User Space) реализован через специальные структуры — eBPF maps. Это даёт удобный способ передачи и хранения информации.

Теперь рассмотрим, где это применять:

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

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

Трассировка и профилирование. уИЗА — идеальный инструмент для создания инструментов анализа производительности и поведения приложений на уровне ядра.

Безопасность. Контроль доступа, выявление подозрительных действий, мониторинг поведения процессов и сетевых соединений.

Как работает eBPF на примере Hello World

Для понимания, как работает eBPF, рассмотрим примитивную программу, печатающую Hello World в Stacktrace.

program = """

#include <uapi/linux/ptrace.h>

int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}

"""

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

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

from bcc import BPF

b = BPF(text=program)
  
hello_function = b.load_func("hello", BPF.KPROBE)

b.attach_kprobe (event=b.get_syscall_fnname("sync"), fn_name="hello")

while True:
      try:
             b.trace_print()
      except KeyboardInterrupt:
break

Мы загружаем eBPF в программу и прикрепляем её к системному вызову sync. Каждый раз при срабатывании syscall sync, из трейса будет выводиться фраза «hello world». 

Любая eBPF-программа — это функция-обработчик на некоторые события, а не фоновый процесс. Она не запускается сама по себе — её обязательно должен запустить какой-то триггер —  например, системный вызов, приход сетевого пакета или событие в ядре. И она обязательно должна завершиться! Соответственно, eBPF-программа не может существовать бесконечно и крутиться фоново как демон или служба. Обязательно должны быть запуск, работа и результат. Выполнение строго ограничено как по времени, так и по доступным операциям.

Немного о Capabilities

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

Самая универсальная и часто используемая из них — это CAP_SYS_ADMIN. Она даёт очень широкий набор прав, почти как у суперпользователя. И именно с ней связано множество проблем. У безопасников при упоминании этой capability обычно начинает нервно дёргаться глаз. В системах, где применяют eBPF или другие технологии, часто появляется избыточное количество прав, что открывает лишние риски.

Когда безопасники проверяют такие системы, они получают гигантские отчёты с флагами нарушений — кто, где и зачем задействовал SYS_ADMIN, хотя мог бы обойтись более узкими привилегиями. В некоторых случаях CAP_SYS_ADMIN превращается в технический долг — систему уже сложно перестроить на более безопасные права без полной переработки.

С выходом ядра Linux 5.8 появилась новая capability — CAP_BPF (Kernel >5.8) даёт право на syscallbpf().

Важно понимать, что не у всех установлены свежие версии ядра, а eBPF существовал и до этого. Поэтому часто во многих источниках рекомендуют ставить именно CAP_SYS_ADMMIN.

Вот выдержка из официальный документации Cilium прямо говорит нам — запускайте Cilium под CAP_SYS_ADMMIN.

Но если у вас современное ядро Linux, безопаснее использовать CAP_BPF и CAP_NET_ADMIN. Этого будет достаточно, к тому же не понадобится устанавливать сверх-широкую capability, которая полезна всем, кроме безопасников.

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

CAP_PERFMON необходима для загрузки программ для трассировки системы.

CAP_NET_ADMIN — для загрузки программ, работающих с сетевым стеком XDP.

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

EPBF Maps

Чтобы делиться данными между User Space и Kernel Space, существует механизм обычных карт EPBF Maps:

  • Особые структуры данных в виде карт ключ-значение.

  • Хранятся в пространстве ядра.

  • К ним могут обращаться как eBPF-программы, так и процессы в User Space.

По факту это общая область памяти между пользовательским пространством, где работают наши приложения и пространством ядра. И туда может писать и оттуда читать как сам EBPF-код, так и приложения из пользовательского пространства. Запомним это, ведь ряд атак будет построен на этой особенности.

Maps — дальше «карты» — бывают разных типов:

  • различные словари;

  • массивы;

  • очереди;

  • представления IPv4-и IPv6-адресов с префиксом сети;

  • хранилища для сокетов;

  • карты ключей для cgroups;

  • и так далее.

Карты — основной механизм общения eBPF программ с User Space.

Посмотрим на прекрасный security мир, в котором существует eBPF.

Проекты Tetragon, Falco, Tracee позволяют проводить аудит на системах в Kubernetes. А ещё их легко установить на операционную систему и провести аудит происходящих там событий. Например, зафиксировать, какие syscall вызываются, а файлы и папки — открываются.

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

Немного о защите: в eBPF есть механизм Linux LSM — это специально расставленные разработчиками Linux ловушки в коде ядра. Когда выполняется какое-либо действие (например, открытие файла), эта ловушка срабатывает и подает сигнал нашему приложению. Достаточно на эти события реагировать, и система аудита на нужные вам события появится, отчасти, сама собой. Именно на них работает практически весь security tooling, построенный вокруг eBPF. Они включаются при компиляции. Найти включенные можно по пути:

/sys/kernel/security/lsm

Объявляются они через директиву SEC в eBPF-программах. У них есть статус возврата: 

  • «0» — проверка прошла успешно, 

  •  «-EPERM» — ошибка, то есть всё плохо и надо разбираться, что произошло. 

Разберёмся, какие атаки кроме доступа к root и защиты бывают.

Атака на shared maps

Первая и самая простая —  атака на shared maps. Да, те самые, которые позволяют делиться данными между User Space и Kernel Space

Рассмотрим тестовый стенд. У нас есть Ubuntu 22.04 и установленный на неё Falco. В качестве PoC рассмотрим следующий сценарий:

  • Map — это общий ресурс.

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

  • К этой map’е имеется доступ у всех, у кого в User Space есть доступ к CAP_BPF или CAP_SYS_ADMIN

  • Можно доставить и запустить на стенде eBPF-программу. Можно загрузить свою eBPF программу разными способами — например, скриптом sploit, который может запуститься из-под привилегий CAP_BPF. Или провести атаку типа supply chain и подшить в устанавливаемую на сервер программу другую нехорошую eBPF-программу. Она запустится, когда приложение приедет на сервер и там развернётся.

  • И вот злоумышленник может смело удалять/засорять maps.

Суть в том, что Falco общается с eBPF-программами через maps. А значит эти maps можно удалить и зачистить вот таким несложным скриптом:

void delete_keys(int fd, const char *map_name) {
    std::cout << "Targeting map " << map_name <<
std::endl;
    struct map_elem map_item = {};
    while (bpf_map_get_next_key(fd, &map_item, &map_item)
== 0) {
        __u32 key = static_cast<__u32>(map_item.key);
        if (bpf_map_delete_elem(fd, &key) == 0) {
            std::cout << "\tDeleted key " << key << "
from " << map_name << std::endl;
        }
        key++;
    }
}
  • Даже простое удаление MAP приведёт к тому, что Falco замолчит.

Просто очистим ключи, и Falco замолчит, не выкинув в лог никаких ошибок — солдат спит, а служба идёт.

io_uring & eBPF.

Читать файлы в операционной системе Linux можно несколькими способами:

  • Стандартный вызов open().

  • Через io_uring (io_uring_setup) интерфейсы, появившиеся относительно недавно. Они дают возможность делать асинхронное чтение и запись файлов.

  • Через системный вызов open_by_handle_at (CAP_DAC_READ_SEARCH).

Механизм асинхронной работы с операциями «ввод-вывод» в io_uring :

read() -> aio_read()

write() -> aio_write()

С ними есть забавный момент. Falco по умолчанию это не отслеживает. Он смотрит на системные вызовы open из коробки, но не смотрит на aio_-системные вызовы. Мы можем воспользоваться таким скриптом и попытаться открыть файлы с использованием io_uring.

Можно просто использовать скрипт, приведённый выше, и читать любой файл на системе, при этом Falco ничего не заметит.

Чтобы гарантировать, что он будет вызван до libc, можно сделать это через LD_ PRELOAD, указав скрипт с bypass в переменную LD_ PRELOAD:

LD_PRELOAD=io_uring_bypass.so ls –la

Overflow maps

Но у maps есть ограничения:

  • Количество экземпляров maps ограничено (как правило, около 10 000 штук).

  • Maps всё ещё общие.

  • Если их заполнить мусором, то в потоке таких данных можно не увидеть что-то интересное.

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

Maps Falco переполняются из-за обилия событий открытия файлов. В то время, пока скрипт занимается бесполезной работой открывая и закрывая файлы, злоумышленник может  сделать что-то другое, на что Falco не среагирует — потому что занят.

С maps разобрались — теперь немножко про rootkits

ebpfkit

Вернёмся к rootkit. Вот сравнительно шуточный rootkit от разработчиков DataDog, который, тем не менее, работает. Посмотрим на его примере как работает любой eBPF-rootkit:

Здесь C2-сервер — это командно-контрольный сервер, который будет подавать нам какие-то команды. Общение идёт по сети, в данном случае работают XDP-сокеты. Мы можем написать eBPF-программу, которая будет подключаться в XDP, забирать пакеты и каким-то образом их обрабатывать. Например, откидывать, игнорировать, перенаправлять в обход основного сетевого стека. Таким образом пакеты будут доезжать, например, до виртуалок, крутящихся на сервере с гипервизором. Но при этом сам сервер с гипервизором ничего об этом знать не будет.

Внутри rootkit масса тулинга: от разных видов bypass до скана сети, есть возможность скрытого исполнения на хосте. Но работает для злоумышленника это примерно так: мы действительно можем запустить rootkit, оставить поработать какое-то время и получить схему сети всех запросов — кто, откуда, с какого порта обращался к нашему серверу. Всё это — лишь по анализу входящих сетевых пакетов.

Если бы такая программа торчала в User Space, скорее всего, её бы быстро заметили. 

Но это rootkit, а он умеет скрываться от ищущего, например, перехватывать системный вызов getdents64syscall. Представьте запуск программы psaux с выходом в каталог /proc и выводом списка процессов. Если наш системный вызов перехватит rootkit и из него заботливо удалят все процессы, связанные с rootkit’ом, то вывод psaux нам волшебным образом ничего не покажет, потому что его уже очистили до нас.

Выводы

Технология eBPF открыла новые горизонты для наблюдения, сетевого взаимодействия и обеспечения безопасности на уровне ядра Linux. Гибкость и мощь сделали её незаменимым инструментом для DevOps-инженеров. Однако с этими возможностями приходит и ответственность.

eBPF — это не только средство наблюдения, но и потенциальный вектор атаки при неправильной настройке. Примеры с удалением maps, обходом через io_uring и eBPF-rootkit'ами доказывают, что теоретически безопасные механизмы могут быть обойдены злоумышленником при недостаточном контроле.

Ключевые меры защиты:

  • Используйте минимально необходимые capabilities (вместо CAP_SYS_ADMIN — CAP_BPF, CAP_NET_ADMIN и CAP_PERFMON).

  • Контролируйте доступ к eBPF maps и следите за утечкой привилегий.

  • Регулярно обновляйте ядро и eBPF-инструменты — уязвимости быстро закрываются.

  • Используйте LSM-хуки и современные решения (Tracee, Tetragon, Falco) для детектирования аномалий.

И главное — обсуждайте, делитесь опытом и учитесь у других. Именно для этого и существует DevOpsConf. На конференции вы сможете пообщаться с экспертами по безопасности, обсудить реальные кейсы, eBPF-инфраструктуру и подходы к защите — как в теории, так и в боевых условиях. Приходите, будем разбираться в безопасности инфраструктуры вместе!

Комментарии (0)