Короткий ответ

Долгоживущий процесс с uid 0 после bind порта — лишняя поверхность. В systemd задайте User=/Group= (или DynamicUser=yes где уместно), NoNewPrivileges=yes, ProtectSystem=strict, capabilities вместо полного root. Привилегированный bind :443 решайте AmbientCapabilities=CAP_NET_BIND_SERVICE + drop rest, либо socket activation, либо reverse-proxy от www-data. Не чините Permission denied через User=root и не отключайте AppArmor enforcing.

Хост host.example (10.0.20.10). Пользователь-админ admin — не путать с User= сервиса.

Симптомы и как отличить

  • ps -o user,pid,cmd -C java → root;
  • systemctl show myservice -p User,UID,MainPID;
  • логи приложения пишутся в /root;
  • после компромисса веб-приложения сразу /etc/shadow.
ЗапускОценка
nginx master root, workers www-dataтипично, смотрите workers
gunicorn rootплохо
Docker --privilegedхуже, чем User=root в unit
cron @reboot sudo pythonне unit, тоже uid 0

Отличие от sudo ALL у человека: sudoers. Отличие от docker.sock: группа docker = root, даже если контейнер non-root: сокет.

Возможные причины

  1. Unit из gist: ExecStart=/usr/bin/python3 app.py без User.
  2. «Порт 80 только root» — устаревшая мантра без capabilities.
  3. Файлы в /opt/app принадлежат root 700, проще не chown.
  4. Docker host network + root в контейнере.
  5. sudo внутри ExecStart.
  6. Старый SysV скрипт.

Диагностика

ps -eo user,uid,pid,cmd | awk '$2==0 {print}' | head -n 50
systemctl list-units --type=service --state=running --no-pager

По подозрительному unit:

systemctl cat myservice.service
systemctl show myservice -p User,Group,UID,GID,CapabilityBoundingSet,NoNewPrivileges,PrivateTmp,ProtectSystem
sudo grep -E '^User=|^Group=|^ExecStart=' /etc/systemd/system/myservice.service /lib/systemd/system/myservice.service 2>/dev/null

Проверка порта:

sudo ss -tlnp | grep myservice
getcap $(readlink -f /usr/sbin/nginx) 2>/dev/null

AppArmor профиль может DENY после понижения uid — это сигнал поправить профиль, не вернуть root: AppArmor.

Решение

Сценарий A. Свой unit в /etc/systemd/system

Создайте системного пользователя:

sudo adduser --system --group --home /var/lib/myservice --no-create-home myservice
sudo mkdir -p /var/lib/myservice /var/log/myservice
sudo chown -R myservice:myservice /var/lib/myservice /var/log/myservice /opt/myservice

Override:

sudo systemctl edit myservice
[Service]
User=myservice
Group=myservice
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/myservice /var/log/myservice
CapabilityBoundingSet=
AmbientCapabilities=

Если нужен bind :80:

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

Лучше слушать 127.0.0.1:8080 и отдать 80/443 nginx/caddy от www-data.

sudo systemctl daemon-reload
sudo systemctl restart myservice
ps -o user,cmd -C python3

Сценарий B. Пакетный nginx/apache

Не переводите master в www-data вручную против пакета. Workers уже non-root. Проверьте, что нет user root в nginx.conf.

Сценарий C. Docker

В образе USER ненулевой UID, без --privileged, без -v /:/host. Root в контейнере + bind mount / = root хоста. Понижение на хосте не заменяет это.

Сценарий D. Нужны привилегии на устройство

DeviceAllow, группа disk точечно, не User=root. SUID на своём бинаре — хуже: SUID.

Практические ловушки на Ubuntu 22.04/24.04. Пакетные unit в /lib/systemd/system не правьте на месте: только systemctl edit или файл в /etc/systemd/system/имя.service.d/override.conf. Иначе apt вернёт User=root или затрёт ExecStart. После edit обязателен daemon-reload; забытый reload оставляет старый uid в памяти до следующего restart.

Проверьте, что сервис не поднимает helper от root через ExecStartPre=/bin/chown -R на всё /opt: это снова окно uid 0 и гонка с live-данными. chown — разово в окне миграции, не каждый старт.

Логи journald от non-root пишутся без проблем; проблемы начинаются, когда приложение само открывает /var/log/myservice.log как файл 644 root. Либо StandardOutput=journal, либо файл заранее chown myservice. Не chmod 666 лог «на время».

Socket activation (*.socket unit) позволяет процессу без CAP_NET_BIND слушать 80: systemd отдаёт fd. Это чище capabilities на самом бинаре. На 24.04 так часто устроен ssh.socket — не копируйте этот паттерн слепо на Java, но имейте в виду для своих reverse-proxy.

Если после понижения uid «не видно» USB/тюнера/GPU — это DeviceAllow и группа video/render, не повод вернуть root. Смотрите journalctl -u myservice на Permission denied с путём /dev/. AppArmor может отдельно DENY /dev/nvidia; профиль, не uid 0.

Связка с SUID: не ставьте chmod u+s на бинарь приложения вместо User= в unit. SUID-обёртка хуже контролируемого systemd. См. SUID.

Проверьте systemctl show myservice -p SupplementaryGroups,PrivateUsers,ProtectKernelTunables. На 24.04 имеет смысл ProtectKernelTunables=yes и LockPersonality=yes, если приложение не настраивает sysctl само. Не включайте PrivateNetwork=yes у сервиса, которому нужна LAN — получите «как будто User= виноват».

Проверьте SupplementaryGroups= в unit: лишняя группа docker или sudo у сервиса = снова root-эквивалент. Список групп должен быть минимальным для устройства/лога.

Как проверить, что проблема устранена

ps -o user,uid,pid,cmd -p $(systemctl show -p MainPID --value myservice)
systemctl show myservice -p User,NoNewPrivileges,ProtectSystem
sudo -u myservice test -w /var/lib/myservice && echo data_ok
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health

MainPID не uid 0 (исключения: мастер с drop в дочерних — тогда дочерние не root). Сервис отвечает. admin по SSH жив. AppArmor профиля в enforce, если был.

Если не помогло

  • Permission denied на лог: ReadWritePaths, не root.
  • Порт 80 не берётся: capabilities или proxy, не uid 0.
  • DynamicUser и фиксированный UID в volume — несовместимо; обычный adduser --system.
  • SELinux denials на Rocky: ausearch -m AVC, не setenforce 0 навсегда. На Ubuntu — journalctl | grep DENIED.
  • После понижения сервис пишет в /root/.config — задайте Environment=HOME=/var/lib/myservice.

Профилактика

  • Шаблон unit в ansible с User= обязателен для своих сервисов.
  • Список процессов uid 0 в мониторинге (белый список systemd/sshd/cron).
  • Code review Dockerfile USER.
  • Не запускать из crontab sudo -u root то, что должно быть timer + User=.

FAQ

CAP_NET_BIND_SERVICE безопаснее root?

Да на порядки, это не uid 0. Всё ещё не идеал: лучше unprivileged порт + proxy.

DynamicUser vs adduser --system

DynamicUser удобен без persistent state. Для БД-файлов — фиксированный system user.

Можно ли оставить root «только в docker»?

Нет как политика. Root в контейнере + слабые mounts = хост. См. docker.sock.

systemd --user для сервиса сайта?

Для пользовательских сессий да. Системный сайт — system user, не интерактивный admin.

ProtectSystem ломает запись в /usr

Это цель. Данные в /var/lib, бинари обновляет apt, не приложение.