Оглавление


В 2023 году у DFIR-команды Solar 4RAYS было интересное расследование, в котором сходу не удалось найти ВПО в образе системы на базе ОС Linux. Недавно отгремел BPFDoor, поэтому у нас появились мысли о том, что ВПО было в eBPF (встроенная в Linux технология запуска кода в пространстве ядра). ВПО, конечно же, было найдено, но оно никак не было связано с eBPF. Однако мысли о вредоносах на eBPF никуда не делись.

Говорить о eBPF в контексте вредоносного использования важно, так как:

  • eBPF работает в пространстве ядра;
  • Порог вхождения для написания eBPF-программ существенно ниже, чем для написания модулей ядра;
  • На базе Linux работает практически весь prod;
  • Систем на базе Linux (не только серверных) в России становится все больше;
  • Нам кажется, что тема в сообществе очень слабо освещена.

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



eBPF 101

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

Главные особенности eBPF:

  • Just-in-Time (JIT) compilation;
  • Event-driven: eBPF-программы прикрепляются к различным точкам в ядре и начинают работать в ответ на определенное событие;
  • k(ret)probes, u(ret)probes, tracepoints: существует множество вариантов точек прикрепления;
  • bpf-helpers: eBPF поддерживает различные безопасные обертки над стандратными функциями, которые позволяют выполнять различные действия (чтение памяти, запись в память, получение информации о процессе);
  • Usermode+kernelmode interaction: eBPF поддерживает простое взаимодействие между пространством пользователя и пространством ядра.

Основные области применения eBPF:

  • Networking
  • Observability
  • Tracing
  • Security
Области использования eBPF
Области использования eBPF
Жизненный цикл eBPF-программы
Жизненный цикл eBPF-программы

Для работы с eBPF обычно используются библиотеки на различных языках программирования (Python, Rust, Go, C). Написанная на языке C программа компилируется, загружается в ядро и верифицируется.

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



(evil)BPF - offensive-возможности

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

  • Files hiding: сокрытия файлов из выводов различных утилит;
  • Processes hiding: сокрытия процессов из выводов различных утилит;
  • Socket hiding: сокрытия сокетов;
  • Input/output hijacking: подмены ввода/вывода различных системных вызовов;
  • Execution flow hijacking: изменения потока выполнения программ;
  • Read/write blocking: блокировки чтения/записи для заданных файлов;
  • Traffic filters (passive backdoor): создания фильтров трафика для сокетов (примерно по этому же принципу работает tcpdump).

Большинство возможностей обеспечивается посредством перехвата системных вызовов, отвечающих за ту или иную операцию, с последующей проверкой каких-либо условий.Например, можно перехватить вызов getdents64, чтобы скрыть из вывода утилиты ps запись об определенном процессе.

В целом, по вредоносным возможностям eBPF напоминает руткиты, так как с ее помощью можно скрывать поведение на системе.

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



Гипотеза

Как же подготовиться к возможным атакам с помощью eBPF-вредоносов? Сформулируем гипотезу, по которой дальше будем работать:

  • Для работы eBPF-программы нужно загрузить ее в ядро;
  • Чтобы предотвратить атаку с помощью такого ВПО, нужно анализировать загружаемую программу в момент ее загрузки, предварительно выделив признаки для анализа;
  • Если реализованные меры предотвращения атаки не сработают на конкретную загружаемую программу, нужно выделить признаки для последующего анализа eBPF-программы в пространстве пользователя.

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



Detection. Trivial


Теория: загрузка eBPF-программы

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

Вывод strace при запуске eBPF-программы
Вывод strace при запуске eBPF-программы

Утилита exechijack загружает в ядро eBPF-программу. В выводе strace видно, что при загрузке программы многократно выполняется один системный вызов: bpf().

Фактически, bpf() - главный и единственный системный вызов, с помощью которого происходит работа с eBPF. Он отвечает за все аспекты работы eBPF: от загрузки программы до передачи значений через мапы.

Системный вызов bpf()
Системный вызов bpf()
  • Первый аргумент (int cmd) - число (константа), которое определяет выполняемую операцию;
  • Второй аргумент (bpf_attr *attr) - указатель на структуру, которая будет определена в зависимости от первого аргумента (мы вернемся к ней позже);
  • Третий аргумент (unsigned int size) - размер структуры bpf_attr.
    
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, // cmd == 5
    ....,
    BPF_TOKEN_CREATE,
    __MAX_BPF_CMD,
};        
    
Множество возможных значений cmd

Enum bpf_cmd содержит в себе множество значений, но в контексте гипотезы наиболее интересным является BPF_PROG_LOAD (cmd = 5), поскольку системный вызов bpf() с этой командой выполняет загрузку программы в ядро.


Логика обнаружения на примере AuditD и Falco

Для обнаружения загрузки eBPF-программ в ядро добавим в конфиги Falco и AuditD записи с логикой отслеживания системного вызова bpf() с первым аргументом (cmd), равным BPF_PROG_LOAD (5):

    
#bpf 
-a always,exit -F arch=b64 -S bpf -F a0=5 -F key=bpf_prog_load
Правило AuditD для обнаружения загрузки eBPF-программ

    
#bpf
- macro: bpfuse
  condition: (evt.type = bpf)

