Un pannello di amministrazione su un server finisce quasi sempre contro lo stesso muro: il processo web deve fare qualcosa per cui non ha i permessi. Bloccare un indirizzo, aprire una porta, leggere il journal. La risposta che viene in mente è concedere una sola riga ristretta in sudoers, e nient’altro:

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

La riga si legge come «solo bloccare». In realtà significa «qualsiasi cosa, come root». Qui sotto: perché succede, come verificarlo sul proprio server in cinque minuti e con che cosa si sostituiscono regole simili.

Che cosa fa davvero *

Il dettaglio decisivo: sudo non confronta gli argomenti uno per uno ma la riga di comando nel suo insieme, e * nel modello attraversa senza problemi gli spazi, cioè i confini fra gli argomenti. Il letterale banip posto in mezzo alla regola non limita quindi nulla: basta che la parola banip compaia da qualche parte nel comando, e prima come dopo si può passare qualunque cosa.

Poi si cerca un programma a cui si possa consegnare un comando da eseguire. Nel nostro caso stava nella regola stessa. In fail2ban l’azione di blocco è definita come testo, e quel testo si può ridefinire a caldo:

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

La prima chiamata sostituisce il testo dell’azione di un’azione reale, la seconda fa scattare il jail — e fail2ban, che gira come root, esegue ciò che è stato inserito. L’abbiamo fatto il 4 luglio sul nostro server: un file in /root è stato creato come root, cioè il processo web ha ottenuto pieni diritti sulla macchina a partire da una regola che sembrava ristretta.

Per strada sono emersi tre particolari che negli scritti altrui di solito mancano:

  • l’opzione -c davanti a set viene tagliata da sudo: nel modello set è attaccato al nome del binario e prima non si può inserire niente;
  • addaction e action senza la parola banip non passano nemmeno loro — ma tutto il necessario sta in un solo comando set … banip …, e il modello è quindi rispettato;
  • il nome dell’azione deve essere reale, altrimenti non c’è nulla da sostituire: lo mostra fail2ban-client get <jail> actions.

Chi altro sta nello stesso elenco

fail2ban qui non ha colpa e non è un caso isolato. La cosa pericolosa è l’accoppiata NOPASSWD più asterisco. Che cosa abbiamo trovato accanto sui nostri server:

  • journalctl * — il journal si apre tramite un pager, e dal pager si avvia una shell. La regola sembra riguardare la lettura dei log; in pratica è una shell di root. Il rimedio non è un modello più stretto ma il gruppo systemd-journal: allora journalctl funziona senza sudo del tutto;
  • grep * /var/log/fail2ban.log — il primo argomento di grep è il modello, ma l’asterisco consente di fornire anche un secondo percorso, e grep gira come root. Leggere /etc/shadow con quella regola è un comando solo. La sostituzione è la stessa: il gruppo adm per la lettura dei log;
  • ufw --force * e i nudi ufw allow/deny/delete * — il processo web può spegnere il firewall per intero. Particolarmente spiacevole: in entrambi i casi il pannello non usava affatto quelle regole, concessioni morte rimaste da versioni iniziali dell’installatore;
  • lynis-scan.sh * — un wrapper che passa "$@" a un processo root equivale a una regola senza alcuna limitazione.

Come vedere che cosa c’è da voi

Da guardare non è il file ma i diritti effettivi di un utente preciso — quello sotto cui gira realmente il pool PHP-FPM (non sempre www-data; su una delle nostre macchine il pannello girava come admin):

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

Poi l’elenco dei file, e qui ci sono due trappole che ci sono costate tempo:

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

La prima: le regole vivono in due posti contemporaneamente. Da noi la riga pericolosa stava sia in /etc/sudoers.d/monitor sia nel /etc/sudoers principale — in quest’ultimo in circa otto copie, accumulate da versioni diverse dell’installatore. Pulire un posto e mettersi tranquilli è il modo consueto di lasciare il buco aperto.

La seconda: sudo ignora i file il cui nome contiene un punto. Un file www-data.bak3 in /etc/sudoers.d/ sembra una regola valida e si legge come una regola valida, ma non ha effetto. Funziona nei due sensi: «la regola c’è, i diritti no», e il falso conforto di una copia di configurazione che «sta lì accanto».

Con che cosa sostituire

Restringere il modello non serve: un asterisco in qualunque punto della riga riporta il problema all’inizio. Un solo approccio tiene: un wrapper di root che riceve argomenti posizionali fissi, li convalida da sé e chiama il programma senza la minima possibilità di aggiungere qualcosa.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, e nulla di più
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

Il wrapper appartiene a root, permessi 755, e — questo non è opzionale — non deve essere scrivibile dal web, altrimenti l’intera costruzione perde senso. In sudoers resta soltanto lui:

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

Gli argomenti nella regola non si elencano: li controlla lo script stesso, e al suo interno le posizioni sono fisse, così non c’è dove infilare un testo di azione. Le regole di sola lettura — stati, ss, ipset list — vanno su righe separate, anch’esse senza asterisco dove è possibile.

A parte, sulla sostituzione di sudo con i gruppi. L’approccio è giusto: systemd-journal per il journal e adm per i log costano meno e sono più sicuri di qualsiasi regola sudo. Ma togliere l’utente web dai gruppi alla cieca non si può. Abbiamo levato www-data da un gruppo su un host con pannello e abbiamo ottenuto 403 su tutti i siti in una volta: là Apache gira come www-data, i file dei siti appartengono a un altro utente e l’accesso in lettura lo dava proprio quell’appartenenza al gruppo. Il ripristino ha richiesto un riavvio completo — non un reload — di Apache e PHP-FPM, perché i worker vecchi conservano il precedente insieme di gruppi e producono un «funziona, poi 403».

Verifica dopo la modifica

Prima di sostituire un file sudoers bisogna controllarne la sintassi, altrimenti si può restare senza sudo del tutto:

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

Dopo, confermare che il vecchio vettore è morto, e farlo a nome di quello stesso utente:

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

La risposta deve essere a password is required, mentre chiamare il wrapper con spazzatura al posto di un indirizzo deve dare invalid ip. Qui è in agguato una trappola propria: sudo -v tiene in cache la conferma per quindici minuti, e dopo di essa qualunque verifica con sudo -n passa «con successo». È esattamente così che una volta abbiamo confermato una regola che sul server non c’era. Per questo sudo -k prima della verifica è obbligatorio.

A che serve osservare

Le regole di sudo cambiano raramente, ma cambiano senza rumore: l’installatore vi aggiunge righe, il pannello dell’hosting completa le proprie, un pacchetto rimosso lascia dietro di sé le sue. Una revisione una volta tanto chiude quello che c’è oggi e non dice nulla su ciò che porterà il prossimo aggiornamento. La conclusione pratica è semplice: l’elenco di chi può diventare root sta meglio in vista, accanto al resto dello stato del server, che nel ricordo successivo a un incidente. Come appare tutto questo messo insieme lo mostrano le pagine demo qui sotto.