Bubblewrap: песочница для недоверенного кода без root

Как запускать недоверенный бинарник, скрипт или сборку в песочнице без root с помощью Bubblewrap, что он изолирует, а что нет, и когда bwrap уместнее Docker, Podman или Firejail

Вам прислали бинарник «просто запусти и проверь». Или CI гоняет npm install с сотнями транзитивных зависимостей, каждая из которых выполняет свой postinstall. Или вы собираете чужой проект из исходников. Во всех случаях вопрос один: как выполнить недоверенный код, не отдав ему свой домашний каталог, SSH-ключи и сеть?

Docker для этого часто избыточен: демон, образы, слои — ради того, чтобы один раз запустить одну команду. Bubblewrap (bwrap) решает ровно эту задачу: непривилегированная песочница для одной команды, без демона и образов. Это тот же движок, на котором построена изоляция Flatpak.

Bubblewrap: процесс, запечатанный в песочнице-пузыре, изолирован от системы хоста

В статье

Что такое Bubblewrap и чем он не является

Bubblewrap — маленькая утилита, которая строит изолированное окружение из Linux namespaces и запускает в нём указанную команду. Никакого фонового процесса: bwrap … команда разворачивает песочницу, выполняет команду и исчезает вместе с ней.

Ключевое — непривилегированность. Через user namespaces bwrap создаёт окружение, где ваш обычный пользователь выглядит как root внутри песочницы, оставаясь непривилегированным снаружи. Root на хосте не нужен (в дистрибутивах со включёнными unprivileged user namespaces; про исключения — ниже).

Чем bwrap не является:

  • Не менеджер контейнеров: нет образов, реестров, слоёв и Dockerfile. Файловая система песочницы собирается из bind-маунтов хостовых каталогов.
  • Не оркестратор и не средство доставки сервисов: он не про «упаковать и выкатить», а про «запустить здесь и сейчас в изоляции».
  • Не полноценная виртуализация: ядро общее с хостом. Это граница модели угроз, а не деталь реализации.

Что он изолирует, а что нет

Bubblewrap оперирует теми же примитивами, что и контейнеры, — namespaces ядра:

  • mount — своя файловая система: хост виден только через явные bind-маунты.
  • pid — свои процессы: код в песочнице не видит и не может сигналить процессам хоста.
  • net — сетевой namespace. Важная тонкость: по умолчанию песочница разделяет сеть хоста (полный доступ, включая loopback-сервисы хоста). Изоляция сети включается явным --unshare-net (входит в --unshare-all) — сама по себе она не появляется.
  • user — своё отображение UID/GID: то, что делает запуск непривилегированным.
  • ipc, uts, cgroup — изоляция System V IPC, hostname и cgroup-иерархии.

Что при этом не обеспечивается автоматически:

  • Защита от уязвимостей ядра. Ядро общее; эксплойт локального повышения привилегий в ядре обходит песочницу. Bubblewrap снижает поверхность атаки, но не заменяет VM для по-настоящему враждебного кода.
  • seccomp по умолчанию. Сам bwrap не ставит seccomp-фильтр — он лишь принимает ваш через --seccomp. Flatpak поставляет свой профиль; если запускаете недоверенный код, фильтр системных вызовов имеет смысл добавить самостоятельно.
  • Изоляция GUI. X11 не изолирует клиентов друг от друга: приложение в песочнице с доступом к сокету X может снимать нажатия клавиш и содержимое других окон. Для GUI нужен Wayland или вложенный X-сервер.

Практика: изолируем недоверенную команду

Начнём с максимально закрытой песочницы: root-файловая система только для чтения, отдельные /tmp и /proc, все namespaces отделены, сети нет.

bwrap \
  --ro-bind /usr /usr \
  --ro-bind /etc /etc \
  --symlink usr/lib /lib \
  --symlink usr/lib64 /lib64 \
  --symlink usr/bin /bin \
  --symlink usr/sbin /sbin \
  --proc /proc \
  --dev /dev \
  --tmpfs /tmp \
  --unshare-all \
  --die-with-parent \
  --new-session \
  --clearenv \
  --setenv PATH /usr/bin \
  /bin/bash

Разбор ключевых флагов:

  • --ro-bind /usr /usr — система доступна только для чтения. --symlink usr/bin /bin учитывает merged-/usr (в большинстве современных дистрибутивов /bin, /lib — это симлинки в /usr). Если у вас не merged-/usr, замените симлинки на --ro-bind /bin /bin и т.д.
  • --proc /proc монтирует свежий procfs (для этого нужен pid namespace, его даёт --unshare-all), --dev /dev — минимальный devtmpfs.
  • --tmpfs /tmp — эфемерный /tmp, ничего не утекает на диск хоста.
  • --unshare-all отделяет все доступные namespaces, включая сеть, — команда полностью офлайн. Строго говоря, это «все возможные» namespaces вместе с --unshare-user-try: если user namespaces запрещены политикой системы, bwrap может вообще не стартовать. Домашнего каталога в песочнице нет — при обращении к $HOME команда упрётся в отсутствующий путь.
  • --die-with-parent убивает песочницу, если родитель умер; --new-session защищает от инъекций в терминал через TIOCSTI; --clearenv обнуляет окружение (иначе токены и секреты из ваших env-переменных протекут внутрь).

Теперь практичный случай: дать недоверенному коду только рабочий каталог на запись, всё остальное — read-only.

