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]
Uso di FUSE
Prima di tutto puoi controllare la versione disponibile con fusermount:
1 | / $ fusermount -V |
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 |
|
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 | $ pkg-config fuse --cflags --libs |
Con il link statico, però, spesso manca anche libdl:
1 | /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': |
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 | $ mkdir /tmp/test |
Se tutto funziona, il programma resta in esecuzione senza errori. Da un altro terminale puoi accedere al file virtuale:
1 | $ cat /tmp/test/file |
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 | int main() |
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:
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 | cpu_set_t pwn_cpu; |
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:
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 | int pwn_fd = -1; |
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:
Un esempio completo è disponibile qui.
Esiste anche
CUSE, che permette di registrare character device virtuali in user space. ↩︎
