Indice
ToggleIntroduzione
Nonostante l’avvento dei container sono ancora molte le realtà che utilizzano le Virtual Machine (VM). Molti distribuiscono delle immagini preconfigurate contenenti le loro applicazioni web proteggendo i sorgenti da occhi indiscreti.
Alcuni si affidano a delle credenziali forti nel login, altri permettono l’accesso solo tramite SSH con gli opportuni certificati, altri disabilitano le shortcut del GRUB per ottenere shell con privilegi di root, altri nascondono il GRUB e il login avviando solamente i servizi web, altri ancora cifrano il disco per non essere montato come esterno.
Quando tutte queste misure di protezione vengono messe in campo allo stesso momento l’unica superficie di attacco che rimane esposta è l’applicazione web stessa; e se viene tolta anche quella, la VM si può considerare inviolabile… oppure no?
(È necessario aver accettato i cookie per poter vedere il video)
Il Page Cache in teoria
Prima di poter spiegare la tecnica di attacco è doveroso spiegare i fondamenti del funzionamento della "Page Cache" di Linux che ne è alla base.
Il Page Cache è una parte del Virtual File System (VFS) che ha lo scopo di migliorare le prestazioni dell’I/O nelle operazioni di lettura/scrittura.
Si parla di caching delle pagine in quanto il kernel Linux lavora con unità di memoria chiamate pagine. Quindi, invece che tenere traccia e gestire ogni singolo bit di informazione cui il sistema accede viene gestita l’intera pagina che contiene quell’informazione. Le pagine hanno generalmente dimensione pari a 4 KB e dato che rappresentano l’unità fondamentale del Page Cache, significa che ogni richiesta di lettura di anche un solo bit comporterà la lettura di 4 KB di dati.
Di seguito un diagramma semplificato che mostra le operazioni fondamentali che il Page Cache compie.
Come si vede dalla figura precedente, un’operazione di lettura si compone di tre momenti fondamentali:
- Un’applicazione user-space richiede al kernel la lettura di dati dal disco utilizzando alcune system call come
read()
,pread()
,vread()
,mmap()
, ecc. - Il kernel Linux controlla che le pagine corrispondenti ai dati richiesti siano già presenti nella cache e in caso positivo (Cache HIT) le restituisce direttamente al chiamante. In questo caso il disco non viene mai acceduto.
- Nel caso le pagine richieste non siano già presenti in cache (Cache MISS), il kernel fa in modo di riservare in cache lo spazio sufficiente ad ospitarle, poi effettua una lettura dei dati dal disco. Questi vengono poi memorizzati in cache e restituiti al chiamante.
Da questo momento in poi ogni successiva richiesta di accesso al file genererà un Cache HIT, quindi senza la necessità di coinvolgere nuovamente il disco.
Diamo ancora uno sguardo a cosa succede quando viene richiesta la lettura di un file.
In operazioni di I/O che utilizzano la syscall read()
, il kernel memorizza le pagine corrispondenti ai dati di cui si richiede l’accesso sia nella Page Cache che in un’altra cache applicativa. Quando si utilizza la syscall mmap()
, invece, questa duplicazione non avviene e il kernel utilizza solamente la Page Cache.
Detto questo, cosa ci aspettiamo di vedere all’interno di un file vmem?
A seconda che l’accesso ai file sia avvenuto tramite una read()
o mmap()
ci aspettiamo di trovare una o più copie delle pagine contenenti i dati richiesti da tutti i processi del sistema, più eventualmente le pagine vicine che il kernel precarica per noi per ridurre gli accessi al disco. Oltre a questo chiaramente saranno presenti anche tutte le strutture dati utilizzate dal kernel, i processi in esecuzione, aree di memoria non utilizzate, ecc.
Il Page Cache in pratica
Per dimostrare praticamente il funzionamento del Page Cache avremo bisogno di:
- sync – un tool per forzare la scrittura delle pagine "dirty" della cache su disco
- /proc/sys/vm/drop_caches – un file su procfs per la cancellazione forzata della cache
- vmtouch – un tool per la diagnostica della Page Cache
Preparazione dell’ambiente
Creiamo un file di prova che utilizzeremo per testare il funzionamento della cache durante le operazioni di lettura.
$ dd if=/dev/random of=file count=128 bs=1M
Puliamo tutte le cache per poter partire da una situazione pulita.
$ sync; echo 3 | sudo tee /proc/sys/vm/drop_caches
Ci possiamo assicurare che il file appena creato sia stato eliminato dalla cache utilizzando il tool VMTouch e assicurandoci che il numero di “Resident Pages” sia uguale a zero.
$ vmtouch file
Files: 1
Directories: 0
Resident Pages: 0/32768 0/128M 0%
Elapsed: 0.002936 seconds
Lettura di un file – read() syscall
Creiamo un semplice programma C che legge 2 byte del file di test.
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>
int main(int argc, char *argv[])
{
int fd = open("file", O_RDONLY);
uint8_t buf[2] = {0};
read(fd, buf, sizeof(buf));
close(fd);
return 0;
}
Compiliamo ed eseguiamo il programma. Essendo la pagina l’unità fondamentale ci aspettiamo che in cache non ci siano solamente i due byte letti, ma l’intera pagina che contiene quei due byte.
$ vmtouch file
Files: 1
Directories: 0
Resident Pages: 12/32768 48K/128M 0.0366%
Elapsed: 0.00183 seconds
Contrariamente a quanto ci aspettavamo, in cache non è presente una sola pagina (4 KB), ma ben 12 (48 KB). Questo avviene perché il kernel implementa la cosiddetta “read ahead logic” per cui vengono caricati in memoria dati non esplicitamente richiesti a cui si farà probabilmente accesso nel prossimo futuro in modo da ridurre il numero di accessi al disco.
Questo comportamento può essere controllato utilizzando ad esempio la syscall posix_fadvise()
; vediamo come.
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>
int main(int argc, char *argv[])
{
int fd = open("file", O_RDONLY);
posix_fadvise(fd, 0, 0, POSIX_FADV_RANDOM);
uint8_t buf[2] = {0};
read(fd, buf, sizeof(buf));
close(fd);
return 0;
}
Puliamo la cache, compiliamo ed eseguiamo il programma, poi verifichiamo lo stato della cache.
$ vmtouch file
Files: 1
Directories: 0
Resident Pages: 1/32768 4K/128M 0.00305%
Elapsed: 0.001783 seconds
Lettura di un file – mmap() syscall
Quanto abbiamo appena fatto può essere realizzato anche utilizzando la syscall mmap()
.
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
int fd = open("file", O_RDONLY);
uint8_t *buf = mmap(NULL, 2, PROT_READ, MAP_SHARED, fd, 0);
printf("0x%02x\n", buf[0]);
munmap(buf, 2);
close(fd);
return 0;
}
Quando eseguiamo il programma però ci accorgiamo che la “read ahead logic” di mmap()
è ancora più aggressiva e legge 32 pagine a fronte di una richiesta di soli 2 byte.
$ vmtouch file
Files: 1
Directories: 0
Resident Pages: 32/32768 128K/128M 0.0977%
Elapsed: 0.001935 seconds
Similmente al caso precedente, si può operare sul comportamento di mmap()
utilizzando la syscall madvise()
.
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
int fd = open("file", O_RDONLY);
uint8_t *buf = mmap(NULL, 2, PROT_READ, MAP_SHARED, fd, 0);
madvise(buf, 2, MADV_RANDOM);
printf("0x%02x\n", buf[0]);
munmap(buf, 2);
close(fd);
return 0;
}
Possiamo verificare come ora venga caricata una sola pagina.
$ vmtouch file
Files: 1
Directories: 0
Resident Pages: 1/32768 4K/128M 0.00305%
Elapsed: 0.001792 seconds
Lo stato di pausa di una VM
Quando utilizziamo un sistema di virtualizzazione possiamo mettere in pausa l’esecuzione della VM. A differenza della sospensione in cui lo stato della VM viene salvato su disco, con la pausa viene salvato in RAM.
La funzione di pausa è utile quando si vuole interrompere temporaneamente l’esecuzione di un’operazione sulla VM per poi riprenderla in un secondo momento.
I file della virtualizzazione
Quando utilizziamo una VM vengono creati nel sistema host alcuni file tra cui i principali sono:
- vmdk – disco virtuale equivalente al disco della VM
- vmsd – metadata e altre informazioni sugli snapshot della VM
- vmx – informazioni di configurazione e impostazioni dell’hardware della VM
- vmsn – stato della VM relativo a un particolare snapshot
- vmem – contenuto della memoria quando si mette in pausa la VM
Il file vmem è particolarmente interessante perché ci permette di analizzare il contenuto della RAM di una VM in pausa; contenuto che non cambierà fintanto che la VM resterà in pausa.
Tampering della RAM
Se mettiamo insieme quanto detto finora sul funzionamento della Page Cache durante le operazioni di lettura e sullo stato di pausa di una VM, otteniamo la nuova tecnica di attacco argomento di questo articolo.
L'attacco si compone di 3 parti:
- Avviamo una VM e interagiamo con essa per forzare la lettura di un file di interesse
- Mettiamo in pausa la VM e modifichiamo il contenuto della Page Cache tramite il file vmem
- Togliamo la VM dallo stato di pausa e forziamo nuovamente la lettura del file di interesse
Vediamo questi tre passi utilizzati in un caso reale per capirne la reale efficacia. Utilizzeremo una VM scaricata da HackMyVM, ma avremmo potuto utilizzare qualunque altra VM.
Vogliamo ottenere una shell di root e lo faremo in 3 passi:
- Enumeriamo gli utenti forzando la lettura del file /etc/passwd
- Enumeriamo gli hash della password forzando la lettura del file /etc/shadow
- Eseguiamo un bypass del login e una privilege escalation facendo tampering della RAM
Enumerazione degli utenti
Una volta avviata la VM tutto ciò che abbiamo è la schermata di login.
Il primo obiettivo è quello di enumerare tutti gli utenti del sistema. Sappiamo che in Linux la lista degli utenti insieme ad altre loro proprietà è presente nel file /etc/passwd
a cui chiaramente non abbiamo accesso.
Quello che sappiamo è che davanti abbiamo il binario /usr/bin/login. Volendo semplificare, possiamo dire che si tratta di un programma che viene eseguito indirettamente dal processo init tramite getty o un display manager. Quando eseguito richiede all’utente il nome utente e la password con cui eseguire l’accesso. Lo username inserito viene controllato che esista nel file /etc/passwd
prima di eventualmente controllare l’esattezza della password.
Sapendo questo ci basta mettere in pausa la VM non appena vediamo la schermata di login e aprire con un hex editor il file vmem corrispondente. A questo punto cerchiamo le corrispondenze della stringa :x:1000:1000::
cioè la riga del file /etc/passwd
che fa riferimento all’utente con ID 1000 (generalmente utilizzato come primo ID per gli utenti creati su Linux).
In questo modo siamo stati in grado di leggere il contenuto del file /etc/passwd
e ottenere la lista completa degli utenti del sistema tra cui troviamo l'utente moksha:
moksha:x:1000:1000:moksha,,,:/home/moksha:/bin/bash
Enumerazione degli hash delle password
Supponiamo di voler effettuare il login con l’utente moksha, quindi di voler cercare nel file vmem una corrispondenza per la stringa moksha:$
, cioè l’inizio della riga nel file /etc/shadow
corrispondente all’hash della password dell’utente moksha.
In questo momento non otterremmo alcun risultato. Questo perché il file /etc/shadow
viene acceduto solamente quando viene fatto un tentativo di login.
Quindi, se tentassimo di effettuare il login con l'utente moksha e una password qualunque verrebbero letti i file /etc/passwd
e /etc/shadow
, quindi le pagine corrispondenti di entrambi i file verrebbero caricate in memoria nella Page Cache.
Eseguiamo un tentativo di login come specificato e mettiamo in pausa la VM.
Fatto questo apriamo di nuovo il file vmem corrispondente alla RAM della VM e cerchiamo nuovamente il pattern moksha:$
.
Questa volta otteniamo una corrispondenza, quindi possiamo leggere il contenuto del file /etc/shadow
e ottenere l’hash delle password di tutti gli utenti del sistema, tra cui l'utente moksha.
moksha:$y$j9T$aahAz65DkZxl.its6aXmV1$7beXMp4sTYWPV9IuR1CIbPqidBBFx7zhqq1nt2IBTl2:19361:0:99999:7:::
Bypass del login e privilege escalation
Ottenuti i nomi utente e gli hash delle password potremmo provare a ottenere le credenziali in chiaro per usarle per effettuare il login. Oppure, sfruttando ancora una volta il funzionamento del Page Cache potremmo sovrascrivere gli hash con un hash di cui conosciamo la password.
Generiamo l’hash per la password “password” specificando un salt di lunghezza pari a quello utilizzato per l’hash corrente; questo permetterà di ottenere un hash di pari lunghezza.
>>> import crypt
>>> crypt.crypt(
... 'password', # Our password of choice
... '$y$j9T$aahAz65DkZxl.its6aXmV1$' # The salt found in the vmem file
... )
'$y$j9T$aahAz65DkZxl.its6aXmV1$oHbJ46tHyqV/QPv8c5lxYh.vHfIC1ovf7GBWpu1tIT1'
A questo punto potremmo fare di più: modificando lo user ID (UID) e il group ID (GID) da 1000 (equivalente all’utente moksha) a 0 (equivalente all’utente root) potremmo avere accesso con i massimi privilegi.
Questo si può fare cercando e sostituendo tutte le occorrenze di moksha:x:1000:1000:
con moksha:x:0000:0000:
facendo attenzione a utilizzare una sequenza di quattro zeri per l’UID e il GID in modo da ottenere la stessa lunghezza di stringa.
A questo punto possiamo salvare il file vmem e togliere la VM dallo stato di pausa per effettuare il login con le credenziali moksha:password
e ottenere l’accesso con privilegi di root sulla VM.
Conclusioni
Questa tecnica non sfrutta alcuna vulnerabilità, ma dei meccanismi di funzionamento dei sistemi operativi e delle macchine virtuali. Per questo motivo non esiste attualmente un rimedio che renda inefficace la tecnica se non quello di disabilitare completamente il Page Cache causando però fortissimi rallentamenti della VM.
Alcuni sistemi di virtualizzazione come VirtualBox cercano di evitare il tampering della RAM inserendo all'inizio del file VMEM un checksum calcolato sul contenuto del file, impedendone il ripristino in caso di manomissione. È chiaro però che è sufficiente ricalcolare l'hash oppure cambiare sistema di virtualizzazione per ottenere di nuovo l'accesso alla VM nel modo spiegato.