Exploitation du CVE-2019-15666 par rétroconception du POC binaire

Par BU Cyber

Le 15 janvier 2020, le chercheur en sécurité Vitaly Nikolenko a publié un article de blog dans la continuité de la divulgation du CVE-2019-15666. Dans ce billet, il détaille un exploit, avec élévation de privilèges, utilisant cette vulnérabilité pour passer root à partir d’un compte utilisateur standard.

En plus de l’article, il a diffusé un rapport technique ainsi qu’une preuve de concept (PoC) pour son exploit. Cependant, seule une version binaire du PoC a été publiée et son code source n’a pas été partagé. L’exploit est basé sur un Use-After-Free dans le sous-système XFRM. Dans le rapport technique, Vitaly résume brièvement les étapes pour déclencher le bogue, mais demeure globalement à un niveau relativement abstrait.

Afin de mieux comprendre la vulnérabilité et la façon dont elle est exploitée, nous avons entrepris une rétroconception du binaire. Le but était principalement d’être en mesure de recoder l’exploit tout en maîtrisant l’ensemble du processus d’exploitation.

Avec une machine virtuelle (VM) exécutant un noyau vulnérable et débogable en direct, deux approches ont été travaillées : une analyse statique et une analyse dynamique. Les sections suivantes détaillent ces deux approches.

Comprendre les politiques XFRM

L’exploit tourne autour de l’insertion d’une nouvelle politique XFRM pour déclencher un bogue de type Use-After-Free.

XFRM est un module du noyau Linux dont le boulot est de contribuer aux fonctionnalités IPsec en gérant la Security Policy Database (SPD) et la Security Association Database (SAD). Cet article a été réellement utile pour la compréhension des politiques XFRM, leur rôle et comment elles peuvent être manipulées par l’utilisateur. Il inclut également un exemple en C avec les données structurelles correctes à utiliser pour manipuler les politiques XFRM à l’intérieur d’un programme.

Le rapport technique concernant la vulnérabilité fait apparaître que l’exploit est basé sur les insertions sucessives de deux nouvelles politiques :

  • l’objet correspondant à la première politique est inséré à l’index 0 (auto-généré par le sous-système), direction 0 and priorité 0;
  • l’objet lié à la seconde politique est inséré avec l’index défini par l’utilisateur 4, direction 0, priorité 1 (>0) et avec un minuteur ;
  • une requête XFRM_SPD_IPV4_HTHRESH est envoyée pour déclencher le rehashing des politiques ;
  • une requête XFRM_FLUSH_POLICY est ensuite envoyée, libérant la première politique ;
  • une fois que la minuterie arrive à échéance, l’UAF est déclenché sur la première politique, qui a été libérée lors de l’étape précédente.

L’article mentionné plus haut montre comment insérer une nouvelle politique de sécurité, ce qui correspond exactement aux premières opérations de l’exploit ; seules quelques adaptations sont nécessaires par rapport à la politique générique donnée dans l’exemple.

Traduction du PoC en code humainement lisible

Ce sont principalement IDA et Ghidra ont été utilisés pour effectuer la rétroconception du binaire ; l’article sur les politiques XFRM a d’ailleurs permis de faciliter ce travail en aidant à retrouver plus facilement l’endroit où les politiques étaient insérées. Comme le code source était en C, la rétroconception dans sa globalité n’était pas si évidente, même si le désassemblage de Ghidra a été bien sollicité pour les structures de données.

Le code C pour l’exploit et tous les autres fichiers sont disponibles sur notre Github.

Identification des appels système

Selon la liste des importations présentées par IDA, la fonction syscall() est employée plusieurs fois dans l’exploit.

https://blog-cyber.riskeco.com/wp-content/uploads/2020/07/imports.png
Fonctions importées dans le binaire

En validant les références croisées, la syscall fonction est utilisée quatre fois, incluant les trois fois de la fonction main et un usage supplémentaire dans une sous-fonction anonyme (comme le binaire est strippé). Pour comprendre quels appels système sont utilisés, la table de ces appels peut être consultée. Il est important de noter que l’exploit est compilé en 64 bits.

