Articoli approfonditi sulla tecnologia che plasma il futuro.

Bug SSH di Alpine Linux: un asterisco per dieci anni

Un bug SSH di Alpine Linux ha lasciato entrare con qualsiasi password lunga oltre 256 caratteri per dieci anni. Ecco la causa in musl crypt().

Una porta blindata con un'apertura a forma di asterisco che brilla in una sala server buia
Un solo carattere in un file di sistema faceva la differenza tra un account bloccato e una porta aperta.

Ecco un numero che dovrebbe preoccuparti: 257. Per circa dieci anni, digitare una password di 257 caratteri o più in un prompt SSH su un certo tipo di sistema Linux non ti faceva respingere: ti faceva entrare. Qualsiasi password. Tasti a caso, un dump in base64 delle foto delle vacanze, mezzo messaggio Slack incollato per sbaglio: bastava che fosse abbastanza lunga e la porta si spalancava. Il bug SSH di Alpine Linux, reso noto con il nome «sshole», è una di quelle vulnerabilità che sembrano uno scherzo finché non controlli le condizioni, e a quel punto somigliano a un rapporto di incidente.

In breve: su OpenSSH compilato con musl libc, cioè Alpine, OpenWrt, Buildroot, Yocto e una quantità impressionante delle immagini base Docker in circolazione, gli account che avrebbero dovuto essere matematicamente impossibili da usare per il login accettavano qualsiasi password tra 257 e 1.024 caratteri. Root. Il tuo utente di deploy che usa solo chiavi. L'utente predefinito alpine delle immagini ufficiali di Alpine per AWS e Azure, che include doas senza password. La correzione è arrivata in OpenSSH 10.6p2. Ma la storia di come è successo è una catena di piccole decisioni sensate che risalgono al 1980, e ogni anello sembrava corretto al momento.

Come funziona davvero il bug SSH di Alpine Linux

Per capire la collisione servono tre pezzi, ognuno gestito da una codebase diversa e in un decennio diverso. Primo: come Linux memorizza le password. Quando imposti una password, l'hash finisce in /etc/shadow, una riga per account. La riga di un account normale appare così $6$salt$hash, dove $6$ indica sha512crypt. Ma gli account che non dovrebbero mai accettare una password (demoni, utenti che usano solo chiavi, root bloccato) ricevono invece un segnaposto:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

Quando fai il login, sshd prende quello che hai digitato, lo sottopone a hash con il salt memorizzato e confronta come stringhe il risultato con il campo salvato. Stessa stringa, sei dentro. L'asterisco è una sentinella: non è l'hash di nulla, quindi nessuna password può mai corrispondere. È questo il meccanismo di blocco degli account che usano solo chiavi fin dai primi tempi di Unix. Funziona perché il confronto non può mai riuscire, a meno che la funzione di hash stessa non possa essere costretta a produrre un asterisco. Il che ci porta al secondo pezzo.

Il secondo pezzo è la gestione degli errori di musl. Tradizionalmente, crypt() restituisce NULL quando non riesce a fare il suo lavoro, ad esempio se il salt richiede un algoritmo che il sistema non ha. Il problema è che decenni di software non controllavano mai il NULL e lo passavano allegramente a strcmp, andando in segfault. Quindi nel 2012 musl fece una scelta di design deliberata: il suo crypt() non avrebbe mai restituito NULL. In caso di errore avrebbe restituito una stringa garantita di non corrispondere a nessun vero hash di password, così i chiamanti difettosi avrebbero visto una password sbagliata invece di crashare. La stringa di errore scelta fu *. Tre settimane dopo musl aggiunse sha512crypt con un limite di 256 byte in input: il costo dell'hash cresce con la lunghezza della password, quindi password enormi sono un vettore di denial of service. Si rifiuta tutto ciò che è più lungo, che è un errore, che restituisce *. Il commento che documentava tutto questo è rimasto nel sorgente per tutto il tempo.

