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ę
-cprzedsetsudo odcina: we wzorcusetprzylega wprost do nazwy programu i nic nie da się przed nim wstawić; addactioniactionbez słowabanipteż nie przechodzą — ale wszystko, co potrzebne, mieści się w jednym poleceniuset … 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 grupasystemd-journal: wtedyjournalctldziała całkiem bez sudo;grep * /var/log/fail2ban.log— pierwszym argumentemgrepjest wzorzec, ale gwiazdka pozwala podać także drugą ścieżkę, a grep wykonuje się jako root. Przeczytanie/etc/shadowtą regułą to jedno polecenie. Zamiana jest ta sama: grupaadmdo czytania logów;ufw --force *i gołeufw 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.