- rule: EBPF Module Load
  desc:  EBPF Module Load
  condition: >
    bpfuse and evt.arg.cmd = 5
  enabled: true
  output: "BPF load (%evt.type %user.name %user.uid %user.loginuid %user.loginname %container.id %container.name %container.image %container.image.id %container.privileged %proc.aexepath[2] %proc.aexepath[3] %proc.aexepath[4] %proc.aexepath[5] %proc.apid[2] %proc.apid[3] %proc.apid[4] %proc.apid[5] %proc.duration %proc.sid %proc.tty %proc.cwd %proc.pid %proc.exepath %proc.exe %proc.name %proc.cmdline %proc.ppid %proc.pexepath         %fd.name %evt.arg.flags %evt.res %evt.args %evt.info %evt.arg.cmd)"
  priority: INFO
  tags: [eventid_XX, bpf_prog_load, version_XX]
    
Конфиг Falco для детектирования загрузки ebpf программ

Соответствующие примеры событий AuditD и Falco:

    
{
    "node": "-------",
    "type": "SYSCALL",
    "msg": "audit(1728891118.040:546016)",
    "details": {
        "arch": "c000003e",
        "syscall": 321,
        "success": "yes",
        "exit": 5,
        "a0": 5,
        "a1": "7ffc66c2afe0",
        "a2": 74,
        "a3": 70,
        "items": 0,
        "ppid": 393196,
        "pid": 393343,
        "auid": 4294967295,
        "uid": 0,
        "gid": 0,
        "euid": 0,
        "suid": 0,
        "fsuid": 0,
        "egid": 0,
        "sgid": 0,
        "fsgid": 0,
        "tty": "pts1",
        "ses": 4294967295,
        "comm": "exechijack",
        "exe": "/home/user/blood/exechijack",
        "subj": "unconfined",
        "key": "bpf_prog_load"
    }
}          
    
Пример события AuditD

    
{
    "hostname": "-------",
    "priority": "Informational",
    "rule": "EBPF Module Load",
    "source": "syscall",
    "tags": [
        "bpf_prog_load",
        "eventid_xxx",
        "version_xxx"
    ],
    "time": "2024-10-15T08:08:05.098803707Z",
    "output_fields": {
        "container.id": "host",
        .........,
        "evt.type": "bpf",
        "fd.name": null,
        "proc.aexepath[2]": "/usr/bin/su",
        "proc.aexepath[3]": "/usr/bin/sudo",
        "proc.aexepath[4]": "/usr/bin/sudo",
        "proc.aexepath[5]": "/usr/bin/bash",
        "proc.apid[2]": 18999,
        "proc.apid[3]": 18998,
        "proc.apid[4]": 18997,
        "proc.apid[5]": 18986,
        "proc.cmdline": "exechijack",
        "proc.cwd": "/home/user/blood/bad-bpf/",
        "proc.duration": 26152894,
        "proc.exe": "./exechijack",
        "proc.exepath": "/home/user/blood/bad-bpf/exechijack",
        "proc.name": "exechijack",
        "proc.pexepath": "/usr/bin/bash",
        "proc.pid": 29328,
        "proc.ppid": 19000,
        "proc.sid": 18998,
        "proc.tty": 34817,
        "user.loginname": "user",
        "user.loginuid": 1000,
        "user.name": "root",
        "user.uid": 0
    }
}          
    
Пример события Falco

Полезных данных в событиях не так много:

  • ppid;
  • pid;
  • Имя процесса-инициатора загрузки eBPF-программы;
  • Имя родительского процесса;
  • user id (*uid);
  • user name.

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

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

Пример правила Sigma
Пример правила Sigma


Detection. Kernel-mode


Теория: структура bpf_attr

Для более точного детекта нам нужно больше контекста, чем предлагает предыдущий метод. Осталось только понять, откуда этот контекст можно получить.

Внимательно посмотрим на структуру bpf_attr, которая является вторым аргументом системного вызова bpf():

Структура bpf_attr
Структура bpf_attr
    
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE, insn_cnt=46,  
insns=0x7608d914ab78, license="GPL",
log_level=0, log_size=0, log_buf=NULL,
kern_version=KERNEL_VERSION(6, 8, 12), prog_flags=0, prog_name="hello", prog_ifindex=0,
expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=3, func_info_rec_size=8,
func_info=0x56f5df3a0cc0, func_info_cnt=1, line_info_rec_size=16,
line_info=0x56f5deed8780,
line_info_cnt=20, attach_btf_id=0, attach_prog_fd=0, fd_array=NULL}, 128) = 5
Второй аргумент - структура bpf_attr (вывод strace)

В структуре bpf_attr присутствует следующая полезная информация:

1) prog_type - тип eBPF-программы. Он означает, какой тип события будет триггером для начала работы загруженной eBPF-программы. К примеру, у BPF_PROG_TYPE_KPROBE триггером для срабатывания будет kprobe.

    
enum bpf_prog_type {
    BPF_PROG_TYPE_UNSPEC,
    BPF_PROG_TYPE_SOCKET_FILTER,
    BPF_PROG_TYPE_KPROBE,
    BPF_PROG_TYPE_SCHED_CLS,
    BPF_PROG_TYPE_SCHED_ACT,
    BPF_PROG_TYPE_TRACEPOINT,
    BPF_PROG_TYPE_XDP,
    BPF_PROG_TYPE_PERF_EVENT,
    BPF_PROG_TYPE_CGROUP_SKB,
    BPF_PROG_TYPE_CGROUP_SOCK,
    BPF_PROG_TYPE_LWT_IN,
    BPF_PROG_TYPE_LWT_OUT,
    BPF_PROG_TYPE_LWT_XMIT,
    BPF_PROG_TYPE_SOCK_OPS,
    BPF_PROG_TYPE_SK_SKB,
};        
    
Множество типов eBPF-программ

2) prog_name - имя функции самой eBPF-программы. Не путать с именем процесса.

    
SEC("tp/syscalls/sys_enter_execve")
int handle_execve_enter(struct trace_event_raw_sys_enter *ctx)
{return 0;}
    
