Nel capitolo LK01 (Holstein) studieremo le tecniche base del kernel exploit. Se non hai ancora scaricato LK01 durante l’introduzione, parti da qui.
Il filesystem della VM si trova in qemu/rootfs.cpio. In questa guida assumiamo di estrarlo in una directory chiamata mount. (Creala come root.)
Verificare l’inizializzazione
Nel filesystem trovi innanzitutto un file /init, che rappresenta il primo processo eseguito in userland dopo il caricamento del kernel. Nei CTF, proprio qui vengono spesso eseguite operazioni come il caricamento dei moduli del kernel, quindi è sempre importante controllarlo.
Nel nostro caso /init è quello standard di buildroot, mentre il caricamento del modulo avviene in /etc/init.d/S99pawnyable.
1 |
|
Qui ci sono alcune righe particolarmente importanti.
Per prima cosa:
1 | echo 2 > /proc/sys/kernel/kptr_restrict |
controlla KADR. Come abbiamo già visto, significa che KADR è attivo. Per il debug, però, questa protezione è di intralcio, quindi conviene disattivarla.
Poi troviamo la riga commentata:
1 | #echo 1 > /proc/sys/kernel/dmesg_restrict |
Nei CTF, questa opzione è spesso attiva. Regola se gli utenti non privilegiati possono leggere dmesg. Qui, trattandosi di un esercizio, dmesg è lasciato accessibile.
Ancora più importante:
1 | insmod /root/vuln.ko |
carica il modulo del kernel e crea il device file /dev/holstein.
insmod carica /root/vuln.ko; poi mknod crea un character device associato al modulo registrato col nome holstein.
Infine:
1 | setsid cttyhack setuidgid 1337 sh |
imposta l’UID a 1337 e avvia una shell. È il motivo per cui all’avvio si ottiene direttamente una shell senza login.
Durante il debug conviene cambiare quell’UID in 0, così da ottenere subito una shell root se non hai già svolto l’esercizio iniziale.
In /etc/init.d troverai anche altri script come S01syslogd o S41dhcpcd, che si occupano per esempio di rete e logging. Per il debug di questo exploit non servono, quindi puoi spostarli altrove o impedire che vengano eseguiti: il boot si velocizzerà di qualche secondo. Lasciare soltanto rcK, rcS e S99pawnyable è sufficiente.
Analisi del modulo Holstein
In questo capitolo useremo un modulo del kernel volutamente vulnerabile chiamato Holstein per imparare i primi attacchi di kernel exploitation. Il sorgente si trova in src/vuln.c, quindi partiamo da lì.
Inizializzazione e cleanup
Ogni modulo del kernel ha una routine di inizializzazione e una di cleanup.
Alla riga 108 trovi:
1 | module_init(module_initialize); |
cioè la registrazione delle funzioni di setup e teardown. Partiamo dalla funzione di inizializzazione module_initialize.
1 | static int __init module_initialize(void) |
Per esporre un’interfaccia verso lo userland, il modulo deve registrare un endpoint. Spesso si passa da /dev o /proc; qui viene usato cdev_add, quindi abbiamo a che fare con un character device in /dev.
Questo, però, non crea automaticamente un file sotto /dev: come abbiamo visto nello script S99pawnyable, /dev/holstein viene creato a mano con mknod.
La chiamata importante è:
1 | cdev_init(&c_dev, &module_fops); |
Il secondo argomento, module_fops, è una tabella di funzioni. È lì che il modulo definisce quali callback debbano essere eseguite quando si invocano operazioni come open, read, write o close su /dev/holstein.
1 | static struct file_operations module_fops = |
Il modulo implementa solo quattro operazioni: open, read, write e close. Tutto il resto rimane non implementato.
La funzione di cleanup è molto semplice:
1 | static void __exit module_cleanup(void) |
Si limita a rimuovere il character device registrato.
open
Vediamo module_open.
1 | static int module_open(struct inode *inode, struct file *file) |
La funzione printk scrive nel log buffer del kernel. Il prefisso KERN_INFO è solo il livello di log. L’output si può vedere con dmesg.
Poi compare kmalloc, che è l’equivalente kernel di malloc. Alloca memoria dall’heap del kernel. Qui viene allocato un buffer da BUFFER_SIZE (0x400) byte e il puntatore viene salvato nella variabile globale g_buf.
Quindi, ogni open su questo modulo alloca 0x400 byte nell’heap del kernel.
close
Passiamo a module_close.
1 | static int module_close(struct inode *inode, struct file *file) |
kfree è la controparte di kmalloc: libera memoria dell’heap del kernel.
Chiude quindi il buffer allocato in open, il che ha senso: quando il file viene chiuso, il buffer non serve più. Anche se il programma userland non chiama esplicitamente close, il kernel lo farà in fase di terminazione del processo.
In realtà, già qui si nasconde una vulnerabilità che può portare a LPE, ma la vedremo in un altro capitolo.
read
module_read viene eseguita quando lo userland invoca read.
1 | static ssize_t module_read(struct file *file, |
I dati contenuti in g_buf vengono prima copiati in un buffer su stack chiamato kbuf, e poi _copy_to_user copia nello userland count byte di quel buffer.
Come abbiamo già detto nel capitolo su SMAP, copy_to_user è la funzione sicura per copiare dallo spazio kernel allo userland. Qui, però, viene usata la variante _copy_to_user, cioè la versione che non effettua i controlli di stack overflow. Normalmente non andrebbe usata: qui compare solo per introdurre di proposito una vulnerabilità.

copy_to_user e copy_from_user sono definite come funzioni inline e, quando possibile, eseguono anche controlli sulla dimensione.
In sintesi, read copia prima tutto g_buf nello stack e poi restituisce al chiamante solo i byte richiesti.
write
Infine leggiamo module_write.
1 | static ssize_t module_write(struct file *file, |
Qui _copy_from_user copia dati dallo userland al buffer su stack kbuf, e poi memcpy trasferisce fino a BUFFER_SIZE byte da kbuf a g_buf.
La vulnerabilità di stack overflow
Ora che abbiamo letto il modulo per intero, quante vulnerabilità hai trovato?
Chi ha già un po’ di pratica col kernel exploitation probabilmente ne avrà individuata almeno una. In questa sezione ci concentreremo sulla seguente porzione, che contiene una vulnerabilità di stack overflow:
1 | static ssize_t module_write(struct file *file, |
La dimensione count viene dallo userland, mentre kbuf misura solo 0x400 byte. Quindi qui c’è un banalissimo stack buffer overflow.
Anche in kernel space il meccanismo di chiamata delle funzioni è lo stesso dello userland: si può sovrascrivere il return address e arrivare fino a una ROP chain.
Innescare la vulnerabilità
Prima di provare a sfruttarla, conviene scrivere un programmino che usi il modulo in modo normale e verificare che funzioni come previsto. Per esempio:
1 |
|
Il programma scrive "Hello, World!" con write è lo rilegge con read.
Se lo eseguiamo nel kernel di prova:
vediamo che tutto funziona. Anche i log del modulo non mostrano errori.
Ora proviamo a forzare lo stack overflow con qualcosa di più aggressivo:
1 |
|
Eseguiamolo:
Compare subito un messaggio piuttosto minaccioso.
Quando un modulo del kernel fa qualcosa di gravemente sbagliato, nella maggior parte dei casi cade l’intero kernel. In quel momento vengono stampati causa del crash, registri e stack trace: informazioni preziosissime per il debug di un kernel exploit.
In questo caso la causa è:
1 | BUG: stack guard page was hit at (____ptrval____) (stack is (____ptrval____)..(____ptrval____)) |
ptrval indica un puntatore, ma l’indirizzo vero è nascosto da KADR.
La cosa interessante è il valore di RIP. Non vediamo ancora 0x4141414141414141:
1 | RIP: 0010:memset_orig+0x33/0xb0 |
Come suggerisce anche il messaggio, durante la scrittura via copy_from_user si è toccata la guard page in fondo allo stack. Stiamo semplicemente scrivendo troppo. Proviamo a ridurre la dimensione:
1 | write(fd, buf, 0x420); |
Questa volta il messaggio cambia:
Ora compare una general protection fault e soprattutto RIP è sotto controllo:
1 | RIP: 0010:0x4141414141414141 |
Quindi, anche in kernel space, uno stack overflow permette di prendere RIP esattamente come in userland. Nel capitolo successivo vedremo come trasformare questo risultato in elevazione di privilegi.