Оценка защиты systemd-юнита занимает одну команду: systemd-analyze security --no-pager sshd. На чистом Debian 12 штатный sshd получает около 9.6 UNSAFE, а в отчёте построчно видно, чего не хватает: NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp, CapabilityBoundingSet, SystemCallFilter. После семи строк в drop-in файле та же команда показывает примерно 3.0-3.5 OK, и сервис продолжает принимать подключения.
Sandboxing в systemd работает без Docker, без SELinux и без правки кода приложения. PID 1 сам создаёт для сервиса mount, PID, network и user namespaces, ограничивает набор Linux capabilities, фильтрует системные вызовы через seccomp и запрещает получение новых привилегий. Всё описывается директивами в unit-файле и доступно на systemd 240 и новее: Debian 10+, Ubuntu 20.04+, RHEL 8+, Rocky Linux, AlmaLinux.
Порядок работ: измерить текущее состояние, включать директивы по одной, проверять сервис после каждой правки, зафиксировать результат в git. Такой ритм позволяет остановиться на любой ступени и не ловить сюрпризы на production. Общая защита сервера, включая firewall, sudo, SSH и автоматический аудит, разобрана в руководстве по hardening и аудиту Linux-сервера.
Зачем усиливать systemd-сервисы и с чего начать
Компромисс демона в Linux не должен означать компромисс хоста. Если worker nginx запущен от root с полным набором возможностей, ошибка в обработчике запроса даёт атакующему те же права: запись в /etc, вызов mount, чтение /root, подключение к любому сокету. Ограничения systemd сокращают этот набор на уровне ядра и не требуют внешних инструментов вроде Docker или SELinux.
Начинайте с аудита. Он покажет, какие директивы уже заданы дистрибутивом, а какие открыты, и даст числовой ориентир для сравнения до и после.
Аудит безопасности: запуск systemd-analyze security
systemd-analyze security --no-pager sshd systemd-analyze security --no-pager -a # все загруженные юниты systemd-analyze security -H root@srv02 nginx # проверка на удалённом хосте systemd-analyze security --offline=yes /etc/systemd/system/myapp.service # разбор файла, systemd 250+
Флаг --no-pager отключает постраничный вывод, ключ -H выполняет проверку на удалённом хосте по SSH, режим --offline=yes разбирает файл без обращения к работающему systemd. Пример вывода (конкретные значения зависят от версии systemd и дистрибутива):
NAME DESCRIPTION EXPOSURE RootDirectory=/RootImage= Service runs within the host root directory 0.1 ProtectSystem= Service has full access to the file system 0.2 ProtectHome= Service has access to home directories 0.2 PrivateTmp= Service has access to other processes temporary files 0.2 NoNewPrivileges= Service processes can acquire new privileges 0.2 CapabilityBoundingSet= Service may use dangerous capabilities 0.3 SystemCallFilter= Service may execute arbitrary system calls 0.4 Overall exposure level for sshd.service: 9.6 UNSAFE
Колонка NAME называет проверяемую директиву, DESCRIPTION описывает риск, EXPOSURE показывает вклад строки в общую оценку. Строки без пометки о проблеме означают, что директива уже задана и риск закрыт. Итог делится на три полосы: UNSAFE (7.0 и выше), EXPOSED (4.0-6.9), OK (ниже 4.0).
Отчёт строится по состоянию загруженного юнита. Правки в файле без systemctl daemon-reload и перезапуска в оценку не попадут, поэтому после каждого изменения повторяйте команду и сверяйтесь с предыдущим числом.
Принципы безопасного усиления: одно изменение за шаг
Главное правило: одна директива, затем проверка. Так видно, что именно сломало сервис, и не приходится искать виновника среди пяти одновременных правок.
- Сохраните исходное состояние: systemctl cat nginx > /root/nginx-unit-before.txt и systemd-analyze security nginx --no-pager > /root/nginx-score-before.txt.
- Создайте каталог /etc/systemd/system/nginx.service.d и файл override.conf в нём.
- Добавьте одну директиву, выполните systemctl daemon-reload и systemctl restart nginx.
- Проверьте: systemctl status nginx --no-pager, journalctl -u nginx -n 50 --no-pager и реальный запрос (curl или подключение клиентом).
- Оставьте правку, если проверки прошли, и переходите к следующей директиве.
Откат сводится к удалению override.conf и двум командам: systemctl daemon-reload и systemctl restart nginx. Оригинальный файл из /lib/systemd/system/ остаётся нетронутым, обновление пакета не снесёт ваши ограничения и не потеряет локальные правки. Пользовательские drop-in файлы переживают обновление сервиса.
Первую раскатку делайте на копии рабочей среды. Виртуальный сервер с почасовой оплатой закрывает вопрос «а если сервис не поднимется» за пару минут: разверните стенд, повторите набор директив, сверьте поведение и только потом идите на боевой узел. Под такие задачи подходит Timeweb Cloud с готовыми образами Ubuntu и Debian.
ProtectSystem и ProtectHome: изоляция файловой системы
ProtectSystem управляет монтированием корневой файловой системы для процессов сервиса и принимает три значения. Режим true монтирует /usr и /boot только для чтения, full добавляет к ним /etc, strict переводит в режим только для чтения всю иерархию, кроме /dev, /proc и /sys.
ProtectHome скрывает домашние каталоги: true делает /home, /root и /run/user недоступными, read-only оставляет доступ на чтение, tmpfs подменяет их пустой памятью. PrivateTmp=true выдаёт сервису собственные /tmp и /var/tmp, изолированные от остальных процессов, и закрывает классический вектор с предсказуемыми именами временных файлов.
Ключевой момент: при ProtectSystem=strict сервис не сможет писать никуда, кроме каталогов из ReadWritePaths. Забытый путь даёт явную ошибку записи, а не тихий сбой. Каталоги из RuntimeDirectory, StateDirectory, CacheDirectory и LogsDirectory systemd делает записываемыми сам, дублировать их в ReadWritePaths не нужно.
Практический пример: настройка ProtectSystem для nginx
mkdir -p /etc/systemd/system/nginx.service.d cat > /etc/systemd/system/nginx.service.d/override.conf <<'EOF' [Service] ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX SystemCallArchitectures=native ReadWritePaths=/var/log/nginx /var/cache/nginx /var/lib/nginx EOF
Применяем и проверяем результат:
systemctl daemon-reload systemctl restart nginx systemctl status nginx --no-pager curl -sI http://127.0.0.1/ | head -n 1 tail -n 20 /var/log/nginx/error.log systemd-analyze security nginx --no-pager | tail -n 2
Признак успеха: сервер отвечает строкой HTTP/1.1 200 OK, в error.log нет строк вида Failed to write, а Exposure ниже исходного. nginx держит pid-файл в /run/nginx.pid, а при ProtectSystem=strict каталог /run доступен только для чтения, поэтому либо добавьте этот путь в ReadWritePaths, либо остановитесь на ProtectSystem=full.
Дополнительные директивы в примере закрывают лишнее: PrivateDevices=true отрезает /dev, ProtectKernelTunables=true запрещает правку sysctl, ProtectKernelModules=true блокирует загрузку модулей, ProtectControlGroups=true защищает cgroup, RestrictAddressFamilies оставляет только нужные семейства сокетов.
Типичные ошибки при настройке ProtectSystem
- Сервис падает с Permission denied при старте. Каталогу для логов или данных нужна запись: добавьте путь в ReadWritePaths, а не отключайте ProtectSystem целиком.
- В журнале Failed to write to ... Read-only file system. Директория состояния не объявлена ни через StateDirectory, ни через ReadWritePaths. Одна строка обычно закрывает вопрос.
- ProtectHome=true ломает сервис, которому нужен /root. Скрипты из домашнего каталога перестают запускаться. Варианты: перенести данные в /var/lib, выставить ProtectHome=read-only или добавить ReadOnlyPaths=/root/scripts.
- PrivateDevices=true ломает доступ к железу. Сервисы, работающие с /dev/sdX, /dev/video* или GPU, потеряют устройства. Для них перечислите нужное через DeviceAllow.
Диагностика одинаковая: journalctl -u nginx -n 100 --no-pager называет точный путь и причину, а systemctl cat nginx показывает, какой набор директив реально применён с учётом всех drop-in.
DynamicUser: изоляция без статических пользователей
DynamicUser=yes заставляет systemd выделить сервису временного пользователя из диапазона UID 61184-65519 на время работы и убрать его после остановки. В системе нет постоянной записи в /etc/passwd, нет группы и нет домашнего каталога, который злоумышленник мог бы использовать как точку опоры.
Процесс не сможет читать файлы, принадлежащие другим пользователям, даже если их владелец не совпадает с текущим UID сервиса. Обратная сторона: каталоги для данных создаёт systemd через StateDirectory, RuntimeDirectory, CacheDirectory и LogsDirectory. Физически они лежат в /var/lib/private/имя и /run/private/имя, а по привычному пути /var/lib/имя доступен симлинк. Права владельца systemd выдаёт на время работы и возвращает прежние при остановке, поэтому данные переживают перезапуск.
Практический пример: DynamicUser для простого сервиса
/etc/systemd/system/myapp.service [Unit] Description=My Python API After=network-online.target [Service] ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.toml DynamicUser=yes StateDirectory=myapp RuntimeDirectory=myapp CacheDirectory=myapp LogsDirectory=myapp WorkingDirectory=/var/lib/myapp Restart=on-failure NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true [Install] WantedBy=multi-user.target
systemctl daemon-reload
systemctl start myapp
systemctl status myapp --no-pager
ls -ld /var/lib/myapp /var/lib/private/myapp
systemctl show -p MainPID --value myapp | xargs -I{} ps -o user=,pid=,cmd= -p {}
systemctl stop myapp
getent passwd myapp
В статусе видно динамическое имя и UID из диапазона 61184-65519, владельцем каталога состояния числится тот же номер. Последняя команда после остановки даёт пустой вывод: пользователь удалён вместе с процессом. Если сервису нужны пароли или API-ключи, не передавайте их через EnvironmentFile, а сочетайте DynamicUser с механизмом systemd credentials: пошаговый разбор в руководстве по безопасному управлению секретами в systemd.
Когда DynamicUser не подходит
- Сервис обращается к NFS или общему хранилищу, где UID должен совпадать на всех узлах: динамический номер на каждом хосте свой, и права на файлы разъедутся.
- Данные лежат в каталоге чужого пользователя, например /var/www или /srv/share. Динамический UID получит отказ в записи.
- Сервису нужно чтение /etc/shadow, доступ к системным базам аутентификации или вызов setuid-хелперов: NoNewPrivileges и временный пользователь закрывают эти пути.
- Внешняя система проверяет права по конкретному имени в passwd или в ACL.
Во всех этих случаях создайте отдельного системного пользователя без оболочки и домашнего каталога, назначьте его через User= и Group=, а остальные ограничения оставьте включёнными. Файловые и syscall-ограничения от этого не теряют смысла.
CapabilityBoundingSet и NoNewPrivileges: ограничение привилегий
Capabilities делят права root на отдельные флаги. Директива CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID оставляет сервису три возможности, а пустое значение CapabilityBoundingSet= убирает все: процесс не сможет ни смонтировать файловую систему, ни сменить владельца файла, ни загрузить модуль ядра. Обратная форма тоже работает: CapabilityBoundingSet=~CAP_SYS_ADMIN вычитает одну опасную возможность из полного набора.
NoNewPrivileges=true запрещает процессу и его потомкам получать привилегии через setuid и setgid бинарники. Директива редко ломает работу, потому что большинство демонов не запускает setuid-утилиты. Исключения: скрипты внутри сервиса, которые вызывают sudo, su или ping.
AmbientCapabilities работает иначе: она выдаёт конкретную возможность процессу, который уже работает не от root. Веб-сервер, слушающий 443 порт без прав root, нуждается в AmbientCapabilities=CAP_NET_BIND_SERVICE, иначе bind вернёт ошибку.
Практический пример: минимальный CapabilityBoundingSet для веб-сервера
/etc/systemd/system/nginx.service.d/caps.conf [Service] CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_DAC_OVERRIDE CAP_SETUID CAP_SETGID AmbientCapabilities=CAP_NET_BIND_SERVICE NoNewPrivileges=true
systemctl daemon-reload systemctl restart nginx systemctl status nginx --no-pager curl -sI http://127.0.0.1/ | head -n 1 systemd-analyze security nginx --no-pager | tail -n 2
Если nginx не может занять 80 или 443 порт, проверьте наличие CAP_NET_BIND_SERVICE в наборе. Сообщение Operation not permitted при старте означает, что директива убрала нужную возможность: верните её в список и перезапустите сервис. Внутреннему сервису, который слушает высокий порт и пишет только в свой StateDirectory, хватит пустого значения CapabilityBoundingSet=.
Таблица: capabilities и их назначение
Справочник для подбора минимального набора. Ориентируйтесь на реальные операции сервиса: если он не меняет владельцев файлов и не слушает привилегированные порты, набор можно сократить до пустого значения.
| Capability | Что разрешает | Пример сервиса |
|---|---|---|
| CAP_NET_BIND_SERVICE | привязка сокета к портам ниже 1024 | nginx и apache2 на портах 80 и 443 |
| CAP_CHOWN | смена владельца и группы файлов | сервисы, создающие файлы для других пользователей |
| CAP_DAC_OVERRIDE | обход проверок прав доступа к файлам | редко нужна, часто маскирует ошибки в правах |
| CAP_SETUID и CAP_SETGID | смена UID и GID процесса и потомков | мастер-процессы, сбрасывающие права на воркерах |
| CAP_NET_RAW | raw-сокеты, ICMP, ARP | ping, tcpdump, DHCP-клиенты |
| CAP_NET_ADMIN | правка интерфейсов, маршрутов, правил firewall | VPN и туннели, сетевые демоны |
| CAP_SYS_ADMIN | монтирование, namespaces, часть ioctl | прикладному сервису почти никогда не нужна |
| CAP_KILL | сигналы процессам других пользователей | менеджеры процессов и воркеров |
| CAP_SYS_PTRACE | трассировка чужих процессов | отладочные агенты и системы мониторинга |
| пустой набор | ничего из перечисленного | сервис, который пишет только в свой StateDirectory |
SystemCallFilter: фильтрация системных вызовов
SystemCallFilter ограничивает системные вызовы, доступные процессу, через seccomp. Синтаксис поддерживает предустановленные группы и явные имена вызовов. Строка без тильды задаёт белый список: всё, что в него не попало, блокируется. Строка с тильдой в начале работает как чёрный список. Вызовы, нужные самому systemd для запуска и надзора, добавляются автоматически, поэтому базовые операции сервис не теряет.
/etc/systemd/system/nginx.service.d/syscalls.conf [Service] SystemCallArchitectures=native SystemCallFilter=@system-service SystemCallErrorNumber=EPERM
Чёрный список уместен, когда сервис использует JIT или нестандартный набор вызовов и белый список его обрезает:
[Service] SystemCallFilter=~@privileged @mount @debug @raw-io @reboot @swap @module
SystemCallArchitectures=native отсекает вызовы 32-битного ABI, через которые иногда обходят фильтры. SystemCallErrorNumber=EPERM задаёт код отказа вместо SIGSYS: сервис получит обычную ошибку и сможет её обработать и залогировать.
Практический пример: SystemCallFilter для типового сервиса
systemctl daemon-reload systemctl restart nginx systemctl status nginx --no-pager journalctl -u nginx -n 20 --no-pager systemd-analyze security nginx --no-pager | tail -n 2
Если после включения фильтра сервис падает или отвечает ошибками, добавьте недостающий вызов явным перечислением: SystemCallFilter=@system-service read write openat. Для узкой задачи выгоднее взять группу и дополнить её несколькими именами. Группа @system-service в systemd 240 и новее уже включает @network-io, @file-system, @basic-io, @process и @signal, поэтому отдельно перечислять их нужно только при ручной сборке белого списка.
Таблица: предустановленные группы системных вызовов
Группы экономят время: вместо сотен имён достаточно указать нужные наборы. Логика простая: оставляйте то, без чего сервис не работает, убирайте всё, что касается ядра, железа и смены привилегий.
| Группа | Что включает | Когда оставлять |
|---|---|---|
| @system-service | базовый набор системного сервиса: ввод-вывод, сеть, сигналы, таймеры | по умолчанию для большинства демонов |
| @network-io | socket, bind, connect, accept, sendmsg | сервисам с сетевыми сокетами |
| @file-system | open, openat, stat, mkdir, unlink, rename | сервисам, работающим с файлами |
| @basic-io | read, write, close, lseek | нужна практически любому процессу |
| @process | fork, clone, execve, wait4 | сервисам, которые запускают дочерние процессы |
| @signal | отправка и ожидание сигналов | менеджерам воркеров |
| @timer | таймеры и планирование | сервисам с периодическими задачами |
| @mount | mount, umount, pivot_root | блокировать везде, кроме контейнерных движков |
| @privileged | setuid, setgid, смена capabilities | оставлять по минимуму и с обоснованием |
| @debug | ptrace и отладочные вызовы | блокировать в production |
| @raw-io | iopl, ioperm, доступ к портам ввода-вывода | блокировать в прикладных сервисах |
| @reboot и @swap | перезагрузка, управление swap | блокировать всегда |
| @module | загрузка и выгрузка модулей ядра | блокировать всегда |
| @keyring | работа с keyring ядра | только сервисам, которым keyring нужен по задаче |
Диагностика ошибок после усиления сервиса
Три команды выручают при любом сбое. systemctl status nginx --no-pager показывает код выхода и последние строки журнала, journalctl -u nginx -n 100 --no-pager даёт подробности, systemctl cat nginx собирает оригинальный файл и все drop-in в один список. Добавьте systemctl show -p ProtectSystem -p ProtectHome -p NoNewPrivileges nginx, чтобы увидеть применённые значения: набор доступных свойств зависит от версии systemd.
Типичные ошибки и их решения
| Симптом | Вероятная причина | Команда проверки | Безопасное исправление |
|---|---|---|---|
| Failed to write to /var/log/myapp: Read-only file system | Каталог не объявлен для записи при ProtectSystem=strict | journalctl -u myapp -n 50 --no-pager | Добавить путь в ReadWritePaths или перевести каталог на LogsDirectory |
| Operation not permitted при работе с сетью | Фильтр вызовов или capabilities блокируют socket и bind | strace -f -p PID -e trace=network | Вернуть @network-io в SystemCallFilter или CAP_NET_BIND_SERVICE в CapabilityBoundingSet |
| Failed to set up mount namespacing: Permission denied | Путь из ReadWritePaths отсутствует на диске или конфликтует с ProtectSystem | systemctl status myapp --no-pager и systemd-analyze verify | Создать каталог заранее или убрать путь из ReadWritePaths |
| Сервис не стартует после DynamicUser=yes | Данные лежат в каталоге чужого пользователя, нужен фиксированный UID | ls -ld /var/lib/private/myapp и journalctl -u myapp -n 50 --no-pager | Перенести данные в StateDirectory или вернуть статического пользователя через User= |
| No such file or directory для pid-файла в /run | /run доступен только для чтения при ProtectSystem=strict | systemctl show -p PIDFile --value nginx | Добавить путь в ReadWritePaths или снизить режим до ProtectSystem=full |
| Процесс убит сигналом SIGSYS | Заблокированный системный вызов при отсутствии SystemCallErrorNumber | journalctl -u myapp -n 20 --no-pager и strace | Добавить вызов в белый список или задать SystemCallErrorNumber=EPERM |
Метод бисекции выручает, когда причина неочевидна: закомментируйте половину директив, перезапустите сервис, затем сузьте поиск до одной строки. На каждое изменение один daemon-reload и один restart, иначе вывод будет случайным.
Использование strace для выявления блокируемых вызовов
strace называет конкретный вызов, получивший отказ:
systemctl start myapp PID=$(systemctl show -p MainPID --value myapp) strace -f -p "$PID" -e trace=all -o /tmp/myapp-trace.log grep -E "EPERM|ENOSYS" /tmp/myapp-trace.log | tail -n 20
Строка socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = -1 EPERM означает, что фильтр режет сетевые вызовы: верните группу @network-io. Запуск бинарника вручную под strace (strace -f /usr/local/bin/myapp) полезен для разбора аргументов, но действия фильтра не покажет: вне systemd профиль seccomp не применяется.
Для трассировки нужны права: root или CAP_SYS_PTRACE вместе с kernel.yama.ptrace_scope=0 на время отладки. Временный журнал блокировок даёт SystemCallLog=@debug, отказы попадут в audit-лог и читаются без strace. Ограничения systemd хорошо дополняет мандатный контроль доступа: профиль AppArmor для собственного сервиса ограничивает доступ к путям и сокетам поверх директив юнита.
Проверка результата и закрепление конфигурации
Финальная проверка повторяет первую: systemd-analyze security --no-pager nginx. Сравните итоговую строку с сохранённой до правок и убедитесь, что сервис выполняет свою работу: отдаёт страницы, принимает подключения, пишет в базу. Показатель ниже 4.0 (полоса OK) для production-сервиса считается хорошим результатом, хотя итог зависит от версии systemd и дистрибутива.
Сравнение Exposure до и после: пример для sshd
Штатный sshd на Debian 12 без дополнительных директив даёт около 9.6 UNSAFE: открыты файловая система, домашние каталоги, набор capabilities и все системные вызовы. Набор из семи директив меняет картину.
/etc/systemd/system/ssh.service.d/override.conf [Service] ProtectSystem=strict ProtectHome=read-only PrivateTmp=true NoNewPrivileges=true CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_CHOWN CAP_DAC_OVERRIDE CAP_SYS_CHROOT CAP_AUDIT_WRITE SystemCallFilter=@system-service SystemCallArchitectures=native ReadWritePaths=/run/sshd
systemctl daemon-reload systemctl restart ssh systemctl status ssh --no-pager ssh -o BatchMode=yes localhost true systemd-analyze security ssh --no-pager | tail -n 2
В типовом случае оценка опускается до 3.0-3.5, то есть в полосу OK. Строки отчёта с ненулевым EXPOSURE показывают следующий шаг. DynamicUser для sshd лучше не включать: демону нужны хостовые ключи и запись в /run/sshd, а такая связка требует дополнительных BindPaths и усложняет откат. Основной выигрыш здесь дают ProtectSystem=strict, ReadWritePaths, ограничение capabilities и фильтр вызовов.
Чек-лист для production-раскатки
- Сохраните исходные данные: вывод systemctl cat nginx и systemd-analyze security nginx в отдельные файлы.
- Проверьте набор директив на стенде, где сбой не задевает пользователей.
- Создайте каталог /etc/systemd/system/nginx.service.d и файл override.conf.
- Включайте директивы по одной в таком порядке: NoNewPrivileges, PrivateTmp, ProtectSystem, ProtectHome, ReadWritePaths, CapabilityBoundingSet, SystemCallFilter, DynamicUser.
- После каждой правки: systemctl daemon-reload, systemctl restart, systemctl status, journalctl -u, реальный запрос к сервису.
- Сравните Exposure с исходным значением, цель - ниже 4.0.
- Положите override.conf в git с комментариями: зачем нужна каждая директива и как откатиться.
- Пересматривайте конфигурацию раз в квартал и после крупного обновления systemd: новые версии добавляют директивы и меняют оценки.
Полный пример усиления чужого юнита с нуля, включая безопасный порядок правок и план отката, есть в статье про sandboxing и systemd-analyze security. Для сервисов, которые смотрят наружу, добавляйте общую защиту хоста: firewall, отключение лишних демонов, контроль прав sudo и регулярный аудит конфигураций.