handle_execve_enter - prog_name


Теория: инструкции программы (insns)

Помимо общей информации о перехватываемой eBPF-программе из bpf_attr возможно получить листинг её инструкций, а также число инструкций (insn_cnt). Все инструкции загружаемой eBPF-программы находятся в массиве insns:

Массив инструкций insns в bpf_attr
Массив инструкций insns в bpf_attr

Каждый элемент массива - это структура bpf_insn (инструкция eBPF-программы):

  • code - opcode операции;
  • dst_reg, src_reg - регистры;
  • off - смещение;
  • imm - константное значение.
Элемент массива - структура bpf_insn
Элемент массива - структура bpf_insn
Пример инструкции eBPF
Пример инструкции eBPF

Теория: bpf-helpers

В eBPF имеются вспомогательные функции (bpf-helpers), которые являются безопасными обертками над различными функциями ядра. С их помощью внутри eBPF-программы становятся возможны различные действия:

  • Чтение памяти;
  • Получение информации об исполняемом файле;
  • Вывод в консоль;
  • Работа с мапами;
  • Работа с различными kernel-структурами.
    
//чтение памяти по указателю
bpf_probe_read_user(&prog_name_orig, TASK_COMM_LEN, (void*)ctx->args[0]);
..
datapid = bpf_get_current_pid_tgid() >> 32;
    
Примеры вызовов bpf_helpers

Вызов bpf-helpers в листинге eBPF-программы производится с помощью инструкции BPF_EMIT_CALL():

  • .code = 133 (0x85)
  • .imm = <helper_num>
  • helper_num – порядковый номер функции, определенный в bpf.h
    
static long (* const bpf_probe_read)(void *dst, __u32 size, const void *unsafe_ptr) = (void *) 4;
//code = 133
//imm = 4
    
Пример определения функции bpf_probe_read

В заголовках определено большое число (200+) хэлперов, некоторые из них можно назвать потенциально подозрительными:

  • bpf_override_return - может использоваться для перезаписи возвращаемого системным вызовом значения;
  • bpf_probe_write_user - может использоваться для изменения различных данных в памяти;
  • bpf_copy_from_user - может использоваться для чтения информации из пространства пользователя.

Реализация

Получение общей информации о eBPF-программе

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

Для реализации была использована библиотека BCC (Python), в качестве точки трассировки выбрана kprobe (вход в системный вызов). В самой программе будем отслеживать вызовы с cmd = BPF_PROG_LOAD:

    
# Load BPF program
bpf_ctx = BPF(text=bpf_prog_txt)
event_name = bpf_ctx.get_syscall_fnname("bpf")
bpf_ctx.attach_kprobe(event=event_name, fn_name="syscall__bpf")
    
Прикрепление функции syscall__bpf к kprobe системного вызова bpf()

    
struct gen_info {
    u32 random_id;
    u32 pid;
    char comm[TASK_COMM_LEN];
    char prog_name[MAX_PROG_FULL_NAME];
    int cmd;
    u32 prog_type;
    u32 insn_cnt;
    u32 log_size;
};
        
int syscall__bpf(struct pt_regs *ctx, int cmd, union bpf_attr *attr, unsigned int size) {
    enum bpf_cmd condition = BPF_PROG_LOAD;
    if (cmd == condition) {
        //randomuniq prog id
        u32 random_id = bpf_get_prandom_u32();
        // load generic_info structure
        struct gen_info s_gen_info = {};
        s_gen_info.pid = bpf_get_current_pid_tgid();
        s_gen_info.cmd = cmd;
        bpf_get_current_comm(&s_gen_info.comm, sizeof(s_gen_info.comm));
        s_gen_info.prog_type = attr->prog_type;
        s_gen_info.insn_cnt = attr->insn_cnt;
        s_gen_info.log_size = attr->log_size;
        s_gen_info.random_id = random_id;
        bpf_probe_read(&s_gen_info.prog_name, sizeof(attr->prog_name), (void *)(attr->prog_name));
        //to outside genericinfo
        generic_info.perf_submit(ctx, &s_gen_info, sizeof(s_gen_info));
        return 0;
    }
}        
    
Получение общей информации о eBPF-программе

Пояснения по коду выше:

  • Собирается информация о процессе, инициировавшем вызов bpf(): comm (имя процесса) с помощью bpf_get_current_comm() и pid с помощью bpf_get_current_pid_tgid();
  • Собирается различная информация о eBPF-программе из структуры bpf_attr (prog_name, prog_type, insn_cnt);
  • Формируется структура gen_info, содержащая собранную информацию.

Далее структура gen_info передается в user-mode с помощью perf_submit().

Листинг инструкций и bpf-helpers

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

    
struct correct_types {
    u32 random_id;
    u32 insn_cnt;
    u32 insn_num;
    u8  code;
    u8 reg;
    s16 off;
    s32  imm;
    char prog_name[32];
};
        
BPF_ARRAY(parseArray, struct correct_types, MAX_INSN_COUNT);
        
