Panel administracyjny na serwerze prawie zawsze trafia na tę samą ścianę: proces webowy musi zrobić coś, do czego nie ma uprawnień. Zablokować adres, otworzyć port, przeczytać dziennik. Odpowiedź, która sama się narzuca, to nadanie jednej wąskiej linii w sudoers i niczego więcej:

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

Linia czyta się jako „tylko blokowanie”. W rzeczywistości znaczy „cokolwiek, jako root”. Poniżej: dlaczego tak wychodzi, jak sprawdzić to na własnym serwerze w pięć minut i czym takie reguły się zastępuje.

Co naprawdę robi *

Kluczowy szczegół: sudo porównuje nie poszczególne argumenty, lecz cały wiersz polecenia, a * we wzorcu spokojnie przechodzi przez spacje — czyli przez granice argumentów. Literał banip stojący w środku reguły nie ogranicza zatem niczego: wystarczy, że słowo banip pojawi się gdziekolwiek w poleceniu, a przed nim i po nim można przekazać cokolwiek.

Dalej szuka się programu, któremu da się przekazać polecenie do wykonania. W naszym przypadku był on wprost w regule. W fail2ban akcja blokady jest zapisana jako tekst, a ten tekst można nadpisać w trakcie pracy:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<polecenie>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

Pierwsze wywołanie podmienia tekst akcji w prawdziwej akcji, drugie sprawia, że więzienie się uruchamia — i fail2ban, działający jako root, wykonuje podstawione. Zrobiliśmy to 4 lipca na własnym serwerze: plik w /root powstał jako root, czyli proces webowy uzyskał pełne prawa na maszynie z reguły, która wyglądała wąsko.

Po drodze wyszły trzy szczegóły, których w cudzych opracowaniach zwykle brakuje:

  • opcję -c przed set sudo odcina: we wzorcu set przylega wprost do nazwy programu i nic nie da się przed nim wstawić;
  • addaction i action bez słowa banip też nie przechodzą — ale wszystko, co potrzebne, mieści się w jednym poleceniu set … banip …, a więc wzorzec jest spełniony;
  • nazwa akcji musi być prawdziwa, inaczej nie ma czego podmieniać: pokazuje ją fail2ban-client get <jail> actions.

Kto jeszcze stoi na tej samej liście

fail2ban nie jest tu winny ani wyjątkowy. Groźne jest samo połączenie NOPASSWD z gwiazdką. Co znaleźliśmy obok na własnych serwerach:

  • journalctl * — dziennik otwiera się przez pager, a z pagera uruchamia się powłokę. Reguła z pozoru dotyczy czytania logów, w praktyce jest powłoką roota. Lekarstwem nie jest węższy wzorzec, lecz grupa systemd-journal: wtedy journalctl działa całkiem bez sudo;
  • grep * /var/log/fail2ban.log — pierwszym argumentem grep jest wzorzec, ale gwiazdka pozwala podać także drugą ścieżkę, a grep wykonuje się jako root. Przeczytanie /etc/shadow tą regułą to jedno polecenie. Zamiana jest ta sama: grupa adm do czytania logów;
  • ufw --force * i gołe ufw allow/deny/delete * — proces webowy może wyłączyć zaporę w całości. Szczególnie nieprzyjemne, że w obu przypadkach panel wcale z tych reguł nie korzystał: martwe uprawnienia pozostałe po wczesnych wersjach instalatora;
  • lynis-scan.sh * — opakowanie przekazujące "$@" do procesu roota jest równoważne regule bez żadnych ograniczeń.

Jak zobaczyć, co jest u was

Patrzeć trzeba nie na plik, lecz na faktyczne uprawnienia konkretnego użytkownika — tego, pod którym naprawdę działa pula PHP-FPM (nie zawsze www-data; na jednej z naszych maszyn panel działał jako admin):

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

Dalej sama lista plików, a tu są dwie pułapki, na których traciliśmy czas:

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

Pierwsza: reguły żyją w dwóch miejscach jednocześnie. U nas groźna linia była i w /etc/sudoers.d/monitor, i w głównym /etc/sudoers — w głównym w około ośmiu kopiach, nazbieranych przez różne wersje instalatora. Wyczyścić jedno miejsce i się uspokoić to zwykły sposób na pozostawienie dziury otwartej.

Druga: sudo ignoruje pliki, których nazwa zawiera kropkę. Plik www-data.bak3 w /etc/sudoers.d/ wygląda jak obowiązująca reguła i czyta się jak obowiązująca reguła, ale nie działa. Działa to w obie strony: „reguła jest, uprawnień nie ma” oraz fałszywy spokój, że kopia konfiguracji „leży zaraz obok”.

Czym zastąpić

Zwężanie wzorca jest bezcelowe — gwiazdka w dowolnym miejscu linii cofa zadanie na początek. Sprawdza się jedno podejście: opakowanie roota, które przyjmuje stałe argumenty pozycyjne, samo je waliduje i wywołuje program bez najmniejszej możliwości dopisania czegokolwiek.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, i nic więcej
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

Opakowanie należy do roota, prawa 755, i — to nie jest opcjonalne — nie może być zapisywalne z sieci, bo inaczej cała konstrukcja traci sens. W sudoers zostaje tylko ono:

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

Argumentów w regule się nie wymienia: sprawdza je sam skrypt, a pozycje w nim są stałe, więc nie ma gdzie wsunąć tekstu akcji. Reguły tylko do odczytu — statusy, ss, ipset list — idą w osobnych linijkach, także bez gwiazdki tam, gdzie to możliwe.

Osobno o zastępowaniu sudo grupami. Podejście jest słuszne: systemd-journal dla dziennika i adm dla logów wychodzą taniej i bezpieczniej niż jakakolwiek reguła sudo. Ale wyciągać użytkownika webowego z grup na oślep nie wolno. Usunęliśmy www-data z jednej grupy na hoście z panelem i od razu dostaliśmy 403 na wszystkich stronach: Apache działa tam jako www-data, pliki stron należą do innego użytkownika, a dostęp do czytania dawała właśnie ta przynależność do grupy. Przywrócenie wymagało pełnego restartu — nie przeładowania — Apache i PHP-FPM, bo stare procesy zachowują dawny zestaw grup i dają „działa, a potem 403”.

Sprawdzenie po zmianie

Przed podmianą pliku sudoers trzeba sprawdzić jego składnię — inaczej można zostać całkiem bez sudo:

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

Potem potwierdzić, że stary wektor jest martwy, i zrobić to w imieniu dokładnie tego użytkownika:

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

Odpowiedzią powinno być a password is required, a wywołanie opakowania ze śmieciem zamiast adresu ma dać invalid ip. Czai się tu własna pułapka: sudo -v zapamiętuje potwierdzenie na piętnaście minut, a po nim każde sprawdzenie przez sudo -n przechodzi „pomyślnie”. Dokładnie tak potwierdziliśmy kiedyś regułę, której na serwerze wcale nie było. Dlatego sudo -k przed sprawdzeniem jest obowiązkowe.

Po co obserwować

Reguły sudo zmieniają się rzadko, ale zmieniają się niezauważalnie: instalator dopisuje swoje, panel hostingu dodaje własne, usunięty pakiet zostawia po sobie linijki. Jednorazowy przegląd zamyka to, co jest dziś, i nic nie mówi o tym, co przyniesie następna aktualizacja. Praktyczny wniosek jest prosty: lista tych, którzy mogą zostać rootem, lepiej wygląda na widoku, obok pozostałego stanu serwera, niż we wspomnieniu po incydencie. Jak to wygląda zebrane razem, pokazują strony demonstracyjne poniżej.