Les trois appels système de main() sont de simples appels à exit , mais pour l’appel de la sous-fonction, il s’agit du numéro d’appel système 0x143, qui correspond à :

323 common userfaultfd sys_userfaultfd

Cet appel est utilisé dans la technique de heap spraying qui sera expliquée davantage dans les paragraphes à venir. Comme l’appel système userfaultfd ne dispose pas d’une prise en compte dans la glibc, il est nécessaire de conserver l’usage de syscall() et de simplement remplacer la valeur 0x143 par la constate plus appropriée SYS_userfaultfd dans le code C reconstitué.

Fanions de mauvaise requête

La rétroconception des fonctions qui envoient des messages au noyau est quasiment directe et produit un code compréhensible :

https://blog-cyber.riskeco.com/wp-content/uploads/2020/07/xfrm_add_policy_0.png
Code d'assemblage pour l'ajout d'une politique

Cependant, les fanions disponibles pour une requête XFRM_MSG_NEWPOLICY sont précisés dans le fichier include/uapi/linux/netlink.h :

/* Flags values */

#define NLM_F_REQUEST		0x01	/* It is request message. 	*/
...

/* Modifiers to NEW request */
#define NLM_F_REPLACE   0x100    /* Override existing		*/
#define NLM_F_EXCL      0x200    /* Do not touch, if it exists	*/
#define NLM_F_CREATE    0x400    /* Create, if it does not exist	*/
#define NLM_F_APPEND    0x800    /* Add to end of list		*/ 

Il n’y donc pas de somme cohérente qui aboutit à 0x301, comme 0x100 et 0x200 sont contradictoires. Et il existe plusieurs endroits dans le noyau où les deux drapeaux sont effectivement mutuellement exclusifs. Ceci tend à laisser penser qu’il pourrait y avoir quelques coquilles dans le PoC.

Autres coquilles dans les sources d'origine ?

Deux adresses IPv6 sont définies dans la fonction xfrm_add_policy_0()  :

  • une destination: fe80:0:0:0:0:0:0:aa(fe80::aa), qui est l’une des valeurs réservées pour l’adresse de lien local;
  • une source: 0:0:0:0:a00:0:0:0, qui mène nulle part.

Il y a également une double copie du premier bloc IPv6 dans la fonction :

https://blog-cyber.riskeco.com/wp-content/uploads/2020/07/typo.png
Typo

La second copie ciblait peut-être l’adresse de source mais une confusion entre daddr et saddr s’est peut-être ensuite installée dans le code.

L'appel système userfaultfd()

Fautes de page

Les fautes de page sont un type d’exceptions levées par le CPU pour informer l’OS d’un accès à des données qui ne sont pas (encore) placées en mémoire. En réponse à la faute, l’OS va allouer davantage de pages au processus et remplir ce nouvel espace avec les données requises. Il va ensuite actualiser la MMU et indiquer au CPU de reprendre son exécution.

Contrairement à ce que pourrait laisser penser son nom, une faute de page n’est donc pas une erreur mais un événement plutôt commun et usuel. La plupart du temps, les fautes de page sont gérées à partir de l’espace noyau, mais l’appel système userfaultfd() est utilisé pour assurer cette gestion dans l’espace utilisateur.

userfaultfd() a été créé à l’origine pour étendre les capacités de Linux au niveau de la mémoire virtuelle : par exemple, le nouvel appel système a augmenté la vitesse d’une migration en direct de machines virtuelles entre des hôtes physiques, ou permet la capture d’instantanés de processus en cours sans interruption. Plusieurs cas d’utilisation de cette fonctionnalité existent et sont listés dans la page citée ci-avant.

Manipulation de la mémoire avec userfaultfd()

L’auteur de l’exploit a publié un excellent billet il y a un an, dans lequel il partage une technique très efficace qui utilise userfaultfd() et setxattr() afin d’exploiter des bogues de type Use-After-Free dans le noyau Linux via un heap spray.

L’idée principale est que userfaultfd() donne le contrôle de l’espérance de vie de données qui sont allouées par setxattr() dans l’espace noyau. Comme spécifié dans la page de manuel associée à userfaultfd(), tant que le descripteur de fichier reste ouvert, l’objet reste dans l’espace noyau :

