Un panou de administrare pe un server dă aproape întotdeauna în același perete: procesul web are nevoie să facă ceva pentru care nu are drepturi. Să blocheze o adresă, să deschidă un port, să citească jurnalul. Răspunsul care se impune este să acorde o singură linie îngustă în sudoers și nimic mai mult:

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

Linia se citește ca „doar blocare”. În realitate înseamnă „orice, ca root”. Mai jos: de ce iese așa, cum se verifică asta pe propriul server în cinci minute și cu ce se înlocuiesc astfel de reguli.

Ce face de fapt *

Detaliul hotărâtor: sudo nu compară argumentele unul câte unul, ci linia de comandă în întregime, iar * din model trece fără probleme peste spații — adică peste granițele dintre argumente. Literalul banip aflat la mijlocul regulii nu limitează prin urmare nimic: e suficient ca vorba banip să apară undeva în comandă, iar înaintea și în urma ei se poate transmite orice.

Mai departe se caută un program căruia i se poate încredința o comandă de executat. În cazul nostru se afla chiar în regulă. În fail2ban acțiunea de blocare este definită ca text, iar acest text poate fi redefinit în timpul funcționării:

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

Primul apel înlocuiește textul acțiunii la o acțiune reală, al doilea face jail-ul să se declanșeze — iar fail2ban, care rulează ca root, execută ceea ce a fost strecurat. Am făcut asta pe 4 iulie pe propriul server: un fișier în /root a apărut cu drepturi de root, adică procesul web a obținut drepturi complete pe mașină dintr-o regulă care părea îngustă.

Pe drum au ieșit la iveală trei amănunte care de obicei lipsesc din analizele altora:

  • opțiunea -c înaintea lui set este tăiată de sudo: în model, set este lipit direct de numele binarului și nimic nu poate fi inserat înaintea lui;
  • addaction și action fără cuvântul banip nu trec nici ele — dar tot ce este necesar încape într-o singură comandă set … banip …, iar modelul este astfel respectat;
  • numele acțiunii trebuie să fie unul real, altfel nu există ce să înlocuiești: îl arată fail2ban-client get <jail> actions.

Cine se mai află pe aceeași listă

fail2ban nu are vină aici și nu este un caz unic. Periculoasă este însăși combinația NOPASSWD plus stea. Ce am găsit alături pe propriile noastre servere:

  • journalctl * — jurnalul se deschide printr-un pager, iar din pager se lansează un interpretor de comenzi. Regula pare a fi despre citirea jurnalelor; în practică este un shell de root. Remediul nu este un model mai îngust, ci grupul systemd-journal: atunci journalctl funcționează cu totul fără sudo;
  • grep * /var/log/fail2ban.log — primul argument al lui grep este modelul, dar steaua permite să fie dată și o a doua cale, iar grep se execută ca root. Citirea fișierului /etc/shadow prin această regulă este o singură comandă. Înlocuirea este aceeași: grupul adm pentru citirea jurnalelor;
  • ufw --force * și ufw allow/deny/delete * goale — procesul web poate opri complet paravanul de protecție. Mai neplăcut: în ambele cazuri panoul nu folosea deloc acele reguli, erau drepturi moarte rămase din versiuni timpurii ale programului de instalare;
  • lynis-scan.sh * — un înveliș care transmite "$@" unui proces root echivalează cu o regulă fără nicio restricție.

Cum se vede ce aveți

De privit nu este fișierul, ci drepturile efective ale unui utilizator anume — cel sub care rulează într-adevăr grupul de procese PHP-FPM (nu întotdeauna www-data; pe una dintre mașinile noastre panoul rula ca admin):

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

Apoi lista fișierelor în sine, și aici sunt două capcane care ne-au costat timp:

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

Prima: regulile trăiesc în două locuri în același timp. La noi linia periculoasă se afla atât în /etc/sudoers.d/monitor, cât și în /etc/sudoers principal — în cel principal în vreo opt copii, adunate de versiuni diferite ale programului de instalare. A curăța un singur loc și a te liniști este modul obișnuit de a lăsa gaura deschisă.

A doua: sudo ignoră fișierele al căror nume conține un punct. Un fișier www-data.bak3 în /etc/sudoers.d/ arată ca o regulă în vigoare și se citește ca o regulă în vigoare, dar nu are efect. Asta funcționează în ambele sensuri: „regula există, drepturile nu” și falsa liniște că o copie a configurației „stă chiar lângă”.

Cu ce se înlocuiește

Îngustarea modelului nu ajută — o stea oriunde pe linie readuce problema la început. Rezistă o singură abordare: un înveliș de root care primește argumente poziționale fixe, le validează el însuși și apelează programul fără nici cea mai mică posibilitate de a adăuga ceva.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, și nimic mai mult
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

Învelișul îi aparține lui root, drepturi 755, și — acest lucru nu este opțional — nu trebuie să poată fi scris din web, altfel toată construcția își pierde rostul. În sudoers rămâne doar el:

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

Argumentele nu se enumeră în regulă: le verifică scriptul însuși, iar pozițiile din el sunt fixe, așa că nu există unde să fie strecurat un text de acțiune. Regulile doar de citire — stări, ss, ipset list — merg pe linii separate, de asemenea fără stea acolo unde este posibil.

Separat, despre înlocuirea sudo cu grupuri. Abordarea este corectă: systemd-journal pentru jurnal și adm pentru fișierele de jurnal ies mai ieftin și mai sigur decât orice regulă sudo. Dar utilizatorul web nu poate fi scos din grupuri orbește. Am scos www-data dintr-un grup pe o gazdă cu panou și am primit dintr-odată 403 pe toate site-urile: Apache rulează acolo ca www-data, fișierele site-urilor aparțin altui utilizator, iar accesul la citire îl dădea tocmai acea apartenență la grup. Restabilirea a cerut o repornire completă — nu o reîncărcare — a Apache și PHP-FPM, fiindcă procesele de lucru vechi își păstrează vechiul set de grupuri și produc starea „funcționează, apoi 403”.

Verificarea după modificare

Înainte de a înlocui un fișier sudoers trebuie verificată sintaxa acestuia — altfel se poate rămâne complet fără sudo:

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

Apoi trebuie confirmat că vectorul vechi este mort, și asta în numele exact acelui utilizator:

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

Răspunsul trebuie să fie a password is required, în timp ce apelarea învelișului cu gunoi în locul unei adrese trebuie să dea invalid ip. Aici pândește o capcană proprie: sudo -v păstrează confirmarea în memorie cincisprezece minute, iar după aceea orice verificare cu sudo -n trece „cu succes”. Exact așa am confirmat odată o regulă care pe server nu exista deloc. De aceea sudo -k înaintea verificării este obligatoriu.

La ce folosește observarea

Regulile sudo se schimbă rar, dar se schimbă fără zgomot: programul de instalare mai adaugă, panoul găzduirii își pune ale sale, un pachet eliminat își lasă liniile în urmă. O revizie de o singură dată închide ceea ce există astăzi și nu spune nimic despre ce va aduce următoarea actualizare. Concluzia practică este simplă: lista celor care pot deveni root își are locul la vedere, alături de restul stării serverului, și nu în amintirea de după un incident. Cum arată toate acestea puse la un loc se vede pe paginile demonstrative de mai jos.