Привет, Хабр!

Меня зовут Алексей, я занимаюсь исследованием и анализом киберугроз в R-Vision. Сегодня я хочу поговорить об eBPF (extended Berkeley Packet Filter) - технологии, с помощью которой можно запускать различные программы внутри ядра Linux. Основная сила eBPF заключается в том, что данный инструмент позволяет изолировать программы внутри ядра без загрузки в него дополнительных модулей и без внесения изменений в исходный код. В настоящее время eBPF используются во многих приложениях для Linux. Обладая рядом преимуществ, технология становится все более популярной среди пользователей и неудивительно, что вызывает интерес и у злоумышленников.

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

Применение eBPF злоумышленником

Начнем с того, что выясним, чем могут быть опасны нелигитимные eBPF. Сразу стоит отметить, что для загрузки eBPF модуля необходимы права root или привилегии CAP_BPF. Поэтому данная технология не походит для начальных этапов атаки.
Обратившись к матрице MITRE ATT&CK, мы увидим, что в основном злоумышленники используют данные программы для закрепления или сокрытия вредоносного ПО в системе.

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

Данные инструменты имеют множество различных функций, которые вряд ли кто-то хотел бы увидеть у себя в инфраструктуре, поскольку они могут:

  • Перехватывать сетевые пакеты и системные вызовы;

  • Изменять перехваченные системные вызовы или же полностью блокировать их;

  • Открывать бэкдоры для злоумышленника и многое другое.

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

Первичный анализ загрузки еBPF

Загруженные BPF программы можно проверить c помощью инструмента bpftool, используя команду:

  bpftool prog list -p

Запустив один из модулей bad bpf, мы получим id программы, его имя, время загрузки и id используемых структур данных - map_ids:

[
  ...
	{
        "id": 168,
        "type": "tracepoint",
        "name": "handle_execve_e",
        "tag": "d4b5eec1dc561f40",
        "gpl_compatible": true,
        "loaded_at": 1684082570,
        "uid": 0,
        "bytes_xlated": 1088,
        "jited": true,
        "bytes_jited": 625,
        "bytes_memlock": 4096,
        "map_ids": [44,43
        ],
        "btf_id": 154
    },
    ...
]

Как видно, этих данных явно недостаточно для выявления вредоносных модулей, так как, опирасясь только на них, мы не сможем определить, какой пользователь и процесс загружали модуль eBPF. Также злоумышленник имеет возможность внедрить eBPF модуль для перехвата и изменения утилиты bpftool (hijacking). Поэтому данный способ, как и другие по выявлению загруженных eBPF постфактум, нам не подходят.

Теперь давайте рассмотрим, как мониторить eBPF программы в момент их загрузки.
Для этого для начала выполним команду strace при загрузке ранее используемого модуля:

