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
-cdavanti asetviene tagliata da sudo: nel modellosetè attaccato al nome del binario e prima non si può inserire niente; addactioneactionsenza la parolabanipnon passano nemmeno loro — ma tutto il necessario sta in un solo comandoset … 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 grupposystemd-journal: allorajournalctlfunziona senza sudo del tutto;grep * /var/log/fail2ban.log— il primo argomento digrepè il modello, ma l’asterisco consente di fornire anche un secondo percorso, e grep gira come root. Leggere/etc/shadowcon quella regola è un comando solo. La sostituzione è la stessa: il gruppoadmper la lettura dei log;ufw --force *e i nudiufw 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.