Панель керування на сервері майже завжди впирається в одне й те саме: вебпроцесу потрібно зробити щось, на що в нього немає прав. Заблокувати адресу, відкрити порт, прочитати журнал. Відповідь, яка сама напрошується, — видати один вузький рядок у 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

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

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

  • ключ -c перед set sudo відрізає: у шаблоні set прибитий одразу до імені бінарника, і вставити щось перед ним не вийде;
  • addaction і action без слова banip теж не проходять — але все потрібне вкладається в одну команду set … banip …, а отже шаблон дотримано;
  • ім’я дії має бути справжнім, інакше підміняти нічого: його показує 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

Відповідь має бути a password is required, а виклик обгортки зі сміттям замість адреси — invalid ip. Тут є своя пастка: sudo -v кешує підтвердження на п’ятнадцять хвилин, і після нього будь-яка перевірка sudo -n проходить «успішно». Саме так ми колись підтвердили правило, якого на сервері не було. Тому sudo -k перед перевіркою обов’язковий.

Сенс спостереження

Правила sudo змінюються рідко, але змінюються непомітно: їх дописує установник, доповнює панель хостингу, залишає після себе видалений пакет. Разова ревізія закриває те, що є сьогодні, і нічого не говорить про те, що з’явиться в наступному оновленні. Практичний висновок простий: список тих, хто може стати root, варто тримати на виду разом з іншим станом сервера, а не згадувати про нього після інциденту. Як це виглядає зібраним — на сторінках демо нижче.