When the last file descriptor referring to a userfaultfd object is closed, all memory ranges that were registered with the object are unregistered and unread events are flushed.

Dans l’ensemble, l’emploi de userfaultfd est relativement complexe, surtout dans l’optique d’une attaque pour ce genre d’exploitation.

Exécution de l'exploit

Même si ce n’est pas obligatoire, l’utilisation d’une machine virtuelle pour exécuter l’exploit facilite la compréhension de l’exploitation. Une petite VM avec 2 Go de RAM, 20 Go d’espace disque, 2 CPU et une installation Ubuntu de base est suffisante.

Compilation d'un noyau vulnérable

Téléchargement des sources

Le CVE-2019-15666 a été corrigé dans le noyau 4.15.0-60.67 pour Ubuntu 18.04 LTS (Bionic Beaver).

La liste de toutes les versions peut être récupérée grâce à la commande suivante :

$ apt-cache search linux-image-unsigned-4.15 | grep generic
...
linux-image-unsigned-4.15.0-55-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-58-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-60-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-62-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-64-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-65-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-66-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-69-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP
linux-image-unsigned-4.15.0-70-generic - Linux kernel image for version 4.15.0 on 64 bit x86 SMP

Afin d’obtenir les sources d’un noyau depuis le serveur d’Ubuntu, la ligne suivante doit être décommentée dans le fichier /etc/apt/sources.list  :

deb-src http://fr.archive.ubuntu.com/ubuntu/ bionic main restricted

Ensuite la liste des données disponibles doit être raffraichie via apt-get update et les dépendances pour la compilation d’un noyau peuvent être téléchargées :

# apt-get update
# apt-get build-dep linux linux-image-$(uname -r)
# apt-get install libncurses-dev flex bison openssl libssl-dev dkms libelf-dev libudev-dev libpci-dev libiberty-dev autoconf
# apt-get install fakeroot

Les sources du dernier noyau existant avant que le correctif ne soit appliqué peuvent être téléchargées dans un nouveau répertoire avec :

$ mv kernel kernel.old
$ mkdir kernel
$ cd kernel
$ apt-get source linux-image-unsigned-4.15.0-58-generic

Un coup d’oeil rapide à la fonction verify_newpolicy_info() définie dans le fichier net/xfrm/xfrm_user.c montre que le correctif n’a pas été appliqué, donc la fonction reste vulnérable comme attendu.

Compilation du noyau

Recompiler un noyau personnalisé et produire des paquets sont des opérations bien documentées, au moins pour Ubuntu. Cependant, le processus est relativement lourd et lent, comme quantité de modules noyau sont compilés afin de produire le noyau officiel Ubuntu. Il est plus efficace de reconstruire un noyau manuellement à partir des sources Ubuntu.

La première étape est de définir une configuration de construction minimale :

$ cd linux-4.15.0/
$ make defconfig

Le fonctionnalités requises par l’exploit doivent être activées :

$ echo CONFIG_USER_NS=y >> .config
$ echo CONFIG_USERFAULTFD=y >> .config

Afin d’extraire certaines informations à propos des structures impliquées depuis le binaire vmlinux final, cette option supplémentaire est nécessaire :

$ echo CONFIG_DEBUG_INFO=y >> .config

Afin de faire du débogage en direct sur le noyau courant avec gdb, il est conseillé de sélectionner quelques options supplémentaires :

$ echo CONFIG_GDB_SCRIPTS=y >> .config
$ echo CONFIG_FRAME_POINTER=y >> .config
$ echo CONFIG_KGDB=y >> .config
$ echo CONFIG_KGDB_SERIAL_CONSOLE=y >> .config
$ echo CONFIG_KDB_KEYBOARD=y >> .config

Le framebuffer est utile pour obtenir les messages de démarrage dans la console TTY. Le support associé peut être rapidement récupéré depuis la configuration courante :

$ < /boot/config-5.3.0-46-generic grep _FB_ >> .config

Si la VM est lancée par KVM, les pilotes virtio sont requis pour cette VM afin de supporter le disque et la carte réseau par défaut :

$ < /boot/config-5.3.0-46-generic grep _VIRTIO >> .config

