BPF-LSM: детектирование в точке решения ядра
Большинство Linux-EDR смотрят на syscall'ы. SecureExec тоже — но вдобавок перехватывает уровень Linux Security Module (LSM) через eBPF, ровно там, где ядро решает, разрешить ли операцию. Syscall-tracepoint видит аргументы так, как их передал userspace: голый файловый дескриптор, пустой путь, указатель, который ядро ещё не разыменовало. BPF-LSM-хук срабатывает несколькими инструкциями позже — после того как ядро разрешило fd в реальный файл, прошло путь и закоммитило привилегии. Он видит разрешённый объект — то, о чём операция на самом деле.
Именно в этом зазоре живёт целый класс техник обхода, и его закрытие — это то, что даёт BPF-LSM. В статье: что уровень LSM видит такого, чего не видят tracepoints; какие детекты это открывает; и как мы сделали это production-grade, а не демо.
Слепое пятно syscall-tracepoints
Syscall-tracepoint подключён на входной границе. Он читает сырые аргументы из регистров и ничего больше — потому что в этот момент ничего больше и нет, ядро ещё не сделало свою работу. Для большинства событий этого достаточно. Для операций, которые интересуют атакующего, это ровно неподходящий момент, чтобы смотреть:
- Выполнение по дескриптору.
execveat(fd, "", AT_EMPTY_PATH)запускает то, на что указываетfd—memfd, удалённый файл, пайп. Tracepoint видитpath = "". Бинарь, который реально запускается, в аргументах не появляется. - W^X-память. Классический паттерн shellcode:
mmap(PROT_READ|PROT_WRITE), запись полезной нагрузки, затемmprotect(…, PROT_EXEC). Хук наmmapвидит безобидный writable-маппинг; момент, когда страница становится исполняемой — собственно сигнал — происходит в последующемmprotect, за которым он не наблюдает. - Пути, которые он не может разрешить.
openat(dirfd, "../../etc/shadow"), symlink, путь через/proc/self/fd/N. У tracepoint есть строка, но не файл. Разрешать её в userspace — это гонка, и для короткоживущих процессов — проигрышная.
Каждое из этих мест можно залатать обогащением в userspace — прочитать /proc/<pid>/exe, заново пройти путь — но вы постфактум реконструируете информацию, которая у ядра уже была и которую оно выбросило. Под нагрузкой, против процесса, который завершается за миллисекунды, реконструкция проигрывает.
BPF-LSM: точка принятия решения ядром
Фреймворк LSM — это набор хуков, которые ядро вызывает, чтобы спросить «разрешить ли это?» — те же хуки, на которых построены SELinux и AppArmor. С ядра 5.7 BPF_PROG_TYPE_LSM позволяет подключить к ним eBPF-программу. К моменту срабатывания LSM-хука ядро уже сделало то разрешение, которое было недоступно tracepoint'у:
execveat(fd=7, "", AT_EMPTY_PATH)
│
userspace ──────┤ входит в ядро
│
┌────────────────────▼──────────────────────┐
│ sys_enter_execveat (syscall tracepoint) │ видит АРГУМЕНТЫ как переданы:
│ → path = "" fd = 7 → цель НЕИЗВЕСТНА │ голый fd, пустая строка
└────────────────────┬──────────────────────┘
│ ядро резолвит fd → файл,
│ проходит путь, коммитит creds
┌────────────────────▼──────────────────────┐
│ bprm_check_security (BPF-LSM-хук) │ видит РАЗРЕШЁННЫЙ объект:
│ → /usr/sbin/sshd → реальный бинарь │ файл, который ядро запустит
└────────────────────┬──────────────────────┘
│
allow / deny
LSM-хуки SecureExec — observe-only: каждый возвращает 0 (allow). Они ничего не энфорсят, не добавляют задержку к решению, не рискуют заблокировать легитимную операцию. Их задача — прочитать разрешённый объект и выпустить событие. Детекция остаётся там, где ей место — на сервере, в движке алертов — тот же инвариант, которому следует любой сенсор SecureExec.
Какие LSM-хуки мы ставим
Семь хуков, каждый читает то, что разрешило ядро и чего не мог получить эквивалентный tracepoint:
| LSM-хук | Что разрешило ядро | Что ловит |
|---|---|---|
bprm_check_security | реальный запускаемый бинарь (exe_resolved) | запуск memfd/по fd, гонки exec-then-exit, подмена пути под нагрузкой, которую теряет /proc/<pid>/exe |
file_mprotect | страница, ставшая writable и executable | in-memory инъекция / shellcode — флип mprotect W→X, который путь mmap не видит |
file_open | разрешённый путь чтения | чтение секретов по symlink / relative / fd путям, которые openat-tracepoint не резолвит |
socket_connect / socket_bind | адресат прямо из sockaddr, протокол из сокета | покрытие UDP-connect без хрупких офсетов sock_common, на которые опирались kprobes |
task_fix_setuid | закоммиченные uid / euid | смена привилегий по успешному коммиту — без ложняка от неудавшегося setuid |
ptrace_access_check | один процесс лезет в память другого | инъекция / дамп кредов — ptrace attach, process_vm_*, /proc/<pid>/mem |
Большинство из них переиспользуют существующие типы событий, поэтому серверные правила и proto-схема не менялись — события просто приходят с разрешённой правдой вместо userspace-приближения.
Что это открывает
Смысл видеть разрешённый объект — в детектах, которые становятся надёжными:
- Гонка-устойчивый lineage выполнения.
bprm_checkзахватывает цель exec прямо во времяexecveи прикрепляет её к событию процесса какexe_resolved— индексируется как keyword-поле. Аналитики и правила могут сравнить её с путём из procfs, что ловит запускmemfdи по fd, а также гонки exec-then-exit, которые под нагрузкой мис-атрибутируют бинарь. - W^X-память (
memory_wx).file_mprotectловит момент, когда страница переводится в writable-и-executable — код записан, затем сделан исполняемым на месте. Это форма reflective loader'а или пакера, и это ровно тот переход, который tracepoint наmmapнаблюдать не может. - Чтение секретов по реальному пути.
file_openприменяет матчинг чувствительного чтения к пути, который разрешило ядро, поэтому чтение SSH-ключа или/etc/shadowчерез symlink или относительный путь репортится один раз и с истинной целью — а не теряется из-за того, что строка аргумента не выглядела чувствительной. - Сетевой egress из самого сокета.
socket_connectчитает адресата прямо изsockaddr, а протокол — из объекта сокета, что добавляет покрытие UDP-connect, которого не было у TCP-kprobes, и убирает хрупкие чтения офсетовsock_common/socket->sk, о которых всегда предупреждал наш conventions-документ. - Смена привилегий, которая действительно случилась.
task_fix_setuidсрабатывает на закоммиченных привилегиях, поэтому репортит реальный переход в root и молчит наsetuid, который не удался — в отличие от syscall-tracepoint, который видит попытку в любом случае.
Подключается best-effort, дедуплицируется и честно об этом сообщает
Хуки уровня ядра стоит ставить, только если их безопасно включать везде. Это обеспечивают три вещи.
Они не заменяют tracepoints — они их уточняют. Каждый LSM-хук подключается best-effort. На ядре без BPF-LSM или без нужного поля структуры хук просто не подключается, и источником остаётся существующий tracepoint или kprobe. Включение BPF-LSM только углубляет сбор; сломать его оно не может.
Нет CO-RE, поэтому офсеты валидируются по работающему ядру. eBPF агента собран на Aya, чей тулчейн поставляет непрозрачные структуры ядра — CO-RE-релокаций нет, а bpf_d_path из Rust-LSM-хука неприменим. Поэтому путь-резолвящий хук проходит цепочку dentry вручную, читая dentry->d_name, ->d_parent и прочее по офсетам, разрешённым из собственного BTF ядра при загрузке. Если BTF не описывает поле — хук отключает себя сам, а не читает неверный офсет:
#[lsm(hook = "bprm_check_security")]
pub fn bprm_check(ctx: LsmContext) -> i32 {
// Ядро уже разрешило бинарь. Читаем путь из linux_binprm->file,
// проходя цепочку dentry по офсетам, которые загрузчик проверил
// по BTF ядра, затем сохраняем по tgid.
let _ = resolve_bprm_path(&ctx);
0 // observe-only: 0 = allow. Детекция остаётся на сервере.
}
Один источник, никогда не два. Там, где LSM-хук и tracepoint/kprobe покрывают одно и то же — connect сокета, чувствительное чтение — агент использует флаг «prefer-LSM». Когда подключается более богатый LSM-хук, он поднимает флаг, который проверяет fallback, и fallback уходит в сторону — одна операция никогда не репортится дважды:
// Kprobe tcp_v4_connect — это fallback. Как только socket_connect (LSM)
// подключился, он становится единственным источником, и kprobe уступает —
// флаг поднимается, только если хук реально подключился, так что connect
// никогда не теряется.
if crate::lsm::off(LSM_OFF_CONNECT_ACTIVE) {
return 0;
}
Вы всегда знаете свой уровень покрытия
BPF-LSM мощный, но на большинстве дистрибутивов он выключен по умолчанию. Сток Ubuntu идёт с CONFIG_BPF_LSM=y, но не добавляет bpf в активный список LSM (/sys/kernel/security/lsm), пока администратор не пропишет его в командную строку ядра. Инструмент, который здесь молча откатывается на tracepoints, оставляет вас гадать о собственной глубине покрытия.
SecureExec об этом сообщает. Агент отправляет статус BPF-LSM в каждом heartbeat — active, degraded (ядро разрешает, но в BTF нет нужных хукам полей) или off — и страница эндпоинта в консоли показывает его вместе с точным способом исправления, когда он off:
GRUB_CMDLINE_LINUX="lsm=lockdown,capability,landlock,yama,apparmor,bpf"
update-grub && reboot
Так что глубокая аналитика — это не загадка. Вы видите по каждому хосту, живы ли хуки уровня ядра, и включаете их там, где они ещё не включены.
Как включить BPF-LSM на Linux-хостах
BPF-LSM нужно от ядра две вещи: оно должно быть собрано с CONFIG_BPF_LSM=y — так у любого mainline-ядра с 5.7 и у всех актуальных ядер Ubuntu, Debian, RHEL и Amazon Linux — и bpf должен быть в активном списке LSM ядра. Обычно не хватает именно второго: сток-дистрибутивы компилируют BPF-LSM, но не включают его.
Проверить текущий список:
cat /sys/kernel/security/lsm
# lockdown,capability,landlock,yama,apparmor ← нет "bpf" — значит выключено
Если bpf там нет, добавьте его в параметр lsm= командной строки ядра. Этот параметр заменяет активный набор, поэтому перечислите всё, что уже есть, и допишите bpf — выкинуть существующий модуль (AppArmor, SELinux, …) значит его отключить.
Debian / Ubuntu. Отредактируйте /etc/default/grub, чтобы GRUB_CMDLINE_LINUX нёс ваш текущий список плюс bpf, затем пересоберите GRUB и перезагрузитесь:
# /etc/default/grub
GRUB_CMDLINE_LINUX="... lsm=lockdown,capability,landlock,yama,apparmor,bpf"
sudo update-grub && sudo reboot
RHEL / Fedora / Amazon Linux. Здесь вместо AppArmor — SELinux, поэтому берите значение из своего /sys/kernel/security/lsm:
sudo grubby --update-kernel=ALL --args="lsm=lockdown,capability,yama,selinux,bpf"
sudo reboot
После перезагрузки убедитесь, что включилось:
cat /sys/kernel/security/lsm
# lockdown,capability,landlock,yama,apparmor,bpf ← bpf активен
Агент SecureExec подхватит это на следующем heartbeat — без переустановки и смены конфигурации — и статус BPF-LSM у эндпоинта в консоли переключится с off на active. Если в ядре действительно нет CONFIG_BPF_LSM, агент остаётся на tracepoints, а статус показывает off; все остальные детекты продолжают работать.
Что это значит для вашего SOC
Syscall-tracepoints говорят, что процесс попросил ядро сделать. BPF-LSM говорит, что ядро собиралось сделать — с уже разрешёнными бинарём, путём, привилегиями и адресатом. Такая телеметрия труднее для обхода (техники обхода живут в зазоре между этими двумя точками), точнее (серверные правила работают по ground truth, а не по userspace-догадке) и закрывает слепые пятна — запуск по fd, флипы W→X, чтение секретов по symlink — которые EDR «только на syscall'ах» структурно увидеть не может.
Оно работает на обычных ядрах, ничего не стоит, когда не может подключиться, и точно сообщает, где оно активно. Это та глубина, которую endpoint-платформа должна давать по умолчанию.
Для guided walkthrough на вашей инфраструктуре — включая включение BPF-LSM и наблюдение за тем, как приходит дополнительный сигнал — запросите демо. Проходит на реальном Linux-хосте, без слайдов.