int syscall__bpf(struct pt_regs *ctx, int cmd, union bpf_attr *attr, unsigned int size) {
enum bpf_cmd condition = BPF_PROG_LOAD;
    if (cmd == condition){
        
/////get pid,comm,prog_type,prog_name
        
    u64 insns_ptr = attr->insns;
    for (u64 i = 0; i < count; i++) {
        u64 insn_addr = insns_ptr + i * sizeof(insn);
        bpf_probe_read(&insn, sizeof(insn), (void *)insn_addr);
        struct correct_types new_correct = {};
        new_correct.code = insn.code;
        new_correct.reg = insn.reg;
        new_correct.off = insn.off;
        new_correct.imm = insn.imm;
        new_correct.insn_num = i;
        new_correct.insn_cnt = attr->insn_cnt;
        new_correct.random_id = random_id;
        bpf_probe_read(new_correct.prog_name, sizeof(prog_n), prog_n);
        int key = i;
        parseArray.update(&key, &new_correct);
    }
}        
    
Извлечение инструкций из массива insns

Каждая инструкция с дополнительной информацией помещается в структуру correct_types, которая помещается в массив parseArray. В итоге получаем листинг инструкций eBPF-программы, которую мы перехватили.

    
random_id: 124124, insn_cnt: 46, insn_num: 0, code: 191, reg: 22, off: 0, imm: 0, name: , prog_name: hello 
random_id: 124124, insn_cnt: 46, insn_num: 1, code: 183, reg: 1, off: 0, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 2, code: 99, reg: 26, off: -8, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 3, code: 123, reg: 26, off: -16, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 4, code: 123, reg: 26, off: -24, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 5, code: 123, reg: 26, off: -32, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 6, code: 183, reg: 1, off: 0, imm: 6581362, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 7, code: 99, reg: 26, off: -48, imm: 0, name: , prog_name: hello
Листинг инструкций eBPF-программы hello

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

    
struct helper_function_string {
    char helper_name[32];
};
BPF_HASH(func_table, u64, struct helper_function_string);
int func_table_init (struct pt_regs *ctx) {
        u64 func_key;
        
        func_key = 6;
        struct helper_function_string bpf_trace_printk_str = {"bpf_trace_printk"};
        func_table.update(&func_key, &bpf_trace_printk_str);
        func_key = 14;
        struct helper_function_string bpf_get_current_pid_tgid_str = {"bpf_get_current_pid_tgid"};
        func_table.update(&func_key, &bpf_get_current_pid_tgid_str);
        // others
        return 0;
}        
    
Инициализация названий bpf-helpers по их номеру imm в хэш-таблице func_table

Остается только пройтись по листингу инструкций и обогатить все инструкции с .code = 133 (BPF_EMIT_CALL) соответствующими названиями в хэш-таблице:

    
for (u32 i = 0; i < count; i++) {
    u64 insn_addr = insns_ptr + i * sizeof(insn);
    bpf_probe_read(&insn, sizeof(insn), (void *)insn_addr);
    if (insn.code == 133) {
        u64 key_to_query = correct.imm;
        bpf_trace_printk("code: %d, imm: %d", insn.code, insn.imm);
        struct helper_function_string *res = func_table.lookup(&key_to_query);
        if (!res){
            return 0;
        }
        char name[MAX_FUNC_NAME_LEN];
        bpf_probe_read(name, sizeof(name), res->helper_name);
        bpf_probe_read(correct.name, sizeof(name), name);
    }
    else {
        continue;
    }
}        
    
Обогащение инструкций именами bpf-helpers

Итого на выходе получаем:

  • Общую информацию о перехватываемой eBPF-программе;
  • Листинг инструкций eBPF-программы;
  • Обогащенные именами вызовы bpf-helpers.
    
{'comm': b'hello-buffer.py', 'pid': 61316, 'cmd': 5, 'prog_type': 2, 'prog_name': b'hello', 'insn_cnt': 46} 
random_id: 124124, insn_cnt: 46, insn_num: 0, code: 191, reg: 22, off: 0, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 1, code: 183, reg: 1, off: 0, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 2, code: 99, reg: 26, off: -8, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 3, code: 123, reg: 26, off: -16, imm: 0, name: , prog_name: hello
random_id: 124124, insn_cnt: 46, insn_num: 4, code: 123, reg: 26, off: -24, imm: 0, name: , prog_name: hello
//////
random_id: 124124, insn_cnt: 46, insn_num: 14, code: 133, reg: 0, off: 0, imm: 15, name: bpf_get_current_pid_tgid, prog_name: hello
///////
random_id: 124124, insn_cnt: 46, insn_num: 28, code: 133, reg: 0, off: 0, imm: 16, name: bpf_trace_printk, prog_name: hello
///////
random_id: 124124, insn_cnt: 46, insn_num: 45, code: 149, reg: 0, off: 0, imm: 0, name: , prog_name: hello
Итоговый обогащенный листинг и общая информация о загружаемой eBPF-программе

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


Сложности реализации

Почему в eBPF-программе нельзя получить больше информации о перехваченной программе? Дело в том, что eBPF имеет множество ограничений. Например:

  • Ограниченное количество инструкций в рамках одной программы (1000000 insns);
  • Вложенные циклы тяжело даются eBPF (даже 1 уровень вложенности), потому что они считаются слишком сложными, и программа не компилируется. Связано это также с тем, что eBPF разворачивает все циклы в “простыню” инструкций с учетом всех ветвлений внутри;
  • В eBPF отсутствуют многие стандартные C-библиотеки, из-за чего функциональность сильно ограничена (пример - сравнение строк).

Далее мы так или иначе будем пробовать обходить эти ограничения.



Prevention. Kernel-mode

Мы решили не останавливаться на достигнутом и попробовать прервать загрузку eBPF до её загрузки в ядро на основе паттернов, которые классифицируют программу как вредоносную.

Предлагаемые паттерны для классификации:

1) Захардкоженный в eBPF-программе prog_name;

2) Характерное количество определенных bpf-helpers в рамках одной программы.

Что нужно сделать для реализации Prevention:

1) Понять, как прервать загрузку программы в ядро;

2) Обойти ограничения на количество инструкций;

3) Сформировать паттерны на основе анализа кода вредоносных eBPF-модулей и протестировать работоспособность.