Tous les modules doivent être intégrés dans le binaire central pour construire le noyau final, et la configuration a besoin d’être mise à jour :

$ sed -i 's/=m$/=y/' .config
$ make olddefconfig

L’ensemble de ces étapes de configuration peuvent également être lancées en utilisant le script define-cfg.sh.

Finalement le noyau peut être compilé :

$ make -j $(( $(nproc) * 2 ))

La compilation entière prend environ 15 minutes dans la petite VM décrite ici.

Démarrer sur un noyau vulnérable

L’image du noyau doit être copiée dans le répertoire /boot en premier lieu :

 # cp arch/x86/boot/bzImage /boot/vmlinuz-4.15.0-lucky

Pour le processus de démarrage, la configuration grub doit être modifiée :

 # sed -i 's/^GRUB_CMDLINE_LINUX_DEFAULT=.*$/GRUB_CMDLINE_LINUX_DEFAULT="text loglevel=1 kgdboc=ttyS0,115200 nokaslr"/' /etc/default/grub

De plus, il peut être confortable d’avoir le temps de modifier les options de démarrage au lancement :

# sed -i 's/^GRUB_TIMEOUT=.*$/GRUB_TIMEOUT=2/' /etc/default/grub
# sed -i 's/^GRUB_TIMEOUT_STYLE=.*$/GRUB_TIMEOUT_STYLE=countdown/' /etc/default/grub

Afin d’apprécier une résolution de console TTY plus grande, le framebuffer peut être également étendu :

# echo GRUB_GFXMODE=1920x1080x16 >> /etc/default/grub
# echo GRUB_GFXPAYLOAD_LINUX=keep >> /etc/default/grub

Ces valeurs viennent de l’outil hwinfo  :

# apt install hwinfo
# hwinfo --framebuffer | grep 1920x
  Mode 0x0387: 1920x1200 (+3840), 16 bits
  Mode 0x0388: 1920x1200 (+5760), 24 bits
  Mode 0x0389: 1920x1200 (+7680), 24 bits
  Mode 0x0390: 1920x1080 (+3840), 16 bits
  Mode 0x0391: 1920x1080 (+5760), 24 bits
  Mode 0x0392: 1920x1080 (+7680), 24 bits

Sans hwinfo, les paramètres peuvent être affichés dans le shell Grub (pressez [c]) grâce à la commande vbeinfo.

A l’intérieur du shell Grub, il existe une manière de faire défiler la sortie de la commande en définissant une variable :

 set pager=1

Sélectionner le nouveau noyau comme noyau par défaut est un peu subtil mais peut être obtenu avec le script update-grub.sh  :

 $ sudo ~/update-grub.sh && sudo reboot

Désactivation de la GUI graphique inutile

Pour éviter de démarrer une session graphique, systemd ne doit pas charger le gestionnaire de connexion graphique :

# systemctl enable multi-user.target --force
# systemctl set-default multi-user.target

A propos du débogage

Une fois que la VM est en fonctionnement, déclencher  kgdb se réalise avec la commande suivante :

# echo g > /proc/sysrq-trigger

Du côté de l’hôte, si /dev/pts/2 fait référence au périphérique série, gdb peut être attaché au noyau en cours d’exécution avec la copie locale du noyau binaire et la commande suivante :

$ gdb ./vmlinux -ex 'set remotebaud 115200' -ex 'target remote /dev/pts/2'

Explications quant à l'exploitation

Premiers indices

Le Use-After-Free décrit dans le rapport technique touche les politiques XFRM. La taille des structures concernées peut être récupérée en s’appuyant sur gdb:

$ gdb ./vmlinux --batch -ex 'p sizeof(struct xfrm_policy)'
$1 = 784

Toutes les politiques sont ainsi allouées en s’appuyant sur l’allocateur SLAB kmalloc-1024, qui prend en compte les demandes allant de 513 à 1024 octets.

Ce qui se révèle surprenant en regardant le code obtenu par rétroconception, c’est le fait que le heap spray produit par l’exploit d’origine ne remplit par la mémoire avec des valeurs choisies. Ce spray utilise une technique publique impliquant les appels système userfaultfd() et setxattr() :

...

void *addr;

addr = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED)
{
    perror("mmap");
    exit(-1);
}

ret = uffd_setup(addr, 0x1000, flag, idx);

...

setxattr("/etc/passwd", "user.test", addr, 0x400, XATTR_CREATE); 

return ret;

Mais dans la situation courante, la source pour la copie de userfaultfd() est uniquement du déchet de la pile de l’espace utilisateur :

...
void *addr;
struct uffdio_copy io_copy;
char src[0x1000];

...

addr = (void *)(msg.arg.pagefault.address & 0xfffffffffffff000);

io_copy.dst = (unsigned long)addr;
io_copy.src = (unsigned long)src;
io_copy.len = 0x1000;
io_copy.mode = 0;

if (...)
{
    if ((ioctl(fd, UFFDIO_COPY, &io_copy)) != 0)
        perror("UFFDIO_COPY");
}
else if ((ioctl(fd, UFFDIO_COPY, &io_copy)) != 0)
    perror("UFFDIO_COPY");
...

L’escalade de privilèges semble ne pas reposer sur une exploitation complexe basée sur une confusion de type opérant dans l’espace noyau.

Le rapport technique évoque une primitive d’écriture de 8 octets. Pourquoi ne pas écrire  struct cred directement ? Une fois encore, gdb arrive à la rescousse ! Le champ écrasé pprev est une valeur de 8 octets localisée à l’emplacement 0x16 :