Il terzo pezzo è sshd che si comporta con educazione. Nel 2016, CVE-2016-6210 mostrò che si potevano enumerare gli username validi misurando i tempi di risposta: gli utenti inesistenti venivano respinti subito, perché non c'era nessun hash da calcolare. La correzione fu elegante: per gli account senza un hash utilizzabile, sshd prende il salt di un altro account e calcola comunque l'hash della password fornita, solo per consumare lo stesso tempo. Un no più lento. Il problema: questo percorso non si attiva solo per gli utenti inesistenti. Si attiva anche per gli utenti reali il cui campo in shadow è *, perché un asterisco non è un hash utilizzabile. Quindi ora sshd calcola crypt(tua_password, salt_preso_in_prestito) e confronta il risultato con *.

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

Ogni elemento deve allinearsi: il campo memorizzato è *, crypt fallisce e anche il valore di errore è *. Su glibc, un crypt fallito restituisce NULL, che fa fallire il confronto in modo sicuro. Sui sistemi con libxcrypt la stringa di errore è *0: un carattere di differenza, e quel carattere fa tutta la differenza, perché *0 non è mai uguale a *. Su musl, il valore di errore e il valore di blocco sono lo stesso carattere, e strcmp non ha modo di distinguere un errore da una corrispondenza. Due significati, un solo canale, e il ricevente non può distinguerli.

Un decennio di decisioni perfettamente ragionevoli

Ciò che rende questo bug interessante da studiare non è il meccanismo: è che nessuno dei coinvolti ha fatto qualcosa di ovviamente stupido. Partiamo dal 1980: quando gli hash delle password stavano nel file /etc/passwd leggibile da tutti, Bill Joy creò un account chiamato daemon in cui nessuno avrebbe dovuto mai fare login, e mise un * al posto dell'hash della password. Una sentinella pulita, in anticipo di decenni sui tempi. (Il campo del nome reale, a quanto si dice, riportava «il diavolo in persona».) Unix blocca ancora gli account esattamente in questo modo.

Anche la decisione di musl del 2012 sembra difendibile da ogni angolazione. I chiamanti che crashano sono un problema reale, e una libreria C che usa il NULL come arma contro decenni di codice applicativo sciatto si fa un sacco di nemici. Restituire una stringa che non può mai corrispondere è un degrado elegante. Lo è anche limitare la lunghezza della password per fermare il denial of service basato sugli hash. Lo è la correzione dei tempi di sshd: riempire le risposte per gli utenti non hashabili è pratica standard. Lo è persino quello che Alpine fece dopo. Nel 2019, CVE-2019-5021 rivelò che le immagini Docker di Alpine venivano distribuite con il campo della password di root vuoto, in alcune configurazioni senza alcuna password. Alpine bloccò root con !. Ma senza PAM, sshd tratta ! come bloccato del tutto, il che elimina anche il login SSH con chiave, e le immagini cloud vivono e muoiono di login con chiave. Così nel 2022 Alpine 3.16 spostò root su *: ancora bloccato, le chiavi funzionano, tutti contenti. Solo che * era proprio la stringa che il crypt di musl poteva essere indotto a stampare.

Ogni passo era una risposta difendibile a un problema reale. Ed è questo che mette a disagio: avresti potuto essere presente per ciascuna decisione e annuire.

I quasi-incidenti sono quasi peggio. Uno sviluppatore Debian chiese una volta a glibc di adottare esattamente il comportamento di musl con *; sei anni dopo Debian passò a libxcrypt, il cui *0 chiuse il buco per caso. Un thread su BusyBox lamentava che passwd si bloccava con una password di 257 caratteri: stesso asterisco, stesso limite, e nessuno nel thread disse «sshd». Un revisore di sicurezza patchò la funzione pick_salt() di sshd con il bug vero quattro righe più sotto, quattro giorni prima che qualcuno lo trovasse davvero. Questa cosa è stata ignorata più volte da persone che la guardavano quasi direttamente.

Tessere del domino di materiali diversi che cadono in catena su un tavolo buio
Quattro decenni di piccole decisioni sensate, ognuna che fa cadere la successiva.

La tua infrastruttura è colpita dal bug OpenSSH di musl?

