Я написал с нуля инструмент для изоляции Linux‑приложений на Rust — Hajiz. Это учебный проект по системной безопасности, который вырос во что‑то, чем можно реально пользоваться. Хочу рассказать, как он устроен изнутри: почему порядок применения механизмов изоляции критичен, как работает eBPF‑аудит с privilege separation, и зачем всё это когда есть Docker.→ github.com/hag19/hajiz Читать далее
Привет, Хабр.
Я написал с нуля инструмент для изоляции 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 |
Четыре шага, и порядок важен:
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‑бинарь не сможет повысить привилегии
Начиная с Linux 5.13 — per‑path allowlists. С 6.7 — ещё и ограничение TCP‑портов. На старых ядрах правила пропускаются с предупреждением.
# read-only /usr, writable /tmp
hajiz --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.
Лимиты ресурсов без лишних движений:
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, либо тихое отсутствие защиты.
Это моя любимая часть. Идея: запустить бинарь без изоляции, посмотреть что он делает, и автоматически сгенерировать TOML‑профиль с нужными разрешениями.
# Сгенерировать профиль в stdout
hajiz --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. Медленнее, но дополнительно захватывает пути файлов и сетевые адреса.
Решение — 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 = true
whitelist = false
strict = false
allow_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 --release
sudo 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/hajiz
sudo 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, дроп caps
hajiz /usr/bin/echo hello
# read-only /usr, writable /tmp
hajiz --fs /usr:ro --fs /tmp:rw /usr/bin/python3 script.py
# только loopback
hajiz --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.com
hajiz --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
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | От boot-кода до shell: несколько месяцев разработки ОС на Rust с ИИ-агентами | 0 | 12.89 | 30-07-2026 |
| 2 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 0 | 8.78 | 27-06-2026 |
| 3 | Считаем пакеты на Rust | 0 | 9.87 | 04-07-2026 |
| 4 | Yet another Rust VPN (TUN-QUIC-TUN) | 0 | 10.98 | 11-07-2026 |
| 5 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 7 | 8 | 27-06-2026 |
| 6 | Как понять, что делает Rust-компилятор: визуализация AST, MIR и LLVM IR | 0 | 10.8 | 15-07-2026 |
| 7 | Заметки о том, как я писал SFU на Rust (1 часть) | 0 | 13.29 | 05-07-2026 |
| 8 | История о том, как я написал компилятор Svelte на Rust | 0 | 11.83 | 02-08-2026 |
| 9 | Проект Kakehashi развивает инструментарий для запуска исполняемых файлов macOS в Linux | 0 | 11.82 | 03-08-2026 |
| 10 | AngaraBase: новая HTAP СУБД | 7 | 8 | 29-06-2026 |