Усиление systemd-сервисов: ProtectSystem, DynamicUser и syscall-фильтры без поломки продакшена | AdminWiki

Усиление systemd-сервисов: ProtectSystem, DynamicUser и syscall-фильтры без поломки продакшена

11 сентября 2026 14 мин. чтения
Содержание статьи

Оценка защиты 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 и перезапуска в оценку не попадут, поэтому после каждого изменения повторяйте команду и сверяйтесь с предыдущим числом.

Принципы безопасного усиления: одно изменение за шаг

Главное правило: одна директива, затем проверка. Так видно, что именно сломало сервис, и не приходится искать виновника среди пяти одновременных правок.

  1. Сохраните исходное состояние: systemctl cat nginx > /root/nginx-unit-before.txt и systemd-analyze security nginx --no-pager > /root/nginx-score-before.txt.
  2. Создайте каталог /etc/systemd/system/nginx.service.d и файл override.conf в нём.
  3. Добавьте одну директиву, выполните systemctl daemon-reload и systemctl restart nginx.
  4. Проверьте: systemctl status nginx --no-pager, journalctl -u nginx -n 50 --no-pager и реальный запрос (curl или подключение клиентом).
  5. Оставьте правку, если проверки прошли, и переходите к следующей директиве.

Откат сводится к удалению 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привязка сокета к портам ниже 1024nginx и apache2 на портах 80 и 443
CAP_CHOWNсмена владельца и группы файловсервисы, создающие файлы для других пользователей
CAP_DAC_OVERRIDEобход проверок прав доступа к файламредко нужна, часто маскирует ошибки в правах
CAP_SETUID и CAP_SETGIDсмена UID и GID процесса и потомковмастер-процессы, сбрасывающие права на воркерах
CAP_NET_RAWraw-сокеты, ICMP, ARPping, tcpdump, DHCP-клиенты
CAP_NET_ADMINправка интерфейсов, маршрутов, правил firewallVPN и туннели, сетевые демоны
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-iosocket, bind, connect, accept, sendmsgсервисам с сетевыми сокетами
@file-systemopen, openat, stat, mkdir, unlink, renameсервисам, работающим с файлами
@basic-ioread, write, close, lseekнужна практически любому процессу
@processfork, clone, execve, wait4сервисам, которые запускают дочерние процессы
@signalотправка и ожидание сигналовменеджерам воркеров
@timerтаймеры и планированиесервисам с периодическими задачами
@mountmount, umount, pivot_rootблокировать везде, кроме контейнерных движков
@privilegedsetuid, setgid, смена capabilitiesоставлять по минимуму и с обоснованием
@debugptrace и отладочные вызовыблокировать в production
@raw-ioiopl, 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=strictjournalctl -u myapp -n 50 --no-pagerДобавить путь в ReadWritePaths или перевести каталог на LogsDirectory
Operation not permitted при работе с сетьюФильтр вызовов или capabilities блокируют socket и bindstrace -f -p PID -e trace=networkВернуть @network-io в SystemCallFilter или CAP_NET_BIND_SERVICE в CapabilityBoundingSet
Failed to set up mount namespacing: Permission deniedПуть из ReadWritePaths отсутствует на диске или конфликтует с ProtectSystemsystemctl status myapp --no-pager и systemd-analyze verifyСоздать каталог заранее или убрать путь из ReadWritePaths
Сервис не стартует после DynamicUser=yesДанные лежат в каталоге чужого пользователя, нужен фиксированный UIDls -ld /var/lib/private/myapp и journalctl -u myapp -n 50 --no-pagerПеренести данные в StateDirectory или вернуть статического пользователя через User=
No such file or directory для pid-файла в /run/run доступен только для чтения при ProtectSystem=strictsystemctl show -p PIDFile --value nginxДобавить путь в ReadWritePaths или снизить режим до ProtectSystem=full
Процесс убит сигналом SIGSYSЗаблокированный системный вызов при отсутствии SystemCallErrorNumberjournalctl -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-раскатки

  1. Сохраните исходные данные: вывод systemctl cat nginx и systemd-analyze security nginx в отдельные файлы.
  2. Проверьте набор директив на стенде, где сбой не задевает пользователей.
  3. Создайте каталог /etc/systemd/system/nginx.service.d и файл override.conf.
  4. Включайте директивы по одной в таком порядке: NoNewPrivileges, PrivateTmp, ProtectSystem, ProtectHome, ReadWritePaths, CapabilityBoundingSet, SystemCallFilter, DynamicUser.
  5. После каждой правки: systemctl daemon-reload, systemctl restart, systemctl status, journalctl -u, реальный запрос к сервису.
  6. Сравните Exposure с исходным значением, цель - ниже 4.0.
  7. Положите override.conf в git с комментариями: зачем нужна каждая директива и как откатиться.
  8. Пересматривайте конфигурацию раз в квартал и после крупного обновления systemd: новые версии добавляют директивы и меняют оценки.

Полный пример усиления чужого юнита с нуля, включая безопасный порядок правок и план отката, есть в статье про sandboxing и systemd-analyze security. Для сервисов, которые смотрят наружу, добавляйте общую защиту хоста: firewall, отключение лишних демонов, контроль прав sudo и регулярный аудит конфигураций.

Поделиться:
Сохранить гайд? В закладки браузера