Привет, Хабр.
Я написал с нуля инструмент для изоляции Linux‑приложений на Rust — Hajiz. Это учебный проект по системной безопасности, который вырос во что‑то, чем можно реально пользоваться. Хочу рассказать, как он устроен изнутри: почему порядок применения механизмов изоляции критичен, как работает eBPF‑аудит с privilege separation, и зачем всё это когда есть Docker.
Идея и постановка задачи
Задача: запустить недоверенный бинарь так, чтобы он не мог навредить системе. Без демона, без setuid‑хелпера, без kernel‑модуля, без sudo для типичного случая.
Думайте о Hajiz как о младшем брате bubblewrap и firejail — меньше, прозрачнее, с дополнительным режимом аудита, который сам учится что нужно бинарю и генерирует политику.
Модель угроз:
-
Доверяем: ядру, бинарю hajiz, файлам профилей.
-
Не доверяем: изолируемому бинарю и всему, что он производит.
-
Защищаем от: побега из filesystem, повторного входа в namespace, утечки в сеть, злоупотребления capabilities, syscall‑эксплойтов, privilege escalation через
exec(). -
Вне scope: side‑channel атаки (Spectre/timing), kernel‑эксплойты, аппаратные атаки, скомпрометированный сам hajiz.
Что применяется под капотом
Hajiz собирает несколько механизмов безопасности ядра в одну связную систему с моделью default‑deny — всё запрещено, вы явно разрешаете только то, что нужно.
1. Linux Namespaces
При запуске создаются новые пространства имён:
unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWIPC | CLONE_NEWUTS | CLONE_NEWNET)
Пишется identity‑маппинг в /proc/self/uid_map и /proc/self/gid_map — UID снаружи совпадает с UID внутри, никаких скрытых привилегий. Затем корень перемонтируется как MS_PRIVATE, чтобы монтирования sandbox не утекали в хост.
Три режима сети:
|
Режим |
Поведение |
|---|---|
|
|
Собственный netns без интерфейсов — полный офлайн |
|
|
Собственный netns с |
|
|
Хостовая сеть; на kernel ≥ 6.7 всё равно применяются Landlock port allowlists |
2. Capabilities
Четыре шага, и порядок важен:
-
PR_CAPBSET_DROPдля всего bounding set (нуженCAP_SETPCAP— он ещё есть) -
PR_CAP_AMBIENT_CLEAR_ALL— чистим ambient set -
capset(0, 0, 0)— обнуляем effective/permitted/inheritable -
prctl(PR_SET_NO_NEW_PRIVS)— даже setuid‑бинарь не сможет повысить привилегии
3. Landlock (filesystem + TCP‑порты)
Начиная с Linux 5.13 — per‑path allowlists. С 6.7 — ещё и ограничение TCP‑портов. На старых ядрах правила пропускаются с предупреждением.
# read-only /usr, writable /tmphajiz --fs /usr:ro --fs /tmp:rw /usr/bin/python3 script.py# только порты 80 и 443 наружуhajiz --net full --allow-connect-port 443 --allow-connect-port 80 \ /usr/bin/curl https://example.com# диапазон портовhajiz --net full --allow-connect-port 8000-8100 /usr/bin/myapp
4. seccomp‑BPF
Три режима на выбор:
|
Флаг |
Режим |
Что делает |
|---|---|---|
|
(по умолчанию) |
Hardening‑deny |
Блокирует высокорисковые syscall‑ы, остальное разрешено |
|
|
Allowlist |
Только явно разрешённые syscall‑ы |
|
|
Strict |
Только |
Список того, что hardening‑режим блокирует по умолчанию: unshare, setns, clone3, bpf, userfaultfd, add_key, request_key, keyctl, ptrace, process_vm_readv, process_vm_writev.
В allowlist‑режиме можно добавлять группы или отдельные syscall‑ы:
hajiz --seccomp-whitelist \ --allow-group io --allow-group memory --allow-group process \ --allow-syscall getrandom \ /usr/bin/myapp
Доступные группы: io, memory, threading, network, process.
5. cgroups v2
Лимиты ресурсов без лишних движений:
hajiz --max-mem 512M --max-cpu 50 --max-pids 64 /usr/bin/myapp
Если cgroups v2 недоступны — предупреждаем и продолжаем без лимитов.
Почему порядок применения механизмов — это не деталь
Самая важная нетривиальная часть: шаги изоляции нельзя переставлять. Каждое нарушение порядка ломает что‑то по‑своему.
Когда вы запускаете hajiz <binary>, родительский процесс делает fork() с хуком pre_exec. Хук срабатывает после fork(), но до exec() и применяет пайплайн:
[parent: hajiz] [child: pre_exec, до exec()] │ │ ├─ парсим CLI │ 1. Namespaces ├─ загружаем профиль │ unshare(NEWUSER|NEWNS|NEWIPC|NEWUTS|NEWNET) ├─ строим IsolationConfig │ пишем uid/gid_map ├─ применяем cgroups к себе │ remount / как MS_PRIVATE ├─ spawn child ────────────────────────► │ ├─ запускаем мониторы телеметрии │ 2. Capabilities ├─ ждём child │ PR_CAPBSET_DROP (0..40) └─ при выходе: лог, cleanup cgroups │ PR_CAP_AMBIENT_CLEAR_ALL │ capset(0) │ PR_SET_NO_NEW_PRIVS │ │ 3. Landlock │ path rules + TCP port rules │ │ 4. seccomp-BPF │ фильтр устанавливается последним │ │ 5. exec(binary, args)
Вот что сломается при нарушении порядка:
|
Неправильный порядок |
Почему ломается |
|---|---|
|
|
|
|
Дроп capabilities до user namespace |
У непривилегированного пользователя нет capabilities для дропа — нужно сначала войти в user‑ns |
|
seccomp до Landlock |
Загрузка Landlock требует |
|
Remount |
|
Это не теория — каждое из этих нарушений на реальном ядре даёт либо EPERM, либо тихое отсутствие защиты.
Audit‑режим: как инструмент учится, что нужно бинарю
Это моя любимая часть. Идея: запустить бинарь без изоляции, посмотреть что он делает, и автоматически сгенерировать TOML‑профиль с нужными разрешениями.
# Сгенерировать профиль в stdouthajiz --audit /usr/bin/curl https://example.com# Записать в файлhajiz --audit --audit-output curl.toml /usr/bin/curl https://example.com# Применить сгенерированный профильhajiz --profile curl.toml /usr/bin/curl https://example.com
Два бэкенда, выбираются автоматически:
-
eBPF — когда запущено от root или с
CAP_BPF. Трейсит syscall‑ы черезraw_tracepoint/sys_enter. Низкий overhead, только имена syscall‑ов. -
strace — непривилегированный fallback. Медленнее, но дополнительно захватывает пути файлов и сетевые адреса.
Проблема: eBPF требует CAP_BPF, но целевой бинарь не должен работать привилегированно
Решение — broker/sandbox split. Тот же паттерн, что использует Chrome, firejail, bwrap.
Ключевой момент: ядро проверяет CAP_BPF только в момент создания BPF‑объекта. После этого fd — это просто файловый дескриптор, который можно передать через SCM_RIGHTS любому процессу, в том числе непривилегированному.
Как это работает:
sudo hajiz --audit /usr/bin/curl ... │ ▼┌──────────────────────────────────────────────────────────────────────────┐│ hajiz процесс (root / CAP_BPF) ││ ││ socketpair(AF_UNIX, SOCK_STREAM) ││ fork() ││ │ │ ││ BROKER (parent) SANDBOX (child) ││ │ │ ││ загружает BPF-объект ★ ПЕРВАЯ ИНСТРУКЦИЯ: ││ aya::Ebpf::load(audit_syscalls.o) drop_all_capabilities() ││ attach("sys_enter") (capset(0) + bounding set + ││ ← ядро проверяет CAP_BPF здесь ambient clear + no_new_privs)││ │ ││ получает fd: config_map, syscall_map recv_fds() ││ send_fds() ──── SCM_RIGHTS ──────────────► (уже без привилегий) ││ │ ││ drop_all_capabilities() записывает target PID ││ ★ broker тоже без привилегий в config_map ││ BPF жив — держим fd │ ││ spawn(binary) ││ │ ││ ядро: raw_tracepoint срабат. ││ per syscall, фильтрует по PID││ инкрементирует syscall_map ││ │ ││ сериализует отчёт ││ read() ◄─── socket ─────────────────────── пишет в socket ││ генерирует профиль exit ││ пишет curl.toml │└──────────────────────────────────────────────────────────────────────────┘
Окна привилегий:
|
Процесс |
Привилегированное окно |
Что происходит |
|---|---|---|
|
Broker |
start → |
BPF load + attach + sendmsg. Микросекунды. |
|
Sandbox |
fork() → первая инструкция в userspace |
Ровно один syscall return. Первое что делает — |
Целевой бинарь никогда не запускается с повышенными правами — ни на одну инструкцию.
BPF‑программа
Намеренно минималистична. Два map‑а: config_map (держит target TGID), syscall_map (хэш syscall_nr → count). Одна программа:
SEC("raw_tracepoint/sys_enter")int audit_syscalls(struct bpf_raw_tracepoint_args *ctx) { u32 tgid = bpf_get_current_pid_tgid() >> 32; u32 *filter_pid = bpf_map_lookup_elem(&config_map, &zero); if (!filter_pid || *filter_pid == 0 || *filter_pid != tgid) return 0; long syscall_id = ctx->args[1]; u64 *count = bpf_map_lookup_elem(&syscall_map, &syscall_id); if (count) __sync_fetch_and_add(count, 1); return 0;}
Верификатор ядра статически доказывает отсутствие бесконечных циклов, выходов за границы, неинициализированных чтений. Это настоящая граница безопасности — не язык реализации.
Телеметрия
Hajiz эмитирует структурированные JSON‑события на каждое решение об изоляции:
hajiz -v --log run.json --fs /usr:ro /usr/bin/myapp
|
Событие |
Когда |
|---|---|
|
|
перед запуском |
|
|
перед запуском — список blocked syscalls, allowed paths |
|
|
при заблокированном syscall |
|
|
предсказание Landlock‑отказа (kernel ≥ 5.5) |
|
|
предсказание Landlock TCP‑отказа (kernel ≥ 5.5) |
|
|
каждые N секунд — mem, pids, cpu_usec |
|
|
при завершении |
На kernel ≥ 5.0 syscall_denied работает через seccomp user notification: заблокированный syscall маршрутизируется в supervisor‑поток родителя, который логирует событие и отвечает -EPERM. Без привилегий, без kernel.dmesg_restrict.
filesystem_denied и network_denied — это предсказания, а не вердикт ядра. Supervisor читает путь или sockaddr из /proc/<pid>/mem, проверяет против правил профиля и отвечает SECCOMP_USER_NOTIF_FLAG_CONTINUE. Реальное решение остаётся за Landlock.
Профили
Вместо длинных флагов — переиспользуемые TOML‑файлы:
name = "untrusted"description = "Minimal profile for untrusted files"[network]mode = "none"allow_connect = []allow_bind = [][filesystem]read_only = ["/usr", "/lib", "/lib64", "/bin"]read_write = ["/tmp"][seccomp]hardening = truewhitelist = falsestrict = falseallow_groups = []allow_syscalls = []
В репозитории лежат готовые профили: untrusted, firefox, document-viewer, media-player, office.
hajiz --profile profiles/firefox.toml /usr/bin/firefox
Установка
Нужны Rust toolchain и libseccomp. clang опционален — если есть, компилируется eBPF‑бэкенд аудита; иначе fallback на strace.
cargo build --releasesudo install -m 755 target/release/hajiz /usr/local/bin/hajiz
Ubuntu 23.10+ / 24.04 LTS: kernel.apparmor_restrict_unprivileged_userns включён по умолчанию и блокирует unshare(CLONE_NEWUSER) без явного AppArmor‑профиля. Hajiz определяет это и печатает подсказку. Решение:
sudo cp packaging/apparmor/hajiz /etc/apparmor.d/hajizsudo systemctl reload apparmor
На Debian, Arch, Fedora, RHEL и Ubuntu ≤ 23.04 дополнительная конфигурация не нужна.
Проверить что поддерживает ваше ядро:
hajiz --kernel-info
Известные ограничения
-
filesystem_deniedиnetwork_denied— предсказания. Supervisor не отслеживает symlink‑ы так, как это делает Landlock. Enforcement всегда за ядром. -
Нет
CLONE_NEWPID.unshare(NEWPID)затрагивает только будущих детей, не сам exec’d бинарь. Требует double‑fork + remount/proc— в планах. -
eBPF audit: только syscall‑ы, без путей и сетевых адресов. strace‑бэкенд это умеет. Переход на ringbuf — следующий шаг.
-
cgroups v2. Лимиты не применяются, если cgroups v2 недоступны. Предупреждение есть, падения нет.
-
fullnetwork без kernel ≥ 6.7 даёт полный доступ к сети без port allowlists. Используйтеnone/loopbackдля жёсткой изоляции.
Быстрый старт
# По умолчанию: офлайн, hardened seccomp, дроп capshajiz /usr/bin/echo hello# read-only /usr, writable /tmphajiz --fs /usr:ro --fs /tmp:rw /usr/bin/python3 script.py# только loopbackhajiz --net loopback /usr/bin/python3 -m http.server 8000# лимиты ресурсовhajiz --max-mem 512M --max-cpu 50 --max-pids 64 /usr/bin/myapp# audit → профиль → запуск под профилемhajiz --audit --audit-output curl.toml /usr/bin/curl https://example.comhajiz --profile curl.toml /usr/bin/curl https://example.com# посмотреть что поддерживает ядроhajiz --kernel-info
Итог
Проект начинался как учебный глубокий dive в механизмы безопасности Linux‑ядра. Оказалось, что самое интересное — не реализация каждого механизма по отдельности, а то, как заставить их корректно работать вместе на разных версиях ядра. И особенно — как организовать privilege separation в audit‑режиме так, чтобы целевой бинарь никогда не получал CAP_BPF даже на одну инструкцию.
Буду рад фидбеку по архитектуре изоляции, вопросам и PR.
→ github.com/hag19/hajiz | Лицензия: MIT / Apache-2.0
ссылка на оригинал статьи https://habr.com/ru/articles/1053220/