MAATRIX / Блог / Антипаттерн: все сервисы под root

Антипаттерн: все сервисы под root

MAATRIX

Сервер поднят, приложение запущено, всё работает — и где-то на этом пути permission denied была решена одним способом: запустить от root. Так быстрее, так «точно сработает», и через полгода уже никто не помнит, почему именно этот процесс имеет полные права на систему. Разберём, как именно так делают, что конкретно от этого ломается технически, и как раздать права так, чтобы permission denied не возвращался, но сервер оставался защищён.

Как это выглядит на практике

Антипаттерн редко выглядит как осознанное решение — обычно это путь наименьшего сопротивления в момент, когда что-то не запускалось.

Вариант первый — ручной запуск через sudo. Разработчик деплоит приложение, ловит EACCES: permission denied при попытке записать лог или открыть порт, и вместо того чтобы разобраться, добавляет sudo:

sudo npm start
# или
sudo python app.py
# или в screen/tmux, чтобы «жило» после отключения от SSH
sudo nohup node server.js > /var/log/app.log 2>&1 &

Permission denied исчезает, приложение стартует, все довольны. Проблема в том, что теперь абсолютно весь код приложения — включая все npm-зависимости или pip-пакеты — выполняется с правами root.

Вариант второй — systemd-юнит без User=. Ещё чаще встречается в автоматическом виде: юнит написан, но директива пользователя просто не указана:

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target

[Service]
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=always

[Install]
WantedBy=multi-user.target

Если юнит запущен через systemctl start от root (а системные юниты обычно так и запускаются), а User= не задан — процесс наследует root по умолчанию. Внешне всё выглядит благополучно: systemctl status myapp зелёный, приложение отвечает на запросы. Но ps aux | grep node покажет процесс, владелец которого — root.

Вариант третий — Docker без USER в Dockerfile. Тот же паттерн в контейнерах:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"]

Здесь нет инструкции USER, а значит по умолчанию процесс внутри контейнера тоже работает от root. Контейнерная изоляция создаёт иллюзию безопасности — «это же в контейнере, что может пойти не так» — но при пробросе тома (-v /data:/app/data) или при побеге из контейнера (container escape через уязвимость в runtime) root внутри контейнера — это гораздо более опасная позиция, чем непривилегированный пользователь внутри того же контейнера.

Общая черта всех трёх вариантов — задача «убрать permission denied» решена, но решена неправильным способом: не выдачей конкретных прав туда, где они реально нужны, а снятием всех ограничений разом.

Почему это работает и почему это ловушка

Root решает permission denied всегда и сразу, потому что у root нет ограничений на файловую систему, сеть и системные вызовы — это единственный пользователь в Linux, для которого проверка прав не выполняется. Именно поэтому решение кажется рабочим: сервис стартует, порт открывается, файл пишется, ошибка не повторяется. С точки зрения «работает — не трогай» задача выглядит закрытой.

Ловушка в том, что root в Linux — это не «повышенный уровень доступа», а полное отсутствие проверки прав. Разница принципиальна: обычный пользователь с правильно настроенными правами получает ровно то, что ему нужно, и ни байтом больше; root получает всё, включая то, что ему никогда не понадобится — доступ к чужим домашним директориям, к /etc/shadow, к бинарникам системы, к сетевым интерфейсам напрямую. Когда сервис работает от root, эта разница становится вашей проблемой в трёх конкретных местах — RCE, ошибки в самом приложении и аудит.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Проблема первая: RCE = root на всю систему

Если в приложении есть уязвимость удалённого выполнения кода (remote code execution) — это не гипотетический сценарий, а вопрос времени: устаревшая зависимость с известным CVE, небезопасная десериализация, инъекция в шаблонизатор, уязвимость в парсере загружаемых файлов. При работе от непривилегированного пользователя атакующий, эксплуатировавший такую уязвимость, получает права ровно этого пользователя: может читать и писать файлы, к которым у сервиса есть доступ, слушать порты выше 1024, но не может читать чужие приватные ключи, менять системные бинарники или добавлять пользователей в систему.

При работе от root та же самая уязвимость мгновенно даёт атакующему полный контроль над сервером: чтение и запись любого файла, включая приватные ключи SSH других сервисов и пользователей, добавление собственного пользователя с правами root, установку бэкдора через cron root, модификацию системных бинарников, доступ ко всем базам данных на сервере, а не только к базе конкретного приложения. Разница — не «немного хуже», а качественная: одна уязвимость превращается либо в инцидент с одним скомпрометированным сервисом, который можно остановить и пересобрать, либо в полную компрометацию сервера, требующую переустановки системы с нуля и ротации всех секретов, которые на нём хранились.

