
Обнаружитель руткитов для Linux на основе eBPF, использующий многоканальный кросс-просмотровый анализ (sched_switch, NMI, /proc) для обнаружения DKOM, подмены точек трассировки и сокрытия процессов с верификацией целостности на уровне оборудования.
Системный анализ целостности процессов и перекрёстный просмотр
«Я спою, так сияй ярко, SPiCa...»
SPiCa — это детектор руткитов для Linux на основе eBPF, написанный на Rust. Название происходит от песни Hatsune Miku SPiCa и звезды, на которую она ссылается — Спики (Альфа Девы), самой яркой точки в созвездии Девы. То, что невооружённому глазу кажется одной звездой, на самом деле является спектроскопической двойной: две звезды во взаимной орбите, неразличимые как отдельные объекты без измерения их спектров. SPiCa применяет тот же принцип к наблюдению за ядром: несколько независимых каналов измеряют одно и то же состояние ядра с помощью физически различных механизмов, и руткит, подавляющий один из них, раскрывается другими.
Отказ от ответственности: Значительные части этой кодовой базы были сгенерированы или рефакторизованы с помощью GLM. Было проведено тщательное тестирование и итеративное проектирование, но перед использованием в производстве проверьте код на безопасность и производительность.
SPiCa разработан для победы над ограниченным eBPF-противником — злоумышленником с повышенными привилегиями (CAP_BPF или CAP_SYS_ADMIN), который загружает привилегированную программу eBPF в ядро. Этот противник принципиально слабее руткита в виде LKM, поскольку верификатор BPF накладывает жёсткие ограничения:
Поскольку LKM блокируются шлюзом LSM после загрузки, этот ограниченный противник является реалистичной остаточной угрозой. Механизмы защиты от обхода в SPiCa настроены именно на эту угрозу — каждая защита честно сообщает, что она покрывает, а что нет.
init_module. SPiCa поднимает планку, блокируя более лёгкий вектор LKM, но не ограничивает противника сверху.SPiCa — это последний рубеж в стеке эшелонированной защиты, а не замена вышележащим уровням.
SPiCa запускает четыре программы eBPF, прикреплённые к хукам ядра, а также пользовательский движок обнаружения, который перекрёстно коррелирует их вывод с представлением системы о самой себе (/proc).
Ключевое архитектурное свойство: руткит не может подавить все три канала одновременно, чтобы само подавление не стало обнаруживаемым или дестабилизирующим. Подавление NMI требует модификации IDT (таблицы дескрипторов прерываний), что приводит к панике на большинстве ядер. Это «живая бомба» — единственный путь атакующего к полной слепоте с высокой вероятностью приведёт к краху системы.
sched sees it, /proc doesn't → DKOM NMI finds sched's heartbeat stopped → TAMPER sched goes globally silent, /proc != empty → SILENT
Каждый класс обнаружения — это дифференциальный вердикт: расхождение между двумя или более каналами. Движок обнаружения — это чистая функция над снимком реестра + /proc + метками времени каналов — без ввода-вывода, без побочных эффектов, полностью модульно тестируемый.
### Редизайн NMI: от наблюдения к целостности
В исходном дизайне NMI был вторым каналом наблюдения за процессами, который производил выборку ЦП и сообщал, какая задача выполняется. Это было избыточно: `sched_switch` уже наблюдает за планированием, а NMI получал те же данные через другой механизм. Избыточность стоила ~1000+ событий в кольцевом буфере/сек/ЦП данных о процессах, которые в 99,999% случаев подтверждали «да, планировщик делает то, что делает планировщик».
В переработанной архитектуре **NMI перепрофилирован с наблюдения за процессами на проверку целостности точек трассировки.** Он больше не сообщает, какой процесс находится на ЦП. Вместо этого он проверяет, что `sched_switch` действительно выполняется, считывая разделяемое сердцебиение в `.bss`. Это:
1. Устраняет ~99% трафика NMI в кольцевом буфере (почти нулевые события в установившемся режиме)
2. Напрямую обнаруживает отсоединение точек трассировки, подавление и сбои BTF/присоединения (исходная ошибка BTF — см. [§11](#11-the-btf-bug-incident))
3. Выполняется из аппаратного прерывания, вне пути диспетчеризации точек трассировки — невосприимчив к `bpf_override_return`, перехвату kprobe и манипуляциям с массивом funcs
4. Читает глобальные переменные `.bss` через прямой доступ к памяти, а не через помощники BPF — невосприимчив к `fmod_ret` на вспомогательных функциях
---
## 3. Канал наблюдения sched\_switch
Программа eBPF, прикрепленная к точке трассировки `sched_switch`, срабатывает каждый раз, когда ядро планирует процесс на ЦП. Она считывает PID и comm входящей задачи непосредственно из аргументов точки трассировки, используя традиционные (не BTF) чтения с фиксированным смещением:```
ctx.read_at::<u32>(56) → next_pid
ctx.read_at::<[u8;16]>(40) → next_comm
Преднамеренно не BTF/CO-RE. Расположение аргументов tracepoint стабильно во всех версиях ядра (это часть ABI tracepoint). Использование жестко закодированных смещений позволяет избежать хрупкости разрешения структур через BTF в зависимости от версии ядра. Это осознанный архитектурный выбор, задокументированный в §11.
При каждом вызове программа:
next_pid и next_comm из контекста tracepointProcessInfo с помощью BASE_KEYsc_schedbpf_ktime_get_ns() в глобальную переменную .bss SCHED_HEARTBEAT — это heartbeat, который отслеживается модулем проверки целостности NMIВ этом контексте программа сознательно избегает вызова bpf_get_current_pid_tgid(). В момент переключения контекста (sched_switch) «текущая» задача — это исходящая, а не входящая. Аргументы tracepoint предоставляют правильную идентификацию процесса (входящего).
Движок обнаружения использует единую монотонную временную базу: наносекунды с момента запуска процесса SPiCa (Instant::now() с точки входа run_detection()). Когда приходит событие планировщика, обработчик события сохраняет переданное вызывающим кодом значение now (наносекунды, локальные для процесса), а не значение bpf_ktime_get_ns(), встроенное в событие eBPF. Это исключает несоответствие временных баз, которое могло бы возникнуть при смешивании времени загрузки ядра с локальным временем процесса — ошибка, присутствовавшая в ранних версиях и приводившая к молчаливому сбою всех предикатов живучести.
.bss, без BTF, без обхода структур ядраПрограмма NMI привязана к аппаратному счетчику производительности (HW_CPU_CYCLES), срабатывающему на уровне аппаратных прерываний. NMI невозможно отключить командой cli — для их подавления требуется перепрограммировать PMU или перехватить IDT, и оба действия являются шумными и дестабилизирующими.
Механизм проверки целостности предельно прост:``` .bss (shared within SPiCa's ELF object — all programs see the same memory): SCHED_HEARTBEAT ← written by sched_switch on every invocation NMI_LAST_HB ← NMI's record of the last heartbeat it observed NMI_FIRST_TICK ← first NMI invocation timestamp (grace period reference) NMI_LAST_EMIT ← throttle: last event emission timestamp
sched_switch runs → SCHED_HEARTBEAT = bpf_ktime_get_ns()
NMI fires (once per second, throttled): if SCHED_HEARTBEAT == NMI_LAST_HB: if past grace period (5 seconds since first NMI tick): → sched_switch stopped running → emit event_type=1 (TAMPER) else: → sched_switch is alive → emit event_type=0 (heartbeat OK) NMI_LAST_HB = SCHED_HEARTBEAT
### Почему это обнаруживает каждую атаку в пределах области действия
| Атака | Что происходит | Что видит NMI |
|---|---|---|
| Трассировочная точка отсоединена от массива funcs | sched\_switch перестает выполняться | `SCHED_HEARTBEAT` замирает → TAMPER |
| Ошибка BTF / сбой присоединения (тихий) | sched\_switch никогда не выполняется | `SCHED_HEARTBEAT` остается 0 → TAMPER (после льготного периода) |
| `bpf_override_return` на функции планировщика | Если это предотвращает срабатывание трассировочной точки | `SCHED_HEARTBEAT` замирает → TAMPER |
| Байт-код исправлен на месте | Требует произвольной записи в ядро (уровень LKM) | Вне модели угроз eBPF |
| Указатель потребителя кольцевого буфера манипулирован | события sched не достигают пользовательского пространства | `SCHED_HEARTBEAT` все еще продвигается (программа работает) → нет ложного TAMPER; пользовательское пространство обнаруживает через устаревшее `max(sched_last)` → SILENT |
### Почему именно `.bss`
Глобальные переменные `.bss` хранятся во внутренней секции данных программы BPF, поддерживаемой внутренней картой массива, управляемой загрузчиком. Они:
- **Не прикрепляются отдельно** — они не появляются как именованные карты в `/sys/fs/bpf/`
- **Не перехватываемы через хуки `bpf_map_update_elem`** — записи в `.bss` являются прямыми операциями с памятью, а не системными вызовами обновления карты. Старый механизм `sc_canary` (сравнение копии `.bss` с копией именованной карты для обнаружения перехвата `bpf_map_update_elem`) больше не нужен.
- **Разделяются между программами в одном объекте ELF** — sched\_switch и NMI общаются через `.bss` без какого-либо внешнего интерфейса
### Иммунитет к перехвату на основе BPF
Проверка целостности NMI структурно невосприимчива к атакам переопределения BPF из-за фундаментального свойства: `bpf_override_return` перехватывает **вызовы** функций, но проверка NMI не *вызывает* проверяемые вещи — она *читает память `.bss` напрямую*. Вы не можете переопределить возвращаемое значение чтения памяти, потому что чтение памяти — это не вызов функции.
Дополнительно:
- `bpf_probe_read_kernel` (используется для чтения структур ядра в альтернативных проектах) — это безопасный при сбоях помощник, который принимает любой адрес — но в конструкции сердцебиения `.bss` SPiCa он даже не нужен. Проверка считывает глобальные переменные `.bss` через прямые инструкции загрузки.
- Программа NMI выполняется в контексте NMI, где kprobes структурно ненадежны (ядро откладывает или подавляет их). Атака на основе kprobe на выполнение проверки борется с аппаратным обеспечением.
### Семантика событий NMI
Кольцевой буфер NMI (`sc_nmi`) переносит легковесные события:
| `event_type` | Значение | Действие пользовательского пространства |
|---|---|---|
| 0 | Сердцебиение — NMI жив, sched\_switch жив | Обновить метку времени `last_nmi_heartbeat` |
| 1 | TAMPER — NMI жив, сердцебиение sched\_switch заморожено | Немедленно вывести `[TAMPER]` |
События испускаются не чаще одного раза в секунду (ограничено `NMI_LAST_EMIT`). Если кольцевой буфер NMI замолкает более чем на 5 секунд, пользовательское пространство запускает `[SILENT]` — сам канал NMI мертв.
---
## 5. Логика обнаружения```mermaid
graph TD
subgraph RING0["Kernel Space: Four eBPF Programs"]
direction TB
SCHED_P["TracePoint, sched_switch<br/>read next_pid, next_comm<br/>write SCHED_HEARTBEAT (.bss)<br/>XOR obfuscate → sc_sched"]
NMI_P["PerfEvent hardware NMI<br/>read SCHED_HEARTBEAT (.bss)<br/>compare to NMI_LAST_HB<br/>frozen → event_type=1 (TAMPER)<br/>alive → event_type=0 (heartbeat)<br/>→ sc_nmi"]
LSM_P["BPF LSM, kernel_read_file<br/>READING_MODULE<br/>gate=0: allow + log<br/>gate=1: EPERM + log → sc_lsm"]
WATCH_P["TracePoint, sched_process_exit<br/>current == SPICA_PID (.bss)? → sc_wd flag"]
end
subgraph RING3["User Space: Differential Engine"]
ENGINE["SPiCa (Tokio async)"] -->|XOR deobfuscate| RB_S[(sc_sched RingBuf)]
ENGINE -->|event_type=0: heartbeat<br/>event_type=1: TAMPER| RB_N[(sc_nmi RingBuf)]
ENGINE -->|raw read| RB_L[(sc_lsm RingBuf)]
ENGINE -->|read_dir| PROC[" /proc"]
RB_S --> FSM{Detection FSM}
PROC --> FSM
FSM -->|scheduled, absent /proc, >2s| D1["[DKOM]"]
FSM -->|in /proc, never seen by sched, >5s| D2["[GHOST]"]
RB_N -->|event_type = 1| D3["[TAMPER]"]
FSM -->|sched channel silent, /proc ≠∅| D4["[SILENT]"]
FSM -->|raw getdents64 ≠ libc readdir| D9["[HOOK]"]
RB_S -->|same TGID, different start_time_ns| D5["[DUPE]"]
RB_L -->|allowed = 1| D7["[LKM-ALLOW]"]
RB_L -->|allowed = 0| D8["[LKM-DENY]"]
WATCH_P -.->|pin exists at startup| D6["[WATCHDOG]"]
end
classDef kernbox fill:#fdf2f8,stroke:#9333ea,stroke-width:2px,color:#581c87;
classDef userbox fill:#eff6ff,stroke:#2563eb,stroke-width:2px,color:#1e3a8a;
classDef alertbox fill:#fff7ed,stroke:#ea580c,stroke-width:1px,color:#7c2d12;
classDef storage fill:#f8fafc,stroke:#64748b,stroke-width:1px;
class RING0 kernbox;
class RING3 userbox;
class D1,D2,D3,D4,D5,D6,D7,D8,D9 alertbox;
class RB_S,RB_N,RB_L,PROC storage;
SILENT — это обнаружение на уровне канала, а не для отдельных процессов. Оно срабатывает, когда:
Канал sched мёртв: max(sched_last) по всем записям реестра устарел больше чем на CHANNEL_DEAD_NANOS, при этом /proc не пуст. Защита «не пустой /proc» — гарантия жизнеспособности: в любой работающей Linux-системе /proc не пуст (init, потоки ядра, kworkers), и планировщик никогда не бывает полностью бездействующим (тики таймера, ожидающие I/O, обратные вызовы RCU). Устаревший max(sched_last) на непустой системе означает, что канал умер — а не то, что система простаивает.
Канал NMI мёртв: Событие пульса (event_type=0) не поступало из кольцевого буфера NMI более 5 секунд. Это означает, что событие производительности NMI перестало срабатывать — либо оно было отключено, либо PMU была перепрограммирована.
Обе проверки имеют 30-секундную задержку для предотвращения лавины оповещений.
[HOOK])SPiCa читает /proc двумя независимыми механизмами при каждом тике:
Сырой системный вызов getdents64 (read_tgids()) — Rust std::fs::read_dir вызывает его напрямую через libc::syscall(SYS_getdents64, ...), обходя все хуки функций libc. LD_PRELOAD руткиты не могут перехватить этот путь.
libc readdir (read_tgids_libc()) — вызывает libc::opendir / libc::readdir через FFI, что проходит через динамический компоновщик и полностью перехватывается хуками из /etc/ld.so.preload.
Если getdents64 видит PID, которых libc readdir не видит, то руткит пользовательского пространства перехватывает readdir для сокрытия процессов. SPiCa срабатывает [HOOK]:```
[HOOK] 3 PID(s) hidden from libc readdir — LD_PRELOAD rootkit suspected
Этот метод превращает привилегию SPiCa на уровне ядра в активное обнаружение пользовательских руткитов. Путь прямого системного вызова является эталоном — никакой хук в пользовательском пространстве не может его скрыть. Путь libc представляет собой «воспринимаемое» представление, которое видит обычная утилита (ps, ls). Расхождение является недвусмысленным доказательством перехвата библиотеки.
**Руткиты, обнаруживаемые с помощью `[HOOK]`:**
| Руткит | Механизм сокрытия | Обнаружен |
|--------|-------------------|-----------|
| Symbiote | Паразитический LD_PRELOAD, перехват `readdir` | Да (когда активно скрывается) |
| JynxKit | LD_PRELOAD, сокрытие по `MAGIC_GID` через `readdir` | Да |
| Azazel | LD_PRELOAD, перехват `readdir` + `stat` | Да |
| Medusa/OrBit | LD_PRELOAD, перехват `readdir` + кража учётных данных | Да |
**Проверено на реальном Symbiote:** Живой образец Symbiote (SHA256 `f55af21f...`, MalwareBazaar) был развёрнут через `/etc/ld.so.preload` на виртуальной машине Ubuntu 24.04. Образец перехватывает `readdir`, `readdir64`, `stat`, `fstatat`, `pam_authenticate`, `pcap_loop`, `recvmsg`, `fopen`, `read`, `execve`. Когда хук LD_PRELOAD активно скрывает PID от `readdir`, SPiCa срабатывает с `[HOOK]` в течение одного тактового цикла (<1 с). Когда Symbiote загружен, но пассивен (не скрывается активно), SPiCa выдаёт ноль ложных срабатываний.
### Окно ожидания (Grace window)
Окно ожидания в 50 мс предотвращает ложные срабатывания на только что созданных процессах. Проверки SILENT на уровне канала имеют 5-секундный льготный период (ссылка `NMI_FIRST_TICK` программы NMI), чтобы задержка запуска не приводила к ложным предупреждениям TAMPER.
---
## 6. Секретность адресов, ограниченная верификатором
### Определение
**Секретность адресов, ограниченная верификатором** — это свойство вычислительной безопасности, которое выполняется в модели противника, ограниченной eBPF. Оно гласит:
> Для противника, который может загружать программы eBPF (CAP\_BPF), но ограничен верификатором BPF (ограниченные циклы, ограниченные вспомогательные функции, отсутствие произвольной записи в ядро), адрес ядра для секции данных `.bss` программы BPF вычислительно невозможно обнаружить.
### Четыре механизма
Свойство является комбинацией четырёх независимых барьеров:
1. **kASLR** — Рандомизация адресного пространства ядра рандомизирует область кучи ядра, где выделяется память для карт BPF (включая резервные карты `.bss`). Точный адрес меняется при каждой загрузке.
2. **Ограничение инструкций верификатора** — Верификатор BPF ограничивает общее количество инструкций на программу примерно до 1 миллиона и требует завершаемых циклов с доказательством. Сканирующая программа противника может проверить не более N ≈ 10^6 адресов за загрузку с помощью `bpf_probe_read_kernel`. Учитывая рандомизированное пространство поиска kASLR, вероятность успеха на программу ничтожно мала.
3. **Шлюз доступа к картам LSM** (см. [§7](#7-lsm-map-access-gate)) — Без шлюза противник мог бы перечислять карты BPF по ID (`BPF_MAP_GET_NEXT_ID` → `BPF_MAP_GET_FD_BY_ID` → `BPF_MAP_LOOKUP_ELEM`) и напрямую читать содержимое `.bss`. Шлюз устраняет этот лёгкий путь, вынуждая противника использовать путь сканирования памяти, ограниченный верификатором.
4. **Размещение `.bss`** — Глобальные переменные `.bss` не появляются в интерфейсах перечисления именованных карт с человекочитаемыми именами. Внутренняя резервная карта `.bss` называется `.bss` в libbpf/aya, но её содержимое (фактические глобальные переменные) доступно только при наличии fd карты. Для получения fd карты требуется либо таблица fd загружающего процесса, либо ID карты (заблокирован шлюзом).
### Структура формального аргумента```
Adversary's goal: locate SPiCa's .bss in kernel virtual memory
Search space: kernel heap region, kASLR-randomized (~2^30 entropy)
Path A — map enumeration (blocked):
BPF_MAP_GET_NEXT_ID → BPF_MAP_GET_FD_BY_ID → BPF_MAP_LOOKUP_ELEM
→ blocked by LSM gate (§7) for non-SPiCa processes
Path B — memory scan (bounded):
bpf_probe_read_kernel(addr, buf, len) — fault-safe, accepts any addr
Per-program capacity: N ≈ 10^6 probes (instruction limit)
Success probability per load: N / |search space| ≈ 10^6 / 2^30 ≈ 10^-24
Can reload programs, but: each load is a syscall (observable), and
the target address doesn't change between loads (same boot session)
Path C — timing side channel (theoretical):
bpf_probe_read_kernel timing varies on fault vs success
Binary search over the heap could reduce scan complexity to O(log |A|)
→ O(30) probes, well within instruction budget
LIMITATION: must be acknowledged. Mitigated by rate-limiting BPF_PROG_LOAD
via the LSM gate (future work).
Большинство исследований безопасности BPF рассматривают верификатор как поверхность атак (ошибки верификатора, необоснованность, JIT-спрей). Эта работа переворачивает этот взгляд: ограничения верификатора — это оборонительный примитив, ограничивающий пространство поиска злоумышленника. Секретность не криптографическая (это не AES); она вычислительная — ограничена тем, что верификатор позволяет злоумышленнику вычислить.
bpf_probe_read_kernel наблюдаем по времени (ошибка или успех выполняются за разное количество тактов). Двоичный поиск по времени в куче теоретически эффективнее линейного сканирования. Ограничение инструкций ограничивает количество зондов на программу, но двоичный поиск может сократить количество зондов до O(log |A|) ≈ 30. Это реальное ограничение. Смягчение: ограничение частоты BPF_PROG_LOAD через шлюз LSM.Глобальные переменные .bss поддерживаются внутренней картой BPF. Любой процесс с CAP_BPF может перечислить все карты в системе:```
bpf(BPF_MAP_GET_NEXT_ID, …) → iterate all map IDs
bpf(BPF_MAP_GET_FD_BY_ID, id) → get fd to any map
bpf(BPF_MAP_LOOKUP_ELEM, fd, key, value) → read map contents
Против eBPF-противника (который не может вызывать системные вызовы), `.bss` скрыт. Против root в userspace это стандартная карта — полностью читаемая. Шлюз LSM закрывает этот пробел.
### Защита
Хук BPF LSM на системный вызов `bpf` проверяет команды доступа к картам:```
hook = "bpf"
read cmd (arg 0)
if cmd == BPF_MAP_GET_FD_BY_ID:
read map_id from userspace bpf_attr (bpf_probe_read_user)
if map_id matches any of SPiCa's stored IDs (.bss):
if caller_tgid != SPICA_PID:
return -EPERM
Map IDs are written to .bss by the userspace loader right after program load, before the LSM hook is armed (same timing pattern as the existing sc_gate for LKM blocking). No BTF needed — cmd and map_id come from syscall arguments, not kernel structs.
Точечно: only blocks access to SPiCa's specific map IDs. Other processes' BPF tools (tcpdump, bpftrace, bcc) access their own maps and are unaffected.
/proc/pid/mem — Другой root-процесс может читать память процесса SPiCa напрямую через ptrace, полностью минуя системный вызов BPF. Это фундаментальное ограничение: невозможно защититься от процесса с тем же уровнем привилегий, читающего вашу память через /proc/pid/mem. Защита: отправка логов на внешний хост (если атакующий может читать локальную память, он также может подавлять локальные оповещения — помогает только удалённое логирование).На хостах с TPM 64-битный ключ обфускации XOR получается из TPM2_GetRandom через крейт tss-esapi (прямой вызов библиотеки libtss2-esys). Ключ не передаётся через канал, командную строку подпроцесса или диск. Он существует только в аппаратном TPM и в энергозависимой памяти ядра (резервная карта .bss) на время работы процесса SPiCa.
На хостах без TPM (виртуальные машины без vTPM, контейнеры, машины без чипа TPM) SPiCa использует резервный ключ времени компиляции, генерируемый из /dev/urandom сборочным скриптом. Резервный вариант явно сигнализирует при запуске предупреждающим баннером.
.bssСледующие значения находятся в секции .bss программы eBPF, записываемые пользовательским пространством через set_global() до загрузки любой программы:
Именованные карты BPF перечисляемы через интерфейс итерации карт системного вызова bpf() (заблокирован шлюзом LSM — см. §7); глобальные переменные .bss доступны через ту же внутреннюю карту, но не имеют отдельных имён и не могут быть закреплены.
Все поля ProcessInfo сворачиваются XOR с 64-битным BASE_KEY перед записью в кольцевые буферы. Поле event_type намеренно оставлено необфусцированным, чтобы пользовательское пространство могло прочитать его как сигнальный маркер перед деобфускацией остальных.
Это обфускация против утечки при чтении, а не шифрование против способного противника. XOR с 8-байтовым повторяющимся ключом структурно уязвим для атаки по известному открытому тексту: предсказуемые значения comm ("bash", "systemd", "kthreadd"), применённые XOR к шифротексту, напрямую восстанавливают байты ключа. Такая защита уместна против противника eBPF (который не может легко прочитать кольцевой буфер — для этого требуется тот же разрыв в возможностях, что защищает .bss), но не против противника с бинарным доступом и перехватом кольцевого буфера.
Статус: Фаза дизайна. Ещё не реализовано. В этом разделе задокументирована целевая архитектура для исследовательской статьи.
Запечатывание TPM, привязанное к PCR, переносит границу распространения ключа из программного слоя в кремний. Ключ запечатывается во время установки для ожидаемых значений регистров конфигурации платформы (PCR) и может быть распечатан только в том случае, если состояние загрузки системы соответствует ожидаемым измерениям.
Во время загрузки прошивка, загрузчик и ядро измеряют критические компоненты в PCR:
Примечание: Выделение PCR зависит от цепочки загрузки. GRUB измеряет ядро в PCR 4; systemd-stub измеряет в PCR 4 и командную строку в PCR 8. Политика запечатывания должна соответствовать целевой цепочке загрузки.
Запечатывание (время установки): 64-битный ключ запечатывается для ожидаемых значений PCR с помощью TPM2_Create и сессии TPM2_PolicyPCR. Запечатанный blob сохраняется на диске. Он зашифрован внутренним ключом TPM и может быть расшифрован только при совпадении указанных PCR.
Распечатывание (ранняя загрузка, этап initramfs): До выполнения любого ненадёжного кода пользовательского пространства хук initramfs SPiCa запрашивает TPM2_Unseal. Если текущие значения PCR соответствуют политике запечатывания, TPM освобождает ключ.
Внедрение: Ключ записывается в .bss через set_global() до загрузки программ.
Ловушка: Если атакующий изменил ядро (несоответствие PCR 4), заменил initramfs (несоответствие PCR 9) или изменил политику Secure Boot (несоответствие PCR 7), хэши PCR расходятся. TPM отказывается распечатывать, и SPiCa безопасно отказывает — он отказывается запускаться, а не работает вслепую с скомпрометированным ключом.
Для исследовательской статьи представьте строгую политику и обсудите компромисс перезапечатывания в разделе ограничений.
Два механизма взаимодополняющие:
.bss, где находится ключ.Ни один из них не достаточен по отдельности. Запечатывание PCR не помогает, если ключ скомпрометирован во время выполнения (перечисление карт). Секретность адреса не помогает, если система была скомпрометирована до запуска SPiCa (враждебный initramfs).
spica_lsm_modblock, шлюз доступа к картам) загружаются во время выполнения и НЕ измеряются ни в один PCR. Руткит, который отсоединяет их после загрузки, не ловится запечатыванием PCR. Это ловит пульс NMI (обнаружение того, что сама система обнаружения перестала работать).SPiCa — это последний уровень принуждения. Он дополняет правильно настроенную систему, а не заменяет вышележащие уровни.```mermaid
flowchart TD
SB["UEFI Secure Boot
─────────────────────────────
Verifies bootloader signature against
the UEFI db certificate store
Measured to PCR 7"]
MS["Kernel Module Signing
─────────────────────────────
CONFIG_MODULE_SIG_FORCE=y
Kernel rejects unsigned .ko at load time"]
IMA["IMA, Integrity Measurement Architecture
─────────────────────────────
Measures file hashes to TPM PCR10
Appraise policy blocks non-matching signatures"]
SPICA["SPiCa
─────────────────────────────
LSM gate: blocks LKM loads + map enumeration
sched_switch + NMI integrity channels
Differential detection: DKOM · GHOST · TAMPER · SILENT · DUPE
PCR-bound key sealing (design phase)"]
SB -->|"boot chain verified"| MS
MS -->|"signed modules only"| IMA
IMA -->|"measured + appraised"| SPICA
classDef firmware fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f
classDef kernel fill:#fdf2f8,stroke:#9333ea,stroke-width:2px,color:#581c87
classDef spica fill:#eff6ff,stroke:#2563eb,stroke-width:2px,color:#1e3a8a
class SB firmware
class MS,IMA kernel
class SPICA spica
SPiCa проверяет все четыре уровня при запуске и выводит их статус.
---
## 11. Инцидент с ошибкой BTF
### Что произошло
Во время тестирования на Ubuntu (последнее ядро) несовместимость BTF привела к тому, что программа точки трассировки `sched_switch` успешно прикрепилась, но **ни разу не сработала.** Системный вызов `attach()` вернул `Ok(())`, поэтому SPiCa продолжила работу нормально — но кольцевой буфер sched остался пустым. Никаких предупреждений об обнаружении не сработало, потому что у движка обнаружения не было данных планировщика. **SPiCa работала вслепую без каких-либо признаков сбоя.**
Это наихудший режим отказа для инструмента безопасности: тихая слепота.
### Почему это не было обнаружено
Исходный движок обнаружения работал **на уровне процессов**: каждая запись процесса содержала временные метки `sched_last` и `nmi_last`, и жизнеспособность вычислялась для каждой записи. Когда sched\_switch глобально перестал работать:
1. `sched_live` стал ложным для каждой записи (нет новых событий sched → все значения `sched_last` устаревают)
2. Предикат `TAMPER` на уровне процесса (`in_proc && nmi_live && !sched_live`) мог сработать, но требовал, чтобы `nmi_live` удерживалось *непрерывно* в течение 2 секунд. NMI производит выборку редко (период 10M циклов), поэтому `suspect_since` постоянно сбрасывался из-за дрожания выборки и никогда не достигал зрелости.
3. Предикат `SILENT` на уровне процесса требовал `sched_live`, который теперь был ложным для всего — предикат инвертировался именно при том условии, которое он должен был обнаружить.
4. Не было **проверки жизнеспособности на уровне канала** — не было механизма «срабатывал ли sched ВООБЩЕ?» или «является ли max(sched_last) устаревшим?».
Кроме того, было обнаружено несоответствие временной базы: `sched_last` хранил `bpf_ktime_get_ns()` (наносекунды загрузки ядра), в то время как `evaluate()` сравнивал с `nanos_since_startup()` (наносекунды процесса). `wrapping_sub` этих разных временных баз давал огромные значения, делая все предикаты жизнеспособности тривиально ложными. Логика обнаружения никогда не работала корректно в продакшене — ошибка BTF просто маскировала это, полностью предотвращая поступление событий.
### Как перепроектирование это исправляет
| Проблема | Исправление |
|---|---|
| Нет проверки жизнеспособности на уровне канала | `evaluate()` теперь вычисляет `max(sched_last)` и запускает `[SILENT]`, если устарел, пока `/proc` не пуст |
| Предикат TAMPER на уровне процесса никогда не достигал зрелости (сброс из-за дрожания) | TAMPER теперь является прямым сигналом от программы NMI (сравнение импульса `.bss`), а не конечным автоматом на уровне процесса с порогами |
| Предикат SILENT на уровне процесса инвертировался при смерти sched | SILENT теперь работает на уровне канала, вычисляется из агрегатов, а не из предикатов отдельных записей |
| Несоответствие временной базы | `sched_last` теперь хранит время процесса (`now` параметр), а не `bpf_ktime_get_ns()` |
| NMI был избыточным наблюдением | NMI теперь является проверяющим целостность — он напрямую обнаруживает смерть sched через импульс `.bss` |
---
## 12. Известные ограничения и поверхность атак
### В рамках модели угроз eBPF
| Атака | Статус | Примечания |
|---|---|---|
| **Манипуляция указателем потребителя кольцевого буфера** | **Обнаружено** (SILENT) | `max(sched_last)` устаревает → SILENT. `SCHED_HEARTBEAT` всё ещё продвигается (программа работает) → нет ложного TAMPER. |
| **DKOM структуры события Perf** (обнуление поля `state`) | **Обнаружено** (SILENT) | NMI перестает срабатывать → нет событий импульсов → SILENT на канале NMI |
| **Подделка поля `task_struct`** (перезапись pid/tgid) | **Частично** (DUPE) | Если поддельный TGID совпадает с существующей записью с другим `start_time_ns`, срабатывает DUPE. Если `start_time_ns` также подделан, это проходит незамеченным. |
| **Открепление программы eBPF** (удаление из массива funcs) | **Обнаружено** (TAMPER) | Импульс NMI замирает → TAMPER |
| **Изменение байткода на месте** | **Не обнаружено** | Требует произвольной записи в ядро (уровень LKM) — вне модели угроз eBPF |
| **Побочный канал времени на `bpf_probe_read_kernel`** | **Теоретический риск** | Двоичный поиск по куче может найти `.bss` примерно за 30 проб. См. [§6](#6-verifier-bounded-address-secrecy) ограничения. |
### Вне модели угроз eBPF (признано, не решено)
| Атака | Статус | Примечания |
|---|---|---|
| **Перехват обработчика IDT/NMI** | **Живая бомба** | Архитектурно полный обход. Почти гарантированная паника ядра. |
| **Подавление по двум каналам** | **Живая бомба** | Одновременное подавление как sched, так и NMI. Наследует нестабильность перехвата NMI. |
| **Эксплойт верификатора** | **Не является целью** | Приводит к произвольному выполнению кода ядра. Отдельный класс угроз. |
| **ptrace / `/proc/pid/mem`** | **Фундаментальное ограничение** | Чтение памяти с теми же привилегиями. Помогает только отправка журналов на внешний хост. |
| **Изменение указателя потребителя + импульса** | **Уровень LKM** | Если атакующий имеет произвольную запись в ядро, он может и продвинуть указатель потребителя, и записать поддельные импульсы. Но произвольная запись в ядро = уровень LKM = вне модели угроз. |
---
## 13. Сборка и запуск
### Предварительные требования
- Ядро Linux >= 5.15 с `CONFIG_DEBUG_INFO_BTF=y` (только для перехвата LSM)
- Для блокировки модулей: `CONFIG_BPF_LSM=y` и `lsm=bpf` в командной строке ядра
- Чип TPM 2.0 + библиотека `tpm2-tss` (опционально; выполняет откат с видимым предупреждением)
- Nightly Rust toolchain
> **Примечание:** Шаг `generate-vmlinux` **больше не требуется**. Программы eBPF используют традиционные смещения точек трассировки и глобальные переменные `.bss` — навигация по структурам CO-RE/BTF не нужна. Команда xtask `generate-vmlinux` сохранена для будущего использования, но не является частью конвейера сборки.
Проверьте, что BPF LSM активен: `cat /sys/kernel/security/lsm` должен содержать `bpf`.
### Настройка```shell
make install-deps # system packages + nightly Rust (pacman/apt/dnf auto-detected)
make install-tools # bpf-linker
make build # compiles eBPF + userspace (no vmlinux generation needed)
make run # sudo ./target/release/spica
### Установка в initramfs (защита при ранней загрузке)```shell
sudo make install # spica install — auto-detects Debian (initramfs-tools) or Fedora (dracut)
sudo insmod some_module.ko # should fail with EPERM (gate locked) cat /sys/kernel/security/lsm # should contain 'bpf' ls /sys/fs/bpf/spica_watchdog # exists if previous run was killed ungracefully
### Разработка (macOS или Linux)
Программы eBPF не могут работать на macOS. `cargo check` и модульные тесты для логики обнаружения работают; полная верификация во время выполнения требует Linux.```shell
make check # cargo check for workspace
make check-ebpf # cargo check for the eBPF crate (bpfel-unknown-none)
make test # unit tests for detection FSM, key derivation, obfuscation
GetRandom на полный аппаратный корень доверия. См. §9.bpf, блокирующий перечисление внутреннего состояния SPiCa из процессов, не являющихся SPiCa. См. §7.BPF_PROG_LOAD — Расширить шлюз LSM для ограничения скорости загрузки программ BPF из процессов, не являющихся SPiCa, снижая риск атаки по времени для обнаружения .bss. См. ограничения §6.siphasher уже есть в дереве для токена целостности).Лицензия SPiCa Engine: MIT OR Apache-2.0 (рабочая область). Программа eBPF экспортирует лицензию GPL через статическую переменную _license — это требование ядра для программ eBPF, использующих вспомогательные функции с лицензией GPL, и применяется только к загруженному байт-коду eBPF, а не к пользовательскому бинарному файлу.
Атрибуция персонажа: "Hatsune Miku" и связанные с ним изображения персонажа являются защищённой авторским правом собственностью Crypton Future Media, INC. (www.piapro.net). Этот проект является независимым некоммерческим исследовательским инструментом, не связанным с Crypton Future Media. Персонаж используется в соответствии с Piapro Character License (PCL).
Название проекта SPiCa вдохновлено оригинальной песней Toku-P.
| Ограничение | Влияние на противника |
|---|
| Ограниченные циклы (~1 млн инструкций на программу) | Не может сканировать всю память ядра |
| Безопасность указателей (прямое разыменование требует доказуемо корректной, ограниченной цели) | Не может следовать произвольным указателям |
| Ограниченные хелперы (белый список для каждого типа программы) | Не может вызывать kmalloc, vmalloc или любую функцию выделения памяти ядра |
Нет произвольной записи в ядро (bpf_probe_write_kernel сильно ограничен) | Не может изменять task_struct, массивы функций точек трассировки или байт-код программы BPF |
| Доступ к картам ограничен загруженными ссылками | Не может получить доступ к картам других программ (если они не перечислены по ID — см. §7) |
| Канал | Механизм | Что его подавляет | Стоимость подавления |
|---|
/proc | Перечисление каталогов procfs | DKOM (отвязка процесса от списка задач) | Низкая — хорошо известный метод руткитов |
sched_switch | Точка трассировки eBPF на планировщике | Открепление программы от массива функций; фильтрация контекста | Средняя — требуется запись в ядро в массив функций |
| NMI | Прерывание аппаратного счётчика производительности → событие eBPF perf | Перехват IDT/обработчика NMI | Почти гарантированная паника ядра — аппаратное прерывание, не маскируемое |
| Оповещение | Условие | Что обнаруживает |
|---|
[DKOM] | TGID, запланированный ядром, отсутствует в /proc более 2 с | Прямая манипуляция объектами ядра — процесс скрыт от перечисления в userspace |
[GHOST] | TGID присутствует в /proc, но не был замечен sched_switch более 5 с | Подделка /proc — вставлена ложная запись для обхода проверок DKOM |
[TAMPER] | NMI обнаруживает заморозку пульса sched_switch (event_type=1) | Точка трассировки отключена, подавлена или произошла ошибка BTF/прикрепления |
[SILENT] | max(sched_last) устарел для всех записей реестра, при этом /proc не пуст; или пульс NMI отсутствует более 5 с | Потеря наблюдения на уровне канала — канал sched или NMI мёртв |
[DUPE] | Одинаковый TGID, но разный start_time_ns в событиях | Подделка поля task_struct — руткит изменяет tgid для маскировки под легитимный процесс |
[HOOK] | PID видны через сырой getdents64, но отсутствуют в libc readdir | LD_PRELOAD руткит — перехват библиотек пользовательского пространства, скрывающий процессы от ps, ls и других инструментов (Symbiote, JynxKit, Azazel, Medusa/OrBit) |
[WATCHDOG] | В /sys/fs/bpf/spica_watchdog существует закрепление при запуске | Предыдущий экземпляр был аварийно завершён (SIGKILL, OOM, крах) |
[LKM-ALLOW] | READING_MODULE перехвачен, когда ворота открыты (окно загрузки) | Аудит записи: модуль загружен до запирания ворот |
[LKM-DENY] | READING_MODULE перехвачен, когда ворота заперты | insmod/modprobe заблокированы после инициализации |
| Глобальная переменная | Назначение | Кем записывается |
|---|
BASE_KEY | Ключ обфускации XOR | пользовательское пространство во время загрузки |
SPICA_PID | Собственный TGID SPiCa (сторожевой таймер) | пользовательское пространство во время загрузки |
SCHED_HEARTBEAT | Временная метка активности sched_switch | программа sched_switch при каждом вызове |
NMI_LAST_HB | Запись NMI последнего пульса sched | программа NMI при каждой проверке |
NMI_FIRST_TICK | Ktime первого вызова NMI (льготный период) | программа NMI при первом вызове |
NMI_LAST_EMIT | Ограничение: ktime последнего события | программа NMI при каждой отправке |
| PCR | Что измеряется | Стабильность |
|---|
| PCR 4 | Код загрузчика + образ ядра (GRUB измеряет оба) | Изменяется при обновлении ядра |
| PCR 5 | Таблица разделов GPT/MBR, конфигурация загрузки | Стабильно при обновлениях |
| PCR 7 | Политика Secure Boot (SI policy, MOK, db/dbx) | Стабильно при обновлениях ядра |
| PCR 8 | Командная строка ядра (измерения systemd-stub) | Стабильно, если не меняется cmdline |
| PCR 9 | Initramfs (GRUB измеряет initrd здесь) | Изменяется при обновлении initramfs |
| PCR 10 | Список измерений IMA | Изменяется по мере измерения исполняемых файлов |
| Политика | Запечатано против | Стойкость | Эксплуатационные затраты |
|---|
| Strong (Строгая) | PCR 4 + 7 + 9 + 10 | Ловит изменения ядра, initramfs, Secure Boot и IMA | Перезапечатывание после каждого обновления ядра/initramfs |
| Balanced (Сбалансированная) | PCR 7 + 10 | Ловит изменения Secure Boot и оценки IMA; стабильно при обновлениях ядра | Перезапечатывание только при изменении политики Secure Boot или IMA |
| Minimal (Минимальная) | PCR 7 only | Ловит только изменения состояния Secure Boot | Очень стабильно; самая слабая привязка |
| Термин | Определение |
|---|
| BPF | Berkeley Packet Filter — механизм выполнения в ядре для изолированных программ. Современный BPF (eBPF) выходит за рамки пакетов в области трассировки, безопасности и сетей. |
| BTF | BPF Type Format — отладочная информация ядра, позволяющая программам CO-RE (Compile Once, Run Everywhere) переносимо навигировать по структурам ядра. |
| CO-RE | Compile Once, Run Everywhere — техника BPF, использующая BTF для написания переносимых программ, адаптирующихся к разным версиям ядра. |
| DKOM | Direct Kernel Object Manipulation — техника руткита удаления процесса из связного списка ядра для сокрытия от /proc. |
| fmod_ret | Тип программы BPF, изменяющий возвращаемое значение функции ядра через трамплин BPF. |
| freplace | Расширение программы BPF — прикрепляется к определённой (под)функции другой программы BPF, перехватывая её выполнение. |
| funcs array | Массив указателей на функции в структуре точки трассировки ядра, содержащий функции обратного вызова (включая программы BPF) для вызова при срабатывании точки трассировки. |
| IDT | Interrupt Descriptor Table — структура CPU, сопоставляющая векторы прерываний функциям-обработчикам. Хукинг точки входа NMI требует модификации IDT. |
| kASLR | Kernel Address Space Layout Randomization — рандомизация адресов кода/данных ядра при каждой загрузке для затруднения эксплуатации. |
| NMI | Non-Maskable Interrupt — аппаратное прерывание, которое нельзя отключить программно (cli). Используется счётчиками perf для аппаратного наблюдения. |
| PCR | Platform Configuration Register — регистр TPM, накапливающий измерения (хэши) компонентов загрузки. Не может быть сброшен (кроме перезагрузки), только расширен. |
| PMU | Performance Monitoring Unit — аппаратные счётчики в CPU, подсчитывающие события (циклы, промахи кэша и т.д.) и способные генерировать прерывания (NMI) при достижении порогов. |
| TPM | Trusted Platform Module — криптографический сопроцессор, обеспечивающий аппаратное хранение ключей, генерацию случайных чисел и аттестацию измерений. |
| Verifier | Верификатор BPF — компонент ядра, статически анализирующий программы BPF перед загрузкой, чтобы гарантировать их завершение и отсутствие доступа к небезопасной памяти. |