Как настроить песочницу systemd через ProtectSystem
Разбираем работу директив ProtectSystem, PrivateTmp, DynamicUser и NoNewPrivileges. Узнайте, какие ресурсы блокирует каждая настройка, что может сломаться и как отладить unit-файл.
Что такое песочница systemd
Песочница systemd — это вторая часть написания unit-файла. Type= определяет, как запускается служба, а директивы вроде ProtectSystem= и PrivateTmp= определяют, к чему она может обращаться после запуска. Это функции ядра, пространства имен монтирования (mount namespaces) и фильтры seccomp, которые systemd применяет до того, как процесс получает управление. Приложение не «видит» этих ограничений и не требует внесения изменений в код.
По умолчанию ограничения отсутствуют. Юнит без настроек песочницы работает от имени root, может записывать данные в любое место, читать любой файл в системе и загружать модули ядра. Если этот юнит — веб-приложение, доступное из Интернета, одна уязвимость при загрузке файлов превращает систему в полностью скомпрометированный сервер. При использовании приведенного ниже юнита то же приложение получает файловую систему только для чтения, пустой /home, /tmp, который не видит ни один другой процесс, и отсутствие доступа к правам root, даже если приложение обнаружит setuid-бинарный файл.
Все эти настройки применяются к каждому юниту по отдельности. Усиление безопасности одной службы никак не влияет на соседние, поэтому начинайте с тех сервисов, которые слушают публичные порты.
Файл юнита целиком
notes — это небольшой веб-сервис. Он слушает localhost, хранит базу данных SQLite в /var/lib/notes и работает за nginx. Type=exec подходит, так как бинарный файл работает в foreground, а разница между Type=simple, exec, forking и notify определяет, как systemd отслеживает запуск. Каждая директива ниже объясняется далее, включая то, от чего она защищает и что обычно нарушает.
[Unit]
Description=Notes web service
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/opt/notes/bin/notes --listen 127.0.0.1:8080
DynamicUser=yes
StateDirectory=notes
Environment=NOTES_DB=/var/lib/notes/notes.db
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.targetЗапишите его в /etc/systemd/system/notes.service, выполните sudo systemctl daemon-reload, затем sudo systemctl restart notes.service. Если юнит был установлен из пакета, не редактируйте файл поставщика. sudo systemctl edit notes.service открывает drop-in файл в /etc/systemd/system/notes.service.d/override.conf, который содержит только ваши дополнения [Service] и сохраняется при обновлении пакетов. systemctl cat notes.service выводит объединенный результат, начиная с файла поставщика.
От имени какого пользователя запускается сервис
Без User= сервис запускается с правами root, и любая другая директива здесь — лишь попытка минимизировать ущерб. Есть два способа избежать этого.
Статический системный пользователь. Создайте его с помощью sudo useradd --system --no-create-home --shell /usr/sbin/nologin notes, а затем укажите User=notes в юните. UID остается неизменным после перезагрузок, что важно, если сервис владеет файлами вне своего каталога состояния или если их должен читать процесс резервного копирования. Аргументы в пользу предоставления каждому сервису собственной непривилегированной учетной записи здесь полностью применимы.
DynamicUser=yes. systemd выделяет UID из зарезервированного диапазона при запуске сервиса и освобождает его при остановке. Учетная запись не существует на диске, поэтому после удаления сервиса не остается «мусора». Пока сервис работает, getent passwd notes разрешает имя через модуль NSS (name service switch) в systemd. После остановки имя исчезает.
DynamicUser=yes также автоматически активирует четыре другие настройки: RemoveIPC=yes, PrivateTmp=yes, ProtectSystem=strict и ProtectHome=read-only. Это создает полноценную «песочницу» одной строкой, поэтому пример юнита остается лаконичным.
Что это ломает. Динамический UID не может владеть файлами в произвольных местах, так как этот номер переиспользуется другими сервисами. Постоянные данные должны размещаться в StateDirectory=, CacheDirectory= или LogsDirectory=, которые systemd создает и назначает владельцем текущий UID при каждом запуске. При использовании DynamicUser=yes реальный каталог находится в /var/lib/private/notes, а /var/lib/notes является символической ссылкой на него. /var/lib/private имеет права 0700 и принадлежит root, поэтому процесс резервного копирования, запущенный от обычного пользователя, получит Permission denied при попытке доступа к пути, который для root выглядит полностью доступным. Любой другой сценарий, требующий фиксированного владельца, SSH-ключа, экспорта NFS или файла, который должен читать второй сервис, требует использования статического пользователя.
Структура файловой системы: ProtectSystem, ProtectHome, PrivateTmp
ProtectSystem= принимает три значения. yes монтирует /usr и загрузочные директории в режиме только для чтения. full добавляет /etc. strict монтирует всю иерархию в режиме только для чтения, за исключением директорий API ядра /dev, /proc и /sys, которые регулируются другими директивами. Начинайте с strict и открывайте доступ по мере необходимости, так как практика «сначала разрешить всё, а потом ограничить» никогда не работает.
Исключения задаются через ReadWritePaths=/srv/notes/uploads. Их требуется меньше, чем вы ожидаете: StateDirectory=, LogsDirectory=, CacheDirectory= и RuntimeDirectory= остаются доступными для записи при ProtectSystem=strict автоматически, поэтому в примере юнита вообще нет строки ReadWritePaths=. При strict директория /tmp также становится доступной только для чтения, если PrivateTmp=yes не предоставляет юниту собственную область для записи.
Что это ломает. Любая попытка записи вне указанных путей теперь завершается ошибкой Read-only file system. Это затрагивает приложения, которые сохраняют конфигурацию обратно в /etc, создают PID-файлы прямо в /run или распаковывают плагины в /opt. Прочитайте путь из сообщения об ошибке и добавьте именно этот путь, а не его родительскую директорию. Если путь, указанный в ReadWritePaths=, не существует, сервис не запустится (это не просто предупреждение), поэтому перед необязательными путями ставьте дефис: ReadWritePaths=-/srv/notes/uploads.
Режим только для чтения не означает скрытие. При ProtectSystem=strict сервис по-прежнему может читать /etc/passwd и любые общедоступные секреты, принадлежащие другим приложениям. InaccessiblePaths=/etc/ssh /srv/otherapp полностью удаляет поддерево из области видимости юнита. Для собственных секретов сервиса LoadCredential=dbpass:/etc/notes/dbpass копирует файл в директорию юнита, которую может читать только этот сервис, и приложение находит его по пути $CREDENTIALS_DIRECTORY.
ProtectHome=yes делает /home, /root и /run/user пустыми. Веб-сервису нечего делать в домашней директории, и это предотвращает использование уязвимостей обхода пути (path traversal) для доступа к /root/.ssh. read-only и tmpfs — более мягкие значения. Это ломает всё, чьи данные действительно хранятся в домашней директории, что касается многих приложений, установленных вручную в /home/app. Перенесите данные в /var/lib или установите ProtectHome=read-only, смирившись с меньшим уровнем защиты.
PrivateTmp=yes предоставляет сервису собственные /tmp и /var/tmp, которые создаются при запуске и удаляются при остановке. Это устраняет целый класс состояний гонки (race conditions) при работе с временными файлами между сервисами, а аварийное завершение больше не оставляет секреты в директории, которую может просмотреть любой пользователь системы.
Что это ломает. Всё, что использует /tmp как точку взаимодействия. Сервис, настроенный на подключение к MySQL через /tmp/mysql.sock, теперь сообщает об ошибке Can't connect to local MySQL server through socket '/tmp/mysql.sock', так как база данных создала сокет в /tmp хоста, а сервис ищет его внутри своего пространства. Укажите путь к 127.0.0.1 или реальный путь к сокету в /run. Тот же сюрприз ждет при отладке: файлы, которые сервис записывает в /tmp, не видны в /tmp вашей оболочки. Чтобы заглянуть внутрь, войдите в пространство имен монтирования (mount namespace) сервиса.
pid=$(systemctl show --property=MainPID --value notes.service)
sudo nsenter --target "$pid" --mount ls -l /tmpPrivateDevices=yes заменяет /dev небольшим набором псевдоустройств, таких как /dev/null, /dev/zero и /dev/urandom. Физические устройства просто отсутствуют. Узлы дисков, /dev/kvm, /dev/net/tun, звуковые карты и GPU исчезают, поэтому медиасервер, выполняющий аппаратное перекодирование, не может открыть /dev/dri/renderD128 и либо переключается на программную обработку, либо завершает работу. Если сервису действительно нужен узел устройства, отключите PrivateDevices= для этого юнита и укажите узел через DeviceAllow=/dev/dri/renderD128 rw, что всё равно гораздо безопаснее, чем доступ ко всем устройствам системы по умолчанию.
Чем может стать сервис: NoNewPrivileges и capabilities
NoNewPrivileges=yes устанавливает флаг процесса, который ядро никогда не сбрасывает. С этого момента процесс и все дочерние процессы не могут получить привилегии через setuid-бинарные файлы или файловые capabilities. Это самая важная строка в юните, так как она останавливает большинство цепочек локального повышения привилегий на первом же этапе.
Что это ломает. Любой вызов sudo внутри сервиса, который теперь будет выдавать sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set or an NFS file system without root privileges?. Проверки паролей PAM (pluggable authentication modules), которые обращаются к unix_chkpwd, завершаются с той же ошибкой, как и инструменты для rootless-контейнеров, которым требуется newuidmap. Если ваш сервис зависит от чего-то подобного, лучше устраните зависимость, а не директиву.
CapabilityBoundingSet= ограничивает набор capabilities, которые может иметь любой процесс в юните; пустое значение сбрасывает их все. Для сервиса, который уже работает от имени пользователя без прав root, это второй замок, а не первый, так как NoNewPrivileges=yes блокирует стандартный способ получения capabilities. Используйте оба параметра, так как они работают по-разному, и лучше иметь двойную защиту.
Единственная capability, которая часто нужна веб-сервису — это CAP_NET_BIND_SERVICE для использования портов ниже 1024. Процессу без прав root её нужно именно предоставить, а не просто разрешить, поэтому требуются обе строки.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICEsystemd применяет ambient capabilities до сброса привилегий, поэтому это работает совместно с NoNewPrivileges=yes. Без этих строк сервис запускается и сразу завершается с ошибкой, указывающей на порт, например listen tcp :443: bind: permission denied. В большинстве конфигураций VPS лучше привязать сервис к 127.0.0.1:8080 и позволить Nginx или Caddy использовать 443 порт — это позволит полностью исключить использование capabilities в юните. Символ ~ в начале списка инвертирует его, поэтому CapabilityBoundingSet=~CAP_SYS_ADMIN блокирует указанную capability и разрешает остальные. Отдавайте предпочтение разрешающему списку: списки запретов быстро устаревают, так как постоянно добавляются новые capabilities.
Что открывает ядро
ProtectKernelTunables=yes делает /proc/sys, /sys и доступные для записи файлы внутри них доступными только для чтения для данного юнита. Это приводит к сбою любого сервиса, который пытается изменить параметры sysctl во время запуска: стартовый скрипт выводит sysctl: setting key "vm.max_map_count": Read-only file system и завершает работу. Укажите значение в /etc/sysctl.d/ — это правильное место, где настройки сохраняются после перезагрузки, после чего директиву можно оставить включенной.
ProtectKernelModules=yes блокирует загрузку модулей ядра. Юнит, который запускает modprobe, получит modprobe: ERROR: could not insert 'nf_conntrack': Operation not permitted. Загружайте модули при старте системы через /etc/modules-load.d/, а не средствами самого сервиса.
ProtectKernelLogs=yes отключает dmesg. ProtectControlGroups=yes переводит /sys/fs/cgroup в режим только для чтения, что немедленно заметят среды выполнения контейнеров и любое ПО, управляющее собственными cgroups. ProtectProc=invisible скрывает процессы других пользователей в /proc, поэтому скомпрометированный сервис не сможет прочитать командную строку другого демона и пароли, переданные в аргументах. Эту опцию нужно отключать только для агентов мониторинга, которые сканируют /proc.
RestrictNamespaces=yes запрещает сервису создавать новые пространства имен (namespaces), что необходимо для работы сред выполнения контейнеров и часто используется злоумышленниками для выхода из контейнера. LockPersonality=yes блокирует изменение домена выполнения ядра, а RestrictSUIDSGID=yes запрещает сервису создавать setuid-файлы. Обе эти настройки не требуют ресурсов и редко нарушают работу обычных приложений.
С чем может взаимодействовать сервис
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 разрешает локальные сокеты, а также IPv4 и IPv6, заставляя socket() завершаться с ошибкой EAFNOSUPPORT для всего остального. Это фильтр seccomp, поэтому он работает на архитектурах x86-64 и arm64, что охватывает любой современный VPS.
Что это ломает. AF_NETLINK, гораздо чаще, чем ожидают пользователи. Функция getifaddrs() в glibc использует netlink-сокет, как и механизмы перечисления интерфейсов в средах выполнения Go, Java и .NET. В результате сервис, который просто хотел узнать свой IP-адрес, завершается с ошибкой OSError: [Errno 97] Address family not supported by protocol или её эквивалентом в соответствующем языке программирования. В таких случаях список можно расширить до AF_UNIX AF_INET AF_INET6 AF_NETLINK, что всё равно значительно уже настроек по умолчанию. AF_PACKET требуется для захвата необработанных пакетов (raw packet capture), и практически ничему другому этот доступ не нужен.
IPAddressDeny=any вместе с IPAddressAllow=localhost — это другой механизм: BPF-фильтр, привязанный к cgroup юнита. Он действует на уровне юнита и невидим для nft list ruleset. Это удобно для сервиса, который должен обращаться только к базе данных на том же хосте, но может сбить с толку того, кто будет отлаживать систему позже. В ядре без поддержки cgroup BPF systemd записывает в лог, что юнит настраивает IP-файрвол, в то время как локальная система не поддерживает BPF/cgroup firewalling, и правила молча не применяются. Поэтому проверяйте журнал, а не полагайтесь на предположения.
Почему перестает запускаться защищенный юнит?
Любая из приведенных выше директив может привести к сбою работающего сервиса, причем ошибка часто не указывает на вызвавшую её директиву. Алгоритм действий всегда один: изучите журнал, отключите ровно одну директиву, повторите тест.
sudo systemctl restart notes.service
systemctl status notes.service
sudo journalctl -u notes.service -n 50 --no-pagerСтроки, описывающие песочницу, выглядят так:
notes.service: Failed to set up mount namespacing: No such file or directory
notes.service: Failed at step NAMESPACE spawning /opt/notes/bin/notes: No such file or directory
notes.service: Main process exited, code=exited, status=226/NAMESPACE226/NAMESPACE означает, что systemd не смог построить представление файловой системы, поэтому ваш бинарный файл даже не был запущен. Чаще всего причина кроется в пути в ReadWritePaths=, BindPaths= или InaccessiblePaths=, который не существует. 228/SECCOMP указывает на ошибку применения SystemCallFilter= или SystemCallArchitectures=. code=killed, status=31/SYS — это другой случай: процесс запустился, но выполнил системный вызов, который был заблокирован фильтром, после чего ядро завершило его работу. Держите открытой справочную таблицу кодов завершения systemd для юнитов, которые не запускаются, пока работаете над этим, так как код ошибки — самый быстрый способ отличить проблему пространств имен от ошибки самого приложения.
Отключайте директивы по одной через drop-in файл, чтобы результат был информативным. Выполните sudo systemctl edit notes.service и добавьте в него одну строку:
[Service]
ProtectSystem=fullПерезапустите сервис. Если он запустился, вы поймете, какую директиву нужно уточнить, а не удалять. Верните её значение к strict, добавьте строку ReadWritePaths= для пути, который действительно необходим приложению, и снова перезапустите сервис. Удаление всего блока из-за того, что сервис не запускается — это путь к юниту без защиты и с комментариями, которые никто не помнит.
Чтобы протестировать песочницу без участия сервиса, запустите в ней оболочку:
sudo systemd-run --pty -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /bin/bashВнутри этой оболочки touch /etc/test вернет Read-only file system, а ls /home ничего не покажет. Это самый быстрый способ узнать, что увидит приложение, так как вы можете запустить его команды вручную и увидеть, какая из них завершится с ошибкой.
Один из режимов сбоя не выдает вообще никаких ошибок. Опечатка в директиве — это лишь предупреждение, и сервис запускается без неё:
/etc/systemd/system/notes.service:14: Unknown key name 'ProtectSytem' in section 'Service', ignoring.Юнит работает, песочница отсутствует, и никаких дальнейших жалоб нет. Две команды позволяют это выявить. sudo systemd-analyze verify /etc/systemd/system/notes.service выводит то же предупреждение по запросу, а systemctl show показывает, что именно получил запущенный сервис:
systemctl show notes.service -p User -p ProtectSystem -p PrivateTmp -p NoNewPrivilegesЕсли команда возвращает ProtectSystem=no, хотя вы написали strict, значит, юнит загрузил не то, что вы ожидали.
Используйте systemd-analyze security как чек-лист
sudo systemd-analyze security notes.service выводит все настройки песочницы, которые может использовать юнит, текущее состояние каждой из них для данного юнита и оценку по каждой строке. Запустите команду без аргументов, чтобы вывести список всех загруженных сервисов на сервере, или добавьте --offline=true с путем к файлу юнита, чтобы проверить его перед установкой.
Воспринимайте вывод как список задач. Пройдитесь по строкам, отмеченным как проблемные, и ответьте на один вопрос для каждой: нужен ли сервису этот доступ? В большинстве случаев ответ — нет, и вы просто добавляете соответствующую строку. Иногда ответ — да. Медиа-серверу нужен доступ к узлу устройства. Агенту резервного копирования нужно читать /home. Эти строки останутся помеченными навсегда, и это правильный результат, а не ошибка.
Итоговая цифра в конце — это агрегированный показатель всех строк выше. Инструмент не знает, что делает ваш сервис, какие данные он хранит и есть ли в приложении уязвимости. Юнит может соответствовать всем критериям проверки и всё равно оставаться самым слабым звеном на сервере, так как инструмент измеряет настройки юнита, а не защищенность самого программного обеспечения. Самохостинг менеджера паролей наглядно демонстрирует этот разрыв: юнит Vaultwarden может пройти все проверки, в то время как его токен администратора и файл резервной копии по-прежнему определяют безопасность хранилища. Погоня за цифрой заставляет людей вставлять директивы, которые они не понимают, а именно они чаще всего ломаются после обновления пакетов, когда уже никто не может объяснить, зачем эта строка была добавлена.
Где заканчивается песочница
Эти директивы определяют, к каким ресурсам может обращаться сервис. Они не ограничивают потребление ресурсов, поэтому полностью изолированный юнит всё равно может занять все ядра процессора и всю оперативную память сервера. Это регулируется отдельным набором настроек, описанным в руководстве по лимитам CPU и памяти для systemd-сервисов.
Они также не заменяют мандатное управление доступом. Пространство имён (namespace) создаётся для каждого юнита отдельно тем, кто пишет файл юнита, в то время как SELinux применяет единую политику ко всей системе. Эти механизмы работают совместно, и наличие одного не отменяет необходимости в другом.
Наконец, данные настройки распространяются только на процессы, которые systemd запускает внутри этого юнита. Если сервис передаёт задачи вспомогательному демону через сокет, этот демон не оказывается в песочнице. Добавьте аналогичный блок в файл юнита этого демона и проверьте результат с помощью systemctl show, вместо того чтобы полагаться только на содержимое файла.
FAQ
Что именно делает доступным только для чтения параметр ProtectSystem=strict?
Всю иерархию файловой системы, за исключением /dev, /proc и /sys, которые вместо этого регулируются параметрами PrivateDevices=, ProtectKernelTunables= и ProtectControlGroups=. Это включает в себя /etc, /var, /srv, /opt и /tmp. Исключения, которые systemd добавляет автоматически — это директории, которыми он управляет: StateDirectory=, CacheDirectory=, LogsDirectory= и RuntimeDirectory=. Для всего остального, куда сервису требуется запись, необходимо явное указание в ReadWritePaths=. Заметьте, что «только для чтения» не означает «недоступно для чтения», поэтому секретный файл в другом месте системы по-прежнему открыт для сервиса, если вы не ограничите его через InaccessiblePaths=.
Почему мой сервис завершается с ошибкой status=226/NAMESPACE?
systemd не смог создать пространство имен монтирования (mount namespace), поэтому исполняемый файл не был запущен. Строка в журнале перед этим обычно содержит Failed to set up mount namespacing: No such file or directory. Почти всегда причина в том, что путь, указанный в ReadWritePaths=, BindPaths= или InaccessiblePaths=, отсутствует на диске. Создайте директорию или добавьте дефис (ReadWritePaths=-/srv/notes/uploads) перед путем, чтобы systemd игнорировал его отсутствие. Если путь существует, проверьте файл юнита на наличие опечаток и подтвердите конфигурацию через systemctl cat notes.service, так как drop-in файл может добавлять строку, которую вы не видите.
Может ли сервис с PrivateTmp обмениваться файлами через /tmp?
Нет, в этом и заключается смысл изоляции. Сервис получает собственные /tmp и /var/tmp, которые существуют только во время его работы, поэтому сокет или файл, созданный другим процессом в системном /tmp, для него невидим. Чаще всего от этого страдает сокет базы данных в /tmp/mysql.sock; решение — подключаться через 127.0.0.1 или указывать клиенту путь к реальному сокету в /run. Чтобы изучить временные файлы самого сервиса, узнайте его основной PID через systemctl show --property=MainPID --value и войдите в его пространство имен монтирования с помощью sudo nsenter --target <pid> --mount.
Как изолированный сервис может слушать порт 443 без прав root?
Предоставьте одну конкретную привилегию вместо прав суперпользователя. Установите AmbientCapabilities=CAP_NET_BIND_SERVICE вместе с CapabilityBoundingSet=CAP_NET_BIND_SERVICE, сохраните User= или DynamicUser=yes, и процесс сможет привязываться к низким портам, не имея других прав. Распространенная ошибка — установка только bounding set: привилегия разрешена, но не назначена, и сервис завершается с ошибкой доступа к порту. Если на VPS уже работает reverse proxy, более чистое решение — привязать сервис к 127.0.0.1:8080, а порт 443 оставить за nginx, тогда юниту вообще не потребуются дополнительные привилегии.
Стоит ли использовать DynamicUser вместо создания системного пользователя?
Используйте его, если сервис хранит все свои данные внутри StateDirectory=, CacheDirectory= или LogsDirectory=, что подходит для большинства небольших веб-приложений. Вы получаете UID, существующий только во время работы сервиса, а также автоматически включенные PrivateTmp=, ProtectSystem=strict, ProtectHome=read-only и RemoveIPC=. Используйте статического системного пользователя, если UID должен оставаться неизменным: например, для файлов вне указанных директорий, SSH-ключей, NFS-монтирований или если второй процесс должен читать те же данные. Помните, что с DynamicUser=yes данные фактически находятся в /var/lib/private/notes, а эта директория имеет права 0700 и принадлежит root, из-за чего резервное копирование от имени обычного пользователя может завершаться ошибкой.