Важный нюанс: это не теоретическая угроза «на случай если найдут уязвимость» — это статистический риск, который растёт с каждым обновлением зависимостей, каждой новой библиотекой и каждым месяцем без патчей. Разграничение прав — это не защита от конкретной атаки, а ограничение ущерба от атаки, которая рано или поздно случится хоть в одном из установленных на сервер сервисов.

Проблема вторая: ошибка приложения бьёт по системным файлам

Вторая проблема не требует злоумышленника вообще — достаточно бага в самом приложении. Классический пример: путь для записи файлов формируется неправильно из-за ошибки в конфигурации, необработанного пользовательского ввода или банальной опечатки в переменной окружения.

# конфигурация приложения
UPLOAD_DIR = os.environ.get("UPLOAD_PATH", "/var/app/uploads")
# если переменная окружения не задана, а опечатка в другом месте
# привела к тому, что путь строится как "/" + relative_path,
# результат может оказаться где угодно на файловой системе
save_path = os.path.join(UPLOAD_DIR, user_filename)

Если user_filename содержит ../../etc/cron.d/malicious (path traversal, который должен был отсекаться валидацией, но не был), а процесс работает от непривилегированного пользователя — запись упрётся в permission denied на системной директории, и максимум, что произойдёт — сервис упадёт с ошибкой в логе. Если процесс работает от root — файл действительно запишется, потому что root имеет права писать куда угодно на файловой системе, включая /etc, /boot, домашние директории других пользователей и системные конфиги планировщика задач.

То же касается ошибок при работе с временными файлами, символическими ссылками (symlink attack — когда атакующий подсовывает симлинк на системный файл вместо ожидаемого временного), неправильно посчитанных путей при архивации или ошибок логики очистки старых файлов, которая внезапно решает удалить не то, что предполагалось. Ограничение прав пользователя здесь работает как защита от собственных багов, а не только от внешней атаки — процесс физически не может выйти за пределы отведённой ему области, даже если сам код ошибся.

Проблема третья: аудит и логи размываются

Третья проблема менее очевидна на старте, но проявляется по мере роста инфраструктуры: усложняется аудит происходящего на сервере. Когда на одном хосте крутится десяток сервисов и все они работают от root, системные логи и журналы обращений к файлам не дают ответа на вопрос «какой именно сервис это сделал».

# если всё под root, эта команда покажет только это:
ps aux | grep -E "node|python|java" | awk '{print $1}'
# root
# root
# root
# root

# при разграничении пользователей та же команда информативна:
# webapp-frontend  node
# api-backend      node
# worker-queue     python3
# metrics-agent    java

Разница проявляется в конкретных ситуациях. При расследовании инцидента — «что-то писало в этот файл в 3 ночи» — владелец процесса в auditd или ps сразу указывает на виновника, если пользователи разделены, и указывает на «root», то есть ни на кого конкретно, если нет. При настройке файрвола или AppArmor/SELinux-профилей — политика по пользователю пишется один раз и работает предсказуемо, а политика по общему root либо слишком широкая, либо ломает половину сервисов. При уходе сотрудника или ошибке в коде одного сервиса — можно точечно ограничить или отключить конкретного пользователя без риска задеть остальные сервисы, потому что права привязаны к нему, а не размазаны по общему root. Разграничение пользователей — это не бюрократия ради галочки, а рабочий инструмент, который экономит часы при инциденте.

Как сделать правильно: пользователь на каждый сервис

Базовый принцип — принцип наименьших привилегий (least privilege): каждый сервис получает ровно те права, которые нужны ему для работы, и не получает никаких сверх этого. На практике это означает отдельного системного пользователя на каждый сервис.

Шаг 1. Создать системного пользователя без домашней директории и без возможности логина:

useradd --system --no-create-home --shell /usr/sbin/nologin myappuser

Флаг --system создаёт служебную учётную запись (UID из системного диапазона, обычно ниже 1000), --no-create-home не создаёт домашнюю директорию, которая такому пользователю не нужна, а /usr/sbin/nologin в качестве шелла гарантирует, что через этого пользователя нельзя будет получить интерактивную SSH-сессию, даже если у него случайно окажется пароль или ключ.

Шаг 2. Указать User=/Group= в systemd-юните:

[Unit]
Description=My App
After=network.target

[Service]
User=myappuser
Group=myappuser
ExecStart=/usr/bin/node /opt/myapp/server.js
WorkingDirectory=/opt/myapp
Restart=always

# дополнительное ужесточение, которое стоит добавлять по умолчанию
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/data /var/log/myapp

[Install]
WantedBy=multi-user.target

ProtectSystem=strict делает всю файловую систему, кроме явно перечисленных в ReadWritePaths, доступной только на чтение для этого процесса — даже если в приложении есть баг с построением пути, записать за пределы разрешённых директорий физически не получится.