bwrap \
  --ro-bind /usr /usr \
  --ro-bind /etc /etc \
  --symlink usr/lib /lib --symlink usr/lib64 /lib64 \
  --symlink usr/bin /bin --symlink usr/sbin /sbin \
  --proc /proc --dev /dev --tmpfs /tmp \
  --bind "$PWD/untrusted" /work \
  --chdir /work \
  --unshare-all --die-with-parent --new-session \
  /bin/bash ./run.sh

Скрипт видит и меняет только /work, но не ваш $HOME, не остальную файловую систему и не сеть.

А что с npm install, которому сеть нужна? Здесь компромисс осознанный: файловую систему запираем, а сеть оставляем. Вместо --unshare-all перечисляем namespaces вручную и не трогаем сетевой; чтобы резолвился DNS, пробрасываем resolv.conf.

bwrap \
  --ro-bind /usr /usr \
  --ro-bind /etc/ssl /etc/ssl \
  --ro-bind /etc/resolv.conf /etc/resolv.conf \
  --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
  --ro-bind /etc/hosts /etc/hosts \
  --symlink usr/lib /lib --symlink usr/lib64 /lib64 \
  --symlink usr/bin /bin --symlink usr/sbin /sbin \
  --proc /proc --dev /dev --tmpfs /tmp \
  --bind "$PWD" /app --chdir /app \
  --clearenv \
  --setenv PATH /usr/bin \
  --setenv HOME /app \
  --unshare-user --unshare-pid --unshare-ipc --unshare-uts \
  --die-with-parent --new-session \
  npm install

Сеть доступна (мы не сделали --unshare-net), но --clearenv вычищает окружение — иначе NPM_TOKEN, AWS_ACCESS_KEY_ID и прочие секреты из вашей сессии протекли бы внутрь к недоверенному коду. А любой postinstall-скрипт заперт в /app и не дотянется до ~/.ssh или ~/.aws. --setenv HOME /app уводит кеш npm в песочницу, где домашнего каталога иначе нет. Это типовой паттерн: изолировать то, что важно (файлы, процессы, секреты в env), и сознательно оставить то, без чего задача не работает (сеть). Помните о цене компромисса: сеть оставлена полностью — недоверенный postinstall не доберётся до $HOME, но сможет ходить в интернет и стучаться в loopback-сервисы хоста (localhost-демоны, облачные метаданные 169.254.169.254). Если это неприемлемо — добавьте --unshare-net или сетевой фильтр.

bwrap против Docker, Podman и Firejail

Bubblewrap Docker Podman Firejail
Механизм namespaces (+ ваш seccomp) namespaces + cgroups + образы то же, без демона namespaces + seccomp + профили
Демон нет dockerd нет нет
Привилегии непривилегированный (userns) демон под root (или rootless) заточен под rootless традиционно setuid root
Файловая система ФС хоста через bind слоёные образы слоёные образы ФС хоста + профили
Сеть как у хоста; отключается --unshare-net bridge/NAT bridge/NAT фильтры
Состояние эфемерное, без образов volumes/образы volumes/образы эфемерное
Ниша изолировать одну команду упаковать и доставить сервис rootless-контейнеры песочница для desktop-приложений

Когда bwrap уместнее контейнеров:

  • Нужно изолировать одну команду, сборку или скрипт здесь и сейчас — без Dockerfile, образа и демона.
  • Хочется непривилегированности без настройки rootless-стека.
  • Вы строите изоляцию как примитив внутри своего инструмента (именно так его использует Flatpak).

Когда bwrap не замена:

  • Доставка и версионирование сервисов — это про образы и оркестрацию (Docker/Podman/Kubernetes).
  • Firejail удобнее для десктопных приложений: готовые профили под браузеры и мессенджеры. bwrap — более низкоуровневый конструктор без базы профилей.
  • Podman ближе, если нужен полноценный OCI-контейнер, но rootless и без демона.

Подводные камни

  • Unprivileged user namespaces могут быть выключены. На части дистрибутивов и hardened-ядер непривилегированные user namespaces ограничены или отключены (kernel.unprivileged_userns_clone=0, политики AppArmor/Debian). Современные версии bwrap в этом случае просто не стартуют — им нужны доступные user namespaces. Исторически существовал setuid-режим как обходной путь (его ещё можно встретить в старых и некоторых дистрибутивных сборках), но в актуальном upstream он удалён — закладываться на него не стоит.
  • Общее ядро — общая судьба. Против уязвимости ядра песочница не спасёт. Для враждебного кода с высокой ценой компрометации нужна виртуализация (VM, gVisor, Kata).
  • Без seccomp вы не фильтруете системные вызовы. Для недоверенного кода добавьте --seccomp с продуманным профилем; голая изоляция namespaces оставляет всю поверхность syscall открытой.
  • GUI и звук — отдельная история. Проброс сокета X11 фактически снимает изоляцию ввода. Wayland, вложенный X или PipeWire с портфелем разрешений — обязательны, если пускаете GUI-приложение.

Итог: когда брать bwrap

Bubblewrap — это не «маленький Docker», а другой инструмент под другую задачу: быстро и без привилегий изолировать выполнение одной команды. Он незаменим, когда нужно запустить чужой бинарник, прогнать сборку или ограничить postinstall-скрипты, не поднимая контейнерный стек. Его же берут как строительный блок изоляции внутри собственных утилит.

Помните про границы: общее ядро, seccomp — на вашей ответственности, а user namespaces могут быть недоступны. Для недоверенного кода комбинируйте bwrap с seccomp-фильтром и bind-мауньте минимально необходимое — тогда «просто запусти и проверь» перестаёт быть русской рулеткой.

Обсуждение в Telegram

Присоединиться →

Комментарии