La buona notizia su sshole è che richiede una combinazione completa di condizioni prima di funzionare. Ne manchi anche una sola e sei al sicuro. Sei esposto se tutte queste condizioni sono vere:

  • Il tuo sshd è compilato con musl: Alpine, OpenWrt, Buildroot, Yocto, o un'immagine Docker derivata da alpine con openssh-server installato.
  • sshd non passa da PAM: il openssh-server di Alpine di default non lo fa, oppure hai impostato UsePAM no. Il percorso di codice di PAM non chiama mai crypt per un account con *.
  • L'autenticazione con password è abilitata. Di default lo è.
  • Almeno un account ha un * nudo in /etc/shadow: utenti che usano solo chiavi, account gestiti tramite LDAP, root su Alpine.
  • Almeno un altro account ha un vero hash di password, così c'è un salt da prendere in prestito.

Se rispetti tutte e cinque, ogni account con * sulla macchina accetta qualsiasi password da 257 a 1.024 caratteri. Lo scenario peggiore sono le immagini ufficiali di Alpine per AWS e Azure: l'utente predefinito alpine usa solo chiavi (quindi *) e ha doas senza password. Un login con password lunga lì non è un'escalation di privilegi: è una porta d'ingresso diretta a root. Va riconosciuto un fattore che ha tenuto la cosa nascosta per tanto tempo: PermitRootLogin di OpenSSH rifiuta i login con password per root dal 2015, quindi il bersaglio più ovvio era parzialmente protetto di default. Nulla proteggeva invece gli utenti di deploy e gli account di servizio.

Se usi Debian, Ubuntu, Fedora o qualsiasi altra distribuzione basata su glibc, sei a posto. Chi passa da PAM: a posto. Dropbear: a posto. OpenBSD: a posto. Un avvertimento per gli scettici: i primi tentativi della community di riprodurre il bug sono stati incoerenti, perché le cinque condizioni sono facili da mancare una alla volta. Qualcuno prova l'immagine ufficiale di Alpine, non si accorge che il campo di root è ! e non *, non riesce e passa oltre. Considera «non riesco a riprodurlo sul mio portatile» come nessuna prova su cosa succede in produzione. Controlla le condizioni, non le sensazioni.

La vera causa profonda: segnalazione in banda e stringhe magiche

Viene naturale archiviare sshole come «il solito caso limite della libc» e andare avanti. Ma la modalità di guasto è più vecchia e più generale: la segnalazione in banda. Il carattere * è stato assegnato a due ruoli: in /etc/shadow significa «non esiste una password per questo account», e nel crypt di musl significa «l'hashing è fallito». Due messaggi che condividono lo stesso canale, senza modo per il ricevente di distinguerli. I valori sentinella incorporati nei dati ordinari sono il modo in cui i sistemi finiscono per sfruttare se stessi. Il NULL era un segnale d'errore accettabile (nonostante i chiamanti fragili). Un'eccezione è meglio. Un Result<T, E> che obbliga il chiamante a gestire esplicitamente il fallimento è la scelta migliore. La regola pratica, quella che tatuerei su ogni programmatore di sistemi: niente stringhe magiche. Se proprio ne serve una, scegline una che non possa collidere con nessun valore del dominio del problema: il *0 di libxcrypt batte il * di musl di esattamente un byte, e quel byte è la differenza tra un difetto curioso e una CVE.

C'è una seconda lezione sugli shim di compatibilità silenziosi. La scelta di musl di restituire un * innocuo esiste per evitare che il software difettoso crashi, il che significa che impedisce anche al software difettoso di essere notato come difettoso. Un segfault è brutto, ma è anche un segnale: qualcuno apre un bug, qualcuno corregge il chiamante. Ingoia l'errore e lo sbaglio si accumula in silenzio per un decennio. Sono sempre più convinto che questa sia la divisione più profonda nel design dei sistemi: l'ingegneria che resiste alle catastrofi tende a fallire in modo rumoroso e presto, mentre l'ingegneria che ottimizza per la fluidità accumula debito nascosto finché il conto non arriva tutto insieme.

E dovrebbe demolire un mito rassicurante: che il codice vecchio e molto diffuso sia più affidabile perché «testato sul campo». L'età non distribuisce l'attenzione in modo uniforme: la concentra sui percorsi caldi e lascia quelli freddi a fossilizzarsi. Tutti controllano la crittografia. Quasi nessuno controlla la colla banale con cui sshd parla con libc riguardo alle password, perché quel codice «funziona da sempre». L'intersezione di due configurazioni minoritarie (sistemi musl e, al loro interno, quelli senza PAM) è esattamente il tipo di posto in cui un bug può vivere indisturbato per dieci anni.