Шаг 3. Указать USER в Dockerfile:

FROM node:20-slim
WORKDIR /app

# создаём непривилегированного пользователя внутри образа
RUN groupadd --system myappuser && useradd --system --gid myappuser myappuser

COPY package*.json ./
RUN npm ci --production
COPY . .

# передаём владение рабочей директорией новому пользователю
RUN chown -R myappuser:myappuser /app

USER myappuser
CMD ["node", "server.js"]

Многие официальные образы (node, postgres, nginx) уже содержат непривилегированного пользователя внутри (часто с именем node или nobody) — тогда достаточно USER node без создания нового. Проверить, есть ли готовый пользователь в образе, можно командой docker run --rm <образ> id nobody или заглянув в /etc/passwd внутри контейнера.

Шаг 4. Права на директории — точечно, не root на всё:

mkdir -p /opt/myapp/data /var/log/myapp
chown -R myappuser:myappuser /opt/myapp /var/log/myapp
chmod 750 /opt/myapp/data

chown передаёт владение конкретному пользователю и группе сервиса, chmod 750 разрешает владельцу читать/писать/выполнять, группе — только читать и выполнять, а остальным — ничего. Это заменяет широкий доступ узким и достаточным.

Шаг 5. setcap — для узкого случая, когда действительно нужна одна привилегия. Если сервису нужно забиндиться на порт ниже 1024 (например 443), а держать его от root ради одной этой возможности не хочется — можно выдать конкретному бинарнику ровно одну capability:

setcap 'cap_net_bind_service=+ep' /usr/bin/node

Это разрешает бинарнику /usr/bin/node привязываться к привилегированным портам, не давая ему никаких других прав root. Важная оговорка: setcap применяется к самому бинарнику, а не к юниту, поэтому при обновлении Node.js (например через пакетный менеджер) capability слетает и её нужно выставлять заново — стоит добавить это в скрипт деплоя или post-install хук. Для веб-приложений чаще практичнее не биндиться напрямую на 443, а слушать локальный порт выше 1024 и поставить перед ним nginx или Caddy от системного пользователя www-data, который уже имеет нужный capability из коробки — это избавляет от необходимости трогать setcap вообще.

Сравнение подходов:

ПодходУровень риска при RCEКогда уместен
Всё под rootПолная компрометация сервераНикогда для продакшена
Отдельный пользователь на сервисОграничено правами сервисаСтандарт по умолчанию
Отдельный пользователь + ProtectSystem/AppArmorОграничено правами + файловой изоляциейПродакшен, публичные сервисы
setcap на конкретный бинарникОдна привилегия, не root целикомБинд на порт < 1024 без reverse proxy

Если сервисов на сервере много и хочется избежать ручного создания пользователя под каждый, стоит посмотреть в сторону Docker для изоляции сервисов — там разграничение приходит вместе с самой архитектурой контейнеров, если не забывать про USER в каждом образе. Управление самими пользователями и группами на уровне ОС подробнее разобрано в статье про пользователей и группы Linux, а если речь про доступ людей, а не сервисов, — в статье как раздать доступ команде без выдачи root.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если сервис всё равно упадёт с permission denied после смены пользователя — что проверять в первую очередь?

Владельца и права на директории, куда сервис пишет: ls -la по рабочей директории и по путям для логов/загрузок. Почти всегда после смены User= в юните забывают выполнить chown на уже существующие файлы, созданные ранее от root.

Можно ли постепенно мигрировать существующий сервис с root на отдельного пользователя без простоя?

Да: создайте пользователя, выполните chown -R на все директории сервиса, добавьте User=/Group= в юнит, systemctl daemon-reload, затем systemctl restart в окно минимального трафика — сам рестарт сервиса даёт секунды простоя, которые почти всегда приемлемы для внутреннего или некритичного сервиса.

PostgreSQL и nginx уже работают от отдельных пользователей — зачем эта статья, если я использую готовые пакеты?

Готовые пакеты из репозитория дистрибутива (apt install postgresql, apt install nginx) действительно создают системного пользователя автоматически — риск в первую очередь у самописных приложений и Docker-образов, собранных вручную без явного USER.

А если сервису реально нужен root, например для управления сетевыми интерфейсами?

Тогда стоит выделить именно эту функцию в отдельный маленький процесс с capability через setcap или ограниченный sudo с конкретной командой в /etc/sudoers.d/, а не запускать всё приложение целиком от root ради одной операции.

Как проверить, какие сервисы на уже работающем сервере запущены от root, если руки до этого не доходили?

ps -eo user,comm,pid --sort=user | grep '^root' покажет все процессы root, дальше вручную отфильтровать системные (systemd, kernel-треды) от прикладных сервисов, которые стоит перевести на отдельных пользователей.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →