
This is a POC I wrote for CVE-2024-6387
Qualys Security Advisory
regreSSHion: RCE dans le serveur OpenSSH, sur systèmes Linux basés sur glibc (CVE-2024-6387)
Résumé SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, de 2005)
Il suffit d'un acte de foi
-- The Interrupters, "Leap of Faith"
Note préliminaire : OpenSSH est l'un des logiciels les plus sécurisés au monde ; cette vulnérabilité est un faux pas dans une implémentation par ailleurs presque parfaite. Sa conception en profondeur et son code sont un modèle et une source d'inspiration, et nous remercions les développeurs d'OpenSSH pour leur travail exemplaire.
Nous avons découvert une vulnérabilité (une condition de course dans un gestionnaire de signal) dans le serveur OpenSSH (sshd) : si un client ne s'authentifie pas en LoginGraceTime secondes (120 par défaut, 600 dans les anciennes versions d'OpenSSH), alors le gestionnaire SIGALRM de sshd est appelé de manière asynchrone, mais ce gestionnaire de signal appelle diverses fonctions qui ne sont pas sûres pour les signaux asynchrones (par exemple, syslog()). Cette condition de course affecte sshd dans sa configuration par défaut.
En investiguant, nous avons réalisé que cette vulnérabilité est en fait une régression de CVE-2006-5051 ("Condition de course dans le gestionnaire de signal dans OpenSSH avant 4.4 permet à des attaquants distants de provoquer un déni de service (crash), et éventuellement d'exécuter du code arbitraire"), qui a été signalée en 2006 par Mark Dowd.
Cette régression a été introduite en octobre 2020 (OpenSSH 8.5p1) par le commit 752250c ("revised log infrastructure for OpenSSH"), qui a accidentellement supprimé un "#ifdef DO_LOG_SAFE_IN_SIGHAND" dans sigdie(), une fonction directement appelée par le gestionnaire SIGALRM de sshd. En d'autres termes :
OpenSSH < 4.4p1 est vulnérable à cette condition de course dans le gestionnaire de signal, s'il n'est pas patché par rétroportage contre CVE-2006-5051, ou s'il n'est pas patché contre CVE-2008-4109, qui était un correctif incorrect pour CVE-2006-5051 ;
4.4p1 <= OpenSSH < 8.5p1 n'est pas vulnérable à cette condition de course dans le gestionnaire de signal (car le "#ifdef DO_LOG_SAFE_IN_SIGHAND" qui a été ajouté à sigdie() par le patch pour CVE-2006-5051 a transformé cette fonction dangereuse en un appel sûr _exit(1)) ;
8.5p1 <= OpenSSH < 9.8p1 est à nouveau vulnérable à cette condition de course dans le gestionnaire de signal (car le "#ifdef DO_LOG_SAFE_IN_SIGHAND" a été accidentellement supprimé de sigdie()).
Cette vulnérabilité est exploitable à distance sur les systèmes Linux basés sur glibc, où syslog() elle-même appelle des fonctions non sûres pour les signaux asynchrones (par exemple, malloc() et free()) : une exécution de code à distance non authentifiée en tant que root, car elle affecte le code privilégié de sshd, qui n'est pas sandboxé et s'exécute avec tous les privilèges. Nous n'avons pas investigué d'autres libc ou systèmes d'exploitation ; mais OpenBSD n'est notablement pas vulnérable, car son gestionnaire SIGALRM appelle syslog_r(), une version plus sûre pour les signaux asynchrones de syslog() inventée par OpenBSD en 2001.
Pour exploiter cette vulnérabilité à distance (à notre connaissance, CVE-2006-5051 n'a jamais été exploitée avec succès auparavant), nous nous sommes inspirés d'un article visionnaire, "Delivering Signals for Fun and Profit", publié en 2001 par Michal Zalewski :
https://lcamtuf.coredump.cx/signals.txt
Néanmoins, nous avons immédiatement été confrontés à trois problèmes majeurs :
D'un point de vue théorique, nous devons trouver un chemin de code utile qui, s'il est interrompu au bon moment par SIGALRM, laisse sshd dans un état incohérent, et nous devons ensuite exploiter cet état incohérent à l'intérieur du gestionnaire SIGALRM.
D'un point de vue pratique, nous devons trouver un moyen d'atteindre ce chemin de code utile dans sshd, et maximiser nos chances de l'interrompre au bon moment.
D'un point de vue temporel, nous devons trouver un moyen d'augmenter encore nos chances d'interrompre ce chemin de code utile au bon moment, à distance.
Pour nous concentrer sur ces trois problèmes sans avoir à lutter immédiatement contre toutes les protections modernes des systèmes d'exploitation (en particulier ASLR et NX), nous avons décidé d'exploiter d'abord les anciennes versions d'OpenSSH, sur i386, puis, sur la base de cette expérience, les versions récentes :
D'abord, "SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3", de "debian-30r6-dvd-i386-binary-1_NONUS.iso" : c'est la première version Debian qui active la séparation des privilèges par défaut et qui est patchée contre toutes les vulnérabilités critiques de cette époque (en particulier, CVE-2003-0693 et CVE-2002-0640).
Pour exploiter cette version à distance, nous interrompons un appel à free() avec SIGALRM (dans le code d'analyse des clés publiques de sshd), laissons le tas dans un état incohérent, et exploitons cet état incohérent lors d'un autre appel à free(), dans le gestionnaire SIGALRM.
Dans nos expériences, il faut en moyenne ~10 000 tentatives pour gagner cette condition de course ; c'est-à-dire, avec 10 connexions (MaxStartups) acceptées par 600 secondes (LoginGraceTime), il faut en moyenne ~1 semaine pour obtenir un shell root distant.
Ensuite, "SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3", de "ubuntu-6.06.1-server-i386.iso" : c'est la dernière version Ubuntu encore vulnérable à CVE-2006-5051 ("Condition de course dans le gestionnaire de signal dans OpenSSH avant 4.4").
Pour exploiter cette version à distance, nous interrompons un appel à pam_start() avec SIGALRM, laissons l'une des structures de PAM dans un état incohérent, et exploitons cet état incohérent lors d'un appel à pam_end(), dans le gestionnaire SIGALRM.
Dans nos expériences, il faut en moyenne ~10 000 tentatives pour gagner cette condition de course ; c'est-à-dire, avec 10 connexions (MaxStartups) acceptées par 120 secondes (LoginGraceTime), il faut en moyenne ~1-2 jours pour obtenir un shell root distant.
Enfin, "SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2", de "debian-12.5.0-i386-DVD-1.iso" : c'est la version stable actuelle de Debian, et elle est vulnérable à la régression de CVE-2006-5051.
Pour exploiter cette version à distance, nous interrompons un appel à malloc() avec SIGALRM (dans le code d'analyse des clés publiques de sshd), laissons le tas dans un état incohérent, et exploitons cet état incohérent lors d'un autre appel à malloc(), dans le gestionnaire SIGALRM (plus précisément, dans syslog()).
Dans nos expériences, il faut en moyenne ~10 000 tentatives pour gagner cette condition de course, donc ~3-4 heures avec 100 connexions (MaxStartups) acceptées par 120 secondes (LoginGraceTime). En fin de compte, il faut en moyenne ~6-8 heures pour obtenir un shell root distant, car nous ne pouvons deviner correctement l'adresse de la glibc que la moitié du temps (à cause de l'ASLR).
Cette recherche est toujours en cours :
nous avons ciblé uniquement des machines virtuelles, pas des serveurs physiques, sur un lien réseau relativement stable (~10 ms de gigue de paquets) ;
nous sommes convaincus que divers aspects de nos exploits peuvent être grandement améliorés ;
nous avons commencé à travailler sur un exploit amd64, qui est beaucoup plus difficile à cause de l'ASLR plus forte.
Quelques jours après avoir commencé notre travail sur amd64, nous avons remarqué le rapport de bug suivant (dans le Bugzilla public d'OpenSSH), concernant un interblocage dans le gestionnaire SIGALRM de sshd :
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
Nous avons donc décidé de contacter immédiatement les développeurs d'OpenSSH (pour les informer que cet interblocage est causé par une vulnérabilité exploitable), nous avons mis notre travail sur amd64 en pause, et nous avons commencé à rédiger cet avis.
Mais ce n'est pas comme moi, je me libère
-- The Interrupters, "Haven't Seen the Last of Me"
Le gestionnaire SIGALRM de cette version d'OpenSSH appelle packet_close(), qui appelle buffer_free(), qui appelle xfree() et donc free(), qui n'est pas sûr pour les signaux asynchrones :
Par conséquent, nous avons commencé à lire le code malloc de la glibc de ce Debian (2.2.5), pour voir si un premier appel à free() peut être interrompu par SIGALRM et exploité lors d'un second appel à free() dans le gestionnaire SIGALRM (aux lignes 341-344, ci-dessus). Comme le malloc de cette glibc n'est pas durci contre la technique unlink() pionnée par Solar Designer en 2000, nous avons rapidement repéré un chemin de code intéressant dans chunk_free() (appelée en interne par free()) :
Pour exploiter ce chemin de code, nous organisons le tas de sshd pour qu'il ait la disposition suivante (chunk_X, chunk_Y et chunk_Z sont des blocs de mémoire alloués par malloc(), et p, s, f, b sont leurs champs prev_size, size, fd et bk) :
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| données utilisateur
D'abord, si un appel à free(chunk_Y) est interrompu par SIGALRM après la ligne 3246 mais avant la ligne 3251, alors chunk_Y est déjà marqué comme libre (car le bit PREV_INUSE de chunk_Z est effacé à la ligne 3246) mais il n'est pas encore lié dans sa liste doublement chaînée (à la ligne 3251) : en d'autres termes, les pointeurs fd et bk de chunk_Y contiennent encore des données utilisateur (contrôlées par l'attaquant).
Ensuite, si (à l'intérieur du gestionnaire SIGALRM) packet_close() appelle free(chunk_X), alors le bloc de code aux lignes 3230-3244 est entré (car chunk_Y est marqué comme libre) et chunk_Y est unlink()é (à la ligne 3241) : une primitive dite aa4bmo (presque arbitraire 4 octets miroir écrasement), car les pointeurs fd et bk de chunk_Y sont toujours contrôlés par l'attaquant. Pour plus d'informations sur la technique unlink() et la primitive aa4bmo :
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
Enfin, avec cette primitive aa4bmo, nous écrasons le pointeur de fonction __free_hook de la glibc (cette ancienne version Debian n'a pas d'ASLR, ni NX) avec l'adresse de notre shellcode dans le tas, réalisant ainsi une exécution de code à distance lors du prochain appel à free() dans packet_close().
Maintenant ils prennent le contrôle et ils ont le contrôle total
-- The Interrupters, "Liberty"
Pour monter cette attaque contre sshd, nous interrompons un appel à free() dans le code d'analyse d'une clé publique DSA par sshd (c'est-à-dire que la ligne 144 ci-dessous est notre free(chunk_Y)) et l'exploitons lors de l'un des appels à free() dans packet_close() (c'est-à-dire que l'une des lignes 341-344 ci-dessus est notre free(chunk_X)) :
Initialement, cependant, nous n'avons jamais réussi à gagner cette condition de course (c'est-à-dire interrompre l'appel à free() à la ligne 144 au bon moment). Finalement, nous avons réalisé que nous pouvions grandement améliorer nos chances de gagner cette course : le code d'analyse des clés publiques DSA nous permet d'appeler free() quatre fois (aux lignes 704-707 ci-dessous), et en outre sshd nous permet de tenter six authentifications utilisateur (AUTH_FAIL_MAX) ; si l'un de ces 24 appels à free() est interrompu au bon moment, alors nous obtenons plus tard une exécution de code à distance dans le gestionnaire SIGALRM.
Avec cette amélioration, nous avons finalement gagné la condition de course après ~1 mois : nous étions heureux (et avons fait une danse du shell root), mais nous avons aussi senti qu'il y avait encore de la place pour l'amélioration.
Ne t'inquiète pas, attends et vois
-- The Interrupters, "Haven't Seen the Last of Me"
Nous avons donc mis en œuvre la stratégie de timing en trois volets suivante :
Nous n'attendons pas le dernier moment pour envoyer notre paquet (plutôt gros) de clé publique DSA à sshd : au lieu de cela, nous envoyons le paquet entier moins un octet (le dernier octet) bien avant le LoginGraceTime, et envoyons le tout dernier octet au dernier moment, pour minimiser les effets des délais réseau. (Et nous désactivons l'algorithme de Nagle.)
Nous suivons le temps de round-trip médian (en envoyant régulièrement des paquets qui produisent une réponse de sshd), et suivons la différence entre le moment où nous nous attendons à ce que notre connexion soit fermée par sshd (essentiellement le moment où nous recevons le premier octet de la bannière de sshd, plus LoginGraceTime) et le moment où notre connexion est réellement fermée par sshd, et ajustons en conséquence notre timing (c'est-à-dire le moment où nous envoyons le dernier octet de notre paquet DSA).
Ces différences de temps nous permettent de suivre les dérives d'horloge et les délais réseau, qui montrent des motifs prévisibles dans le temps : nous avons expérimenté avec des régressions linéaires et splines, mais au final, rien n'a mieux fonctionné que de simplement réutiliser la mesure la plus récente. Peut-être que l'apprentissage profond pourrait donner de meilleurs résultats ; cela est laissé en exercice pour le lecteur intéressé.
Plus important encore, nous augmentons encore nos chances de gagner cette condition de course en ajustant lentement notre timing grâce à un feedback involontaire de sshd :
si nous recevons une réponse (SSH2_MSG_USERAUTH_FAILURE) à notre paquet de clé publique DSA, alors nous l'avons envoyé trop tôt (sshd a eu le temps de recevoir notre paquet dans le fils non privilégié, de l'analyser, de l'envoyer au fils privilégié, de l'analyser là-bas, et de renvoyer une réponse jusqu'à nous) ;
si nous ne pouvons même pas envoyer le dernier octet de notre paquet DSA, alors nous avons attendu trop longtemps (sshd a déjà reçu le SIGALRM et a fermé notre connexion) ;
si nous pouvons envoyer le dernier octet de notre paquet DSA, et ne recevons aucune réponse avant que sshd ne ferme notre connexion, alors notre timing était raisonnablement précis.
Ce feedback nous permet de cibler ce que nous appelons la "grande" fenêtre de course : l'atteindre ne garantit pas de gagner la condition de course, mais à l'intérieur de cette grande fenêtre se trouvent les 24 "petites" fenêtres de course (à l'intérieur des 24 appels à free()) qui, si elles sont atteintes, garantissent que nous gagnons la condition de course.
Avec ces améliorations, il faut en moyenne ~10 000 tentatives pour gagner cette condition de course ; c'est-à-dire, avec 10 connexions (MaxStartups) acceptées par 600 secondes (LoginGraceTime), il faut en moyenne ~1 semaine pour obtenir un shell root distant.
Je dors quand le soleil commence à se lever
-- The Interrupters, "Alien"
Le gestionnaire SIGALRM de cette version d'OpenSSH n'appelle plus packet_close() ; de plus, la glibc de cet Ubuntu (2.3.6) prend toujours un verrou obligatoire lors de l'entrée dans les fonctions de la famille malloc (même si mono-threadé comme sshd), ce qui nous empêche d'interrompre un appel à l'une des fonctions malloc et de l'exploiter plus tard lors d'un autre appel à ces fonctions (elles seraient toujours en interblocage). Nous devons trouver une autre solution.
CVE-2006-5051 mentionne un double-free dans GSSAPI, mais GSSAPI (ou Kerberos) n'est pas activé par défaut, donc cela ne semble pas très attrayant. D'un autre côté, PAM est activé par défaut, et pam_end() est appelée par le gestionnaire SIGALRM de sshd (et n'est bien sûr pas sûre pour les signaux asynchrones). Nous avons donc cherché une fonction PAM qui, si elle est interrompue par SIGALRM au bon moment, laisserait les structures internes de PAM dans un état incohérent, exploitable pendant pam_end() dans le gestionnaire SIGALRM. Nous avons trouvé 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; ------------------------------------------------------------------------If this function is interrupted by SIGALRM after line 66 but before line 74, then data_entry is already linked into PAM's structures (pamh), but its cleanup field (a function pointer) is not yet initialized (since the malloc() at line 57 does not initialize its memory). If we are able to control cleanup (through leftovers from previous heap allocations), then we can execute arbitrary code when pam_end() (inside the SIGALRM handler) calls _pam_free_data() (at line 118):
Cela aurait été une exploitation extrêmement simple ; malheureusement, nous avons complètement négligé que pam_set_data() ne peut être appelée que depuis des modules PAM : si nous l'interrompons avec SIGALRM, alors pamh->caller_is est toujours _PAM_CALLED_FROM_MODULE, auquel cas pam_end() retourne immédiatement, sans jamais appeler _pam_free_data(). Retour à la planche à dessin.
On n'abandonne pas, ce n'est pas ce qu'on fait
-- The Interrupters, « Title Holder »
Nous avons remarqué qu'à la ligne 601 ci-dessous, sshd passe un pointeur vers son pointeur global sshpam_handle directement à pam_start() (qui est appelée une fois par connexion) :
Nous avons donc décidé d'examiner pam_start() elle-même : si interrompue par SIGALRM, elle pourrait laisser la structure pointée par sshpam_handle dans un état incohérent, qui pourrait ensuite être exploité à l'intérieur du gestionnaire SIGALRM, lorsque « pam_end(sshpam_handle, sshpam_err) » est appelée.
À la ligne 32, pam_start() définit immédiatement sshpam_handle de sshd vers un bloc de mémoire alloué par calloc() ; c'est sûr, car calloc() initialise cette mémoire à zéro. En revanche, si _pam_add_handler() (qui est appelée plusieurs fois par pam_start()) est interrompue par SIGALRM après la ligne 874 mais avant la ligne 886, alors une structure allouée par malloc() est liée dans pamh, mais son champ next n'est pas encore initialisé. Si nous pouvons contrôler next (via des résidus d'allocations précédentes du tas), alors nous pouvons passer un pointeur arbitraire à free() lors de l'appel à pam_end() (à l'intérieur du gestionnaire SIGALRM), aux lignes 1020 (et 1017) ci-dessous :
Comme le malloc de la glibc de cet Ubuntu est déjà durci contre l'ancienne technique unlink(), nous avons décidé de transformer notre free() arbitraire en House of Mind du Malloc Maleficarum (version fastbin) : nous libérons notre propre bloc NON_MAIN_ARENA, pointons notre fausse arena sur .got.plt de sshd (cet Ubuntu a ASLR mais pas PIE), et écrasons l'entrée de _exit() avec l'adresse de notre shellcode dans le tas (le tas de cet Ubuntu est encore exécutable par défaut). Pour plus d'informations sur le Malloc Maleficarum :
https://seclists.org/bugtraq/2005/Oct/118
J'ai tout appris à la dure
-- The Interrupters, « The Hard Way »
Pour monter cette attaque contre sshd, nous avons d'abord fait face à trois problèmes :
La House of Mind exige que nous stockions le pointeur vers notre fausse arena à l'adresse 0x08100000 dans le tas ; mais pouvons-nous stocker des données contrôlées par l'attaquant à une adresse aussi élevée ? Comme sshd appelle pam_start() au tout début de l'authentification de l'utilisateur, nous ne contrôlons rien d'autre que le nom d'utilisateur lui-même ; heureusement, un nom d'utilisateur d'une longueur d'environ 128 Ko (plus court que DEFAULT_MMAP_THRESHOLD) nous permet de stocker nos propres données à l'adresse 0x08100000.
Le champ size de notre faux bloc NON_MAIN_ARENA ne doit pas être trop grand (pour passer les vérifications de sécurité de free()) ; c'est-à- dire qu'il doit contenir des octets nuls. Mais notre long nom d'utilisateur est une chaîne terminée par un null qui ne peut pas contenir d'octets nuls ; heureusement, nous nous sommes souvenus que _pam_free_handlers_aux() met à zéro les structures qu'elle libère (ligne 1019 ci-dessus) : nous « patchons » donc le champ size de notre faux bloc avec un tel memset(0), et seulement ensuite le libérons.
Nous devons survivre à plusieurs appels à free() (aux lignes 1017 et 1020 ci-dessus) avant le free() de notre faux bloc NON_MAIN_ARENA. Nous transformons ces free() en no-op en les pointant vers de faux blocs IS_MMAPPED : free() appelle munmap_chunk(), qui appelle munmap(), qui échoue parce que ces faux blocs IS_MMAPPED sont mal alignés ; effectivement un no-op, car les échecs d'assert()ion ne sont pas appliqués dans la glibc de cet Ubuntu.
Enfin, notre long nom d'utilisateur nous permet également de contrôler le champ next potentiellement non initialisé de 20 structures différentes (via des résidus de copies temporaires de notre long nom d'utilisateur), car pam_start() appelle _pam_add_handler() plusieurs fois ; c'est-à-dire que notre grande fenêtre de concurrence contient 20 petites fenêtres de concurrence.
Les mêmes trucs qu'ils utilisaient avant
-- The Interrupters, « Divide Us »
Pour cette attaque contre Ubuntu 6.06.1, nous avons simplement réutilisé la stratégie de timing que nous avions utilisée contre Debian 3.0r6 : il faut en moyenne ~10 000 tentatives pour gagner la condition de course, et avec 10 connexions (MaxStartups) acceptées toutes les 120 secondes (LoginGraceTime), il faut en moyenne ~1-2 jours pour obtenir un shell root distant.
Remarque : comme la glibc de cet Ubuntu prend toujours un verrou obligatoire en entrant dans les fonctions de la famille malloc, un attaquant malchanceux pourrait bloquer toutes les 10 connexions MaxStartups avant d'obtenir un shell root ; nous n'avons pas essayé de contourner ce problème car notre objectif ultime était de toute façon d'exploiter une version moderne d'OpenSSH.
Maintenant tu es prêt, fais face aux démons
-- The Interrupters, « Be Gone »
Le gestionnaire SIGALRM de cette version d'OpenSSH n'appelle ni packet_close() ni pam_end() ; en fait, il n'appelle qu'une seule fonction intéressante, syslog() :
Nos deux questions clés sont alors : est-ce que syslog() de la glibc de cette Debian (2.36) appelle des fonctions non async-signal-safe comme malloc() et free() ? Et si oui, cette glibc prend-elle toujours un verrou obligatoire en entrant dans les fonctions de la famille malloc ?
Remarque : comme nous ne contrôlons rien à propos de ces allocations malloc() (ni leur ordre, ni leurs tailles, ni leurs contenus), nous avons pris le « rce » à la ligne 166 comme un présage bienvenu.
Et heureusement pour nous, la réponse à notre deuxième question est non ; depuis octobre 2017, les fonctions malloc de la glibc ne prennent plus aucun verrou lorsqu'elles sont en monothread (comme 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
De plus, cette version de Debian souffre de la faiblesse d'ASLR décrite dans les excellents articles de blog suivants (par Justin Miller et Mathias Krause, respectivement) :
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
Concrètement, dans le cas de sshd sur i386, chaque mappage mémoire est randomisé normalement (PIE de sshd, le tas, la plupart des bibliothèques, la pile), mais la glibc elle-même est toujours mappée soit à l'adresse 0xb7200000, soit à l'adresse 0xb7400000 ; en d'autres termes, nous pouvons deviner correctement l'adresse de la glibc la moitié du temps (un petit prix à payer pour vaincre l'ASLR). Dans notre exploit, nous supposons que la glibc est mappée à l'adresse 0xb7400000, car elle est légèrement plus courante que 0xb7200000.
Notre question suivante est : quels chemins de code à l'intérieur des fonctions malloc de la glibc, si interrompus par SIGALRM au bon moment, laissent le tas dans un état incohérent, exploitable lors d'un des appels à malloc() à l'intérieur du gestionnaire SIGALRM ?
Nous avons trouvé plusieurs chemins de code intéressants (et surprenants !), mais celui que nous avons choisi n'implique que des tailles relatives, pas d'adresses absolues (contrairement à divers chemins de code dans unlink_chunk(), par exemple) ; cette différence pourrait s'avérer cruciale pour un futur exploit amd64. Ce chemin de code, à l'intérieur de malloc(), divise un grand bloc libre (victim) en deux blocs plus petits ; le premier bloc est retourné à l'appelant de malloc() (à la ligne 4345) et le second bloc (remainder) est lié dans une liste non triée de blocs libres (aux lignes 4324-4327) :
Si ce chemin de code est interrompu par SIGALRM après la ligne 4327 mais avant la ligne 4339, alors le bloc remainder de cette division est déjà lié dans la liste non triée des blocs libres (lignes 4324- 4327), mais son champ size (mchunk_size) n'est pas encore initialisé (ligne 4339).
Si nous pouvons contrôler son champ size (via des résidus d'allocations précédentes du tas), alors nous pouvons agrandir ce bloc remainder et le faire chevaucher d'autres blocs du tas, et donc corrompre la mémoire du tas lorsque ce bloc remainder agrandi et chevauchant sera finalement alloué par malloc() et écrit (à l'intérieur du gestionnaire SIGALRM).
Notre dernière question est alors : étant donné que nous ne contrôlons rien à propos des appels à malloc() à l'intérieur du gestionnaire SIGALRM, que pouvons-nous écraser dans le tas pour parvenir à une exécution de code arbitraire avant que sshd n'appelle _exit() (dans sshsigdie()) ?
Parce que __tzfile_read() (à l'intérieur du gestionnaire SIGALRM) malloc() une structure FILE dans le tas (à la ligne 166 ci-dessus), et parce que les structures FILE ont une longue histoire d'abus pour l'exécution de code arbitraire, nous avons décidé de viser notre corruption du tas sur cette structure FILE. C'est cependant plus facile à dire qu'à faire : notre corruption du tas est très limitée, et les structures FILE ont été considérablement durcies au fil des ans (par IO_validate_vtable() et PTR_DEMANGLE(), par exemple).
Finalement, nous avons conçu la technique suivante (qui semble être spécifique à la glibc i386 — la glibc amd64 ne semble pas du tout utiliser _vtable_offset) :
avec notre corruption limitée du tas, nous écrasons le champ _vtable_offset (un seul caractère signé) de la structure FILE de __tzfile_read() ;
les fonctions libio de la glibc chercheront donc le pointeur vtable de cette structure FILE (un pointeur vers un tableau de pointeurs de fonctions) à un décalage non nul (notre _vtable_offset écrasé), au lieu du décalage par défaut de zéro ;
nous (attaquants) pouvons facilement contrôler ce faux pointeur vtable (via des résidus d'allocations précédentes du tas), car le champ de la structure FILE autour de ce décalage n'est pas explicitement initialisé par fopen() ;
pour passer les vérifications de sécurité de la glibc, notre faux pointeur vtable doit pointer quelque part dans la section __libc_IO_vtables : nous avons décidé de le pointer vers la vtable pour les flux de caractères larges, _IO_wfile_jumps (c'est-à-dire vers 0xb761b740, puisque nous supposons que la glibc est mappée à l'adresse 0xb7400000) ;
en conséquence, __fread_unlocked() (à la ligne 186 ci-dessus) appelle _IO_wfile_underflow() (au lieu de _IO_file_underflow()), qui appelle un pointeur de fonction (__fct) qui provient fondamentalement d'une structure dont le pointeur (_codecvt) est encore un autre champ de la structure FILE ;
nous (attaquants) pouvons facilement contrôler ce pointeur _codecvt (via des résidus d'allocations précédentes du tas, car ce champ de la structure FILE n'est pas explicitement initialisé par fopen()), ce qui nous permet également de contrôler le pointeur de fonction __fct.
En résumé, en écrasant un seul octet (_vtable_offset) de la structure FILE allouée par fopen(), nous pouvons appeler notre propre pointeur de fonction __fct et exécuter du code arbitraire pendant __fread_unlocked().
Je le voulais parfait, sans aucun pli
-- The Interrupters, « In the Mirror »
Pour monter cette attaque contre le processus fils privilégié de sshd, imaginons d'abord la disposition suivante du tas (les « XXX » sont des blocs « barrières » qui nous permettent de faire des trous dans le tas ; par exemple, de petits blocs avec fuite mémoire) :
---|----------------------------------------------|---|------------|---
| XXX | large trou | XXX | petit trou | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | grand bloc alloué | bloc remainder libre | XXX | petit trou | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
mais si ce malloc() est interrompu par SIGALRM après la ligne 4327 mais avant la ligne 4339, alors le bloc remainder de cette division est déjà lié dans la liste non triée des blocs libres, mais son champ size est sous notre contrôle (via des résidus d'allocations précédentes du tas), et ce bloc remainder artificiellement agrandi chevauche le petit trou suivant :---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | real remainder chunk |XXX| small hole |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| remainder chunk artificiellement agrandi
lorsque le gestionnaire SIGALRM appelle syslog() et donc __tzfile_read(), fopen() malloc()ate le petit trou pour sa structure FILE, et __fread_unlocked() malloc()ate un tampon de lecture de 4 Ko, divisant ainsi le remainder chunk agrandi en deux (le tampon de lecture de 4 Ko et un petit remainder chunk) :
---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<------->| 4KB read buffer remainder
nous écrasons donc des parties de la structure FILE avec l'en-tête interne de ce petit remainder chunk : plus précisément, nous écrasons le _vtable_offset du FILE avec le troisième octet du champ bk de cet en-tête, qui est un pointeur vers la liste non triée des chunks libres, 0xb761d7f8 (c'est-à-dire que nous écrasons _vtable_offset avec 0x61) ;
puis, comme expliqué dans la sous-section « Theory », __fread_unlocked() appelle _IO_wfile_underflow() (au lieu de _IO_file_underflow()), qui appelle notre propre pointeur de fonction __fct (via notre propre pointeur _codecvt) et exécute notre code arbitraire.
Remarque : nous n'avons pas encore expliqué comment passer de manière fiable d'un pointeur _codecvt contrôlé à un pointeur de fonction __fct contrôlé ; nous le ferons, mais nous devons d'abord résoudre un problème plus pressant.
En effet, nous avons appris de nos travaux sur les anciennes versions d'OpenSSH que nous ne gagnerions jamais cette condition de course du gestionnaire de signal si notre grande fenêtre de course ne contenait qu'une seule petite fenêtre de course. Par conséquent, nous avons mis en œuvre la stratégie suivante, basée sur la disposition de tas suivante :
---|------------|---|------------|---|------------|---|------------|---
| XXX | large hole 1 | XXX | small hole 1 | XXX | large hole 2 | XXX | small hole 2 | ... |
|---|---|---|---|---|---|---|---|---|
Le dernier paquet que nous envoyons à sshd (peu avant la livraison de SIGALRM) force sshd à effectuer la séquence d'appels malloc() suivante : malloc(~4 Ko), malloc(304), malloc(~4 Ko), malloc(304), etc.
1/ Notre premier malloc(~4 Ko) divise le large hole 1 en deux :
si cette première division est interrompue par SIGALRM au bon moment, alors le fopen() à l'intérieur du gestionnaire SIGALRM malloc()ate le small hole 1 pour sa structure FILE, et nous obtenons une exécution de code arbitraire comme expliqué ci-dessus ;
sinon, nous malloc()ons nous-mêmes le small hole 1 avec notre premier malloc(304), et :
2/ Notre deuxième malloc(~4 Ko) divise le large hole 2 en deux :
si cette deuxième division est interrompue par SIGALRM au bon moment, alors le fopen() à l'intérieur du gestionnaire SIGALRM malloc()ate le small hole 2 pour sa structure FILE, et nous obtenons une exécution de code arbitraire comme expliqué ci-dessus ;
sinon, nous malloc()ons nous-mêmes le small hole 2 avec notre deuxième malloc(304), etc.
Nous avons pu créer 27 paires de tels grands et petits trous dans le tas de sshd (28 dépasseraient PACKET_MAX_SIZE, 256 Ko) : notre grande fenêtre de course contient maintenant 27 petites fenêtres de course ! Obtenir cette disposition de tas complexe a été extrêmement pénible et long, mais les deux points forts sont :
Pour obtenir de manière fiable cette disposition de tas, nous envoyons cinq paquets de clé publique différents à sshd (les paquets a/ à d/ peuvent être envoyés bien avant SIGALRM ; la majeure partie du paquet e/ peut également être envoyée bien avant SIGALRM, mais son tout dernier octet doit être envoyé au tout dernier moment) :
a/ Nous malloc()ons et free()ons une variété de chunks tcache, pour garantir que les allocations de tas que nous ne contrôlons pas aboutissent dans ces chunks tcache et n'interfèrent pas avec notre disposition de tas minutieuse.
b/ Nous malloc()ons et free()ons des chunks de différentes tailles, pour créer nos 27 paires de grands et petits trous (et les chunks « barrière » correspondants).
c/ Nous malloc()ons et free()ons des chunks d'environ 4 Ko et 320 octets, pour :
écrire le faux en-tête (le grand champ de taille) de notre remainder chunk potentiellement agrandi, au milieu de nos grands trous ;
écrire le faux pied de page de notre remainder chunk potentiellement agrandi, à la fin de nos petits trous (pour passer les vérifications de sécurité de glibc) ;
écrire nos faux pointeurs vtable et _codecvt, dans nos petits trous (qui sont des structures FILE potentielles).
d/ Nous malloc()ons et free()ons une très grande chaîne (près de 256 Ko), pour garantir que nos grands et petits trous soient retirés de la liste non triée des chunks libres et placés dans leurs bins malloc respectifs.
e/ Nous forçons sshd à effectuer notre séquence finale d'appels malloc() (malloc(~4 Ko), malloc(304), malloc(~4 Ko), malloc(304), etc.), pour ouvrir nos 27 petites fenêtres de course.
Les lecteurs attentifs auront remarqué que nous n'avons toujours pas abordé (littéralement et figurativement) le problème de _codecvt. En fait, _codecvt est un pointeur vers une structure (_IO_codecvt) qui contient un pointeur vers une structure (__gconv_step) qui contient le pointeur de fonction __fct qui nous permet d'exécuter du code arbitraire. Pour contrôler de manière fiable __fct via _codecvt, nous pointons simplement _codecvt vers l'un des bins malloc de glibc, qui contient opportunément un pointeur vers l'un de nos chunks libres dans le tas, lequel contient notre propre pointeur de fonction __fct vers du code glibc arbitraire (toutes ces adresses glibc nous sont connues, car nous supposons que glibc est mappée à l'adresse 0xb7400000).
We're running out of time
-- The Interrupters, "As We Live"
En implémentant ce troisième exploit, il est devenu clair que nous ne pouvions pas simplement réutiliser la stratégie de synchronisation que nous avions utilisée contre les deux anciennes versions d'OpenSSH : nous ne gagnions jamais cette nouvelle condition de course. Finalement, nous avons compris pourquoi :
Il faut beaucoup de temps (~10 ms) à sshd pour analyser notre cinquième et dernière clé publique (paquet e/ ci-dessus) ; en d'autres termes, notre grande fenêtre de course est trop grande (nos 27 petites fenêtres de course sont comme des aiguilles dans une botte de foin).
Le user_specific_delay() qui a été introduit récemment (OpenSSH 7.8p1) retarde la réponse de sshd à notre dernier paquet de clé publique jusqu'à ~9 ms et détruit donc notre stratégie de synchronisation basée sur le feedback.
En conséquence, nous avons développé une stratégie de synchronisation complètement différente :
de temps en temps, nous envoyons notre dernier paquet de clé publique avec une petite erreur qui produit une réponse d'erreur (lignes 138-142 ci-dessous), juste avant l'appel à sshkey_from_blob() qui analyse notre clé publique ;
de temps en temps, nous envoyons notre dernier paquet de clé publique avec une autre petite erreur qui produit une réponse d'erreur (lignes 151-155 ci-dessous), juste après l'appel à sshkey_from_blob() qui analyse notre clé publique ;
la différence entre ces deux temps de réponse est le temps qu'il faut à sshd pour analyser notre dernière clé publique, et cela nous permet de synchroniser précisément la transmission de nos derniers paquets (pour garantir que sshd a le temps d'analyser notre clé publique dans le processus enfant non privilégié, de l'envoyer au processus enfant privilégié, et de commencer à l'analyser là-bas, avant la livraison de SIGALRM).
Avec ce changement de stratégie, il faut en moyenne ~10 000 tentatives pour gagner la condition de course ; c'est-à-dire, avec 100 connexions (MaxStartups) acceptées toutes les 120 secondes (LoginGraceTime), il faut en moyenne ~3-4 heures pour gagner la condition de course, et ~6-8 heures pour obtenir un shell root distant (à cause d'ASLR).
What's your plan for tomorrow?
-- The Interrupters, "Take Back the Power"
Nous avons décidé de cibler Rocky Linux 9 (un dérivé de Red Hat Enterprise Linux 9), à partir de « Rocky-9.4-x86_64-minimal.iso », pour deux raisons :
sa version d'OpenSSH (8.7p1) est vulnérable à cette condition de course du gestionnaire de signal et sa glibc est toujours mappée à un multiple de 2 Mo (à cause de la faiblesse d'ASLR discutée dans la sous-section « Theory » précédente), ce qui rend les écrasements partiels de pointeur beaucoup plus puissants ;
la fonction syslog() (qui n'est pas async-signal-safe mais est appelée par le gestionnaire SIGALRM de sshd) de cette version de glibc (2.34) appelle en interne __open_memstream(), qui malloc()ate une structure FILE dans le tas, et appelle également calloc(), realloc(), et free() (ce qui nous donne une liberté bien nécessaire).
Avec une corruption de tas comme primitive, deux structures FILE malloc()ées dans le tas, et 21 bits fixes dans les adresses de glibc, nous pensons que cette condition de course du gestionnaire de signal est exploitable sur amd64 (probablement pas en ~6-8 heures, mais espérons-le en moins d'une semaine). Seul le temps nous le dira.
Remarque annexe : nous avons découvert qu'Ubuntu 24.04 ne re-randomise pas l'ASLR de ses enfants sshd (il n'est randomisé qu'une seule fois, au démarrage) ; nous avons retracé cela jusqu'au correctif ci-dessous, qui désactive le rexec_flag de sshd. C'est généralement une mauvaise idée, mais dans le cas particulier de cette condition de course du gestionnaire de signal, cela empêche sshd d'être exploitable : le syslog() à l'intérieur du gestionnaire SIGALRM n'appelle aucune des fonctions malloc, car ce n'est jamais le tout premier appel à syslog().
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
The storm has come and gone
-- The Interrupters, "Good Things"
Le 6 juin 2024, cette condition de course du gestionnaire de signal a été corrigée par le commit 81c1099 (« Add a facility to sshd(8) to penalise particular problematic client behaviours »), qui a déplacé le code non async-signal-safe du gestionnaire SIGALRM de sshd vers le processus écouteur de sshd, où il peut être traité de manière synchrone :
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
Étant donné que ce correctif fait partie d'un grand commit (81c1099), par-dessus un commit de défense en profondeur encore plus grand (03e3de4, « Start the process of splitting sshd into separate binaries »), il pourrait s'avérer difficile à rétroporter. Dans ce cas, la condition de course du gestionnaire de signal elle-même peut être corrigée en supprimant ou en commentant le code non async-signal-safe de la fonction sshsigdie() ; par exemple :
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);
Enfin, si sshd ne peut pas être mis à jour ou recompilé, cette condition de course du gestionnaire de signal peut être corrigée en définissant simplement LoginGraceTime à 0 dans le fichier de configuration. Cela rend sshd vulnérable à un déni de service (l'épuisement de toutes les connexions MaxStartups), mais cela le rend sûr de l'exécution de code à distance présentée dans cet avis.
Nous remercions les développeurs d'OpenSSH pour leur excellent travail et leur étroite collaboration sur cette version. Nous remercions également distros@openwall. Enfin, nous dédions cet avis à Sophia d'Antoine.
2024-05-19 : Nous avons contacté les développeurs d'OpenSSH. Des itérations successives de correctifs et de révisions de correctifs ont suivi.
2024-06-20 : Nous avons contacté distros@openwall.
2024-07-01 : Date de publication coordonnée.
| ~8KB |
| 320B |
| ~8KB |
| 320B |