Come correggere il bug sshole e controllare i log

La patch è la parte facile: OpenSSH 10.6p2 chiude il buco. Su Alpine basta un comando. Se non puoi aggiornare oggi, la mitigazione è altrettanto semplice e completamente efficace: disattiva l'autenticazione con password. Nessun controllo della password, nessun bug. Onestamente dovresti fare entrambe le cose, perché l'autenticazione solo con chiavi era la risposta giusta anche prima di questo bug e lo resterà dopo che tutti avranno applicato la patch. Riecheggia una lezione vista in altri dibattiti tra patch e mitigazione: quando la mitigazione è economica e totale, non fare il furbo.

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
Una lanterna che illumina stampe di log a cascata nel buio, con una riga evidenziata in rosso
Da qualche parte in dieci anni di log di autenticazione, una password molto lunga potrebbe aver aperto un account bloccato.

Poi la domanda più difficile: qualcuno c'è già arrivato prima? Dieci anni sono un tempo di permanenza lungo. Quello che cerchi sono autenticazioni con password riuscite per account che non hanno mai avuto password: utenti di deploy che usano solo chiavi, account di servizio, identità LDAP, root sulle macchine Alpine. I login riusciti per questi account tramite il metodo password dovrebbero essere rarissimi; ogni caso è un allarme di massima priorità. La scomoda verità è che sshd registra il successo, non la lunghezza della password, quindi gran parte delle prove dirette svanisce già in scrittura. E come abbiamo già visto, i log di autenticazione nel cloud hanno punti ciechi anche in una giornata buona, e i log sshd delle istanze su VM di breve durata sono proprio dove si trovano. Cerca in quello che hai. Dai per scontato che sia incompleto.

Cosa dovrebbero insegnarci dieci anni silenziosi

La scoperta in sé merita quanta attenzione quanta il bug. Il ricercatore che ha trovato sshole non ha fissato la crittografia: ha guardato il confine banale dove OpenSSH parla con libc, ha compilato sshd in ogni configurazione che gli è venuta in mente (glibc, musl, PAM, senza PAM, shadow, senza shadow) e ha lanciato circa 17.600 login malformati contro la matrice. Hash strani, account rotti, password troppo lunghe. Ogni build concordava su ogni login tranne una: musl senza PAM. Quella diceva sì a cose che non avrebbe dovuto accettare. Non è una scoperta fortunata: è copertura. Il testing differenziale su matrici di configurazione è il modo per trovare i bug che si nascondono nelle intersezioni, ed è esattamente il tipo di lavoro esaustivo e instancabile in cui gli sciami di agenti si rivelano sorprendentemente bravi. Il prossimo sshole non avrà dieci anni di tranquillità.

La domanda aperta che dovrebbe tenere sveglio di notte chi gestisce i sistemi è se quei dieci anni siano davvero passati senza che nessuno se ne accorgesse, o se se ne siano accorte le persone sbagliate. Basta considerare il caso accidentale: incolla il contenuto sbagliato degli appunti in un prompt di password (mezzo thread Slack, un'intera chiave privata SSH, qualsiasi cosa oltre 257 caratteri) e su una macchina vulnerabile un account bloccato si apre semplicemente. Se non hai mai incollato per sbaglio qualcosa in un campo password, sei più attento di chiunque io abbia conosciuto. Qualcuno, da qualche parte, ha fatto login su una macchina su cui non voleva entrare e ha chiuso il terminale in silenzio. Forse molti qualcuno.

Quindi, sì, applica la patch. Disattiva le password, assolutamente. Poi prenditi un'ora e vai a caccia nei tuoi sistemi dei caratteri che svolgono due ruoli: i valori sentinella, i fallback silenziosi, i rami «questo non può mai succedere», gli shim di compatibilità che nessuno ricorda di aver scritto. Ogni codebase matura ne ha. Sono invisibili proprio perché non hanno mai causato un problema che qualcuno potesse vedere. Da qualche parte nel tuo stack, in questo momento, c'è un asterisco che significa silenziosamente due cose insieme. Trovalo prima che lo faccia qualcuno con una password molto lunga.