Панель управления на сервере почти всегда упирается в одно и то же: веб-процессу нужно сделать что-то, на что у него нет прав. Забанить адрес, открыть порт, перечитать журнал. Решение выглядит очевидным — выдать одну узкую строку в sudoers, и только её:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

Строка читается как «можно только банить». На самом деле она означает «можно всё, что угодно, от root». Ниже — почему так выходит, как это проверяется на своём сервере за пять минут и чем такие правила заменяются.

Что на самом деле делает *

Ключевая деталь: sudo сопоставляет не аргументы по отдельности, а строку команды целиком, и * в шаблоне спокойно проходит через пробелы — то есть через границы аргументов. Литерал banip, стоящий в середине правила, поэтому не ограничивает ничего: достаточно, чтобы слово banip где-нибудь в команде встретилось, а до него и после него можно передать что угодно.

Дальше ищется любая программа, которой можно передать команду на выполнение. В нашем случае она была прямо в правиле. У fail2ban действие бана задаётся текстом, и его можно переопределить на ходу:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<команда>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

Первый вызов подменяет текст действия у реального action, второй заставляет джейл сработать — и fail2ban, работающий от root, выполняет подставленное. Мы проделали это на своём сервере 4 июля: файл в /root создался от root, то есть веб-процесс получил полные права на машине из правила, которое выглядело узким.

По ходу выяснились три подробности, которые в чужих разборах обычно не встречаются:

  • ключ -c перед set sudo отрезает: в шаблоне set прибит сразу к имени бинарника, и вставить что-то перед ним не получится;
  • addaction и action без слова banip тоже не проходят — но всё нужное укладывается в одну команду set … banip …, а значит шаблон соблюдён;
  • имя action должно быть настоящим, иначе подменять нечего: его показывает fail2ban-client get <jail> actions.

Кто ещё стоит в этом же списке

fail2ban здесь не виноват и не уникален. Опасно само сочетание «NOPASSWD плюс *». Что мы находили на своих серверах рядом:

  • journalctl * — журнал открывается через pager, а из pager запускается оболочка. Правило с виду про чтение логов, на деле — root-шелл. Лечится не сужением шаблона, а группой systemd-journal: тогда journalctl работает вообще без sudo;
  • grep * /var/log/fail2ban.log — первым аргументом grep идёт шаблон, но * позволяет подставить и второй путь, а grep выполняется от root. Прочитать /etc/shadow таким правилом — одна команда. Замена та же: группа adm на чтение логов;
  • ufw --force * и голые ufw allow/deny/delete * — веб-процесс может выключить фаервол целиком. Отдельно неприятно, что в обоих случаях панель этими правилами не пользовалась вовсе: мёртвые гранты, оставшиеся от ранних версий установщика;
  • lynis-scan.sh * — обёртка, прокидывающая "$@" в root-процесс, равна правилу без ограничений.

Как посмотреть, что есть у вас

Смотреть надо не на файл, а на итоговые права конкретного пользователя — того, под которым реально работает пул PHP-FPM (это не всегда www-data; у нас на одной машине панель работала под admin):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

Дальше — сам список файлов, и здесь две ловушки, на которых мы теряли время:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

Первая: правила живут в двух местах одновременно. У нас опасная строка была и в /etc/sudoers.d/monitor, и в главном /etc/sudoers — в главном примерно в восьми копиях, накопленных разными версиями установщика. Вычистить одно место и успокоиться — обычный способ оставить дыру открытой.

Вторая: sudo игнорирует файлы, в имени которых есть точка. Файл www-data.bak3 в /etc/sudoers.d/ выглядит действующим правилом, читается как действующее правило, но не действует. Это работает в обе стороны: и «правило есть, а прав нет», и ложное спокойствие от того, что копия конфига «вроде бы лежит рядом».

Чем заменить

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

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, и ничего больше
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

Обёртка принадлежит root, права 755, и — обязательно — она не должна быть записываемой из веба, иначе вся конструкция бессмысленна. В sudoers остаётся только она:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

Аргументы в правиле не перечисляются: их проверяет сам скрипт, и позиции в нём фиксированы, так что подставить текст действия некуда. Правила «только посмотреть» — статусы, ss, ipset list — выносятся отдельными строками, тоже без *, если это возможно.

Отдельно — про подмену sudo группами. Приём правильный: systemd-journal для журнала, adm для логов дешевле и безопаснее любого sudo-правила. Но выдёргивать веб-пользователя из групп вслепую нельзя. Мы убрали www-data из одной группы на панельном хосте и получили 403 на всех сайтах сразу: там Apache работает под www-data, файлы сайтов принадлежат другому пользователю, и доступ на чтение давало именно членство в группе. Восстановление потребовало полного рестарта (не reload) Apache и PHP-FPM, потому что старые воркеры сохраняют прежний набор групп и дают «то работает, то 403».

Проверка после правки

Перед заменой файла sudoers его нужно проверить синтаксически — иначе можно остаться без sudo вообще:

sudo visudo -cf /etc/sudoers.d/monitor

После — проверить, что старый вектор умер, причём от имени того самого пользователя:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

Ответ должен быть password required или a password is required, а вызов обёртки с мусором вместо адреса — invalid ip. Здесь есть своя ловушка: sudo -v кэширует подтверждение на пятнадцать минут, и после него любая проверка sudo -n проходит «успешно». Именно так мы однажды подтвердили правило, которого на сервере не было. Поэтому sudo -k перед проверкой обязателен.

Смысл наблюдения

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