$ gdb ./vmlinux --batch -ex 'pt /o struct xfrm_policy'
/* offset    |  size */  type = struct xfrm_policy {
/*    0      |     8 */    possible_net_t xp_net;
/*    8      |    16 */    struct hlist_node {
/*    8      |     8 */        struct hlist_node *next;
/*   16      |     8 */        struct hlist_node **pprev;

                               /* total size (bytes):   16 */
                           } bydst;
/*   24      |    16 */    struct hlist_node {
/*   24      |     8 */        struct hlist_node *next;
/*   32      |     8 */        struct hlist_node **pprev;

                               /* total size (bytes):   16 */
                           } byidx;
/*   40      |     8 */    rwlock_t lock;
/*   48      |     4 */    refcount_t refcnt;
...

L’analyse de la contenu de struct cred révèle que les champs sgid et euid sont des valeurs de 8 octets localisées à l’emplacement 0x16 :

$ gdb ./vmlinux --batch -ex 'pt /o struct cred'
/* offset    |  size */  type = struct cred {
/*    0      |     4 */    atomic_t usage;
/*    4      |     4 */    kuid_t uid;
/*    8      |     4 */    kgid_t gid;
/*   12      |     4 */    kuid_t suid;
/*   16      |     4 */    kgid_t sgid;
/*   20      |     4 */    kuid_t euid;
/*   24      |     4 */    kgid_t egid;
/*   28      |     4 */    kuid_t fsuid;
/*   32      |     4 */    kgid_t fsgid;
/*   36      |     4 */    unsigned int securebits;
/*   40      |     8 */    kernel_cap_t cap_inheritable;
/*   48      |     8 */    kernel_cap_t cap_permitted;
/*   56      |     8 */    kernel_cap_t cap_effective;
/*   64      |     8 */    kernel_cap_t cap_bset;
/*   72      |     8 */    kernel_cap_t cap_ambient;
...

Les objets struct cred sont alloués à partir d’un cache dédié. Mais comme l’allocateur SLUB fusionne tous les caches similaires, ces objets sont au final alloués en utilisant l’allocateur kmalloc-192 :

$ gdb ./vmlinux --batch -ex 'p sizeof(struct cred)'
$1 = 168

Comment un écrasement d’objets issus d’un allocateur donné peut-il avoir des conséquences sur des objets appartenant à un allocateur autre ?

Comportement d'un allocateur noyau

L’objet struct kmem_cache gère les allocations dynamiques de mémoire dans le noyau. Son comportement est bien documenté sur Internet :

 

https://blog-cyber.riskeco.com/wp-content/uploads/2020/07/understand-html037.png
Organisation de l'allocateur SLAB allocator selon la documentation du noyau Linux

Chaque type d’allocateurs (SLAB, SLUB) repose sur des slabs encadrant plusieurs pages de mémoire physique. Les allocations utilisent ces pages comme sources pour fournir les zones de mémoire allouées à la demande. Les slabs peuvent se retrouver dans l’un des états suivants :

  • vide : tous les objets du slab sont libres ;
  • partiel : le slab est constitué à la fois d’objets libres et utilisés ;
  • rempli : tous les objets du slab sont utilisés.

Quand le noyau Linux décide de libérer un slab vide, les pages physiques correspondantes retournent dans l’ensemble des pages disponibles. De telles pages peuvent migrer d’un cache à un autre, selon l’activité du système.

Chemin d'exploitation

L’exploit lucky0 fait en sorte que la politique XFRM #1 écrase un champ dans la politique XFRM #0 libérée.

Les deux objets appartiennent à des slabs différents si la politique #0 doit évoluer vers un objet d’une taille différente :

  • la politique #1 reste une politique XFRM valide, donc la politique reste dans le cache kmalloc-1024 ;
  • la politique #0 est libérée. Si son slab est libéré, la mémoire assoiée peut être réutilisée pour un autre cache, le kmalloc-192 pour les objets struct cred par exemple.

C’est exatement ce que l’exploit d’origine produit.

 

Avant d’entrer dans les détails, voici une vue d’ensemble :

https://blog-cyber.riskeco.com/wp-content/uploads/2020/07/lucky_process.png
Flot d'exécution de l'exploit d'origine

Allocation de la politique #0

Cette opération est menée avec un appel à la fonction xfrm_add_policy_0(). Ensuite un heap spray est effectué en utilisant des appels système setxattr(). Une partie du spray est seulement lancé après l’allocation d’un objet xfrm_policy pour la politique #0, en débloquant les opérations de lecture sur les pipes.

Cela est fait pour assurer une distance entre les deux politiques XFRM en mémoire : ce spray en deux temps réduit le risque d’avoir une politique #0 et une politique #1 dans le même slab car la seconde étape remplit l’ensemble de l’espace disponible restant dans le slab utilisé pour la politique #0.

Allocation de la politique #1 et déclenchement du bogue

La seconde politique est mise en place avec un appel à la fonction xfrm_add_policy1(). Ensuite la situation à problèmes est préparée en appelant les fonctions xfrm_hash_rebuild() et xfrm_flush_policy0(). Le minuteur à venir sur la politique #1 va réellement déclencher le bogue un peu après.

Remplacement de mémoire

Fermer le second pipe débloque les autres opérations de lecture en attente dans le gestionnaire pour userfaultfd() : cela interrompt la lecture bloquante dans la fonction uffd_spray_handler(), l’appel système setxattr() se termine et les enfants poursuivent alors leur exécution.

Quand l’appel système setxattr()se termine, la mémoire allouée pour copier les 1024 octets de données dans la mémoire du noyau sont libérés. Si cette mémoire était voisine de la politique #0 libérée, alors les chances sont bonnes pour que le slab associé devienne vide et soit ainsi également libéré. L’appel suivant à setgid() mène à l’allocation de struct cred , avec une potentielle réutilisation de la mémoire utilisée par la politique #0.

Le minuscule code de l’appel système setgid()montre un appel à la fonction prepare_creds() sans condition, comme le trace un ajout de  WARN_ON(1) :

Apr 13 12:56:31 ubuntu18 kernel: [   30.088409] ------------[ cut here ]------------
Apr 13 12:56:31 ubuntu18 kernel: [   30.088442] WARNING: CPU: 0 PID: 2405 at kernel/cred.c:296 prepare_creds+0x150/0x160
Apr 13 12:56:31 ubuntu18 kernel: [   30.088443] Modules linked in:
Apr 13 12:56:31 ubuntu18 kernel: [   30.088448] CPU: 0 PID: 2405 Comm: lucky0 Tainted: G        W        4.15.18 #38
Apr 13 12:56:31 ubuntu18 kernel: [   30.088450] Hardware name: QEMU Standard PC (Q35 + ICH9, 2009), BIOS 1.12.0-1 04/01/2014
Apr 13 12:56:31 ubuntu18 kernel: [   30.088454] RIP: 0010:prepare_creds+0x150/0x160
Apr 13 12:56:31 ubuntu18 kernel: [   30.088456] RSP: 0018:ffffc90001f1ff08 EFLAGS: 00010296
Apr 13 12:56:31 ubuntu18 kernel: [   30.088459] RAX: 0000000000000024 RBX: ffff88801c183b40 RCX: 0000000000000006
Apr 13 12:56:31 ubuntu18 kernel: [   30.088461] RDX: 0000000000000007 RSI: 0000000000000086 RDI: ffff88803fc154f0
Apr 13 12:56:31 ubuntu18 kernel: [   30.088463] RBP: ffff88802bf0e200 R08: 0000000000011331 R09: 0000000000000004
Apr 13 12:56:31 ubuntu18 kernel: [   30.088465] R10: 0000000000000000 R11: 0000000000000001 R12: ffff88802bf0e200
Apr 13 12:56:31 ubuntu18 kernel: [   30.088466] R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000
Apr 13 12:56:31 ubuntu18 kernel: [   30.088469] FS:  00007fc32fb81740(0000) GS:ffff88803fc00000(0000) knlGS:0000000000000000
Apr 13 12:56:31 ubuntu18 kernel: [   30.088471] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
Apr 13 12:56:31 ubuntu18 kernel: [   30.088480] CR2: 00007f660640fe00 CR3: 000000002bfa4005 CR4: 0000000000360ef0
Apr 13 12:56:31 ubuntu18 kernel: [   30.088482] DR0: 0000000000000000 DR1: 0000000000000000 DR2: 0000000000000000
Apr 13 12:56:31 ubuntu18 kernel: [   30.088484] DR3: 0000000000000000 DR6: 00000000fffe0ff0 DR7: 0000000000000400
Apr 13 12:56:31 ubuntu18 kernel: [   30.088485] Call Trace:
Apr 13 12:56:31 ubuntu18 kernel: [   30.088493]  SyS_setgid+0x32/0xb0
Apr 13 12:56:31 ubuntu18 kernel: [   30.088497]  do_syscall_64+0x5b/0x110
Apr 13 12:56:31 ubuntu18 kernel: [   30.088501]  entry_SYSCALL_64_after_hwframe+0x3d/0xa2
Apr 13 12:56:31 ubuntu18 kernel: [   30.088504] RIP: 0033:0x7fc32f759f10
Apr 13 12:56:31 ubuntu18 kernel: [   30.088505] RSP: 002b:00007fffdd090070 EFLAGS: 00000246 ORIG_RAX: 000000000000006a
Apr 13 12:56:31 ubuntu18 kernel: [   30.088508] RAX: ffffffffffffffda RBX: 00007fffdd0900d0 RCX: 00007fc32f759f10
Apr 13 12:56:31 ubuntu18 kernel: [   30.088510] RDX: f8d5916a2c50cf00 RSI: 0000000000000000 RDI: 00000000000003e8
Apr 13 12:56:31 ubuntu18 kernel: [   30.088512] RBP: 00000000000000ca R08: 00007fc32f971330 R09: 00007fc32f96d2b0
Apr 13 12:56:31 ubuntu18 kernel: [   30.088514] R10: 00007fc32f971330 R11: 0000000000000246 R12: 00007fc32f971330
Apr 13 12:56:31 ubuntu18 kernel: [   30.088516] R13: 00007fc32f96d2b0 R14: 00007fffdd0900f0 R15: 00007fc32fb81740
Apr 13 12:56:31 ubuntu18 kernel: [   30.088519] Code: 49 b8 cd ab 78 56 cd ab 34 12 48 89 da 48 89 de 4c 89 c1 48 c7 c7 38 83 1e 82 e8 71 d1 02 00 48 c7 c7 c0 52 1d 82 e8 65 d1 02 00 <0f> 0b eb ab 66 90 66 2e 0f 1f 84 00 00 00 00 00 53 48 89 fb 48 
Apr 13 12:56:31 ubuntu18 kernel: [   30.088557] ---[ end trace ae5a0f95dd91ddd4 ]---

Obtention d'un accès root

Si le processus fils obtient l’écrasement de sa struct cred, son champ euid comporte désormais une valeur nulle. Cette situation est évaluée avec un appel à  seteuid(0). Si cet appel réussit, alors l’accès root est garanti !

Une fois que le processus courant dispose des privilèges root, il met à jour le contenu du fichier /etc/sudoers en ajoutant l’utilisateur courant comme sudoer sans contrainte de mot de passe afin de rendre ce niveau de privilèges persistant pour cet utilisateur courant.

Preuve du chemin d'exécution

Le correctif lucky-trace.patch peut être appliqué aux sources du noyau pour tracer l’exécution de l’exploit.

Il est important de souligner que la trace de l’exécution du noyau avec des messages de débogage peut modifier le comportement de ce noyau mais dans la situation courante, l’exploit continue d’exploiter.

Les adresses écrasées peuvent être récupérées après quelques essais (5 ici) :

# cat $( ls -tr /var/log/syslog* ) | grep overwrite
Apr 13 15:55:57 ubuntu18 kernel: [   16.525727] POLICY overwrite *pprev=ffff88001d5c0808 <- next=0
Apr 13 15:56:12 ubuntu18 kernel: [   31.778637] POLICY overwrite *pprev=ffff88002abf4808 <- next=0
Apr 13 15:56:28 ubuntu18 kernel: [   47.029712] POLICY overwrite *pprev=ffff88001c125008 <- next=0
Apr 13 15:56:44 ubuntu18 kernel: [   62.302815] POLICY overwrite *pprev=ffff880026072008 <- next=0
Apr 13 15:56:59 ubuntu18 kernel: [   77.547406] POLICY overwrite *pprev=ffff88002dd5a008 <- next=0

La dernière entrée fait référence à l’écrasement effectif du champ prev de la politique #1, qui renvoie que champ next de la politique #0. Donc la politique #0 était allouée à :

0xffff88002dd5a008 - 8 = 0xffff88002dd5a000

Il y avait un slab libre de 1024 octets à cette emplacement :

# cat $( ls -tr /var/log/syslog* ) | grep "SLAB free" | grep ffff88002dd5
Apr 13 15:57:00 ubuntu18 kernel: [   78.548715] SLAB free: ffff88002dd58000 -> ffff88002dd5c000
Apr 13 15:57:00 ubuntu18 kernel: [   78.548720] SLAB free: ffff88002dd50000 -> ffff88002dd54000
Apr 13 15:57:00 ubuntu18 kernel: [   78.556735] SLAB free: ffff88002dd4c000 -> ffff88002dd50000

Cette page a été plus tard réutilisée pour un  slab de 192 octets :

# cat $( ls -tr /var/log/syslog* ) | grep "SLAB alloc" | grep ffff88002dd5a000
Apr 13 15:57:01 ubuntu18 kernel: [   79.373317] SLAB alloc: ffff88002dd59000 -> ffff88002dd5a000
Apr 13 15:57:01 ubuntu18 kernel: [   79.374448] SLAB alloc: ffff88002dd5a000 -> ffff88002dd5b000

Et il y avait en effet des objets struct cred alloués pour un processus lucky à l’emplacement de la politique #0 :

# cat $( ls -tr /var/log/syslog* ) | grep "CRED" | grep ffff88002dd5a000
Apr 13 15:57:01 ubuntu18 kernel: [   79.374450] CRED alloc: ffff88002dd5a000
Apr 13 15:57:06 ubuntu18 kernel: [   84.454714] CRED alloc: ffff88002dd5a000

Donc la primitive d’écriture obtenue à partir du UAF a écrasé les  credentials du processus.

Conclusion

Au final, une exploration profonde du CVE-2019-15666 a été menée. Tous les concepts requis pour une bonne compréhension du fonctionnement de la vulnérabilité ont été abordés, complétés par quelques éléments intéressants survenus pendant l’analyse.

Des détails utiles pour essayer et reproduire la recherche sont également inclus.

A partir de là, il reste encore beaucoup de choses à poursuivre dans le cadre de cette exploration. Une bonne chose à faire serait d’améliorer le taux de succès lors de la situation de compétition, car il faut parfois du temps pour obtenir une exploitation valide. Il pourrait également être intéressant de porter l’exploit sur une autre plateforme qui satisfait les prérequis.