PoC eBPF corruption

Для прерывания загрузки eBPF-программы, которую мы перехватили, был разработан PoC под названием “eBPF corruption”.

Принцип работы PoC:

1) Формируется структура bpf_insn, где .code присваивается значение 149 BPF_EXIT_INSN(). Таким образом мы формируем инструкцию, которая выполняет выход из программы;

2) С помощью bpf_probe_write_user на место первой инструкции перехватываемой eBPF-программы помещается инструкция BPF_EXIT_INSN();

3) Таким образом, перехваченная нами eBPF-программа ломается и выдает ошибку при попытке загрузки.

    
struct bpf_insn_out {
    u8   code;
    u8   dst_reg:4;
    u8   src_reg:4;
    s16  off;
    s32  imm;
};
        
int syscall__bpf(struct pt_regs *ctx, int cmd, union bpf_attr *attr, unsigned int size) {
    if (cmd == BPF_PROG_LOAD) {
        struct event_t event = {};
        event.insns_ptr = (u64) attr->insns;
        
        struct bpf_insn_out new_insn = {0};
        new_insn.code = 0x95;
        
        // Запись новой инструкции по указателю insns_ptr
        bpf_probe_write_user((void *)event.insns_ptr, &new_insn, sizeof(new_insn));
    }
    return 0;
}        
    
Код перехватывает любую eBPF-программу и осуществляет её corruption путём перезаписи первой инструкции


Tail Calls. Обход ограничений

Для того, чтобы обойти ограничения на количество инструкций в рамках одной eBPF-программы, был использован механизм Tail Calls. Он позволяет разбить логику одной eBPF-программы на несколько последовательно вызываемых eBPF-программ, чтобы немного смягчить ограничения на количество инструкций.

    
int syscall__bpf(struct pt_regs *ctx, int cmd, union bpf_attr *attr, unsigned int size) {
    enum bpf_cmd condition = BPF_PROG_LOAD;
    if (cmd == condition) {
        for (u64 i = 0; i < count; i++) {
            //do some things
        }
    }
        //tail call in next eBPF prog filtersInit()
        prog_array.call(ctx, 1);
        return 0;
    }
        
int filtersInit(struct pt_regs *ctx) {
    //init patterns
    // call next eBPF prog...
    prog_array.call(ctx, 2);
    return 0;
    }        
    
Пример tailcall вызова из syscall__bpf функции filtersInit


Реализация

Теперь наша eBPF-программа имеет следующую логику:

1) Точка входа - syscall__bpf. Перехватывает загрузку eBPF-программы, получает общую информацию о ней, осуществляет листинг инструкций insns;

2) filtersInit. Инициализирует ранее описанные нами паттерны (количество определенных bpf-helpers и prog_name);

3) funcPatternfilter и funcPatternfilterverdict. Осуществляют парсинг bpf-helpers из листинга и сравнивают их с паттернами. Если программа попадает под один из них, то происходит eBPF corruption, иначе происходит переход к следующей проверке;

4) funcPatternProgname. Осуществляет проверку по паттернам имен eBPF-программ. Если программа попадает под один из них, то происходит eBPF corruption, иначе происходит переход к следующей программ;.

5) successfulVerification. К этому моменту все проверки пройдены. Листинг программы и общая информация отдается в user-mode.

Схема вызова tailCalls в Prevention
Схема вызова tailCalls в Prevention

Пример работы Prevention

ExecHijack

Для проверки работы механизма Prevention использован модуль ExecHijack из пака bad-bpf. Программа перехватывает все системные вызовы execve (запуск процессов) и вместо целевого процесса запускает другой (определенный на этапе запуска ExecHijack). Для примера сначала вызываем whoami с включенным ExecHijack, потом без него.

    
user@k8smaster:~$ whoami
user
user@k8smaster:~$ whoami
User 1000 | argv[0] whoami
    
whoami с работающим ExecHijack

ExecHijack. Принцип работы

1) eBPF-модуль handle_execve_enter прикрепляется к execve (sys_enter_execve):

    
SEC("tp/syscalls/sys_enter_execve")
int handle_execve_enter(struct trace_event_raw_sys_enter *ctx)
{
    size_t pid_tgid = bpf_get_current_pid_tgid();
    // Check if we're a process of interest
    if (target_ppid != 0) {
    }
....................
}
    

2) Далее программа подменяет args[0], в котором хранится путь до бинарного файла, на /a (подложенный бинарный файл):

    
// Attempt to overwrite with hijacked binary path
prog_name[0] = '/';
prog_name[1] = 'a';
for (int i = 2; i < TASK_COMM_LEN ; i++) {
    prog_name[i] = '\x00';
}
long ret = bpf_probe_write_user((void*)ctx->args[0], &prog_name, 3);
    

3) /a может иметь любой функционал, в нашем случае выводится uid и argv[0]:

    
int main(int argc, char *argv[]) {
    printf("User %d | argv[0] %s\n", getuid(), argv[0]);
    return 0;
}        
    

ExecHijack. Формирование паттернов

Проанализировав исходный код ExecHijack, можно выделить характерное количество вызываемых bpf-helpers (название, количество вызовов):

  • bpf_get_current_pid_tgid – 1
  • bpf_get_current_task – 1
  • bpf_probe_read_kernel – 2
  • bpf_probe_read_user – 2
  • bpf_trace_printk – 2
  • bpf_probe_write_user – 1
  • bpf_ringbuf_reserve – 1
  • bpf_ringbuf_submit - 1

А также добавить в паттерны захардкоженное название программы:

  • handle_execve_enter
    
detectname == "exechijack":
funclimitpatterns = {
	14: 1, #bpf_get_current_pid_tgid
	35: 1, #bpf_get_current_task
	113: 2, #bpf_probe_read_kernel
	112: 2, #bpf_probe_read_user
	6: 2, #bpf_trace_printk
	36: 1, #bpf_probe_write_user
	131: 1, #bpf_ringbuf_reserve
	132: 1, #bpf_ringbuf_submit
}
prognamepatterns = ["handle_execve_enter"]
    
Итоговый паттерн для Prevention ExecHijack

ExecHijack. Тестирование Prevention

При использовании в нашей программе паттернов, указанных выше, ExecHijack не сможет загрузиться:

    
root@k8smaster:/bad-bpf# ./exechijack 
libbpf: loading object 'exechijack_bpf' from buffer
.......
jump out of range from insn 2 to 25467
processed 0 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0
-- END PROG LOAD LOG --
libbpf: prog 'handle_execve_enter': failed to load: -22
libbpf: failed to load object 'exechijack_bpf'
libbpf: failed to load BPF skeleton 'exechijack_bpf': -22
Failed to load and verify BPF skeleton
    
ExecHijack Corruption

Pamspy

Также для тестирования механизма Prevention проанализирована eBPF-утилита Pamspy.

PAM (Pluggable Authentication Modules) используется в ходе обработки аутентификаций такими программами, как sudo, sshd, etc. Это условный API, который используют приложения для проверки подлинности пользователя.

Утилита Pamspy цепляется к функции pam_get_authtok в библиотеке libpam.so.0, перехватывает данные аутентификации и выводит пароль в открытом виде.

Таким образом, каждый раз, когда кто-то осуществит аутентификацию с помощью sudo, sshd, etc, Pamspy перехватит данные в открытом виде.

Pamspy. Принцип работы

1) На вход передается путь до elf файла библиотеки libpam.so.0. По пути и названию нужной функции (pam_get_authtok) вычисляется ее смещение в библиотеке:

    
int offset = pamspy_find_symbol_address(env.libpam_path, "pam_get_authtok");
    

2) По данному смещению прикрепляются eBPF-обработчики uprobe: get_addr_pam_get_authtok (вход в фунцию) и trace_pam_get_authtok (выход из функции):

    
// Attach userland probes
skel->links.get_addr_pam_get_authtok = bpf_program__attach_uprobe(
    skel->progs.get_addr_pam_get_authtok,
	false,           /* uprobe */
	-1,             /* any pid */
	env.libpam_path,       /* path to the lib*/
	offset
);
skel->links.trace_pam_get_authtok = bpf_program__attach_uprobe(
    skel->progs.trace_pam_get_authtok,
	true,           /* uretprobe */
	-1,             /* any pid */
	env.libpam_path,       /* path to the lib*/
	offset
);
    

3) Переходим в логику eBPF-программы. При входе в функцию сохраняется указатель на структуру pam_handle_t в хэш-таблицу pam_handle_t_map, ключом является pid. Данная структура содержит в себе логин и пароль.

    
SEC("uprobe/pam_get_authtok")
int get_addr_pam_get_authtok(struct pt_regs *ctx)
{
  if (!PT_REGS_PARM1(ctx))
    return 0;
  pam_handle_t* phandle = (pam_handle_t*)PT_REGS_PARM1(ctx);
  // Get current PID to track
  u32 pid = bpf_get_current_pid_tgid() >> 32;
  // Store pam_handle_t pointer in map for later use
  bpf_map_update_elem(&pam_handle_t_map, &pid, &phandle, BPF_ANY);
  return 0;
};
    

