Панель управления на сервере почти всегда упирается в одно и то же: веб-процессу нужно сделать что-то, на что у него нет прав. Забанить адрес, открыть порт, перечитать журнал. Решение выглядит очевидным — выдать одну узкую строку в 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передsetsudo отрезает: в шаблоне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, стоит держать на виду вместе с остальным состоянием сервера, а не вспоминать о нём после инцидента. Как это выглядит собранным — на страницах демо ниже.