...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE, insn_cnt=6, insns=0x7ffd3ba9ec70, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0, pro                                                                                           g_name="", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=0, func_info_rec_size=0, func_info=NULL, func_info_cnt=0, line_info_rec_size=0, line_info=NULL, line_info_cnt=                                                                                           0, attach_btf_id=0, attach_prog_fd=0}, 120) = 6
close(6)                                = 0
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_CGROUP_SOCK, insn_cnt=2, insns=0x7ffd3ba9ea10, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0                                                                                           , prog_name="", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_SOCK_CREATE, prog_btf_fd=0, func_info_rec_size=0, func_info=NULL, func_info_cnt=0, line_info_rec_size=0, line_info=NULL, line_                                                                                           info_cnt=0, attach_btf_id=0, attach_prog_fd=0}, 120) = 6
close(6)                                = 0
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_TRACEPOINT, insn_cnt=136, insns=0x56534c1f6650, license="Dual BSD/GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 15, 0), p                                                                                           rog_flags=0, prog_name="handle_execve_e", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=3, func_info_rec_size=8, func_info=0x56534c1f5ba0, func_info_cnt=1, line_info_r                                                                                           ec_size=16, line_info=0x56534c1f5c30, line_info_cnt=69, attach_btf_id=0, attach_prog_fd=0}, 120) = 6
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, key_size=4, value_size=32, max_entries=1, map_flags=0, inner_map_fd=0, map_name="", map_ifindex=0, btf_fd=0, btf_key_type_id=0, btf_value_type_id                                                                                           =0, btf_vmlinux_value_type_id=0, map_extra=0}, 120) = 7
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_SOCKET_FILTER, insn_cnt=2, insns=0x7ffd3ba9e980, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags                                                                                           =0, prog_name="", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=0, func_info_rec_size=0, func_info=NULL, func_info_cnt=0, line_info_rec_size=0, line_info=NULL, line_in                                                                                           fo_cnt=0, attach_btf_id=0, attach_prog_fd=0}, 120) = 8
bpf(BPF_PROG_BIND_MAP, 0x7ffd3ba9e850, 120) = 0
close(7)                                = 0
close(8)                                = 0
bpf(BPF_PROG_BIND_MAP, 0x7ffd3ba9e9d0, 120) = 0
munmap(0x7fe2d0e23000, 5173248)         = 0
munmap(0x7fe2d0d84000, 561152)          = 0
mmap(0x7fe2d1aa6000, 4096, PROT_READ, MAP_SHARED|MAP_FIXED, 5, 0) = 0x7fe2d1aa6000
openat(AT_FDCWD, "/sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/id", O_RDONLY) = 7
newfstatat(7, "", {st_mode=S_IFREG|0440, st_size=0, ...}, AT_EMPTY_PATH) = 0
read(7, "716\n", 4096)                  = 4
read(7, "", 4096)                       = 0
close(7)                                = 0
perf_event_open({type=PERF_TYPE_TRACEPOINT, size=PERF_ATTR_SIZE_VER7, config=716, sample_period=0, sample_type=0, read_format=0, precise_ip=0 /* arbitrary skid */, ...}, -1, 0, -1, PERF_FLAG_FD_C                                                                                           LOEXEC) = 7
ioctl(7, PERF_EVENT_IOC_SET_BPF, 6)     = 0
ioctl(7, PERF_EVENT_IOC_ENABLE, 0)      = 0
epoll_create1(EPOLL_CLOEXEC)            = 8
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=4, info_len=80, info=0x7ffd3ba9ee60}}, 120) = 0
...

Здесь мы видим множество системных вызовов, некоторые из них также полезно мониторить. Например perf_event_open(), который может применяться для перехвата критичных данных. Но в этой статье мы остановимся на вызове bpf(), используемом для загрузки и управления eBPF программами:

bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_TRACEPOINT, insn_cnt=136, insns=0x56534c1f6650, license="Dual BSD/GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 15, 0), p                                                                                           rog_flags=0, prog_name="handle_execve_e", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=3, func_info_rec_size=8, func_info=0x56534c1f5ba0, func_info_cnt=1, line_info_r                                                                                           ec_size=16, line_info=0x56534c1f5c30, line_info_cnt=69, attach_btf_id=0, attach_prog_fd=0}, 120) = 6

В нем мы видим три аргумента:

  1. Идентификатор типа программы BPF. Все доступные типы можно посмотреть в заголовке bpf.h в перечислении bpf_cmd;

  2. Указатель на структуру BPF-инструкции. Он показывает на массив BPF-инструкций, которые определяют программу, необходимую для загрузки в ядро;

  3. Размер массива BPF-инструкций в байтах.

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

  • prog_type- тип загружаемой eBPF программы, позволяющий понять, на что нацелена программа;

  • insn_cnt- количество инструкций в программе eBPF. Чем больше инструкций, тем сложнее загружаемый модуль и тем больше у него возможностей;

  • prog_name- название программы eBPF. Можно фильтровать доверенные программы или сверять атрибуты доверенных программ с загружаемыми, поскольку злоумышленник может использовать любое название;

  • expected_attach_type- тип объекта ядра, к которому, как ожидается, будет прикреплена eBPF-программа.

Также интересными типами программ для мониторинга являются:

  • BPF_MAP_CREATE создает новый объект BPF map, который является структурой данных для хранения пар ключ-значение. Сложное вредоносное ПО будет оперировать данными, для этого придется создавать различные map;

  • BPF_PROG_GET_NEXT_ID находит идентификатор следующей BPF-программы. Может использоваться при разведке и проверке уже загруженных eBPF модулей;

  • BPF_PROG_ATTACH присоединяет BPF-программу к определенному событию ядра Linux, например, к событию сетевой карты. Может использоваться злоумышленником для перехвата данных, проходящий через систему;

  • BPF_PROG_DETACH отсоединяет BPF-программу от события ядра. Злоумышленник может отключать BPF модули, связанные с безопасностью/мониторингом и в дальнейшем оставаться незамеченным;

  • BPF_PROG_QUERYполучает информацию о BPF-программе. Также используется для получения информации об уже загруженных программах;

  • BPF_RAW_TRACEPOINT_OPEN открывает точку отслеживания в ядре Linux. Кроме этого, позволяет отслеживать процессы в ядре;

  • BPF_BTF_LOADзагружает BTF-типы в ядро Linux. Может использоваться для загрузки вредоносных типов.

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

Мониторинг системных вызовов bpf()

Auditd

Чтобы мониторить все системные вызовы bpf(), используя auditd, добавим следующее правило:

-a always,exit -F arch=b64 -S bpf -F key=bpf

С помощью него мы будем логировать все вызовы bpf(). Запустив уже знакомую нам вредоносную утилиту exechijack, мы увидим как выглядит данный системный вызов:

type=BPF msg=audit(1684082570.912:2485): prog-id=168 op=LOAD
type=SYSCALL msg=audit(1684082570.912:2485): arch=c000003e syscall=321 success=yes exit=6 a0=5 a1=7ffe4bfa6c00 a2=78 a3=0 items=0 ppid=11598 pid=11793 auid=1000 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid
=0 tty=pts1 ses=191 comm="exechijack" exe="/home/sea-admin/bad-bpf/src/bin/exechijack" subj=unconfined key="bpf"^]ARCH=x86_64 SYSCALL=bpf AUID="sea-admin" UID="root" GID="root" EUID="root" SUID="root" FSUID="root"
EGID="root" SGID="root" FSGID="root"
type=PROCTITLE msg=audit(1684082570.912:2485): proctitle=2E2F6578656368696A61636B002D7400333332

Тип SYSCALL как раз показывает заданный системный вызов. Здесь также видно, какой процесс его инициировал из параметра exe. Еще имеются auid и tty, содержащие информацию об audit ID пользователя и используемом терминале соответсвенно. Аргументы a0,a1,a2 передают аргументы вызова bpf(), которые мы разбирали в выводе strace.

Тип BPF содержит информацию о загрузке и выгрузке программы вместе с ее порядковым номером - prog-id.

Но если аргументы a0 и a2 были типами int и unsigned int, и они полностью отображены в выводе auditd, то аргумент a1 является типом union. И здесь мы видим только указатель на его адресное пространство. Чтобы сопоставить числовое значение аргумента a0, нужно посмотреть enum bpf_cmd в файле заголовка bpf - /usr/include/linux/bpf.h. Выглядеть он будет так:

enum bpf_cmd {
	BPF_MAP_CREATE,
	BPF_MAP_LOOKUP_ELEM,
	BPF_MAP_UPDATE_ELEM,
	BPF_MAP_DELETE_ELEM,
	BPF_MAP_GET_NEXT_KEY,
	BPF_PROG_LOAD,
	BPF_OBJ_PIN,
	BPF_OBJ_GET,
	BPF_PROG_ATTACH,
	BPF_PROG_DETACH,
	BPF_PROG_TEST_RUN,
	BPF_PROG_RUN = BPF_PROG_TEST_RUN,
	BPF_PROG_GET_NEXT_ID,
	BPF_MAP_GET_NEXT_ID,
	BPF_PROG_GET_FD_BY_ID,
	BPF_MAP_GET_FD_BY_ID,
	BPF_OBJ_GET_INFO_BY_FD,
	BPF_PROG_QUERY,
	BPF_RAW_TRACEPOINT_OPEN,
	BPF_BTF_LOAD,
	BPF_BTF_GET_FD_BY_ID,
	BPF_TASK_FD_QUERY,
	BPF_MAP_LOOKUP_AND_DELETE_ELEM,
	BPF_MAP_FREEZE,
	BPF_BTF_GET_NEXT_ID,
	BPF_MAP_LOOKUP_BATCH,
	BPF_MAP_LOOKUP_AND_DELETE_BATCH,
	BPF_MAP_UPDATE_BATCH,
	BPF_MAP_DELETE_BATCH,
	BPF_LINK_CREATE,
	BPF_LINK_UPDATE,
	BPF_LINK_GET_FD_BY_ID,
	BPF_LINK_GET_NEXT_ID,
	BPF_ENABLE_STATS,
	BPF_ITER_CREATE,
	BPF_LINK_DETACH,
	BPF_PROG_BIND_MAP,
};

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

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

eBPF мониторинг

Для написания eBPF модуля воспользуемся библиотекой BCC. При этом рассматривать мы будем только тип BPF_PROG_LOAD. Для других типов принцип работы аналогичный, но отличаться будут поля структуры. Все структуры также можно посмотреть в заголовке bpf.h.

По итогу мы получим С-код, который "присоединим" к системному вызову bpf()и в случае соответсвия типа BPF_PROG_LOAD заберем необходимые аргументы, передав их в пользовательское пространство.

BPF_PERF_OUTPUT(args);

#include <linux/ptrace.h>
#include <linux/bpf.h>
// структура данных для передачи в пользовательское пространство
struct args {
    char task[TASK_COMM_LEN];
    int com;
    int ptype;
    int icount;
    char pname[32];
    int atype;
    unsigned int psize;
};
// основная функция для перехвата системных вызовов bpf()
int syscall__bpf(struct pt_regs *ctx, int cmd, union bpf_attr *attr, unsigned int size){
    // условия для вывода только типа BPF_PROG_LOAD
    enum bpf_cmd condition;
    condition=BPF_PROG_LOAD;
    if (cmd == condition) {
       struct args arg_data = {};
       // 
	
       bpf_get_current_comm(&arg_data.task, sizeof(&arg_data.task));

       bpf_probe_read(&arg_data.com, sizeof(arg_data.com), &cmd);

       bpf_probe_read(&arg_data.ptype, sizeof(arg_data.ptype), &attr->prog_type);
       bpf_probe_read(&arg_data.icount, sizeof(arg_data.icount), &attr->insn_cnt);
       bpf_probe_read(&arg_data.pname, sizeof(arg_data.pname), &attr->prog_name);
       bpf_probe_read(&arg_data.atype, sizeof(arg_data.atype), &attr->expected_attach_type);

       bpf_probe_read(&arg_data.psize, sizeof(arg_data.psize), &size);

       args.perf_submit(ctx, &arg_data, sizeof(arg_data));

       return 0;
       }
}

Далее мы используем python-код, который будет вызывать C модуль, представленный выше, и выводить переменные в пользовательское пространство:

from bcc import BPF 
# Читаем ebpf код из файла 
with open("bpf.c") as file: 
	bpf_txt = file.read()
def handle_event(cpu, data, size):
    output = bpf_ctx["args"].event(data)
    print("{:<10} {:<10} {:<10} {:<10} {:<20} {:<10} {:<10}".format(output.task.decode("utf-8") , 
        output.com, output.ptype, output.icount, output.pname.decode("utf-8") , output.atype, output.psize))

bpf_ctx = BPF(text=bpf_txt)
# Присоединяем наш ebpf к системному вызову bpf
syscall_fnname = bpf_ctx.get_syscall_fnname("bpf")
bpf_ctx.attach_kprobe(event=syscall_fnname, fn_name="syscall__bpf")

bpf_ctx["args"].open_perf_buffer(handle_event)

# Настраиваем вывод в консоль 
print("{:<10} {:<10} {:<10} {:<10} {:<20} {:<10} {:<10}".format('exe_task', 
    'bpf_type', 'prog_type', 'insn_cnt','prog_name', 'attach_type', 'size'))
while 1:
    try:
        bpf_ctx.perf_buffer_poll()
    except KeyboardInterrupt:
        print()
        exit()


Для работы нашего модуля необходимо просто запустить Python файл.

python3 bpf-detect.py

При запуске того же вредоносного модуля exechijack, мы получим всю необходимую нам информацию:

exe_task   bpf_type   prog_type  insn_cnt   prog_name            a_type     size
exechij    5          1          2                               0          120
exechij    5          1          5                               0          120
exechij    5          1          2          test                 0          120
exechij    5          2          6                               0          120
exechij    5          9          2                               2          120
exechij    5          5          136        handle_execve_e      0          120
exechij    5          1          2                               0          120

Здесь также видно, что все типы представлены в числовом формате, но их enum тоже хранятся в заголовке bpf.h. Таким образом мы можем получать максимально возможную информацию о загружаемых eBPF модулях и использовать ее для обнаружения нарушителя.

Вывод

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

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

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

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

Автор: Епишев Алексей(@alexepishev), аналитик-исследователь киберугроз в компании R-Vision

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


  1. slava_k
    24.05.2023 17:14
    +2

    Большое спасибо за статью!


    1. alexepishev
      24.05.2023 17:14
      +3

      Пожалуйста!
      Рад, что понравилось