Когда сервис на Linux запускается «как есть», он обычно получает больше прав и доступа к файловой системе, чем ему нужно. В итоге любая RCE-уязвимость превращается в проблему уровня сервера: чтение конфигов и ключей, запись в «системные» каталоги, запуск дочерних процессов и попытки закрепиться.
У systemd есть мощный набор директив для sandboxing и hardening unit без контейнеров. Ниже — практичный набор: DynamicUser, ProtectSystem, ProtectHome, PrivateTmp, NoNewPrivileges, CapabilityBoundingSet, SystemCallFilter, а также подготовка каталогов через системные директории и tmpfiles.d.
Важная мысль: hardening почти всегда делается итеративно. Добавили ограничение, проверили функциональность, посмотрели логи, поправили. Это нормальный цикл, и его стоит заложить в план внедрения.
Базовая идея hardening в systemd
Удобная модель: сначала определяем, что сервису точно нужно, затем разрешаем это явно, а остальное запрещаем. Обычно всё сводится к трём зонам:
- Файловая система: куда можно читать и куда можно писать.
- Привилегии: какие capabilities и повышение прав допустимы.
- Поведение процесса: какие системные вызовы и пространства имён разрешены.
Практическое правило: чем меньше «область записи» и чем меньше привилегий у процесса, тем сложнее атакующему превратить баг в компрометацию системы.
Где размещать настройки
Unit-файлы из пакетов лучше не править напрямую. Используйте drop-in:
systemctl edit yourservice.service
Откроется файл вида /etc/systemd/system/yourservice.service.d/override.conf. После изменений:
systemctl daemon-reload
systemctl restart yourservice.service
systemctl status yourservice.service
Проверить итоговую конфигурацию (с учётом всех drop-in и наследований):
systemctl cat yourservice.service
systemd-analyze security yourservice.service
DynamicUser: сервис без постоянного пользователя
DynamicUser=yes говорит systemd: «создай временного пользователя/группу на время запуска сервиса и удали после остановки». Это снижает риск, что процесс будет писать в «чужие» каталоги, и упрощает эксплуатацию: не нужно заводить системного пользователя вручную.
Нюанс: динамический пользователь не должен писать куда попало. Вам нужно явно подготовить каталоги под запись.
StateDirectory/CacheDirectory/LogsDirectory/RuntimeDirectory вместо ручных mkdir
Самый правильный путь — дать systemd создать директории с корректными правами:
[Service]
DynamicUser=yes
StateDirectory=yourservice
CacheDirectory=yourservice
LogsDirectory=yourservice
RuntimeDirectory=yourservice
Что это даёт на практике:
StateDirectoryсоздаёт/var/lib/yourservice(данные состояния).CacheDirectoryсоздаёт/var/cache/yourservice(кэш).LogsDirectoryсоздаёт/var/log/yourservice(если нужны файлы логов; часто хватает journald).RuntimeDirectoryсоздаёт/run/yourservice(сокеты, pid-файлы, runtime-данные).
Ключевое: systemd выставит владельца/права так, чтобы процесс мог писать туда даже без постоянного UID.

