
Questo è un PoC che ho scritto per CVE-2024-6387
Qualys Security Advisory
regreSSHion: RCE nel server OpenSSH, su sistemi Linux basati su glibc (CVE-2024-6387)
Sommario SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, del 2005)
Basta un atto di fede
-- The Interrupters, "Leap of Faith"
Nota preliminare: OpenSSH è uno dei software più sicuri al mondo; questa vulnerabilità è un passo falso in un'implementazione altrimenti quasi impeccabile. Il suo design e codice con difesa in profondità sono un modello e un'ispirazione, e ringraziamo gli sviluppatori di OpenSSH per il loro lavoro esemplare.
Abbiamo scoperto una vulnerabilità (una race condition del gestore di segnali) nel server OpenSSH (sshd): se un client non si autentica entro LoginGraceTime secondi (120 di default, 600 nelle vecchie versioni di OpenSSH), il gestore SIGALRM di sshd viene chiamato in modo asincrono, ma questo gestore di segnali chiama varie funzioni che non sono async-signal-safe (ad esempio, syslog()). Questa race condition colpisce sshd nella sua configurazione predefinita.
Dopo aver indagato, abbiamo realizzato che questa vulnerabilità è in realtà una regressione di CVE-2006-5051 ("Race condition del gestore di segnali in OpenSSH precedente alla 4.4 permette a un attaccante remoto di causare un denial of service (crash), ed eventualmente eseguire codice arbitrario"), riportata nel 2006 da Mark Dowd.
Questa regressione è stata introdotta nell'ottobre 2020 (OpenSSH 8.5p1) dal commit 752250c ("revised log infrastructure for OpenSSH"), che ha accidentalmente rimosso un "#ifdef DO_LOG_SAFE_IN_SIGHAND" da sigdie(), una funzione chiamata direttamente dal gestore SIGALRM di sshd. In altre parole:
OpenSSH < 4.4p1 è vulnerabile a questa race condition del gestore di segnali, se non è stato backportato con la patch per CVE-2006-5051, o non è stato patchato contro CVE-2008-4109, che era una correzione errata per CVE-2006-5051;
4.4p1 <= OpenSSH < 8.5p1 non è vulnerabile a questa race condition del gestore di segnali (perché l' "#ifdef DO_LOG_SAFE_IN_SIGHAND" aggiunto a sigdie() dalla patch per CVE-2006-5051 ha trasformato questa funzione non sicura in una chiamata sicura _exit(1));
8.5p1 <= OpenSSH < 9.8p1 è di nuovo vulnerabile a questa race condition del gestore di segnali (perché l' "#ifdef DO_LOG_SAFE_IN_SIGHAND" è stato accidentalmente rimosso da sigdie()).
Questa vulnerabilità è sfruttabile da remoto su sistemi Linux basati su glibc, dove syslog() stesso chiama funzioni async-signal-unsafe (ad esempio, malloc() e free()): una esecuzione di codice remoto non autenticata come root, perché colpisce il codice privilegiato di sshd, che non è in sandbox ed esegue con pieni privilegi. Non abbiamo investigato nessun altro libc o sistema operativo; ma OpenBSD non è notoriamente vulnerabile, perché il suo gestore SIGALRM chiama syslog_r(), una versione di syslog() più async-signal-safer, inventata da OpenBSD nel 2001.
Per sfruttare questa vulnerabilità da remoto (per quanto ne sappiamo, CVE-2006-5051 non è mai stata sfruttata con successo prima), ci siamo ispirati a un lavoro visionario, "Delivering Signals for Fun and Profit", pubblicato nel 2001 da Michal Zalewski:
https://lcamtuf.coredump.cx/signals.txt
Tuttavia, ci siamo immediatamente trovati di fronte a tre problemi principali:
Da un punto di vista teorico, dobbiamo trovare un percorso di codice utile che, se interrotto al momento giusto da SIGALRM, lasci sshd in uno stato incoerente, e dobbiamo quindi sfruttare questo stato incoerente all'interno del gestore SIGALRM.
Da un punto di vista pratico, dobbiamo trovare un modo per raggiungere questo percorso di codice utile in sshd, e massimizzare le nostre possibilità di interromperlo al momento giusto.
Da un punto di vista temporale, dobbiamo trovare un modo per aumentare ulteriormente le nostre possibilità di interrompere questo percorso di codice utile al momento giusto, da remoto.
Per concentrarci su questi tre problemi senza dover combattere subito contro tutte le moderne protezioni del sistema operativo (in particolare, ASLR e NX), abbiamo deciso di sfruttare prima le vecchie versioni di OpenSSH, su i386, e poi, basandoci su questa esperienza, le versioni recenti:
Prima, "SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3", da "debian-30r6-dvd-i386-binary-1_NONUS.iso": questa è la prima versione Debian che ha la separazione dei privilegi abilitata per default e che è patchata contro tutte le vulnerabilità critiche di quell'epoca (in particolare, CVE-2003-0693 e CVE-2002-0640).
Per sfruttare questa versione da remoto, interrompiamo una chiamata a free() con SIGALRM (all'interno del codice di parsing delle chiavi pubbliche di sshd), lasciamo l'heap in uno stato incoerente, e sfruttiamo questo stato incoerente durante un'altra chiamata a free(), all'interno del gestore SIGALRM.
Nei nostri esperimenti, ci vogliono in media ~10.000 tentativi per vincere questa race condition; cioè, con 10 connessioni (MaxStartups) accettate ogni 600 secondi (LoginGraceTime), ci vuole in media ~1 settimana per ottenere una shell remota come root.
Secondo, "SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3", da "ubuntu-6.06.1-server-i386.iso": questa è l'ultima versione Ubuntu ancora vulnerabile a CVE-2006-5051 ("Race condition del gestore di segnali in OpenSSH precedente alla 4.4").
Per sfruttare questa versione da remoto, interrompiamo una chiamata a pam_start() con SIGALRM, lasciamo una delle strutture di PAM in uno stato incoerente, e sfruttiamo questo stato incoerente durante una chiamata a pam_end(), all'interno del gestore SIGALRM.
Nei nostri esperimenti, ci vogliono in media ~10.000 tentativi per vincere questa race condition; cioè, con 10 connessioni (MaxStartups) accettate ogni 120 secondi (LoginGraceTime), ci vuole in media ~1-2 giorni per ottenere una shell remota come root.
Infine, "SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2", da "debian-12.5.0-i386-DVD-1.iso": questa è l'attuale versione stabile Debian, ed è vulnerabile alla regressione di CVE-2006-5051.
Per sfruttare questa versione da remoto, interrompiamo una chiamata a malloc() con SIGALRM (all'interno del codice di parsing delle chiavi pubbliche di sshd), lasciamo l'heap in uno stato incoerente, e sfruttiamo questo stato incoerente durante un'altra chiamata a malloc(), all'interno del gestore SIGALRM (più precisamente, dentro syslog()).
Nei nostri esperimenti, ci vogliono in media ~10.000 tentativi per vincere questa race condition, quindi ~3-4 ore con 100 connessioni (MaxStartups) accettate ogni 120 secondi (LoginGraceTime). In ultima analisi, ci vuole in media ~6-8 ore per ottenere una shell remota come root, perché possiamo indovinare correttamente l'indirizzo di glibc solo la metà delle volte (a causa di ASLR).
Questa ricerca è ancora in corso:
abbiamo preso di mira solo macchine virtuali, non server bare-metal, su un collegamento di rete per lo più stabile (~10ms di jitter dei pacchetti);
siamo convinti che vari aspetti dei nostri exploit possano essere notevolmente migliorati;
abbiamo iniziato a lavorare a un exploit per amd64, che è molto più difficile a causa del ASLR più forte.
Pochi giorni dopo aver iniziato il nostro lavoro su amd64, abbiamo notato il seguente rapporto di bug (nel Bugzilla pubblico di OpenSSH), riguardo a un deadlock nel gestore SIGALRM di sshd:
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
Abbiamo quindi deciso di contattare immediatamente gli sviluppatori di OpenSSH (per far loro sapere che questo deadlock è causato da una vulnerabilità sfruttabile), abbiamo messo in pausa il nostro lavoro su amd64 e abbiamo iniziato a scrivere questo advisory.
Ma non è da me, mi sto liberando
-- The Interrupters, "Haven't Seen the Last of Me"
Il gestore SIGALRM di questa versione di OpenSSH chiama packet_close(), che chiama buffer_free(), che chiama xfree() e quindi free(), che non è async-signal-safe:
Di conseguenza, abbiamo iniziato a leggere il codice malloc di questa glibc Debian (2.2.5), per vedere se una prima chiamata a free() può essere interrotta da SIGALRM e sfruttata durante una seconda chiamata a free() all'interno del gestore SIGALRM (alle righe 341-344, sopra). Poiché il malloc di questa glibc non è indurito contro la tecnica unlink() pionieristica di Solar Designer del 2000, abbiamo rapidamente individuato un percorso di codice interessante in chunk_free() (che viene chiamato internamente da free()):
Per sfruttare questo percorso di codice, predisponiamo l'heap di sshd ad avere il seguente layout (chunk_X, chunk_Y e chunk_Z sono chunk di memoria allocati con malloc(), e p, s, f, b sono i loro campi prev_size, size, fd e bk):
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| dati utente
Primo, se una chiamata a free(chunk_Y) è interrotta da SIGALRM dopo la riga 3246 ma prima della riga 3251, allora chunk_Y è già contrassegnato come libero (perché il bit PREV_INUSE di chunk_Z è cancellato alla riga 3246) ma non è ancora collegato nella sua lista doppiamente collegata (alla riga 3251): in altre parole, i puntatori fd e bk di chunk_Y contengono ancora dati utente (dati controllati dall'attaccante).
Secondo, se (all'interno del gestore SIGALRM) packet_close() chiama free(chunk_X), allora viene inserito il blocco di codice alle righe 3230-3244 (perché chunk_Y è contrassegnato come libero) e chunk_Y viene unlink()ato (alla riga 3241): una cosiddetta primitiva aa4bmo (quasi arbitrario mirroring di 4 byte sovrascritti), perché i puntatori fd e bk di chunk_Y sono ancora controllati dall'attaccante. Per maggiori informazioni sulla tecnica unlink() e sulla primitiva aa4bmo:
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
Infine, con questa primitiva aa4bmo sovrascriviamo il puntatore a funzione __free_hook di glibc (questa vecchia versione Debian non ha ASLR, né NX) con l'indirizzo del nostro shellcode nell'heap, ottenendo così l'esecuzione di codice remoto durante la successiva chiamata a free() in packet_close().
Ora stanno prendendo il controllo e hanno il completo dominio
-- The Interrupters, "Liberty"
Per montare questo attacco contro sshd, interrompiamo una chiamata a free() all'interno del codice di parsing di una chiave pubblica DSA di sshd (cioè, la riga 144 sotto è la nostra free(chunk_Y)) e la sfruttiamo durante una delle chiamate a free() in packet_close() (cioè, una delle righe 341-344 sopra è la nostra free(chunk_X)):
Inizialmente, tuttavia, non siamo mai riusciti a vincere questa race condition (cioè interrompere la chiamata a free() alla riga 144 al momento giusto). Alla fine, abbiamo realizzato che potevamo migliorare notevolmente le nostre possibilità di vincere questa gara: il codice di parsing delle chiavi pubbliche DSA ci permette di chiamare free() quattro volte (alle righe 704-707 sotto), e inoltre sshd ci permette di tentare sei autenticazioni utente (AUTH_FAIL_MAX); se una qualsiasi di queste 24 chiamate a free() viene interrotta al momento giusto, allora otteniamo successivamente l'esecuzione di codice remoto all'interno del gestore SIGALRM.
Con questo miglioramento, abbiamo finalmente vinto la race condition dopo ~1 mese: siamo stati felici (e abbiamo ballato la danza della shell root), ma abbiamo anche sentito che c'era ancora margine di miglioramento.
Non preoccuparti, aspetta e vedrai
-- The Interrupters, "Haven't Seen the Last of Me"
Abbiamo quindi implementato la seguente strategia temporale a tre componenti:
Non aspettiamo l'ultimo momento per inviare il nostro (piuttosto grande) pacchetto di chiave pubblica DSA a sshd: invece, inviamo l'intero pacchetto meno un byte (l'ultimo byte) molto prima del LoginGraceTime, e inviamo l'ultimo byte all'ultimo momento, per minimizzare gli effetti dei ritardi di rete. (E disabilitiamo l'algoritmo di Nagle.)
Teniamo traccia del tempo di andata e ritorno mediano (inviando regolarmente pacchetti che producono una risposta da sshd), e teniamo traccia della differenza tra il momento in cui ci aspettiamo che la nostra connessione venga chiusa da sshd (essenzialmente il momento in cui riceviamo il primo byte del banner di sshd, più LoginGraceTime) e il momento in cui la nostra connessione viene effettivamente chiusa da sshd, e regoliamo di conseguenza la nostra temporizzazione (cioè il momento in cui inviamo l'ultimo byte del nostro pacchetto DSA).
Queste differenze di tempo ci permettono di tracciare gli sfasamenti di clock e i ritardi di rete, che mostrano pattern prevedibili nel tempo: abbiamo sperimentato con regressioni lineari e spline, ma alla fine, niente ha funzionato meglio che riutilizzare semplicemente la misurazione più recente. Forse, il deep learning potrebbe dare risultati ancora migliori; questo è lasciato come esercizio per il lettore interessato.
Più importante, aumentiamo ulteriormente le nostre possibilità di vincere questa race condition regolando lentamente la nostra temporizzazione attraverso un feedback involontario da sshd:
se riceviamo una risposta (SSH2_MSG_USERAUTH_FAILURE) al nostro pacchetto di chiave pubblica DSA, allora lo abbiamo inviato troppo presto (sshd ha avuto il tempo di ricevere il nostro pacchetto nel figlio non privilegiato, analizzarlo, inviarlo al figlio privilegiato, analizzarlo lì, e inviare una risposta fino a noi);
se non possiamo nemmeno inviare l'ultimo byte del nostro pacchetto DSA, allora abbiamo aspettato troppo (sshd ha già ricevuto SIGALRM e ha chiuso la nostra connessione);
se possiamo inviare l'ultimo byte del nostro pacchetto DSA, e non riceviamo alcuna risposta prima che sshd chiuda la nostra connessione, allora la nostra temporizzazione era ragionevolmente accurata.
Questo feedback ci permette di colpire quella che chiamiamo la finestra di gara "grande": colpirla non garantisce che vinciamo la race condition, ma all'interno di questa grande finestra ci sono le 24 finestre di gara "piccole" (all'interno delle 24 chiamate a free()) che, se colpite, garantiscono che vinciamo la race condition.
Con questi miglioramenti, ci vogliono in media ~10.000 tentativi per vincere questa race condition; cioè, con 10 connessioni (MaxStartups) accettate ogni 600 secondi (LoginGraceTime), ci vuole in media ~1 settimana per ottenere una shell remota come root.
Dormo quando il sole inizia a sorgere
-- The Interrupters, "Alien"
Il gestore SIGALRM di questa versione di OpenSSH non chiama più packet_close(); inoltre, la glibc di questo Ubuntu (2.3.6) prende sempre un lock obbligatorio quando entra nelle funzioni della famiglia malloc (anche se single-threaded come sshd), il che ci impedisce di interrompere una chiamata a una delle funzioni malloc e successivamente sfruttarla durante un'altra chiamata a queste funzioni (si bloccherebbero sempre in un deadlock). Dobbiamo trovare un'altra soluzione.
CVE-2006-5051 menziona un double-free in GSSAPI, ma GSSAPI (o Kerberos) non è abilitato per default, quindi non sembra molto promettente. D'altra parte, PAM è abilitato per default, e pam_end() viene chiamata dal gestore SIGALRM di sshd (e, naturalmente, non è async-signal-safe). Abbiamo quindi cercato una funzione PAM che, se interrotta da SIGALRM al momento giusto, lascerebbe le strutture interne di PAM in uno stato incoerente, sfruttabile durante pam_end() nel gestore SIGALRM. Abbiamo trovato pam_set_data():
33 int pam_set_data( 34 pam_handle_t *pamh, .. 37 void (*cleanup)(pam_handle_t *pamh, void *data, int error_status)) 38 { 39 struct pam_data *data_entry; .. 57 } else if ((data_entry = malloc(sizeof(*data_entry)))) { .. 65 data_entry->next = pamh->data; 66 pamh->data = data_entry; .. 74 data_entry->cleanup = cleanup; ------------------------------------------------------------------------Se questa funzione viene interrotta da SIGALRM dopo la riga 66 ma prima della riga 74, allora data_entry è già collegato nelle strutture di PAM (pamh), ma il suo campo cleanup (un puntatore a funzione) non è ancora inizializzato (poiché malloc() alla riga 57 non inizializza la sua memoria). Se siamo in grado di controllare cleanup (attraverso residui di precedenti allocazioni heap), possiamo eseguire codice arbitrario quando pam_end() (all'interno del gestore SIGALRM) chiama _pam_free_data() (alla riga 118):
Sarebbe stato uno sfruttamento estremamente semplice; sfortunatamente, abbiamo completamente trascurato che pam_set_data() può essere chiamato solo dai moduli PAM: se lo interrompiamo con SIGALRM, allora pamh->caller_is è ancora _PAM_CALLED_FROM_MODULE, nel qual caso pam_end() ritorna immediatamente, senza mai chiamare _pam_free_data(). Di nuovo al tavolo da disegno.
Non ci arrendiamo, non è ciò che facciamo
-- The Interrupters, "Title Holder"
Abbiamo notato che, alla riga 601 qui sotto, sshd passa un puntatore al suo puntatore globale sshpam_handle direttamente a pam_start() (che viene chiamato una volta per connessione):
Abbiamo quindi deciso di esaminare pam_start() stesso: se interrotto da SIGALRM, potrebbe lasciare la struttura puntata da sshpam_handle in uno stato inconsistente, che potrebbe poi essere sfruttato all'interno del gestore SIGALRM, quando viene chiamato "pam_end(sshpam_handle, sshpam_err)".
Alla riga 32, pam_start() imposta immediatamente sshpam_handle di sshd su un blocco di memoria allocato con calloc(); questo è sicuro perché calloc() inizializza questa memoria a zero. D'altra parte, se _pam_add_handler() (che viene chiamato più volte da pam_start()) viene interrotto da SIGALRM dopo la riga 874 ma prima della riga 886, allora una struttura allocata con malloc() viene collegata in pamh, ma il suo campo next non è ancora inizializzato. Se siamo in grado di controllare next (attraverso residui di precedenti allocazioni heap), possiamo passare un puntatore arbitrario a free() durante la chiamata a pam_end() (all'interno del gestore SIGALRM), alla riga 1020 (e riga 1017) qui sotto:
Poiché la malloc di questa glibc di Ubuntu è già indurita contro la vecchia tecnica unlink(), abbiamo deciso di trasformare la nostra free() arbitraria nella Casa della Mente (versione fastbin) del Malloc Maleficarum: liberiamo il nostro chunk NON_MAIN_ARENA fittizio, puntiamo la nostra arena fittizia a .got.plt di sshd (questo Ubuntu ha ASLR ma non PIE), e sovrascriviamo l'entry di _exit() con l'indirizzo del nostro shellcode nell'heap (l'heap di questo Ubuntu è ancora eseguibile per impostazione predefinita). Per maggiori informazioni sul Malloc Maleficarum:
https://seclists.org/bugtraq/2005/Oct/118
Ho imparato tutto nel modo più duro
-- The Interrupters, "The Hard Way"
Per montare questo attacco contro sshd, inizialmente abbiamo affrontato tre problemi:
La Casa della Mente richiede di memorizzare il puntatore alla nostra arena fittizia all'indirizzo 0x08100000 nell'heap; ma siamo in grado di memorizzare dati controllati dall'attaccante a un indirizzo così alto? Poiché sshd chiama pam_start() all'inizio dell'autenticazione utente, non controlliamo nulla tranne il nome utente stesso; fortunatamente, un nome utente di lunghezza ~128KB (più corto di DEFAULT_MMAP_THRESHOLD) ci permette di memorizzare i nostri dati all'indirizzo 0x08100000.
Il campo size del nostro chunk NON_MAIN_ARENA fittizio non deve essere troppo grande (per superare i controlli di sicurezza di free()); cioè, deve contenere byte nulli. Ma il nostro lungo nome utente è una stringa terminata da null che non può contenere byte nulli; fortunatamente ci siamo ricordati che _pam_free_handlers_aux() azzera le strutture che libera (riga 1019 sopra): quindi "patchiamo" il campo size del nostro chunk fittizio con tale memset(0), e solo dopo lo liberiamo.
Dobbiamo sopravvivere a diverse chiamate a free() (alle righe 1017 e 1020 sopra) prima della free() del nostro chunk NON_MAIN_ARENA fittizio. Trasformiamo queste free() in no-op puntandole a chunk IS_MMAPPED fittizi: free() chiama munmap_chunk(), che chiama munmap(), che fallisce perché questi chunk IS_MMAPPED fittizi sono disallineati; di fatto un no-op, perché gli errori di assert() non sono imposti in questa glibc di Ubuntu.
Infine, il nostro lungo nome utente ci permette anche di controllare il campo next potenzialmente non inizializzato di 20 strutture diverse (attraverso residui di copie temporanee del nostro lungo nome utente), perché pam_start() chiama _pam_add_handler() più volte; cioè, la nostra ampia finestra di race condition contiene 20 piccole finestre di race condition.
Stessi trucchi usati prima
-- The Interrupters, "Divide Us"
Per questo attacco contro Ubuntu 6.06.1, abbiamo semplicemente riutilizzato la strategia di tempismo che abbiamo usato contro Debian 3.0r6: ci vogliono ~10.000 tentativi in media per vincere la race condition, e con 10 connessioni (MaxStartups) accettate ogni 120 secondi (LoginGraceTime), ci vogliono ~1-2 giorni in media per ottenere una shell root remota.
Nota: poiché la glibc di questo Ubuntu prende sempre un lock obbligatorio quando entra nelle funzioni della famiglia malloc, un attaccante sfortunato potrebbe deadlockare tutte e 10 le connessioni MaxStartups prima di ottenere una shell root; non abbiamo cercato di aggirare questo problema perché il nostro obiettivo finale era comunque sfruttare una versione moderna di OpenSSH.
Ora sei pronto, affronta i demoni a testa alta
-- The Interrupters, "Be Gone"
Il gestore SIGALRM di questa versione di OpenSSH non chiama packet_close() né pam_end(); di fatto chiama una sola funzione interessante, syslog():
Le nostre due domande chiave, quindi, sono: la syslog() di questa glibc di Debian (2.36) chiama funzioni non sicure per i segnali asincroni come malloc() e free()? E se sì, questa glibc prende ancora un lock obbligatorio quando entra nelle funzioni della famiglia malloc?
Nota: poiché non controlliamo nulla riguardo a queste malloc() (né il loro ordine, né le loro dimensioni, né i loro contenuti), abbiamo preso il "rce" alla riga 166 come un presagio di buon auspicio tanto necessario.
E fortunatamente per noi, la risposta alla seconda domanda è no; dall'ottobre 2017, le funzioni malloc della glibc non prendono più alcun lock, quando sono single-threaded (come sshd):
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
Inoltre, questa versione di Debian soffre della debolezza di ASLR descritta nei seguenti ottimi post blog (di Justin Miller e Mathias Krause, rispettivamente):
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
Concretamente, nel caso di sshd su i386, ogni mapping di memoria è randomizzato normalmente (PIE di sshd, heap, la maggior parte delle librerie, stack), ma la glibc stessa è sempre mappata o all'indirizzo 0xb7200000 o all'indirizzo 0xb7400000; in altre parole, possiamo indovinare correttamente l'indirizzo della glibc la metà delle volte (un piccolo prezzo da pagare per sconfiggere ASLR). Nel nostro exploit assumiamo che la glibc sia mappata all'indirizzo 0xb7400000, perché è leggermente più comune di 0xb7200000.
La nostra prossima domanda è: quali percorsi di codice all'interno delle funzioni malloc della glibc, se interrotti da SIGALRM al momento giusto, lasciano l'heap in uno stato inconsistente, sfruttabile durante una delle chiamate a malloc() all'interno del gestore SIGALRM?
Abbiamo trovato diversi percorsi di codice interessanti (e sorprendenti!), ma quello che abbiamo scelto coinvolge solo dimensioni relative, non indirizzi assoluti (a differenza di vari percorsi di codice all'interno di unlink_chunk(), per esempio); questa differenza potrebbe rivelarsi cruciale per un futuro exploit su amd64. Questo percorso di codice, all'interno di malloc(), divide un grande chunk libero (victim) in due chunk più piccoli; il primo chunk viene restituito al chiamante di malloc() (alla riga 4345) e il secondo chunk (remainder) viene collegato in una lista non ordinata di chunk liberi (alle righe 4324-4327):
Se questo percorso di codice viene interrotto da SIGALRM dopo la riga 4327 ma prima della riga 4339, allora il chunk remainder di questa divisione è già collegato nella lista non ordinata di chunk liberi (righe 4324-4327), ma il suo campo size (mchunk_size) non è ancora inizializzato (riga 4339).
Se siamo in grado di controllare il suo campo size (attraverso residui di precedenti allocazioni heap), possiamo rendere questo chunk remainder più grande e sovrapporlo ad altri chunk dell'heap, e quindi corrompere la memoria dell'heap quando questo chunk remainder ingrandito e sovrapposto viene eventualmente allocato con malloc() e scritto (all'interno del gestore SIGALRM).
La nostra ultima domanda, quindi, è: dato che non controlliamo nulla riguardo alle chiamate a malloc() all'interno del gestore SIGALRM, cosa possiamo sovrascrivere nell'heap per ottenere l'esecuzione di codice arbitrario prima che sshd chiami _exit() (in sshsigdie())?
Poiché __tzfile_read() (all'interno del gestore SIGALRM) alloca con malloc() una struttura FILE nell'heap (alla riga 166 sopra), e poiché le strutture FILE hanno una lunga storia di abusi per l'esecuzione di codice arbitrario, abbiamo deciso di indirizzare la nostra corruzione dell'heap verso questa struttura FILE. Questo è, tuttavia, più facile a dirsi che a farsi: la nostra corruzione dell'heap è molto limitata, e le strutture FILE sono state notevolmente indurite nel corso degli anni (da IO_validate_vtable() e PTR_DEMANGLE(), per esempio).
Alla fine, abbiamo escogitato la seguente tecnica (che sembra essere specifica della glibc i386 — la glibc amd64 non sembra usare affatto _vtable_offset):
con la nostra limitata corruzione dell'heap, sovrascriviamo il campo _vtable_offset (un singolo char con segno) della struttura FILE di __tzfile_read();
le funzioni libio della glibc cercheranno quindi il puntatore vtable di questa struttura FILE (un puntatore a un array di puntatori a funzione) a un offset diverso da zero (il nostro _vtable_offset sovrascritto), invece dell'offset predefinito zero;
noi (attaccanti) possiamo facilmente controllare questo puntatore vtable fittizio (attraverso residui di precedenti allocazioni heap), perché il campo della struttura FILE intorno a questo offset non è esplicitamente inizializzato da fopen();
per superare i controlli di sicurezza della glibc, il nostro puntatore vtable fittizio deve puntare da qualche parte nella sezione __libc_IO_vtables: abbiamo deciso di puntarlo alla vtable per i flussi di caratteri wide, _IO_wfile_jumps (cioè a 0xb761b740, poiché assumiamo che la glibc sia mappata all'indirizzo 0xb7400000);
di conseguenza, __fread_unlocked() (alla riga 186 sopra) chiama _IO_wfile_underflow() (invece di _IO_file_underflow()), che chiama un puntatore a funzione (__fct) che in pratica proviene da una struttura il cui puntatore (_codecvt) è un altro campo della struttura FILE;
noi (attaccanti) possiamo facilmente controllare questo puntatore _codecvt (attraverso residui di precedenti allocazioni heap, perché questo campo della struttura FILE non è esplicitamente inizializzato da fopen()), il che ci permette anche di controllare il puntatore a funzione __fct.
In sintesi, sovrascrivendo un singolo byte (_vtable_offset) della struttura FILE allocata da fopen(), possiamo chiamare il nostro puntatore a funzione __fct ed eseguire codice arbitrario durante __fread_unlocked().
Lo volevo perfetto, senza rughe
-- The Interrupters, "In the Mirror"
Per montare questo attacco contro il processo figlio privilegiato di sshd, immaginiamo innanzitutto il seguente layout dell'heap (le "XXX" sono chunk "barriera" che ci permettono di creare buchi nell'heap; per esempio, piccoli chunk con memory leak):
---|----------------------------------------------|---|------------|---
| XXX | grande buco | XXX | piccolo buco | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | grande chunk allocato | chunk remainder libero | XXX | piccolo buco | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
ma se questa malloc() viene interrotta da SIGALRM dopo la riga 4327 ma prima della riga 4339, allora il chunk remainder di questa divisione è già collegato nella lista non ordinata di chunk liberi, ma il suo campo size è sotto il nostro controllo (attraverso residui di precedenti allocazioni heap), e questo chunk remainder artificialmente ingrandito si sovrappone con il piccolo buco successivo:---|-----------------------|----------------------|---|------------|--- XXX| grande chunk allocato | chunk resto reale |XXX| piccolo buco |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| chunk resto artificialmente ingrandito
quando il gestore SIGALRM chiama syslog() e quindi __tzfile_read(), fopen() malloc()a il piccolo buco per la sua struttura FILE, e __fread_unlocked() malloc()a un buffer di lettura di 4KB, dividendo così il chunk resto ingrandito in due (il buffer di lettura di 4KB e un piccolo chunk resto):
---|-----------------------|----------------------|---|------------|--- XXX| grande chunk allocato | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<------->| buffer di lettura 4KB resto
sovrascriviamo quindi parti della struttura FILE con l'intestazione interna di questo piccolo chunk resto: più precisamente, sovrascriviamo il _vtable_offset del FILE con il terzo byte del campo bk di questa intestazione, che è un puntatore alla lista non ordinata di chunk liberi, 0xb761d7f8 (cioè sovrascriviamo _vtable_offset con 0x61);
quindi, come spiegato nella sottosezione "Teoria", __fread_unlocked() chiama _IO_wfile_underflow() (invece di _IO_file_underflow()), che chiama il nostro puntatore a funzione __fct (tramite il nostro puntatore _codecvt) ed esegue il nostro codice arbitrario.
Nota: non abbiamo ancora spiegato come passare in modo affidabile da un puntatore _codecvt controllato a un puntatore a funzione __fct controllato; lo faremo, ma dobbiamo prima risolvere un problema più urgente.
In effetti, abbiamo imparato dal nostro lavoro su versioni precedenti di OpenSSH che non vinceremo mai questa condizione di gara del gestore di segnali se la nostra grande finestra di gara contiene solo una piccola finestra di gara. Di conseguenza, abbiamo implementato la seguente strategia, basata sul seguente layout dell'heap:
---|------------|---|------------|---|------------|---|------------|---
| XXX | grande buco 1 | XXX | piccolo buco 1 | XXX | grande buco 2 | XXX | piccolo buco 2 | ... |
|---|
L'ultimo pacchetto che inviamo a sshd (poco prima della consegna di SIGALRM) forza sshd a eseguire la seguente sequenza di chiamate malloc(): malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), ecc.
1/ La nostra prima malloc(~4KB) divide il grande buco 1 in due:
se questa prima divisione viene interrotta da SIGALRM al momento giusto, allora la fopen() all'interno del gestore SIGALRM malloc()a il piccolo buco 1 per la sua struttura FILE, e otteniamo l'esecuzione di codice arbitrario come spiegato sopra;
altrimenti, malloc()iamo noi stessi il piccolo buco 1 con la nostra prima malloc(304), e:
2/ La nostra seconda malloc(~4KB) divide il grande buco 2 in due:
se questa seconda divisione viene interrotta da SIGALRM al momento giusto, allora la fopen() all'interno del gestore SIGALRM malloc()a il piccolo buco 2 per la sua struttura FILE, e otteniamo l'esecuzione di codice arbitrario come spiegato sopra;
altrimenti, malloc()iamo noi stessi il piccolo buco 2 con la nostra seconda malloc(304), ecc.
Siamo riusciti a creare 27 coppie di tali buchi grandi e piccoli nell'heap di sshd (28 supererebbero PACKET_MAX_SIZE, 256KB): la nostra grande finestra di gara ora contiene 27 piccole finestre di gara! Raggiungere questo complesso layout dell'heap è stato estremamente doloroso e dispendioso in termini di tempo, ma due punti salienti sono:
Per raggiungere affidabilmente questo layout dell'heap, inviamo cinque diversi pacchetti di chiave pubblica a sshd (i pacchetti da a/ a d/ possono essere inviati molto prima di SIGALRM; la maggior parte del pacchetto e/ può essere inviata molto prima di SIGALRM, ma il suo ultimo byte deve essere inviato all'ultimo momento):
a/ malloc()iamo e free()iamo una varietà di chunk tcache, per assicurarci che le allocazioni dell'heap che non controlliamo finiscano in questi chunk tcache e non interferiscano con il nostro attento layout dell'heap.
b/ malloc()iamo e free()iamo chunk di varie dimensioni, per creare le nostre 27 coppie di buchi grandi e piccoli (e i corrispondenti chunk "barriera").
c/ malloc()iamo e free()iamo chunk di circa 4KB e 320B, per:
scrivere l'intestazione fittizia (il grande campo size) del nostro potenziale chunk resto ingrandito, nel mezzo dei nostri buchi grandi;
scrivere il piè di pagina fittizio del nostro potenziale chunk resto ingrandito, alla fine dei nostri buchi piccoli (per superare i controlli di sicurezza di glibc);
scrivere i nostri puntatori fittizi vtable e _codecvt, nei nostri buchi piccoli (che sono potenziali strutture FILE).
d/ malloc()iamo e free()iamo una stringa molto grande (quasi 256KB), per assicurarci che i nostri buchi grandi e piccoli vengano rimossi dalla lista non ordinata di chunk liberi e posti nei rispettivi bin malloc.
e/ Forziamo sshd a eseguire la nostra sequenza finale di chiamate malloc() (malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), ecc.), per aprire le nostre 27 piccole finestre di gara.
I lettori attenti potrebbero aver notato che non abbiamo ancora affrontato (letteralmente e figurativamente) il problema di _codecvt. In realtà, _codecvt è un puntatore a una struttura (_IO_codecvt) che contiene un puntatore a una struttura (__gconv_step) che contiene il puntatore a funzione __fct che ci permette di eseguire codice arbitrario. Per controllare affidabilmente __fct tramite _codecvt, puntiamo semplicemente _codecvt a uno dei bin malloc di glibc, che convenientemente contiene un puntatore a uno dei nostri chunk liberi nell'heap, che contiene il nostro puntatore a funzione __fct verso codice glibc arbitrario (tutti questi indirizzi glibc ci sono noti, perché assumiamo che glibc sia mappata all'indirizzo 0xb7400000).
Stiamo esaurendo il tempo
-- The Interrupters, "As We Live"
Mentre implementavamo questo terzo exploit, è diventato chiaro che non potevamo semplicemente riutilizzare la strategia di temporizzazione che avevamo utilizzato contro le due versioni precedenti di OpenSSH: non vincevamo mai questa nuova condizione di gara. Alla fine, abbiamo capito perché:
Ci vuole molto tempo (~10ms) perché sshd analizzi la nostra quinta e ultima chiave pubblica (pacchetto e/ sopra); in altre parole, la nostra grande finestra di gara è troppo grande (le nostre 27 piccole finestre di gara sono come aghi in un pagliaio).
La user_specific_delay() introdotta di recente (OpenSSH 7.8p1) ritarda la risposta di sshd al nostro ultimo pacchetto di chiave pubblica fino a ~9ms e quindi distrugge la nostra strategia di temporizzazione basata sul feedback.
Di conseguenza, abbiamo sviluppato una strategia di temporizzazione completamente diversa:
di tanto in tanto, inviamo il nostro ultimo pacchetto di chiave pubblica con un piccolo errore che produce una risposta di errore (righe 138-142 sotto), subito prima della chiamata a sshkey_from_blob() che analizza la nostra chiave pubblica;
di tanto in tanto, inviamo il nostro ultimo pacchetto di chiave pubblica con un altro piccolo errore che produce una risposta di errore (righe 151-155 sotto), subito dopo la chiamata a sshkey_from_blob() che analizza la nostra chiave pubblica;
la differenza tra questi due tempi di risposta è il tempo che sshd impiega per analizzare la nostra ultima chiave pubblica, e questo ci permette di temporizzare con precisione la trasmissione dei nostri ultimi pacchetti (per assicurarci che sshd abbia il tempo di analizzare la nostra chiave pubblica nel figlio non privilegiato, inviarla al figlio privilegiato, e iniziare ad analizzarla lì, prima della consegna di SIGALRM).
Con questo cambiamento di strategia, ci vogliono in media ~10.000 tentativi per vincere la condizione di gara; cioè, con 100 connessioni (MaxStartups) accettate ogni 120 secondi (LoginGraceTime), ci vogliono in media ~3-4 ore per vincere la condizione di gara, e ~6-8 ore per ottenere una shell root remota (a causa di ASLR).
Qual è il tuo piano per domani?
-- The Interrupters, "Take Back the Power"
Abbiamo deciso di puntare a Rocky Linux 9 (un derivato di Red Hat Enterprise Linux 9), da "Rocky-9.4-x86_64-minimal.iso", per due motivi:
la sua versione di OpenSSH (8.7p1) è vulnerabile a questa condizione di gara del gestore di segnali e la sua glibc è sempre mappata a un multiplo di 2MB (a causa della debolezza dell'ASLR discussa nella precedente sottosezione "Teoria"), il che rende le sovrascritture parziali di puntatori molto più potenti;
la funzione syslog() (che non è async-signal-safe ma viene chiamata dal gestore SIGALRM di sshd) di questa versione di glibc (2.34) chiama internamente __open_memstream(), che malloc()a una struttura FILE nell'heap, e chiama anche calloc(), realloc() e free() (il che ci dà una libertà tanto necessaria).
Con una corruzione dell'heap come primitiva, due strutture FILE malloc()ate nell'heap e 21 bit fissi negli indirizzi di glibc, crediamo che questa condizione di gara del gestore di segnali sia sfruttabile su amd64 (probabilmente non in ~6-8 ore, ma si spera in meno di una settimana). Solo il tempo lo dirà.
Nota a margine: abbiamo scoperto che Ubuntu 24.04 non ri-randomizza l'ASLR dei suoi figli sshd (viene randomizzato solo una volta, all'avvio); abbiamo rintracciato questo alla patch qui sotto, che disattiva rexec_flag di sshd. Questo è generalmente una cattiva idea, ma nel caso particolare di questa condizione di gara del gestore di segnali, impedisce che sshd sia sfruttabile: syslog() all'interno del gestore SIGALRM non chiama nessuna delle funzioni malloc, perché non è mai la primissima chiamata a syslog().
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
La tempesta è arrivata e passata
-- The Interrupters, "Good Things"
Il 6 giugno 2024, questa condizione di gara del gestore di segnali è stata corretta dal commit 81c1099 ("Add a facility to sshd(8) to penalise particular problematic client behaviours"), che ha spostato il codice non async-signal-safe dal gestore SIGALRM di sshd al processo listener di sshd, dove può essere gestito in modo sincrono:
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
Poiché questa correzione fa parte di un grande commit (81c1099), al di sopra di un commit ancora più grande di difesa in profondità (03e3de4, "Start the process of splitting sshd into separate binaries"), potrebbe essere difficile da backportare. In tal caso, la condizione di gara del gestore di segnali stessa può essere corretta rimuovendo o commentando il codice non async-signal-safe dalla funzione sshsigdie(); per esempio:
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
Infine, se sshd non può essere aggiornato o ricompilato, questa condizione di gara del gestore di segnali può essere corretta semplicemente impostando LoginGraceTime a 0 nel file di configurazione. Questo rende sshd vulnerabile a un denial of service (l'esaurimento di tutte le connessioni MaxStartups), ma lo rende sicuro dall'esecuzione remota di codice presentata in questo advisory.
Ringraziamo gli sviluppatori di OpenSSH per il loro eccezionale lavoro e la stretta collaborazione su questo rilascio. Ringraziamo anche il distros@openwall. Infine, dedichiamo questo advisory a Sophia d'Antoine.
2024-05-19: Abbiamo contattato gli sviluppatori di OpenSSH. Sono seguite iterazioni successive di patch e revisioni delle patch.
2024-06-20: Abbiamo contattato il distros@openwall.
2024-07-01: Data di rilascio coordinata.
| ~8KB | 320B | ~8KB | 320B |