4) При выходе из функции мы получаем свой pid, и по которому (как по ключу) обращаемся в хэш-мапу, извлекаем соответствующую структуру pam_handle_t и получаем логин и пароль. Отправляем их в пользовательское пространство через кольцевой буфер.

    
SEC("uretprobe/pam_get_authtok")
int trace_pam_get_authtok(struct pt_regs *ctx)
{
  pam_handle_t* phandle = 0;
  // Get current PID to track
  u32 pid = bpf_get_current_pid_tgid() >> 32;
  // Get pam_handle_t pointer from map
  void *pam_handle_t_ptr = bpf_map_lookup_elem(&pam_handle_t_map, &pid);
  if (!pam_handle_t_ptr)
    return 0;
  bpf_probe_read(&phandle, sizeof(phandle), pam_handle_t_ptr);
  // Delete map entry after use
  if (bpf_map_delete_elem(&pam_handle_t_map, &pid)) return 0;
  // retrieve output parameter
  u64 password_addr = 0;
  bpf_probe_read(&password_addr, sizeof(password_addr), &phandle->authtok);
  u64 username_addr = 0;
  bpf_probe_read(&username_addr, sizeof(username_addr), &phandle->user);
  event_t *e;
  e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
  if (e)
  {
    e->pid = pid;
    bpf_probe_read(&e->password, sizeof(e->password), (void *)password_addr);
    bpf_probe_read(&e->username, sizeof(e->username), (void *)username_addr);
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    bpf_ringbuf_submit(e, 0);
  }
    

Pamspy. Формирование паттернов

После анализа исходного кода Pamspy можно выделить характерное количество вызываемых bpf-helpers (название, количество вызовов) для функции trace_pam_get_authtok:

  • bpf_get_current_pid_tgid – 1
  • bpf_map_lookup_elem – 1
  • bpf_probe_read – 5
  • bpf_map_delete_elem – 1
  • bpf_get_current_comm – 1
  • bpf_ringbuf_submit - 1

Также можно добавить в паттерны название программы trace_pam_get_authtok.

    
detectname == "pamspy":
funclimitpatterns = {
	14: 1, #bpf_get_current_pid_tgid
	1: 1, #bpf_map_lookup_elem
	4: 5, #bpf_probe_read
	3: 1, #bpf_map_delete_elem
	16: 1, #bpf_get_current_comm
	132: 1, #bpf_ringbuf_submit
	131: 1, #bpf_ringbuf_reserve
}
prognamepatterns = ["trace_pam_get_authtok"]
    
Итоговый паттерн для Prevention Pamspy

Pamspy. Тестирование Prevention

Результат тестирования аналогичен результату для ExecHijack: загрузка программы успешно прерывается:

    
root@k8smaster:/pamspy# ./pamspy -p $(/usr/sbin/ldconfig -p | grep libpam.so | cut -d ' ' -f4)
libbpf: prog 'trace_pam_get_authtok': BPF program load failed: Invalid argument
libbpf: prog 'trace_pam_get_authtok': -- BEGIN PROG LOAD LOG --
jump out of range from insn 2 to 2546
processed 0 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0
-- END PROG LOAD LOG --
libbpf: failed to load program 'trace_pam_get_authtok'
libbpf: failed to load object 'pamspy_bpf'
libbpf: failed to load BPF skeleton 'pamspy_bpf': -22
pamspy: Failed to load BPF program: Invalid argument
    
Pamspy Corruption



Detection. User-mode

Если загруженная eBPF-программа прошла все проверки в пространстве ядра, то мы продолжим последующий анализ в пространстве пользователя, куда передаем следующую информацию:

  • Метаданные загруженной программы;
  • Информация о процессе;
  • Листинг загруженной программы в формате структур, который мы упоминали выше.

Инструкции

Напомним, как выглядит инструкция:

Инструкция eBPF-программы, полученная из kernel-mode
Инструкция eBPF-программы, полученная из kernel-mode

и как определена структура bpf_insn в заголовках:

Структура bpf_insn
Структура bpf_insn

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

Итак, код eBPF-программы запускается в виртуальной машине и имеет свой набор инструкций и регистров (Instruction Set). Инструкции задаются комбинациями констант в code, их прототипы перечислены в заголовочных файлах filter.h и bpf_common.h:

Прототипы инструкций
Прототипы инструкций

Дело за малым: распарсить полученные структуры bpf_insn в более человекочитаемые инструкции.

Первым делом нужно разбить значение reg на регистры: dst_reg и src_reg. Такая реализация связана с тем, что Python не очень хорошо дружит с типом u8:4. Делаем это следующим образом:

Получение регистров
Получение регистров

Вооружившись прототипами из исходников ядра, парсим инструкции примерно следующим образом:

    
if bpf_class_name == "BPF_LD":
if (code & 0x18) == BPF_SIZE["BPF_DW"]:
    if (code & 0xe0) == BPF_MODE["BPF_IMM"]:
        if src_reg == 1: # BPF_PSEUDO_MAP_FD
            insn_proto = "BPF_LD_MAP_FD({0}, {1}, {2})"
            insn_type = "BPF_LD_MAP_FD"
            dst_reg_name = parse_reg(dst_reg)
            src_reg_name = "BPF_PSEUDO_MAP_FD"
            insn_params = {'type': insn_type, 'dst_reg': dst_reg_name, 'src_reg': src_reg_name, 'imm': imm}
            insn_parsed = insn_proto.format(dst_reg_name, src_reg_name, str(imm))
        elif src_reg == 0: # BPF_LD_IMM64
            insn_proto = "BPF_LD_IMM64({0}, {1})"
            insn_type = "BPF_LD_IMM64"
            dst_reg_name = parse_reg(dst_reg)
            insn_params = {'type': insn_type, 'dst_reg': dst_reg_name, 'imm': imm}
            insn_parsed = insn_proto.format(dst_reg_name, str(imm))                   
        else:
            insn_proto = "BPF_LD_IMM64_RAW({0}, {1}, {2})"
            insn_type = "BPF_LD_IMM64_RAW"
            dst_reg_name = parse_reg(dst_reg)
            src_reg_name = parse_reg(src_reg)
            insn_params = {'type': insn_type, 'dst_reg': dst_reg_name, 'src_reg': src_reg_name, 'imm': imm}
            insn_parsed = insn_proto.format(dst_reg_name, src_reg_name, str(imm))
    
Фрагмент кода парсера инструкций

По итогу работы парсера получаем листинг в значительно более дружелюбном виде:

Листинг eBPF-программы после работы парсера
Листинг eBPF-программы после работы парсера

bpf-helpers

Как и в реализации в kernel-mode, можно обогатить листинг именами вызываемых bpf-helpers. Для этого при запуске решения можно получить номера функций конкретно для системы, на которой производится работа:

    
def get_func_numbers():
bpfh_file_path = '/usr/include/linux/bpf.h'
bpfh_file = open(bpfh_file_path, 'r')
bpfh_text = bpfh_file.read()
bpfh_file.close()
regexp_fn = 'FN\((.*)\),\t*\\\\'
fn_matches = re.findall(regexp_fn, bpfh_text)
return fn_matches
    
Получение актуального маппинга bpf-helpers

Далее по уже описанному ранее алгоритму обогащаем инструкции BPF_EMIT_CALL() именами вызываемых хэлперов:

Имена bpf-helpers в листинге eBPF-программы
Имена bpf-helpers в листинге eBPF-программы

Строки

Строки - следующий признак, который мы можем использовать при построении детекта вредоносных eBPF-модулей. Хоть строки в явном виде и не часто встречаются в листинге eBPF-программ (чаще они передаются в мапах), они могут стать хорошим индикатором. Хороший пример: строки, с которыми происходит сравнение в цикле:

Фрагмент кода sudoadd (bad-bpf)
Фрагмент кода sudoadd (bad-bpf)

На скриншоте пример кода из модуля sudoadd (bad-bpf). Программа перехватывает операции открытия файлов, при этом сравнивая имя исполняемого файла с “sudo”, а открываемый файл - с “/etc/sudoers”. С помощью данного модуля можно вмешаться в процесс проверки принадлежности пользователя к sudoers и повысить привилегии, подменив содержимое “/etc/sudoers” на этапе чтения файла.

В процессе анализа того, как формируются строки в eBPF-программах, мы обнаружили три паттерна:

  • Строка, составленная из нескольких разных частей;
  • Строка, составленная из одной части, повторенной несколько раз;
  • Строка, с которой происходит сравнение в цикле.
Паттерны формирования строк
Паттерны формирования строк

Общий принцип для первых двух паттернов:

  • Строка (в imm) помещается в регистр REG_ARG1 с помощью инструкций BPF_LD_IMM64/BPF_MOV64_IMM. Строка в imm лежит в hex;
  • Из REG_ARG1 строка перемещается в REG_FP по определенному смещению с помощью инструкции BPF_STX_MEM.

Таким образом в стеке (frame) формируется строка, которая может быть использована в дальнейшем.

Фрагмент листинга, выделены части строк в десятичном формате
Фрагмент листинга, выделены части строк в десятичном формате

Принцип формирования строки в цикле:

  • Строка, которая сравнивается, посимвольно перемещается из REG_FP с помощью инструкции BPF_LDX_MEM в REG_ARG_1;
  • Символ сравнивается со строкой, с которой происходит сравнение (BPF_JMP_IMM).
Цикл сравнения из sudoadd
Цикл сравнения из sudoadd, подчеркнуты символы в десятичном виде, а также инструкция завершения программы, на которую происходит переход в случае неравенства строк

В нашей реализации вывод парсера строк выглядит так:

Итог работы парсера строк
Итог работы парсера строк

Итого на выходе из парсера в user-mode мы имеем следующую информацию:

  • Метаданные программы и процесса, ее загрузившего;
  • Полный листинг программы (в виде инструкций и структур);
  • Массив используемых bpf-helpers;
  • Массив используемых строк.

Всю эту информацию можно смело собирать в лог и слать в любое стороннее решение для последующей обработки (например, в SIEM).


Sudoadd

В качестве примера рассмотрим уже описанный выше sudoadd из bad-bpf. Информация об одной из загруженных eBPF-программ (handle_openat_enter) выглядит следующим образом:

Листинг eBPF-программы handle_openat_enter (sudoadd)
Листинг eBPF-программы handle_openat_enter (sudoadd)

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

Sigma-правило для обнаружения sudoadd
Sigma-правило для обнаружения sudoadd


Response. User-mode

При обнаружении потенциально вредоносного eBPF-модуля в user-mode существует возможность реагирования (Response). Самый общий и работоспособный вариант - завершение процесса, загрузившего eBPF-программу.

    
def handle_event(cpu, data, size):
    event = ctypes.cast(data, ctypes.POINTER(ProgInfo)).contents
    progname = event.progname.decode('utf-8')
    print(f"Program Name: {progname}, PID: {event.pid}, FD: {event.fd}")

    if progname == 'hello':
        print(f"Detaching program {progname} with FD {event.fd}")
        os.kill(event.pid, signal.SIGUSR1)
    
Фрагмент кода, выполняющий завершение процесса

В eBPF также существует функциональность “отвязавания” программы от точки ее прикрепления: prog_detach. У системного вызова bpf() первый аргумент может быть равен BPF_PROG_DETACH, однако это работает только для очень ограниченного числа типов программ, что можно увидеть в исходниках:

Фрагмент кода, обрабатывающего BPF_PROG_DETACH
Фрагмент кода, обрабатывающего BPF_PROG_DETACH

Видно, что BPF_PROG_DETACH работает в основном для типов программ, связанных с сокетами. Для остальных типов вызов bpf() с этой командой не сделает ничего.



Проблемы, с которыми мы столкнулись

При работе над Proof-of-Concept решения для обнаружения и предотвращения загрузки eBPF-программ мы столкнулись с рядом проблем, которые часто возникают у специалистов при работе над “объемным” решением, функционирующим в eBPF:

  • Некоторые из первоначальных идей оказались нереализуемыми в eBPF из-за различных ограничений. Некоторые из ограничений мы смогли обойти легитимными средствами eBPF;
  • Какая-либо документация, связанная с eBPF и структурами ядра в целом, практически отсутствует. Впрочем, трудно назвать это проблемой, потому что вполне достаточно иметь под рукой исходники ядра Linux, которые есть в открытом доступе;
  • BCC имеет ряд ограничений и проблем с импортом заголовочных файлов в eBPF-программе. Хоть BCC и является (на наш взгляд) самым простым способом начать работать с eBPF, библиотека не годится для каких-либо более серьезных “продовых” решений.


Выводы

Во время работы над вышеописанным, мы пришли к следующим выводам:

  • Несмотря на то, что серьезные инциденты с применением eBPF-программ нам пока не встречались, в ближайшем будущем злоумышленники скорее всего возьмут этот инструмент на вооружение;
  • В runtime можно собрать большое количество полезной информации о eBPF-программе, а также сформировать признаки, по которым можно строить логику детекта;
  • Detection и Prevention в пространстве ядра для eBPF-программ возможны;
  • Response также возможен, но с некоторыми нюансами и оговорками;
  • Работа с eBPF-программами в runtime является обыкновенным парсингом различных структур.