Когда всё-таки нужен tmpfiles.d
tmpfiles.d полезен, когда нужен нестандартный путь, симлинк, файл-заглушка, FIFO, или вы хотите управлять очисткой (retention). Также он выручает, если приложение «жёстко» ожидает каталог не в стандартном месте.
Пример: создаём каталог и очищаем старые файлы. Файл /etc/tmpfiles.d/yourservice.conf:
d /var/lib/yourservice 0750 root root -
d /var/lib/yourservice/uploads 0750 root root -
q /var/lib/yourservice/tmp 0750 root root 7d
Важно: если у вас DynamicUser=yes, то владение каталогом «динамическим» UID вне запуска сервиса проблематично. Поэтому чаще используйте StateDirectory/RuntimeDirectory, а в tmpfiles.d оставляйте только то, что действительно нельзя выразить штатными директивами.
ProtectSystem и ProtectHome: ограничиваем файловую систему
Большинство успешных атак после RCE — это чтение секретов и запись/подмена файлов. Поэтому защита ФС — одна из самых эффективных мер.
ProtectSystem
ProtectSystem монтирует части файловой системы в режим «только чтение» для процесса сервиса.
ProtectSystem=trueобычно переводит/usr,/boot,/etcв read-only.ProtectSystem=fullделает read-only почти всё, кроме стандартных путей для записи (поведение зависит от версии systemd).ProtectSystem=strictмаксимально «закрывает» ФС, а вы затем точечно открываете нужные пути черезReadWritePaths.
Практический рецепт: для новых сервисов начинайте с ProtectSystem=strict и добавляйте ReadWritePaths только туда, где действительно нужна запись.
ProtectHome
ProtectHome ограничивает доступ к домашним каталогам пользователей:
ProtectHome=trueскрывает/home,/root,/run/user.ProtectHome=read-onlyпозволяет читать, но не писать.ProtectHome=tmpfsподменяет на пустую tmpfs (жёстче, иногда ломает софт).
Для серверных демонов почти всегда уместно ProtectHome=true.
ReadWritePaths/ReadOnlyPaths/InaccessiblePaths
Эти директивы — «скальпель», когда нужно разрешить минимум:
[Service]
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/yourservice
ReadWritePaths=/run/yourservice
ReadOnlyPaths=/etc/ssl
InaccessiblePaths=/srv
Совет: начинайте с минимального набора путей на запись. Если сервис падает, смотрите ошибки доступа в journald и добавляйте только конкретный путь, который действительно нужен.
PrivateTmp: отдельный /tmp для сервиса
PrivateTmp=yes даёт процессу отдельные /tmp и /var/tmp. Это снижает риск атак через «общий» tmp (подмена файлов, гонки, утечки данных между процессами) и часто убирает конфликты имён.
Типичная проблема: сервис ожидает обмениваться файлами через /tmp с другим процессом (например, внешним конвертером или агентом). Тогда нужно либо отключить PrivateTmp, либо перенести обмен в RuntimeDirectory (например, /run/yourservice) и дать обоим сервисам доступ через группы/права или сокеты.
NoNewPrivileges: запрет на повышение привилегий
NoNewPrivileges=yes запрещает процессу и дочерним процессам получать новые привилегии через setuid/setgid-бинарники и некоторые механизмы exec. Это почти всегда «бесплатная» защита для веб-воркеров, воркеров очередей и небольших API-демонов.
Где ломается: если приложение по дизайну запускает setuid-хелперы или требует привилегированного поведения при exec. Для типовых серверных демонов это скорее исключение.
CapabilityBoundingSet: оставляем только нужные capabilities
Linux capabilities позволяют разделить «root-права» на части. systemd даёт удобный способ «срезать» их у сервиса через CapabilityBoundingSet. Идея: даже если сервис запущен от root (или получил root из-за ошибки), ядро не даст ему выполнить часть привилегированных операций.
Типовой безопасный подход: обнулить набор и затем добавить только нужное. Пример для сервиса, которому не нужны привилегии:
[Service]
CapabilityBoundingSet=
NoNewPrivileges=yes
Если сервису нужно слушать порт ниже 1024, добавляют CAP_NET_BIND_SERVICE:
[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
Пояснение: CapabilityBoundingSet задаёт «потолок», а AmbientCapabilities помогает передать capability в процесс без полного root.
SystemCallFilter: режем поверхность атаки на уровне syscalls
SystemCallFilter — сильный инструмент, но внедрять его нужно аккуратно: неправильный фильтр ломает сервис «странно» и иногда только под нагрузкой.
Практичный путь внедрения:
- Сначала включите «мягкие» ограничения (ФС, tmp, привилегии).
- Затем добавляйте
SystemCallFilterна основе реальных потребностей сервиса. - Тестируйте функциональность, перезапуски, обработку сигналов и работу под нагрузкой.
Пример стартового фильтра
Ниже пример достаточно универсального усиления, которое часто подходит для сетевых демонов без экзотики. Его всё равно нужно проверять в вашем окружении:
[Service]
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
Дальше можно точечно запрещать опасные классы, если уверены, что они не нужны:
[Service]
SystemCallFilter=@system-service
SystemCallFilter=~@mount
SystemCallFilter=~@swap
SystemCallFilter=~@reboot
SystemCallErrorNumber=EPERM
Если после включения фильтра сервис падает, смотрите journald: часто там видно, какой syscall был заблокирован. Альтернатива — временно ослабить фильтр, стабилизировать, затем снова «закручивать».
Практический шаблон: «закрытый» unit для простого демона
Представим сервис yourservice, который слушает TCP-порт, пишет состояние в /var/lib/yourservice и использует runtime-сокет в /run/yourservice. Пример drop-in (адаптируйте под ваш софт):
[Service]
DynamicUser=yes
StateDirectory=yourservice
RuntimeDirectory=yourservice
ProtectSystem=strict
ProtectHome=true
PrivateTmp=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/yourservice
ReadWritePaths=/run/yourservice
CapabilityBoundingSet=
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=@system-service
SystemCallFilter=~@mount
SystemCallFilter=~@reboot
SystemCallErrorNumber=EPERM
Почему здесь нет User=: при DynamicUser=yes systemd сам назначит пользователя.

Диагностика и внедрение без сюрпризов
Начинайте с «малых шагов»
Если включить всё сразу, будет трудно понять, что именно сломало сервис. Рабочая последовательность (почти всегда):
NoNewPrivileges,PrivateTmp,ProtectHome.DynamicUserплюсStateDirectory/RuntimeDirectory.ProtectSystem=strictи точечныеReadWritePaths.CapabilityBoundingSet(обнуление или минимум).SystemCallFilter(последним).
Проверяйте оценку security и фактические ошибки
systemd-analyze security не гарантирует безопасность, но хорошо подсвечивает очевидные «дыры» и помогает видеть прогресс:
systemd-analyze security yourservice.service
Фактические проблемы будут в логах:
journalctl -u yourservice.service -b --no-pager
Как понять, куда сервису нужна запись
Если вы включили ProtectSystem=strict и сервис перестал стартовать, ищите в журнале “Permission denied” и путь. Дальше обычно есть два безопасных варианта:
- Перенастроить приложение на запись в
StateDirectory/RuntimeDirectory. - Если приложение не настраивается — добавить конкретный путь в
ReadWritePaths.
Старайтесь не «открывать» крупные деревья. Hardening выигрывает именно от минимизации.
Частые ошибки и типовые «ломается потому что…»
DynamicUser и запись в /etc
Некоторые приложения пытаются писать runtime-файлы в /etc (кэш, pid, «сгенерённый конфиг»). С ProtectSystem и DynamicUser это почти всегда плохо. Переносите такие файлы в /var/lib/… или /run/…, а /etc оставляйте только для статических конфигов.
PrivateTmp и интеграции
Если сервис взаимодействует с внешними процессами через файлы в /tmp, после включения PrivateTmp они окажутся в разных пространствах. Решение: общий каталог в /run или сокетный IPC.
CapabilityBoundingSet: «вроде всё сломалось»
При обнулении capabilities ломаются операции, которые раньше «случайно работали», потому что сервис запускался как root. Признаки:
- не открывается привилегированный порт (ниже 1024);
- не удаётся менять владельца/права файлов;
- ошибки работы с raw-socket/ICMP, если сервис — мониторинг/пингер.
Решение: возвращайте только точечно необходимое (например, CAP_NET_BIND_SERVICE), либо выносите привилегированную часть в отдельный маленький сервис.
SystemCallFilter: «падает только под нагрузкой»
Часть syscalls используется только на отдельных путях кода: при ротации логов, при форке воркеров, при reload-конфигурации. Поэтому фильтры проверяйте не только стартом, но и типовыми админ-действиями: reload, graceful restart, пик нагрузок.
Мини-чеклист для production hardening
- Сервис пишет только в
StateDirectory/RuntimeDirectoryи нигде больше. ProtectSystem=strictвключён; пути на запись минимальны и понятны.ProtectHome=trueвключён, если сервису не нужен доступ к/home.PrivateTmp=yesвключён, если нет обмена через/tmpс другими процессами.NoNewPrivileges=yesвключён.CapabilityBoundingSetлибо пустой, либо содержит 1–2 необходимых capability.SystemCallFilterдобавлен и протестирован (или отложен, если риск простоя важнее).- Проверены
systemd-analyze securityи журналыjournalctl.
Почему это особенно полезно на VDS
На виртуальном сервере часто живёт несколько сервисов рядом: веб, воркеры, агенты мониторинга, планировщики. systemd-hardening даёт «мягкую изоляцию» между ними: даже если один процесс скомпрометирован, ему сложнее прочитать чужие секреты и повлиять на систему.
Если вы поднимаете инфраструктуру на VDS, то hardening сервисов через systemd — один из самых быстрых способов снизить риск бокового перемещения без внедрения контейнеризации.
Контейнеры и MAC-системы (SELinux/AppArmor) тоже важны, но hardening на уровне systemd часто даёт заметный выигрыш за часы, а не за недели внедрения.
Что почитать дальше
- Песочница systemd: дополнительные директивы sandboxing и подходы к усилению
- Воркеры и очереди: как правильно управлять процессами через systemd
Итог
DynamicUser помогает отказаться от постоянных системных аккаунтов для сервисов. В связке с ProtectSystem, ProtectHome и PrivateTmp вы сокращаете доступ к файловой системе и риск утечек. NoNewPrivileges и CapabilityBoundingSet режут эскалацию привилегий, а SystemCallFilter снижает поверхность атаки на уровне ядра. Если нужны нестандартные пути или правила очистки — подключайте tmpfiles.d, но сначала используйте встроенные директории systemd.


