Hajiz: rootless Linux‑sandbox на Rust — namespaces, Landlock, seccomp‑BPF и eBPF‑аудит без sudo

от автора

Привет, Хабр.

Я написал с нуля инструмент для изоляции Linux‑приложений на Rust — Hajiz. Это учебный проект по системной безопасности, который вырос во что‑то, чем можно реально пользоваться. Хочу рассказать, как он устроен изнутри: почему порядок применения механизмов изоляции критичен, как работает eBPF‑аудит с privilege separation, и зачем всё это когда есть Docker.

github.com/hag19/hajiz


Идея и постановка задачи

Задача: запустить недоверенный бинарь так, чтобы он не мог навредить системе. Без демона, без 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 не утекали в хост.

Три режима сети:

Режим

Поведение

none (по умолчанию)

Собственный netns без интерфейсов — полный офлайн

loopback

Собственный netns с lo UP и 127.0.0.1/8 — только локальные сервисы

full

Хостовая сеть; на kernel ≥ 6.7 всё равно применяются Landlock port allowlists

2. Capabilities

Четыре шага, и порядок важен:

  1. PR_CAPBSET_DROP для всего bounding set (нужен CAP_SETPCAP — он ещё есть)

  2. PR_CAP_AMBIENT_CLEAR_ALL — чистим ambient set

  3. capset(0, 0, 0) — обнуляем effective/permitted/inheritable

  4. 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‑ы, остальное разрешено

--seccomp-whitelist

Allowlist

Только явно разрешённые syscall‑ы

--strict-seccomp

Strict

Только read/write/exit/sigreturn

Список того, что 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)

Вот что сломается при нарушении порядка:

Неправильный порядок

Почему ломается

capset(0) до PR_CAPBSET_DROP

capset(0) убирает CAP_SETPCAP → дроп bounding set завершится с EPERM

Дроп capabilities до user namespace

У непривилегированного пользователя нет capabilities для дропа — нужно сначала войти в user‑ns

seccomp до Landlock

Загрузка Landlock требует landlock_create_ruleset — системного вызова, который строгий seccomp уже заблокировал

Remount / до user namespace

mount(MS_PRIVATE) требует CAP_SYS_ADMIN, доступный непривилегированному юзеру только внутри user‑ns

Это не теория — каждое из этих нарушений на реальном ядре даёт либо 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 → drop_all_capabilities() после send_fds()

BPF load + attach + sendmsg. Микросекунды.

Sandbox

fork() → первая инструкция в userspace

Ровно один syscall return. Первое что делает — drop_all_capabilities().

Целевой бинарь никогда не запускается с повышенными правами — ни на одну инструкцию.

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

Событие

Когда

sandbox_started

перед запуском

policy_applied

перед запуском — список blocked syscalls, allowed paths

syscall_denied

при заблокированном syscall

filesystem_denied

предсказание Landlock‑отказа (kernel ≥ 5.5)

network_denied

предсказание Landlock TCP‑отказа (kernel ≥ 5.5)

resource_usage

каждые N секунд — mem, pids, cpu_usec

sandbox_exited

при завершении

На 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 недоступны. Предупреждение есть, падения нет.

  • full network без 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/